Fix duplicate card creation when the same email enrolls at different stores under the same merchant, and implement cross-location-aware enrollment behavior. - Cross-location enabled (default): one card per customer per merchant. Re-enrolling at another store returns the existing card with a "works at all our locations" message + store list. - Cross-location disabled: one card per customer per store. Enrolling at a different store creates a separate card for that store. Changes: - Migration loyalty_004: replace (merchant_id, customer_id) unique index with (enrolled_at_store_id, customer_id). Per-merchant uniqueness enforced at application layer when cross-location enabled. - card_service.resolve_customer_id: cross-store email lookup via merchant_id param to find existing cardholders at other stores. - card_service.enroll_customer: branch duplicate check on allow_cross_location_redemption setting. - card_service.search_card_for_store: cross-store email search when cross-location enabled so staff at store2 can find cards from store1. - card_service.get_card_by_customer_and_store: new service method. - storefront enrollment: catch LoyaltyCardAlreadyExistsException, return existing card with already_enrolled flag, locations, and cross-location context. Server-rendered i18n via Jinja2 tojson. - enroll-success.html: conditional cross-store/single-store messaging, server-rendered translations and context, i18n_modules block added. - dashboard.html, history.html: replace $t() with server-side _() to fix i18n flicker across all storefront templates. - Fix device-mobile icon → phone icon. - 4 new i18n keys in 4 locales (en, fr, de, lb). - Docs: updated data-model, business-logic, production-launch-plan, user-journeys with cross-location behavior and E2E test checklist. - 12 new unit tests + 3 new integration tests (334 total pass). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
11 KiB
Loyalty Data Model
Entity relationships and database schema for the loyalty module.
Entity Relationship Diagram
┌──────────────────────┐
│ Merchant │ (from tenancy module)
│ (one program per │
│ merchant) │
└──────────┬───────────┘
│ 1
│
┌─────┴─────┐
│ │
▼ 1 ▼ 1
┌──────────┐ ┌──────────────────────┐
│ Loyalty │ │ MerchantLoyalty │
│ Program │ │ Settings │
│ │ │ │
│ type │ │ staff_pin_policy │
│ stamps │ │ allow_self_enrollment│
│ points │ │ allow_void │
│ branding │ │ allow_cross_location │
│ anti- │ │ require_order_ref │
│ fraud │ │ log_ip_addresses │
└──┬───┬───┘ └──────────────────────┘
│ │
│ │ 1..*
│ ▼
│ ┌──────────────┐
│ │ StaffPin │
│ │ │
│ │ name │
│ │ pin_hash │ (bcrypt)
│ │ store_id │
│ │ failed_ │
│ │ attempts │
│ │ locked_until │
│ └──────────────┘
│
│ 1..*
▼
┌──────────────────┐ ┌──────────────────┐
│ LoyaltyCard │ │ Customer │ (from customers module)
│ │ *───1 │ │
│ card_number │ └──────────────────┘
│ qr_code_data │
│ stamp_count │ ┌──────────────────┐
│ points_balance │ │ Store │ (from tenancy module)
│ google_object_id│ *───1 │ (enrolled_at) │
│ apple_serial │ └──────────────────┘
│ is_active │
└──────┬───────────┘
│
│ 1..*
▼
┌──────────────────────┐
│ LoyaltyTransaction │ (immutable audit log)
│ │
│ transaction_type │
│ stamps_delta │ (signed: +1 earn, -N redeem)
│ points_delta │ (signed: +N earn, -N redeem)
│ stamps_balance_after│
│ points_balance_after│
│ purchase_amount │
│ staff_pin_id │──── FK to StaffPin
│ store_id │──── FK to Store (location)
│ related_txn_id │──── FK to self (for voids)
│ ip_address │
│ user_agent │
└──────────────────────┘
┌──────────────────────────┐
│ AppleDeviceRegistration │
│ │
│ card_id │──── FK to LoyaltyCard
│ device_library_id │
│ push_token │
│ │
│ UNIQUE(device, card) │
└──────────────────────────┘
Models
LoyaltyProgram
Merchant-wide loyalty program configuration. One program per merchant, shared across all stores.
| Field | Type | Description |
|---|---|---|
merchant_id |
FK (unique) | One program per merchant |
loyalty_type |
Enum | STAMPS, POINTS, or HYBRID |
stamps_target |
Integer | Stamps needed for reward |
stamps_reward_description |
String | Reward description text |
stamps_reward_value_cents |
Integer | Reward monetary value |
points_per_euro |
Integer | Points earned per euro spent |
points_rewards |
JSON | Reward catalog (id, name, points_cost) |
points_expiration_days |
Integer | Days until points expire (nullable) |
welcome_bonus_points |
Integer | Points given on enrollment |
minimum_redemption_points |
Integer | Minimum points to redeem |
minimum_purchase_cents |
Integer | Minimum purchase for earning |
cooldown_minutes |
Integer | Minutes between stamps (anti-fraud) |
max_daily_stamps |
Integer | Max stamps per card per day |
require_staff_pin |
Boolean | Whether PIN is required |
card_name |
String | Display name on card |
card_color |
String | Primary brand color (hex) |
card_secondary_color |
String | Secondary brand color (hex) |
logo_url |
String | Logo image URL |
hero_image_url |
String | Hero/banner image URL |
google_issuer_id |
String | Google Wallet issuer ID |
google_class_id |
String | Google Wallet class ID |
apple_pass_type_id |
String | Apple Wallet pass type identifier |
terms_text |
Text | Terms and conditions |
privacy_url |
String | Privacy policy URL |
is_active |
Boolean | Whether program is live |
activated_at |
DateTime | When program was activated |
LoyaltyCard
Customer loyalty card linking a customer to a merchant's program.
Card uniqueness depends on the allow_cross_location_redemption merchant setting:
- Cross-location enabled (default): One card per customer per merchant. The application layer enforces this by checking all stores under the merchant before creating a card. Re-enrolling at another store returns the existing card.
- Cross-location disabled: One card per customer per store. A customer can hold separate cards at different stores under the same merchant, each scoped to its enrollment store.
Database constraint: Unique index on (enrolled_at_store_id, customer_id) — always enforced. The per-merchant uniqueness (cross-location enabled) is enforced at the application layer in card_service.enroll_customer.
| Field | Type | Description |
|---|---|---|
merchant_id |
FK | Links to program's merchant |
customer_id |
FK | Card owner |
program_id |
FK | Associated program |
enrolled_at_store_id |
FK | Store where customer enrolled (part of unique constraint) |
card_number |
String (unique) | Formatted XXXX-XXXX-XXXX |
qr_code_data |
String (unique) | URL-safe token for QR codes |
stamp_count |
Integer | Current stamp count |
total_stamps_earned |
Integer | Lifetime stamps earned |
stamps_redeemed |
Integer | Total redemptions |
points_balance |
Integer | Current points balance |
total_points_earned |
Integer | Lifetime points earned |
points_redeemed |
Integer | Total points redeemed |
total_points_voided |
Integer | Total points voided |
google_object_id |
String | Google Wallet object ID |
google_object_jwt |
Text | Google Wallet JWT |
apple_serial_number |
String | Apple Wallet serial number |
apple_auth_token |
String | Apple Wallet auth token |
last_stamp_at |
DateTime | Last stamp timestamp |
last_points_at |
DateTime | Last points timestamp |
last_redemption_at |
DateTime | Last redemption timestamp |
last_activity_at |
DateTime | Last activity of any kind |
is_active |
Boolean | Whether card is active |
LoyaltyTransaction
Immutable audit log of all loyalty operations. Every stamp, point, redemption, and void is recorded.
| Field | Type | Description |
|---|---|---|
merchant_id |
FK | Merchant program owner |
card_id |
FK | Affected card |
store_id |
FK | Store where transaction occurred |
staff_pin_id |
FK (nullable) | Staff who verified |
related_transaction_id |
FK (nullable) | For void/return linking |
transaction_type |
Enum | See transaction types below |
stamps_delta |
Integer | Signed stamp change |
points_delta |
Integer | Signed points change |
stamps_balance_after |
Integer | Stamp count after transaction |
points_balance_after |
Integer | Points balance after transaction |
purchase_amount_cents |
Integer | Purchase amount for points earning |
order_reference |
String | External order reference |
reward_id |
String | Redeemed reward identifier |
reward_description |
String | Redeemed reward description |
ip_address |
String | Client IP (audit) |
user_agent |
String | Client user agent (audit) |
notes |
Text | Staff/admin notes |
transaction_at |
DateTime | When transaction occurred |
Transaction Types:
| Type | Category | Description |
|---|---|---|
STAMP_EARNED |
Stamps | Customer earned a stamp |
STAMP_REDEEMED |
Stamps | Stamps exchanged for reward |
STAMP_VOIDED |
Stamps | Stamp reversed (return) |
STAMP_ADJUSTMENT |
Stamps | Manual adjustment |
POINTS_EARNED |
Points | Points from purchase |
POINTS_REDEEMED |
Points | Points exchanged for reward |
POINTS_VOIDED |
Points | Points reversed (return) |
POINTS_ADJUSTMENT |
Points | Manual adjustment |
POINTS_EXPIRED |
Points | Points expired due to inactivity |
CARD_CREATED |
Lifecycle | Card enrollment |
CARD_DEACTIVATED |
Lifecycle | Card deactivated |
WELCOME_BONUS |
Bonus | Welcome bonus points on enrollment |
StaffPin
Staff authentication PINs for fraud prevention. Scoped to a store within a merchant's program.
| Field | Type | Description |
|---|---|---|
merchant_id |
FK | Merchant |
program_id |
FK | Associated program |
store_id |
FK | Store this PIN is for |
name |
String | Staff member name |
staff_id |
String | External staff identifier |
pin_hash |
String | Bcrypt-hashed PIN |
failed_attempts |
Integer | Consecutive failed attempts |
locked_until |
DateTime | Lockout expiry (nullable) |
last_used_at |
DateTime | Last successful use |
is_active |
Boolean | Whether PIN is active |
MerchantLoyaltySettings
Admin-controlled settings for a merchant's loyalty program. Separate from program config to allow admin overrides.
| Field | Type | Description |
|---|---|---|
merchant_id |
FK (unique) | One settings record per merchant |
staff_pin_policy |
Enum | REQUIRED, OPTIONAL, or DISABLED |
staff_pin_lockout_attempts |
Integer | Failed attempts before lockout |
staff_pin_lockout_minutes |
Integer | Lockout duration |
allow_self_enrollment |
Boolean | Whether customers can self-enroll |
allow_void_transactions |
Boolean | Whether voids are allowed |
allow_cross_location_redemption |
Boolean | Cross-store redemption |
require_order_reference |
Boolean | Require order ref for points |
log_ip_addresses |
Boolean | Log IPs in transactions |
AppleDeviceRegistration
Tracks Apple devices registered for wallet push notifications when card balances change.
| Field | Type | Description |
|---|---|---|
card_id |
FK | Associated loyalty card |
device_library_identifier |
String | Apple device identifier |
push_token |
String | APNs push token |
Unique constraint on (device_library_identifier, card_id).