Connect the fully-implemented Google Wallet service to the loyalty module: - Create wallet class/object on customer enrollment - Sync wallet passes on stamp and points operations - Expose wallet URLs in storefront API responses - Add conditional "Add to Google Wallet" buttons on dashboard and enroll-success pages - Use platform-wide env var config (not per-merchant DB column) - Add Google service account patterns to .gitignore - Add LOYALTY_GOOGLE_* fields to app Settings - Update deployment docs and add local testing guide Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
322 lines
11 KiB
Markdown
322 lines
11 KiB
Markdown
# Loyalty Module
|
|
|
|
The Loyalty Module provides stamp-based and points-based loyalty programs for Orion stores with Google Wallet and Apple Wallet integration.
|
|
|
|
## Overview
|
|
|
|
| Aspect | Description |
|
|
|--------|-------------|
|
|
| Module Code | `loyalty` |
|
|
| Dependencies | `customers` |
|
|
| Status | Phase 2 Complete |
|
|
|
|
### Key Features
|
|
|
|
- **Stamp-based loyalty**: Collect N stamps, get a reward (e.g., "Buy 10 coffees, get 1 free")
|
|
- **Points-based loyalty**: Earn points per euro spent, redeem for rewards
|
|
- **Hybrid programs**: Support both stamps and points simultaneously
|
|
- **Anti-fraud system**: Staff PINs, cooldown periods, daily limits, lockout protection
|
|
- **Wallet integration**: Google Wallet and Apple Wallet pass generation
|
|
- **Full audit trail**: Transaction logging with IP, user agent, and staff attribution
|
|
|
|
## Entity Model
|
|
|
|
```
|
|
┌─────────────────┐ ┌─────────────────┐
|
|
│ Merchant │───────│ LoyaltyProgram │
|
|
└─────────────────┘ 1:1 └─────────────────┘
|
|
│ │
|
|
▼ ┌────────┼──────────┐
|
|
┌──────────────────┐ │ │ │
|
|
│ MerchantLoyalty │ ▼ ▼ ▼
|
|
│ Settings │┌──────────┐┌──────────┐┌────────┐
|
|
└──────────────────┘│ StaffPin ││LoyaltyCard││(config)│
|
|
└──────────┘└──────────┘└────────┘
|
|
│ │
|
|
│ ▼
|
|
│ ┌──────────────┐
|
|
└──▶│ Transaction │
|
|
└──────────────┘
|
|
│
|
|
▼
|
|
┌──────────────────────┐
|
|
│AppleDeviceRegistration│
|
|
└──────────────────────┘
|
|
```
|
|
|
|
### Database Tables
|
|
|
|
| Table | Purpose |
|
|
|-------|---------|
|
|
| `loyalty_programs` | Merchant's program configuration (type, targets, branding) |
|
|
| `loyalty_cards` | Customer cards with stamp/point balances (merchant-scoped) |
|
|
| `loyalty_transactions` | Immutable audit log of all operations |
|
|
| `staff_pins` | Hashed PINs for fraud prevention |
|
|
| `apple_device_registrations` | Apple Wallet push notification tokens |
|
|
| `merchant_loyalty_settings` | Admin-controlled per-merchant settings |
|
|
|
|
## Configuration
|
|
|
|
Environment variables (prefix: `LOYALTY_`):
|
|
|
|
```bash
|
|
# Anti-fraud defaults
|
|
LOYALTY_DEFAULT_COOLDOWN_MINUTES=15
|
|
LOYALTY_MAX_DAILY_STAMPS=5
|
|
LOYALTY_PIN_MAX_FAILED_ATTEMPTS=5
|
|
LOYALTY_PIN_LOCKOUT_MINUTES=30
|
|
|
|
# Points
|
|
LOYALTY_DEFAULT_POINTS_PER_EURO=10
|
|
|
|
# Google Wallet
|
|
LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
|
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
|
|
|
|
# Apple Wallet
|
|
LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
|
LOYALTY_APPLE_TEAM_ID=ABCD1234
|
|
LOYALTY_APPLE_WWDR_CERT_PATH=/path/to/wwdr.pem
|
|
LOYALTY_APPLE_SIGNER_CERT_PATH=/path/to/signer.pem
|
|
LOYALTY_APPLE_SIGNER_KEY_PATH=/path/to/signer.key
|
|
```
|
|
|
|
## API Endpoints
|
|
|
|
### Store Endpoints (`/api/v1/store/loyalty/`)
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/program` | Get store's loyalty program |
|
|
| `POST` | `/program` | Create loyalty program |
|
|
| `PATCH` | `/program` | Update loyalty program |
|
|
| `GET` | `/stats` | Get program statistics |
|
|
| `GET` | `/cards` | List customer cards |
|
|
| `POST` | `/cards/enroll` | Enroll customer in program |
|
|
| `POST` | `/cards/lookup` | Look up card by QR/number |
|
|
| `POST` | `/stamp` | Add stamp to card |
|
|
| `POST` | `/stamp/redeem` | Redeem stamps for reward |
|
|
| `POST` | `/points` | Earn points from purchase |
|
|
| `POST` | `/points/redeem` | Redeem points for reward |
|
|
| `GET` | `/pins` | List staff PINs |
|
|
| `POST` | `/pins` | Create staff PIN |
|
|
| `PATCH` | `/pins/{id}` | Update staff PIN |
|
|
| `DELETE` | `/pins/{id}` | Delete staff PIN |
|
|
| `POST` | `/pins/{id}/unlock` | Unlock locked PIN |
|
|
|
|
### Admin Endpoints (`/api/v1/admin/loyalty/`)
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/programs` | List all loyalty programs |
|
|
| `GET` | `/programs/{id}` | Get specific program |
|
|
| `GET` | `/programs/{id}/stats` | Get program statistics |
|
|
| `GET` | `/stats` | Platform-wide statistics |
|
|
|
|
### Storefront Endpoints (`/api/v1/storefront/loyalty/`)
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/card` | Get customer's loyalty card and balance |
|
|
| `GET` | `/transactions` | Get customer's transaction history |
|
|
| `POST` | `/enroll` | Self-enrollment in loyalty program |
|
|
|
|
### Public Endpoints (`/api/v1/loyalty/`)
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/programs/{store_code}` | Get program info for enrollment |
|
|
| `GET` | `/passes/apple/{serial}.pkpass` | Download Apple Wallet pass |
|
|
| `POST` | `/apple/v1/devices/...` | Apple Web Service: register device |
|
|
| `DELETE` | `/apple/v1/devices/...` | Apple Web Service: unregister |
|
|
| `GET` | `/apple/v1/devices/...` | Apple Web Service: get updates |
|
|
| `GET` | `/apple/v1/passes/...` | Apple Web Service: get pass |
|
|
|
|
## Anti-Fraud System
|
|
|
|
### Staff PIN Verification
|
|
|
|
All stamp and points operations require staff PIN verification (configurable per program).
|
|
|
|
```python
|
|
# PIN is hashed with bcrypt
|
|
pin.set_pin("1234") # Stores bcrypt hash
|
|
pin.verify_pin("1234") # Returns True/False
|
|
```
|
|
|
|
### Lockout Protection
|
|
|
|
- **Max failed attempts**: 5 (configurable)
|
|
- **Lockout duration**: 30 minutes (configurable)
|
|
- After lockout expires, PIN can be used again
|
|
- Admin can manually unlock via API
|
|
|
|
### Cooldown Period
|
|
|
|
Prevents rapid stamp collection (fraud prevention):
|
|
|
|
```
|
|
Customer scans card → Gets stamp → Must wait 15 minutes → Can get next stamp
|
|
```
|
|
|
|
### Daily Limits
|
|
|
|
Maximum stamps per card per day (default: 5).
|
|
|
|
## Wallet Integration
|
|
|
|
### Google Wallet
|
|
|
|
Architecture: **Server-side storage with automatic API updates**
|
|
|
|
All wallet operations are triggered automatically — no manual API calls needed:
|
|
|
|
| Event | Wallet Action | Trigger |
|
|
|-------|---------------|---------|
|
|
| Customer enrolls | Create `LoyaltyClass` (first time) + `LoyaltyObject` | `card_service.enroll_customer()` → `wallet_service.create_wallet_objects()` |
|
|
| Stamp/points change | `PATCH` the object with new balance | `stamp_service`/`points_service` → `wallet_service.sync_card_to_wallets()` |
|
|
| Customer views dashboard | Generate JWT "Add to Wallet" URL (1h expiry) | `GET /storefront/loyalty/card` → `wallet_service.get_add_to_wallet_urls()` |
|
|
|
|
**Setup:** Configure `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` env vars. This is a platform-wide setting — all merchants automatically get Google Wallet support. See [Google Wallet Setup](../deployment/hetzner-server-setup.md#step-25-google-wallet-integration) for full instructions.
|
|
|
|
No device registration needed — Google syncs automatically.
|
|
|
|
### Apple Wallet
|
|
|
|
Architecture: **Push notification model**
|
|
|
|
1. Customer adds pass → Device registers with our server
|
|
2. Stamp/points change → Send push notification to APNs
|
|
3. Device receives push → Fetches updated pass from our server
|
|
|
|
Requires `apple_device_registrations` table for push tokens.
|
|
|
|
## Usage Examples
|
|
|
|
### Create a Loyalty Program
|
|
|
|
```python
|
|
from app.modules.loyalty.services import program_service
|
|
from app.modules.loyalty.schemas import ProgramCreate
|
|
|
|
data = ProgramCreate(
|
|
loyalty_type="stamps",
|
|
stamps_target=10,
|
|
stamps_reward_description="Free coffee",
|
|
cooldown_minutes=15,
|
|
max_daily_stamps=5,
|
|
require_staff_pin=True,
|
|
card_color="#4F46E5",
|
|
)
|
|
|
|
program = program_service.create_program(db, store_id=1, data=data)
|
|
```
|
|
|
|
### Enroll a Customer
|
|
|
|
```python
|
|
from app.modules.loyalty.services import card_service
|
|
|
|
card = card_service.enroll_customer(db, customer_id=123, store_id=1)
|
|
# Returns LoyaltyCard with unique card_number and qr_code_data
|
|
```
|
|
|
|
### Add a Stamp
|
|
|
|
```python
|
|
from app.modules.loyalty.services import stamp_service
|
|
|
|
result = stamp_service.add_stamp(
|
|
db,
|
|
qr_code="abc123xyz",
|
|
staff_pin="1234",
|
|
ip_address="192.168.1.1",
|
|
)
|
|
# Returns dict with stamp_count, reward_earned, next_stamp_available_at, etc.
|
|
```
|
|
|
|
### Earn Points from Purchase
|
|
|
|
```python
|
|
from app.modules.loyalty.services import points_service
|
|
|
|
result = points_service.earn_points(
|
|
db,
|
|
card_number="123456789012",
|
|
purchase_amount_cents=2500, # €25.00
|
|
order_reference="ORD-12345",
|
|
staff_pin="1234",
|
|
)
|
|
# Returns dict with points_earned (250 at 10pts/€), points_balance, etc.
|
|
```
|
|
|
|
## Services
|
|
|
|
| Service | Purpose |
|
|
|---------|---------|
|
|
| `program_service` | Program CRUD and statistics |
|
|
| `card_service` | Card enrollment, lookup, management |
|
|
| `stamp_service` | Stamp operations with anti-fraud |
|
|
| `points_service` | Points operations and redemption |
|
|
| `pin_service` | Staff PIN CRUD and verification |
|
|
| `wallet_service` | Unified wallet abstraction |
|
|
| `google_wallet_service` | Google Wallet API integration |
|
|
| `apple_wallet_service` | Apple Wallet pass generation |
|
|
|
|
## Scheduled Tasks
|
|
|
|
| Task | Schedule | Description |
|
|
|------|----------|-------------|
|
|
| `loyalty.sync_wallet_passes` | Hourly | Sync cards that missed real-time updates |
|
|
| `loyalty.expire_points` | Daily 02:00 | Expire points for inactive cards (based on `points_expiration_days`) |
|
|
|
|
## UI Pages
|
|
|
|
### Admin Pages
|
|
|
|
| Page | Path | Description |
|
|
|------|------|-------------|
|
|
| Programs Dashboard | `/admin/loyalty/programs` | List all loyalty programs with stats |
|
|
| Merchant Detail | `/admin/loyalty/merchants/{id}` | Detailed view of a merchant's program |
|
|
| Merchant Settings | `/admin/loyalty/merchants/{id}/settings` | Admin-controlled merchant settings |
|
|
| Analytics | `/admin/loyalty/analytics` | Platform-wide analytics |
|
|
|
|
### Store Pages
|
|
|
|
| Page | Path | Description |
|
|
|------|------|-------------|
|
|
| Terminal | `/store/loyalty/terminal` | Scan card, add stamps/points, redeem |
|
|
| Cards List | `/store/loyalty/cards` | Browse customer cards |
|
|
| Card Detail | `/store/loyalty/cards/{id}` | Individual card detail |
|
|
| Enroll | `/store/loyalty/enroll` | Enroll new customer |
|
|
| Settings | `/store/loyalty/settings` | Program settings |
|
|
| Stats | `/store/loyalty/stats` | Store-level statistics |
|
|
|
|
### Storefront Pages
|
|
|
|
| Page | Path | Description |
|
|
|------|------|-------------|
|
|
| Dashboard | `/loyalty/dashboard` | Customer's card and balance |
|
|
| History | `/loyalty/history` | Transaction history |
|
|
| Enroll | `/loyalty/enroll` | Self-enrollment page |
|
|
|
|
## Localization
|
|
|
|
Available in 5 languages:
|
|
- English (`en.json`)
|
|
- French (`fr.json`)
|
|
- German (`de.json`)
|
|
- Luxembourgish (`lu.json`, `lb.json`)
|
|
|
|
## Future Enhancements (Phase 3+)
|
|
|
|
- Rewards catalog with configurable tiers
|
|
- Customer tiers (Bronze/Silver/Gold)
|
|
- Promotions engine (bonus points, discounts, free items)
|
|
- Referral program
|
|
- Gamification (spin wheel, scratch cards)
|
|
- POS integration
|
|
- Batch import of existing loyalty cards
|
|
- Real-time WebSocket updates
|
|
- Receipt printing
|