Files
orion/app/modules/loyalty/docs/production-launch-plan.md
Samir Boulahtit 3ade1b9354
Some checks failed
CI / pytest (push) Failing after 2h31m6s
CI / validate (push) Successful in 29s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 13s
docs(loyalty): rewrite launch plan with step-by-step pre-launch checklist
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>
2026-04-16 22:34:57 +02:00

20 KiB
Raw Blame History

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
Email 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_JSON from dev path to ~/apps/orion/google-wallet-sa.json in prod .env.
  • Add Pydantic @field_validator in app/modules/loyalty/config.py:39-43 that checks file exists and is readable when google_issuer_id is 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 != with hmac.compare_digest() at app/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_limit to routes/api/store.py:600-795:
    • POST /stamp 60/min
    • POST /stamp/redeem 30/min
    • POST /stamp/void 20/min
    • POST /points/earn 60/min
    • POST /points/redeem 30/min
    • POST /points/void 20/min
    • POST /cards/{card_id}/points/adjust 20/min
    • POST /pins/{pin_id}/unlock 10/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.py adds birth_date: Date | None to customers.
  • Update customer_service.create_customer_for_enrollment to accept and persist birth_date.
  • Update card_service.py:215-222 to pass customer_birthday through, with date.fromisoformat() parsing and validation (must be a real past date, sensible age range).
  • Update customer_service.update_customer to allow backfill.
  • Add unit + integration tests for enrollment-with-birthday and enrollment-without-birthday paths.
  • Validation: enroll with birthday → row in customers has birth_date set; 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 CheckConstraint in 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.py with 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.py with @shared_task(name="loyalty.send_notification_email", bind=True, max_retries=3, default_retry_delay=60).
  • Opens fresh SessionLocal, calls EmailService(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 after db.commit().

2.5 Wire expiration warning into expiration task

  • Migration loyalty_006 adds last_expiration_warning_at to 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, if card.stamp_count == program.stamps_target, dispatch.

2.7 Birthday Celery beat task

  • New @shared_task loyalty.send_birthday_emails, daily 0 8 * * * in definition.py.
  • Queries cards where customer.birth_date matches 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 in loyalty_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-185 from per-card loop to set-based SQL:
    • Per program, chunk card IDs with LIMIT 500 FOR UPDATE SKIP LOCKED
    • Build expiration loyalty_transactions rows 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
  • 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 at tasks/wallet_sync.py:71-95 with exponential backoff [1s, 4s, 16s].
  • Per-card try/except (one bad card doesn't kill the batch).
  • Log failed_card_ids to observability.
  • Use tenacity if available, otherwise small helper.

Phase 4 — Accessibility & T&C ( DONE 2026-04-11)

4.1 T&C via store CMS integration

  • Migration loyalty_007: add terms_cms_page_slug: str | None to loyalty_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; legacy terms_text fallback.
  • 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.
  • .env line 196 updated.
  • Startup validators from 1.1 confirm load.

5.2 Conditional UI flag

  • Storefront API returns google_wallet_enabled: bool (derived from config.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=true API.
  • 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 notes if 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_id in audit log
  • New loyalty_transactions.acting_admin_id column in loyalty_006.

6.5 Manual override: restore expired points

  • POST /admin/loyalty/cards/{card_id}/restore_points with {points, reason}.
  • Creates ADMIN_ADJUSTMENT transaction via existing points_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 extend program_service).
  • Group cards by enrollment month, track % active (transaction in last 30d) per subsequent month.
  • Endpoint /merchants/loyalty/analytics/cohorts returns 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 (where points_earned.order_reference set) 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.json under app/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 — interpreting failed_card_ids, manual re-sync, common failures.

8.4 Monitoring & alerting

  • Celery loyalty.expire_points not succeeded in 26h → page
  • loyalty.sync_wallet_passes failure 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_idid normalization across schemas/JS
  • Card detail: enrolled store name resolution, copy buttons, paginated transactions
  • i18n flicker: server-rendered translations on success page
  • Icon fix: device-mobilephone

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.json in 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-status returns google_configured: true

Step 3: Apply database migrations

  • Run: alembic upgrade heads
  • Verify migrations applied: loyalty_003 through loyalty_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_title
    • store.analytics.at_risk_title
    • store.analytics.cards_at_risk
    • store.analytics.no_at_risk
    • store.analytics.cohort_title
    • store.analytics.cohort_month
    • store.analytics.cohort_enrolled
    • store.analytics.no_data_yet

Step 5: Investigate email template menu visibility

  • Check if messaging.manage_templates permission is assigned to merchant_owner role
  • 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-templates shows 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.