Move 39 documentation files from top-level docs/ into each module's docs/ folder, accessible via symlinks from docs/modules/. Create data-model.md files for 10 modules with full schema documentation. Replace originals with redirect stubs. Remove empty guide stubs. Modules migrated: tenancy, billing, loyalty, marketplace, orders, messaging, cms, catalog, inventory, hosting, prospecting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
265 lines
8.4 KiB
Markdown
265 lines
8.4 KiB
Markdown
# 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
|
||
|
||
When `allow_cross_location_redemption` is enabled in merchant settings:
|
||
|
||
- Cards are scoped to the **merchant** (not individual stores)
|
||
- 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
|
||
|
||
When disabled, stamp/point operations are restricted to the enrollment store.
|
||
|
||
## Enrollment Flow
|
||
|
||
### Store-Initiated Enrollment
|
||
|
||
Staff enrolls customer via terminal:
|
||
1. Enter customer email (and optional name)
|
||
2. System resolves or creates customer record
|
||
3. Creates loyalty card with unique card number and QR code
|
||
4. Creates `CARD_CREATED` transaction
|
||
5. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
|
||
6. Creates Google Wallet object and Apple Wallet serial
|
||
7. 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 and name
|
||
3. System creates customer + card
|
||
4. Redirected to success page with card number
|
||
5. 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.
|