# Loyalty Business Logic

Core algorithms, anti-fraud systems, and wallet integration logic for the loyalty module.

## Anti-Fraud System

The loyalty module implements a multi-layer fraud prevention system to prevent abuse of stamp and points operations.

### Layer 1: Staff PIN Verification

Every stamp/points operation can require a staff PIN. PINs are bcrypt-hashed and scoped to a specific store within a merchant.

**Flow:**
1. Staff enters 4-digit PIN on terminal
2. System checks all active PINs for the program
3. On match: records success, updates `last_used_at`
4. On mismatch: increments `failed_attempts`
5. After N failures (configurable, default 5): PIN is locked for M minutes (default 30)

**PIN Policy** (set via `MerchantLoyaltySettings.staff_pin_policy`):

| Policy | Behavior |
|--------|----------|
| `REQUIRED` | All stamp/point operations require PIN |
| `OPTIONAL` | PIN can be provided but not required |
| `DISABLED` | PIN entry is hidden from UI |

### Layer 2: Stamp Cooldown

Prevents rapid-fire stamping (e.g., customer stamps 10 times in one visit).

- Configurable via `LoyaltyProgram.cooldown_minutes` (default: 15)
- Checks `LoyaltyCard.last_stamp_at` against current time
- Returns `next_stamp_available` timestamp in response

### Layer 3: Daily Stamp Limits

Prevents excessive stamps per day per card.

- Configurable via `LoyaltyProgram.max_daily_stamps` (default: 5)
- Counts today's `STAMP_EARNED` transactions for the card
- Returns `remaining_stamps_today` in response

### Layer 4: Audit Trail

Every transaction records:
- `staff_pin_id` — Which staff member verified
- `store_id` — Which location
- `ip_address` — Client IP (if `log_ip_addresses` enabled)
- `user_agent` — Client device
- `transaction_at` — Exact timestamp

## Stamp Operations

### Adding a Stamp

```
Input: card_id, staff_pin (optional), store_id
Checks:
  1. Card is active
  2. Program is active and stamps-enabled
  3. Staff PIN valid (if required by policy)
  4. Cooldown elapsed since last_stamp_at
  5. Daily limit not reached
Action:
  - card.stamp_count += 1
  - card.total_stamps_earned += 1
  - card.last_stamp_at = now
  - Create STAMP_EARNED transaction
  - Sync wallet passes
Output:
  - stamp_count, stamps_target, stamps_until_reward
  - reward_earned (true if stamp_count >= target)
  - next_stamp_available, remaining_stamps_today
```

### Redeeming Stamps

```
Input: card_id, staff_pin (optional), store_id
Checks:
  1. stamp_count >= stamps_target
  2. Staff PIN valid (if required)
Action:
  - card.stamp_count -= stamps_target (keeps overflow stamps)
  - card.stamps_redeemed += 1
  - Create STAMP_REDEEMED transaction (with reward_description)
  - Sync wallet passes
Output:
  - success, reward_description, redemption_count
  - remaining stamp_count after reset
```

### Voiding Stamps

```
Input: card_id, stamps_count OR transaction_id, staff_pin, store_id
Checks:
  1. allow_void_transactions enabled in merchant settings
  2. Card has enough stamps to void
  3. Staff PIN valid (if required)
Action:
  - card.stamp_count -= stamps_count
  - Create STAMP_VOIDED transaction (linked to original via related_transaction_id)
  - Sync wallet passes
```

## Points Operations

### Earning Points

```
Input: card_id, purchase_amount_cents, staff_pin, store_id, order_reference
Calculation:
  euros = purchase_amount_cents / 100
  points = floor(euros × program.points_per_euro)
Checks:
  1. Card is active, program is active and points-enabled
  2. Purchase amount >= minimum_purchase_cents (if configured)
  3. Order reference provided (if require_order_reference enabled)
  4. Staff PIN valid (if required)
Action:
  - card.points_balance += points
  - card.total_points_earned += points
  - Create POINTS_EARNED transaction (with purchase_amount_cents)
  - Sync wallet passes
Output:
  - points_earned, points_balance, purchase_amount, points_per_euro
```

### Redeeming Points

```
Input: card_id, reward_id, staff_pin, store_id
Checks:
  1. Reward exists in program.points_rewards
  2. card.points_balance >= reward.points_cost
  3. points_balance >= minimum_redemption_points (if configured)
  4. Staff PIN valid (if required)
Action:
  - card.points_balance -= reward.points_cost
  - card.points_redeemed += reward.points_cost
  - Create POINTS_REDEEMED transaction (with reward_id, reward_description)
  - Sync wallet passes
Output:
  - reward name/description, points_spent, new balance
```

### Voiding Points

```
Input: card_id, transaction_id OR order_reference, staff_pin, store_id
Checks:
  1. allow_void_transactions enabled
  2. Original transaction found and is an earn transaction
  3. Staff PIN valid (if required)
Action:
  - card.points_balance -= original points
  - card.total_points_voided += original points
  - Create POINTS_VOIDED transaction (linked via related_transaction_id)
  - Sync wallet passes
```

### Adjusting Points

Admin/store operation for manual corrections.

```
Input: card_id, points_delta (positive or negative), notes, store_id
Action:
  - card.points_balance += points_delta
  - Create POINTS_ADJUSTMENT transaction with notes
  - Sync wallet passes
```

## Wallet Integration

### Google Wallet

Uses the Google Wallet API with a service account for server-to-server communication.

**Class (Program-level):**
- One `LoyaltyClass` per program
- Contains program name, branding (logo, hero), rewards info
- Created when program is activated; updated when settings change

**Object (Card-level):**
- One `LoyaltyObject` per card
- Contains balance (stamps or points), card number, member name
- Created on enrollment; updated on every balance change
- "Add to Wallet" URL is a JWT-signed save link

### Apple Wallet

Uses PKCS#7 signed `.pkpass` files and APNs push notifications.

**Pass Generation:**
1. Build `pass.json` with card data (stamps grid or points balance)
2. Add icon/logo/strip images
3. Create `manifest.json` (SHA256 of all files)
4. Sign manifest with PKCS#7 using certificates and private key
5. Package as `.pkpass` ZIP file

**Push Updates:**
1. When card balance changes, send APNs push to all registered devices
2. Device receives push → requests updated pass from server
3. Server generates fresh `.pkpass` with current balance

**Device Registration (Apple Web Service protocol):**
- `POST /v1/devices/{device}/registrations/{passType}/{serial}` — Register device
- `DELETE /v1/devices/{device}/registrations/{passType}/{serial}` — Unregister device
- `GET /v1/devices/{device}/registrations/{passType}` — List passes for device
- `GET /v1/passes/{passType}/{serial}` — Get latest pass

## Cross-Store Redemption

The `allow_cross_location_redemption` merchant setting controls both card scoping and enrollment behavior:

### When enabled (default)

- **One card per customer per merchant** — enforced at the application layer
- Customer can earn stamps at Store A and redeem at Store B
- Each transaction records which `store_id` it occurred at
- The `enrolled_at_store_id` field tracks where the customer first enrolled
- If a customer tries to enroll at a second store, the system returns their existing card with a message showing all available locations

### When disabled

- **One card per customer per store** — each store under the merchant issues its own card
- Stamp/point operations are restricted to the card's enrollment store
- A customer can hold separate cards at different stores under the same merchant
- Re-enrolling at the **same** store returns the existing card
- Enrolling at a **different** store creates a new card scoped to that store

**Database constraint:** Unique index on `(enrolled_at_store_id, customer_id)` prevents duplicate cards at the same store regardless of the cross-location setting.

## Enrollment Flow

### Store-Initiated Enrollment

Staff enrolls customer via terminal:
1. Enter customer email (and optional name)
2. System resolves customer — checks the current store first, then searches across all stores under the merchant for an existing cardholder with the same email
3. If the customer already has a card (per-merchant or per-store, depending on the cross-location setting), raises `LoyaltyCardAlreadyExistsException`
4. Otherwise creates loyalty card with unique card number and QR code
5. Creates `CARD_CREATED` transaction
6. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
7. Creates Google Wallet object and Apple Wallet serial
8. Returns card details with "Add to Wallet" URLs

### Self-Enrollment (Public)

Customer enrolls via public page (if `allow_self_enrollment` enabled):
1. Customer visits `/loyalty/join` page
2. Enters email, name, and optional birthday
3. System resolves customer (cross-store lookup for existing cardholders under the same merchant)
4. If already enrolled: returns existing card with success page showing location info
   - Cross-location enabled: "Your card works at all our locations" + store list
   - Cross-location disabled: "Your card is registered at {original_store}"
5. If new: creates customer + card, redirected to success page with card number
6. Can add to Google/Apple Wallet from success page

## Scheduled Tasks

| Task | Schedule | Logic |
|------|----------|-------|
| `loyalty.sync_wallet_passes` | Hourly | Re-sync cards that missed real-time wallet updates |
| `loyalty.expire_points` | Daily 02:00 | Find cards with `points_expiration_days` set and no activity within that window; create `POINTS_EXPIRED` transaction |

## Feature Gating

The loyalty module declares these billable features via `LoyaltyFeatureProvider`:

- `loyalty_stamps`, `loyalty_points`, `loyalty_hybrid`
- `loyalty_cards`, `loyalty_enrollment`, `loyalty_staff_pins`
- `loyalty_anti_fraud`, `loyalty_google_wallet`, `loyalty_apple_wallet`
- `loyalty_stats`, `loyalty_reports`

These integrate with the [billing module's feature gating system](../billing/feature-gating.md) to control access based on subscription tier.
