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