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