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