Files
orion/app/modules/loyalty/docs/data-model.md
Samir Boulahtit f804ff8442
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
fix(loyalty): cross-store enrollment, card scoping, i18n flicker
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>
2026-04-11 18:28:19 +02:00

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)`.