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>
9.7 KiB
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:
- Staff enters 4-digit PIN on terminal
- System checks all active PINs for the program
- On match: records success, updates
last_used_at - On mismatch: increments
failed_attempts - 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_atagainst current time - Returns
next_stamp_availabletimestamp 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_EARNEDtransactions for the card - Returns
remaining_stamps_todayin response
Layer 4: Audit Trail
Every transaction records:
staff_pin_id— Which staff member verifiedstore_id— Which locationip_address— Client IP (iflog_ip_addressesenabled)user_agent— Client devicetransaction_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
LoyaltyClassper program - Contains program name, branding (logo, hero), rewards info
- Created when program is activated; updated when settings change
Object (Card-level):
- One
LoyaltyObjectper 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:
- Build
pass.jsonwith card data (stamps grid or points balance) - Add icon/logo/strip images
- Create
manifest.json(SHA256 of all files) - Sign manifest with PKCS#7 using certificates and private key
- Package as
.pkpassZIP file
Push Updates:
- When card balance changes, send APNs push to all registered devices
- Device receives push → requests updated pass from server
- Server generates fresh
.pkpasswith current balance
Device Registration (Apple Web Service protocol):
POST /v1/devices/{device}/registrations/{passType}/{serial}— Register deviceDELETE /v1/devices/{device}/registrations/{passType}/{serial}— Unregister deviceGET /v1/devices/{device}/registrations/{passType}— List passes for deviceGET /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_idit occurred at - The
enrolled_at_store_idfield 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:
- Enter customer email (and optional name)
- System resolves customer — checks the current store first, then searches across all stores under the merchant for an existing cardholder with the same email
- If the customer already has a card (per-merchant or per-store, depending on the cross-location setting), raises
LoyaltyCardAlreadyExistsException - Otherwise creates loyalty card with unique card number and QR code
- Creates
CARD_CREATEDtransaction - Awards welcome bonus points (if configured) via
WELCOME_BONUStransaction - Creates Google Wallet object and Apple Wallet serial
- Returns card details with "Add to Wallet" URLs
Self-Enrollment (Public)
Customer enrolls via public page (if allow_self_enrollment enabled):
- Customer visits
/loyalty/joinpage - Enters email, name, and optional birthday
- System resolves customer (cross-store lookup for existing cardholders under the same merchant)
- 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}"
- If new: creates customer + card, redirected to success page with card number
- 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_hybridloyalty_cards,loyalty_enrollment,loyalty_staff_pinsloyalty_anti_fraud,loyalty_google_wallet,loyalty_apple_walletloyalty_stats,loyalty_reports
These integrate with the billing module's feature gating system to control access based on subscription tier.