Files
orion/app/modules/loyalty/docs/business-logic.md
Samir Boulahtit f804ff8442
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
fix(loyalty): cross-store enrollment, card scoping, i18n flicker
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>
2026-04-11 18:28:19 +02:00

280 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.