Some checks failed
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>
243 lines
11 KiB
Markdown
243 lines
11 KiB
Markdown
# 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)`.
|