fix(loyalty): use Code 128 barcode for retail scanner compatibility

Switch wallet pass barcodes from QR to Code 128 format using the
card_number (digits only), so standard retail barcode scanners can
read loyalty cards. Apple Wallet keeps QR as fallback in barcodes
array. Also fix stale Vendor.loyalty_program relationship (now
company-based), add parent init calls in vendor JS components,
and update module docs to reflect Phase 2 completion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 18:55:20 +01:00
parent df784d718b
commit 74bbf84702
10 changed files with 116 additions and 39 deletions

View File

@@ -125,16 +125,23 @@ class AppleWalletService:
"authenticationToken": card.apple_auth_token, "authenticationToken": card.apple_auth_token,
"webServiceURL": self._get_web_service_url(), "webServiceURL": self._get_web_service_url(),
"barcode": { "barcode": {
"message": card.qr_code_data, "message": card.card_number.replace("-", ""),
"format": "PKBarcodeFormatQR", "format": "PKBarcodeFormatCode128",
"messageEncoding": "iso-8859-1", "messageEncoding": "iso-8859-1",
"altText": card.card_number,
}, },
"barcodes": [ "barcodes": [
{
"message": card.card_number.replace("-", ""),
"format": "PKBarcodeFormatCode128",
"messageEncoding": "iso-8859-1",
"altText": card.card_number,
},
{ {
"message": card.qr_code_data, "message": card.qr_code_data,
"format": "PKBarcodeFormatQR", "format": "PKBarcodeFormatQR",
"messageEncoding": "iso-8859-1", "messageEncoding": "iso-8859-1",
} },
], ],
} }

View File

@@ -275,8 +275,9 @@ class GoogleWalletService:
"accountId": card.card_number, "accountId": card.card_number,
"accountName": card.card_number, "accountName": card.card_number,
"barcode": { "barcode": {
"type": "QR_CODE", "type": "CODE_128",
"value": card.qr_code_data, "value": card.card_number.replace("-", ""),
"alternateText": card.card_number,
}, },
} }

View File

@@ -20,6 +20,12 @@ function vendorLoyaltyCardDetail() {
if (window._loyaltyCardDetailInitialized) return; if (window._loyaltyCardDetailInitialized) return;
window._loyaltyCardDetailInitialized = true; window._loyaltyCardDetailInitialized = true;
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Extract card ID from URL // Extract card ID from URL
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
const cardsIndex = pathParts.indexOf('cards'); const cardsIndex = pathParts.indexOf('cards');

View File

@@ -41,6 +41,12 @@ function vendorLoyaltyCards() {
if (window._loyaltyCardsInitialized) return; if (window._loyaltyCardsInitialized) return;
window._loyaltyCardsInitialized = true; window._loyaltyCardsInitialized = true;
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
if (window.PlatformSettings) { if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} }

View File

@@ -29,6 +29,12 @@ function vendorLoyaltyEnroll() {
if (window._loyaltyEnrollInitialized) return; if (window._loyaltyEnrollInitialized) return;
window._loyaltyEnrollInitialized = true; window._loyaltyEnrollInitialized = true;
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadProgram(); await this.loadProgram();
loyaltyEnrollLog.info('=== LOYALTY ENROLL PAGE INITIALIZATION COMPLETE ==='); loyaltyEnrollLog.info('=== LOYALTY ENROLL PAGE INITIALIZATION COMPLETE ===');
}, },

View File

@@ -30,6 +30,12 @@ function vendorLoyaltySettings() {
if (window._loyaltySettingsInitialized) return; if (window._loyaltySettingsInitialized) return;
window._loyaltySettingsInitialized = true; window._loyaltySettingsInitialized = true;
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadSettings(); await this.loadSettings();
loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ==='); loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
}, },

View File

@@ -29,6 +29,12 @@ function vendorLoyaltyStats() {
if (window._loyaltyStatsInitialized) return; if (window._loyaltyStatsInitialized) return;
window._loyaltyStatsInitialized = true; window._loyaltyStatsInitialized = true;
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadStats(); await this.loadStats();
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZATION COMPLETE ==='); loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZATION COMPLETE ===');
}, },

View File

@@ -52,6 +52,12 @@ function vendorLoyaltyTerminal() {
} }
window._loyaltyTerminalInitialized = true; window._loyaltyTerminalInitialized = true;
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadData(); await this.loadData();
loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZATION COMPLETE ==='); loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZATION COMPLETE ===');

View File

@@ -253,14 +253,6 @@ class Vendor(Base, TimestampMixin):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# Loyalty program (one-to-one)
loyalty_program = relationship(
"LoyaltyProgram",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self): def __repr__(self):
"""String representation of the Vendor object.""" """String representation of the Vendor object."""
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>" return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"

View File

@@ -8,7 +8,7 @@ The Loyalty Module provides stamp-based and points-based loyalty programs for Wi
|--------|-------------| |--------|-------------|
| Module Code | `loyalty` | | Module Code | `loyalty` |
| Dependencies | `customers` | | Dependencies | `customers` |
| Status | Phase 1 MVP Complete | | Status | Phase 2 Complete |
### Key Features ### Key Features
@@ -23,36 +23,37 @@ The Loyalty Module provides stamp-based and points-based loyalty programs for Wi
``` ```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
Vendor │───────│ LoyaltyProgram │ Company │───────│ LoyaltyProgram │
└─────────────────┘ 1:1 └─────────────────┘ └─────────────────┘ 1:1 └─────────────────┘
┌───────────┼───────────┐ ┌──────────────────┐
┌──────────────────┐
CompanyLoyalty │ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ Settings ┌──────────┐┌──────────┐────────┐
│ StaffPin │ │LoyaltyCard│(config)│ └──────────────────┘│ StaffPin ││LoyaltyCard│(config)│
└──────────┘ └──────────┘ └──────────┘ └──────────┘└──────────┘└────────┘
│ │
│ ▼
┌──────────────┐ │ ┌──────────────┐
──▶│ Transaction │ └──▶│ Transaction │
└──────────────┘ └──────────────┘
┌──────────────────────┐ ┌──────────────────────┐
│AppleDeviceRegistration│ │AppleDeviceRegistration│
└──────────────────────┘ └──────────────────────┘
``` ```
### Database Tables ### Database Tables
| Table | Purpose | | Table | Purpose |
|-------|---------| |-------|---------|
| `loyalty_programs` | Vendor's program configuration (type, targets, branding) | | `loyalty_programs` | Company's program configuration (type, targets, branding) |
| `loyalty_cards` | Customer cards with stamp/point balances | | `loyalty_cards` | Customer cards with stamp/point balances (company-scoped) |
| `loyalty_transactions` | Immutable audit log of all operations | | `loyalty_transactions` | Immutable audit log of all operations |
| `staff_pins` | Hashed PINs for fraud prevention | | `staff_pins` | Hashed PINs for fraud prevention |
| `apple_device_registrations` | Apple Wallet push notification tokens | | `apple_device_registrations` | Apple Wallet push notification tokens |
| `company_loyalty_settings` | Admin-controlled per-company settings |
## Configuration ## Configuration
@@ -112,6 +113,14 @@ LOYALTY_APPLE_SIGNER_KEY_PATH=/path/to/signer.key
| `GET` | `/programs/{id}/stats` | Get program statistics | | `GET` | `/programs/{id}/stats` | Get program statistics |
| `GET` | `/stats` | Platform-wide 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/`) ### Public Endpoints (`/api/v1/loyalty/`)
| Method | Endpoint | Description | | Method | Endpoint | Description |
@@ -254,22 +263,54 @@ result = points_service.earn_points(
| Task | Schedule | Description | | Task | Schedule | Description |
|------|----------|-------------| |------|----------|-------------|
| `loyalty.sync_wallet_passes` | Hourly | Sync cards that missed real-time updates | | `loyalty.sync_wallet_passes` | Hourly | Sync cards that missed real-time updates |
| `loyalty.expire_points` | Daily 02:00 | Expire old points (future enhancement) | | `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 |
| Company Detail | `/admin/loyalty/companies/{id}` | Detailed view of a company's program |
| Company Settings | `/admin/loyalty/companies/{id}/settings` | Admin-controlled company settings |
| Analytics | `/admin/loyalty/analytics` | Platform-wide analytics |
### Vendor Pages
| Page | Path | Description |
|------|------|-------------|
| Terminal | `/vendor/loyalty/terminal` | Scan card, add stamps/points, redeem |
| Cards List | `/vendor/loyalty/cards` | Browse customer cards |
| Card Detail | `/vendor/loyalty/cards/{id}` | Individual card detail |
| Enroll | `/vendor/loyalty/enroll` | Enroll new customer |
| Settings | `/vendor/loyalty/settings` | Program settings |
| Stats | `/vendor/loyalty/stats` | Vendor-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 ## Localization
Available in 4 languages: Available in 5 languages:
- English (`en.json`) - English (`en.json`)
- French (`fr.json`) - French (`fr.json`)
- German (`de.json`) - German (`de.json`)
- Luxembourgish (`lu.json`) - Luxembourgish (`lu.json`, `lb.json`)
## Future Enhancements (Phase 2) ## Future Enhancements (Phase 3+)
- Rewards catalog with configurable tiers - Rewards catalog with configurable tiers
- Customer tiers (Bronze/Silver/Gold) - Customer tiers (Bronze/Silver/Gold)
- Promotions engine (bonus points, discounts, free items)
- Referral program - Referral program
- Gamification (spin wheel, scratch cards) - Gamification (spin wheel, scratch cards)
- POS integration - POS integration
- Points expiration rules
- Batch import of existing loyalty cards - Batch import of existing loyalty cards
- Real-time WebSocket updates
- Receipt printing