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:
@@ -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",
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ===');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 ===');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 ===');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 ===');
|
||||||
|
|||||||
@@ -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}')>"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user