Replace the old effort/critical-path sections with current status: all dev phases 0-8 marked DONE with dates. Added a clear 8-step pre-launch checklist (seed templates, deploy wallet certs, migrations, translations, permissions, E2E testing, device test, go live) and a post-launch roadmap table (Apple Wallet, marketing module, coverage, trash UI, bulk PINs, cross-location enforcement). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
20 KiB
Loyalty Module — Production Launch Plan
Created: 2026-04-09 Owner: Samir Status: Planning locked, awaiting Phase 1 kickoff
This is the active execution plan for taking the Loyalty module to production. It complements the earlier Production Readiness review by sequencing the work into phases and recording the decisions made.
Locked Decisions
| # | Topic | Decision |
|---|---|---|
| 1 | Apple Wallet | Deferred to Phase 9 (post-launch). Google Wallet only at launch. |
| 2 | Secrets path | Google service account JSON lives at ~/apps/orion/google-wallet-sa.json in production (app user, mode 600). Different from Caddy TLS certs at /etc/caddy/certs/. |
| 3 | Notification channels | Email only. No SMS at launch. |
| 4 | Notification scope | Enrollment, welcome bonus, points-expiring (14d warning), points-expired, reward-ready, birthday, re-engagement (inactive). 7 templates × 4 locales = 28 seed rows. |
| 5 | T&C strategy | CMS integration scoped to the program's owning store. New terms_cms_page_slug column on loyalty_programs. Legacy terms_text kept one release as fallback. |
| 6 | Coverage target | 80% on app/modules/loyalty/* (enforced in CI for loyalty job only). |
| 7 | GDPR deletion | Anonymize (null customer_id, scrub PII fields, keep aggregate transactions). |
| 8 | Bulk actions | Primary endpoints on merchant router; admin "act on behalf of" endpoints under admin router accept merchant_id and stamp acting_admin_id in audit log. |
Verified Reuse (no new infra)
| Need | Existing component |
|---|---|
app/modules/messaging/services/email_service.py — multi-provider, DB templates, 4 locales, EmailLog. SMTP enabled. |
|
| Background tasks | Celery (@shared_task, beat schedule in app/modules/loyalty/definition.py). |
| Rate limiting | middleware/decorators.py @rate_limit decorator (already used in routes/api/storefront.py). |
| Migrations | Alembic loyalty branch (loyalty_001, loyalty_002). |
| pytest-cov | Wired in pyproject.toml; currently --cov-fail-under=0 (disabled). |
Migration Chain
loyalty_002 (existing)
loyalty_003 — CHECK constraints (points_balance >= 0, stamp_count >= 0, etc.)
loyalty_004 — relax card uniqueness: replace (merchant_id, customer_id) unique index
with (enrolled_at_store_id, customer_id) for cross-location support
loyalty_005 — seed 28 notification email templates
loyalty_006 — add columns: last_expiration_warning_at, last_reengagement_at on cards;
acting_admin_id on transactions
loyalty_007 — terms_cms_page_slug on programs
customers_003 — birth_date on customers (Phase 1.4 fix, dropped data bug)
Phases
Phase 0 — Decisions & prerequisites (done)
All 8 decisions locked. No external blockers.
Phase 1 — Config, Secrets & Security Hardening (✅ DONE 2026-04-09)
1.1 Google Wallet config cleanup
- Move
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSONfrom dev path to~/apps/orion/google-wallet-sa.jsonin prod.env. - Add Pydantic
@field_validatorinapp/modules/loyalty/config.py:39-43that checks file exists and is readable whengoogle_issuer_idis set. Fail fast at import time if not. - Validation: unit test using
tmp_path; manual: missing file → clear startup error.
1.2 Apple Wallet timing-safe token compare (pull-forward from Phase 9)
- Replace
!=withhmac.compare_digest()atapp/modules/loyalty/services/apple_wallet_service.py:98. - 15 min, code is safe whenever Apple ships.
- Validation: unit test with wrong token.
1.3 Rate-limit store mutating endpoints
- Apply
@rate_limittoroutes/api/store.py:600-795:POST /stamp60/minPOST /stamp/redeem30/minPOST /stamp/void20/minPOST /points/earn60/minPOST /points/redeem30/minPOST /points/void20/minPOST /cards/{card_id}/points/adjust20/minPOST /pins/{pin_id}/unlock10/min
- Caps confirmed.
- Validation: httpx loop exceeds limit → 429.
1.4 Fix dropped birthday data (P0 bug)
Background: The enrollment form (enroll.html:87) collects a birthday, the schema accepts customer_birthday (schemas/card.py:34), and card_service.resolve_customer_id has the parameter (card_service.py:172) — but the call to customer_service.create_customer_for_enrollment at card_service.py:215-222 does not pass it, and the Customer model has no birth_date column at all. Every enrollment silently loses the birthday. Not live yet, so no backfill needed.
- Migration
customers_003_add_birth_date.pyaddsbirth_date: Date | Nonetocustomers. - Update
customer_service.create_customer_for_enrollmentto accept and persistbirth_date. - Update
card_service.py:215-222to passcustomer_birthdaythrough, withdate.fromisoformat()parsing and validation (must be a real past date, sensible age range). - Update
customer_service.update_customerto allow backfill. - Add unit + integration tests for enrollment-with-birthday and enrollment-without-birthday paths.
- Validation: enroll with birthday → row in
customershasbirth_dateset; enrollment without birthday still works.
1.5 DB CHECK constraints migration loyalty_003
- Add:
loyalty_cards.points_balance >= 0,loyalty_cards.stamp_count >= 0,loyalty_programs.minimum_purchase_cents >= 0,loyalty_programs.points_per_euro >= 0,loyalty_programs.stamps_target >= 1,loyalty_programs.welcome_bonus_points >= 0. - Mirror as
CheckConstraintin models. - Pre-flight: scan dev DB for existing violations; include data fix in
upgrade()if found. - Validation: migration runs clean; direct UPDATE to negative value fails.
Phase 2A — Notifications Infrastructure (✅ DONE 2026-04-11)
2.1 LoyaltyNotificationService
- New
app/modules/loyalty/services/notification_service.pywith methods:send_enrollment_confirmation(db, card)send_welcome_bonus(db, card, points)(may merge into enrollment)send_points_expiration_warning(db, card, days_before, expiring_points, expiration_date)send_points_expired(db, card, expired_points)send_reward_available(db, card, reward_description)send_birthday_message(db, card)send_reengagement(db, card, days_inactive)
- All methods enqueue via Celery (2.2), never call EmailService inline.
2.2 Celery dispatch task
- New
app/modules/loyalty/tasks/notifications.pywith@shared_task(name="loyalty.send_notification_email", bind=True, max_retries=3, default_retry_delay=60). - Opens fresh
SessionLocal, callsEmailService(db).send_template(...), retries on SMTP errors.
2.3 Seed templates loyalty_005
- 7 templates × 4 locales (en, fr, de, lb) = 28 rows in
email_templates. - Template codes:
loyalty_enrollment,loyalty_welcome_bonus,loyalty_points_expiring,loyalty_points_expired,loyalty_reward_ready,loyalty_birthday,loyalty_reengagement. - Copywriting needs sign-off before applying to prod.
2.4 Wire enrollment confirmation
- In
card_service.enroll_customer_for_store(~lines 480-540), call notification service afterdb.commit().
2.5 Wire expiration warning into expiration task
- Migration
loyalty_006addslast_expiration_warning_atto prevent duplicates. - In rewritten
tasks/point_expiration.py(see 3.1), find cards 14 days from expiry, fire warning, stamp timestamp. - Validation: time-mocked test — fires once at 14-day mark.
2.6 Wire reward-ready notification
- In
services/stamp_service.add_stamp, after commit, ifcard.stamp_count == program.stamps_target, dispatch.
2.7 Birthday Celery beat task
- New
@shared_task loyalty.send_birthday_emails, daily0 8 * * *indefinition.py. - Queries cards where
customer.birth_datematches today (month/day), respects timezone. - Depends on Phase 1.4 (column + persistence already in place).
2.8 Re-engagement Celery beat task
- Weekly schedule. Finds cards inactive > N days (default 60, configurable).
- Throttled via
last_reengagement_at(added inloyalty_006) — once per quarter per card.
Phase 3 — Task Reliability (✅ DONE 2026-04-11)
3.1 Batched point expiration
- Rewrite
tasks/point_expiration.py:154-185from per-card loop to set-based SQL:- Per program, chunk card IDs with
LIMIT 500 FOR UPDATE SKIP LOCKED - Build expiration
loyalty_transactionsrows in bulk per chunk UPDATE loyalty_cards SET points_balance=0, total_points_voided=total_points_voided+points_balance WHERE id = ANY(...)- Commit per chunk inside savepoint
- Per program, chunk card IDs with
- Done same pass as 2.5 (warning logic).
- Validation: seed 10k cards, time the run, verify flat memory and chunked commits.
3.2 Wallet sync exponential backoff
- Replace
time.sleep(2)retry attasks/wallet_sync.py:71-95with exponential backoff[1s, 4s, 16s]. - Per-card try/except (one bad card doesn't kill the batch).
- Log
failed_card_idsto observability. - Use
tenacityif available, otherwise small helper.
Phase 4 — Accessibility & T&C (✅ DONE 2026-04-11)
4.1 T&C via store CMS integration
- Migration
loyalty_007: addterms_cms_page_slug: str | Nonetoloyalty_programs. - Update
schemas/program.py(ProgramCreate,ProgramUpdate). program-form.html:251— CMS page picker scoped to the program's owning store.enroll.html:99-160— resolve slug to CMS page URL/content; legacyterms_textfallback.- Service layer:
program_service.py:491,948. - JS:
loyalty-program-form.js:34,75,106. - Pre-flight: confirm CMS module exposes "get pages by store" API.
4.2 Accessibility audit
- All loyalty templates: icon-only buttons →
aria-label; modals →role="dialog"+aria-modal="true"+aria-labelledby+ focus trap + Escape key + return-focus-on-close. - PIN entry modal:
inputmode="numeric",autocomplete="one-time-code",aria-live="polite"for errors. - Pattern reference: existing Escape handler in
enroll.html:147. - Validation: axe-core on the 5 main loyalty pages, zero critical violations.
Phase 5 — Google Wallet Production Hardening (✅ UI done 2026-04-11, deploy is manual)
5.1 Cert deployment
- Place service account JSON at
~/apps/orion/google-wallet-sa.json, app user, mode 600. .envline 196 updated.- Startup validators from 1.1 confirm load.
5.2 Conditional UI flag
- Storefront API returns
google_wallet_enabled: bool(derived fromconfig.is_google_wallet_enabled). enroll.html:99-125: hide Google button when disabled.- Apple Wallet button removed entirely (or hidden behind feature flag for Phase 9 reintroduction).
5.3 End-to-end real-device test
- Android device → enroll → "Add to Google Wallet" → verify pass renders → trigger stamp → verify pass auto-updates within ~60s.
Phase 6 — Admin UX, GDPR, Bulk (✅ DONE 2026-04-11)
6.1 Admin trash UI
- Trash tab on programs list and cards list, calling existing
?only_deleted=trueAPI. - Restore button per row + hard-delete destructive action.
- Templates:
templates/loyalty/admin/programs.html,cards.html+ Alpine JS.
6.2 Cascade restore
program_service.restore_for_merchant(db, merchant_id)— restores soft-deleted programs and cards under a merchant.- Hooked into tenancy module's restore flow via event subscriber or direct call.
- Validation: soft-delete merchant + 2 programs + 50 cards; restore merchant; all 52 rows un-deleted.
6.3 Customer GDPR delete (anonymize)
DELETE /api/v1/loyalty/cards/customer/{customer_id}on admin router.- New service method
card_service.anonymize_cards_for_customer:card.customer_id = NULL- Scrub PII fields, scrub
notesif PII suspected - Keep transaction rows (aggregate data)
- Audit log entry with admin ID
6.4 Bulk endpoints
- Merchant router (
routes/api/merchant.py):POST /merchants/loyalty/cards/bulk/deactivate—{card_ids, reason}POST /merchants/loyalty/pins/bulk/assign—{store_id, pins}POST /merchants/loyalty/cards/bulk/bonus—{card_ids | filter, points, reason}, fires notifications
- Admin "act on behalf" (
routes/api/admin.py):POST /admin/loyalty/merchants/{merchant_id}/cards/bulk/deactivate(etc.)- Shared service layer; route stamps
acting_admin_idin audit log
- New
loyalty_transactions.acting_admin_idcolumn inloyalty_006.
6.5 Manual override: restore expired points
POST /admin/loyalty/cards/{card_id}/restore_pointswith{points, reason}.- Creates
ADMIN_ADJUSTMENTtransaction via existingpoints_service.adjust_points. - UI button on admin card detail page.
Phase 7 — Advanced Analytics (✅ DONE 2026-04-11)
7.1 Cohort retention
- New
services/analytics_service.py(or extendprogram_service). - Group cards by enrollment month, track % active (transaction in last 30d) per subsequent month.
- Endpoint
/merchants/loyalty/analytics/cohortsreturns matrix. - Chart.js heatmap on
analytics.html.
7.2 Simple churn rule
- "At risk" = inactive > 2× normal inter-transaction interval.
- Expose count + list.
7.3 Revenue attribution
- Join
loyalty_transactions(wherepoints_earned.order_referenceset) with orders module. - Loyalty-customer revenue vs non-loyalty per store per month.
- Cross-module rule: use orders module's query helper, do not reach into its tables directly.
Phase 8 — Tests, Docs, Observability (✅ DONE 2026-04-11)
8.1 Coverage enforcement
- Loyalty CI job:
pytest app/modules/loyalty/tests --cov=app/modules/loyalty --cov-fail-under=80. - Audit current with
--cov-report=term-missing, fill biggest gaps (notifications, batched expiration, rate-limited endpoints).
8.2 OpenAPI polish
- Audit all loyalty routes for
summary,description,response_model, example payloads,tags=["Loyalty - Store"]etc. - Export static
loyalty-openapi.jsonunderapp/modules/loyalty/docs/.
8.3 Runbooks
Three new docs under docs/modules/loyalty/:
runbook-wallet-certs.md— obtain, rotate, install, expiry monitoring, rollback.runbook-expiration-task.md— re-run for specific merchant, partial failure handling, link to 6.5 manual restore.runbook-wallet-sync.md— interpretingfailed_card_ids, manual re-sync, common failures.
8.4 Monitoring & alerting
- Celery
loyalty.expire_pointsnot succeeded in 26h → page loyalty.sync_wallet_passesfailure rate > 5% → warn- Loyalty email failure rate > 1% → warn
- Google Wallet service account JSON cert expiry < 30d → warn
- Rate-limit 429s per store > 100/min → investigate
- Wire into
app/core/observability.py(or whatever stack is present).
Phase 9 — Apple Wallet (post-launch, 3d)
Tracked separately, not blocking launch.
- 9.1 Apple Developer account + Pass Type ID + WWDR cert
- 9.2 Apple config wiring + all-or-nothing validators in
config.py:33-37 - 9.3 Cert deployment under
~/apps/orion/apple-wallet/(matching the Google secrets convention) - 9.4 (already done in 1.2 — timing-safe compare)
- 9.5 UI feature flag flip + Apple button re-enabled
- 9.6 iOS Simulator + real device E2E test
Development Status (as of 2026-04-16)
All development phases (0-8) are COMPLETE. 342 automated tests pass.
| Phase | Status | Completed |
|---|---|---|
| Phase 0 — Decisions | ✅ Done | 2026-04-09 |
| Phase 1 — Config & Security | ✅ Done | 2026-04-09 |
| Phase 1.x — Cross-store enrollment fix | ✅ Done | 2026-04-10 |
| Phase 2A — Transactional notifications (5 templates) | ✅ Done | 2026-04-11 |
| Phase 3 — Task reliability (batched expiration + wallet backoff) | ✅ Done | 2026-04-11 |
| Phase 4.1 — T&C via CMS | ✅ Done | 2026-04-11 |
| Phase 4.2 — Accessibility audit | ✅ Done | 2026-04-11 |
| Phase 5 — Wallet UI flags | ✅ Done (already handled) | 2026-04-11 |
| Phase 6 — GDPR, bulk ops, point restore, cascade restore | ✅ Done | 2026-04-11 |
| Phase 7 — Analytics (cohort, churn, revenue + Chart.js) | ✅ Done | 2026-04-11 |
| Phase 8 — Runbooks, monitoring docs, OpenAPI tags | ✅ Done | 2026-04-11 |
Additional bugfixes during manual testing (2026-04-15):
- Terminal redeem:
card_id→idnormalization across schemas/JS - Card detail: enrolled store name resolution, copy buttons, paginated transactions
- i18n flicker: server-rendered translations on success page
- Icon fix:
device-mobile→phone
Pre-Launch Checklist
Everything below must be completed before going live. Items are ordered by dependency.
Step 1: Seed email templates on prod DB
- SSH into prod server
- Run:
python scripts/seed/seed_email_templates_loyalty.py - Verify: 20 rows created (5 templates × 4 locales)
- Review EN email copy — adjust subject lines/body if needed via admin UI at
/admin/email-templates
Step 2: Deploy Google Wallet service account
- Place service account JSON at
~/apps/orion/google-wallet-sa.json(app user, mode 600) - Set
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.jsonin prod.env - Set
LOYALTY_GOOGLE_ISSUER_ID=<your issuer ID>in prod.env - Restart app — verify no startup error (validator checks file exists)
- Verify:
GET /api/v1/admin/loyalty/wallet-statusreturnsgoogle_configured: true
Step 3: Apply database migrations
- Run:
alembic upgrade heads - Verify migrations applied:
loyalty_003throughloyalty_006,customers_003
Step 4: FR/DE/LB translations for new analytics i18n keys
- Add translations for 7 keys in
app/modules/loyalty/locales/{fr,de,lb}.json:store.analytics.revenue_titlestore.analytics.at_risk_titlestore.analytics.cards_at_riskstore.analytics.no_at_riskstore.analytics.cohort_titlestore.analytics.cohort_monthstore.analytics.cohort_enrolledstore.analytics.no_data_yet
Step 5: Investigate email template menu visibility
- Check if
messaging.manage_templatespermission is assigned tomerchant_ownerrole - If not, add it to permission discovery or default role assignments
- Verify menu appears at
/store/{store_code}/email-templates - Verify admin menu at
/admin/email-templatesshows loyalty templates
Step 6: Manual E2E testing (user journeys)
Follow the Pre-Launch E2E Test Checklist at the bottom of user-journeys.md:
- Test 1: Customer self-enrollment (with birthday)
- Test 2: Cross-store re-enrollment (cross-location enabled)
- Test 3: Staff operations — stamps/points via terminal
- Test 4: Cross-store redemption (earn at store1, redeem at store2)
- Test 5: Customer views dashboard + transaction history
- Test 6: Void/return flow
- Test 7: Admin oversight (programs, merchants, analytics)
- Test 8: Cross-location disabled behavior (separate cards per store)
Step 7: Google Wallet real-device test
- Enroll a test customer on prod
- Tap "Add to Google Wallet" on success page
- Open Google Wallet on Android device — verify pass renders
- Trigger a stamp/points transaction — verify pass auto-updates within 60s
Step 8: Go live
- Remove any test data from prod DB (test customers, test cards)
- Verify Celery workers are running (
loyalty.expire_points,loyalty.sync_wallet_passes) - Verify SMTP is configured and test email sends work
- Enable the loyalty platform for production stores
- Monitor first 24h: check email logs, wallet sync, expiration task
Post-Launch Roadmap
| Item | Priority | Effort | Notes |
|---|---|---|---|
| Phase 9 — Apple Wallet | P1 | 3d | Requires Apple Developer certs. See runbook-wallet-certs.md. |
| Phase 2B — Marketing module | P2 | 4d | Birthday + re-engagement emails. Cross-platform (OMS, loyalty, hosting). |
| Coverage to 80% | P2 | 2d | Needs Celery task mocking infrastructure for task-level tests. |
| Admin trash UI | P3 | 2d | Trash tab on programs/cards pages using existing ?only_deleted=true API. The cascade restore API exists but has no UI. |
| Bulk PIN assignment | P3 | 1d | Batch create staff PINs. API exists for single PIN; needs bulk endpoint + UI. |
| Cross-location enforcement | P3 | 2d | allow_cross_location_redemption controls enrollment behavior but stamp/point operations don't enforce it yet. |
| Email template menu | P2 | 0.5d | Investigate and fix messaging.manage_templates permission for store owners. |