Files
orion/docs/proposals/loyalty-go-live-readiness.md
Samir Boulahtit a21dbbcddf
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
docs(loyalty): record 2026-05-24 Test 4 + storefront auth body-schema fix
End-of-day update.

- Test 4 (cross-store redemption) verified: card #5's transaction
  history now spans store_id=4 (FASHIONHUB, all the earnings) and
  store_id=5 (FASHIONOUTLET, today's -100 redemption). Cross-location
  flow confirmed.

- Bug found + fixed (478c3a9c) on the storefront auth API. Both
  POST /api/v1/storefront/auth/forgot-password and .../reset-password
  declared bare `email: str` / `reset_token: str, new_password: str`
  params, which FastAPI treats as query strings. The frontend sends
  JSON body, so the call 422'd with "missing query parameter email".
  Added PasswordResetRequest + PasswordResetConfirm Pydantic body
  schemas; switched both endpoints to body: <Schema>. Surfaced
  trying to test Test 5's customer login flow.

- /loyalty-wrap skill committed (d03b96da) — mechanises the end-of-day
  routine. First invokable as /loyalty-wrap tomorrow (skills load at
  session start).

Carries Test 5 into next session (now unblocked by the auth fix), plus
a new TODO from the user: transaction categories should be creatable
by merchants and store owners, not admin-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:36:31 +02:00

28 KiB
Raw Blame History

Loyalty Go-Live Readiness — 2026-05-10

Snapshot of where the loyalty platform stands the night of 2026-05-10. The canonical sequenced plan is still app/modules/loyalty/docs/production-launch-plan.md; this doc records the current state ( / / 🟡) and what surfaced during the prod readiness pass.

TL;DR

The technical pre-launch checklist is green. The remaining gate is a human one — walking the 8 user-journey E2E tests on prod with a real test customer and confirming nothing surprises us. After that, flip the loyalty platform live for FASHIONHUB's stores and start the Google Wallet production-access review in parallel (13 day Google review, non-blocking).

2026-05-16 update — Test 1 round 1: 7 bugs found, 6 fixed, 1 pending

First attempt at the customer-facing journey on FASHIONHUB's fallback subdomain (fashionhub.rewardflow.lu) surfaced more than expected. A critical timestamp bug was masquerading as a re-enrollment confusion, which sent us briefly down the wrong investigation path. The clean-slate reset described below cleared the bad data so the remaining gates can be verified on a known-good baseline.

Six bugs fixed and deployed to prod (5 commits):

Bug Layer Fix
TimestampMixin evaluated datetime.now(UTC) once at module import — every row stamped at process-start time models/database/base.py Pass _utc_now callable as default / onupdate. Critical: affected every created_at / updated_at on every table that uses the mixin since the last app restart.
Admin/store/merchant card detail page showed "-" for phone + birthday even when both were captured during enrollment app/modules/loyalty/schemas/card.py + 3 endpoints Added customer_phone + customer_birthday to CardDetailResponse and populated from customer.phone / customer.birth_date. Data was persisting all along — purely a serialization gap.
Storefront <html lang="en"> hardcoded made <input type="date"> show in mm/dd/yyyy on the FR storefront app/templates/storefront/base.html Dynamic lang="{{ current_language|default('en') }}" so the browser respects the FR locale.
Storefront nav "Home" rendered as English literal across all locales despite nav.home existing in every locale file app/templates/storefront/base.html Use {{ _('nav.home') }} on both desktop and mobile nav.
Store.description (the per-store tagline) was single-language only — FASHIONHUB's "Trendy clothing and accessories" rendered in EN on the FR storefront footer Store model + migration tenancy_005 + template + seed_demo.py Added description_translations JSON column with the same shape used by CMS / Platform / Subscription. Added get_translated_description(lang) getter with FR/DE → DEFAULT_LANGUAGE → description fallback. Seeded FR/DE/LB/EN for Fashion Group's two stores so they render correctly out of the box.
make init-prod and make db-reset referenced scripts/seed/seed_email_templates.py, which doesn't exist (the real seeders are _core.py + _loyalty.py) — db-reset would silently bomb mid-way Makefile Call both real scripts in both targets.
scripts/seed/create_default_content_pages.py still passed meta_keywords to ContentPage, but the column was dropped in migration cms_003 — fresh seeding failed on the first platform scripts/seed/create_default_content_pages.py Drop the meta_keywords kwarg.

One bug still open:

  • B1-F — welcome email not received. The original investigation was confounded by the timestamp bug (customer looked like it was from May 12 when it was actually fresh, making the re-enrollment hypothesis seem plausible). Needs fresh repro on the clean DB: enroll with a new email, tail api + celery-worker logs live, check email_logs for a row. If still no email, then there's a real bug in the dispatch path — notification_service.send_enrollment_confirmation is called from card_service.enroll_customer:636 and wraps the call in a try/except that only logs warnings (card_service.py:631-645), so a silent failure in _resolve_context or the Celery enqueue would be invisible from the user's perspective.

Two product decisions pending (from the same session, not yet implemented):

  • B1-E — QR code in welcome email. Scoped: pass wallet_save_url into the loyalty_enrollment template, generate QR server-side (Python qrcode), update the HTML body in all 4 locales in scripts/seed/seed_email_templates_loyalty.py:294-299, reseed. Blocked on B1-F (no point adding a QR to an email that doesn't send).
  • C1-C backfill scope. Other stores (WizaTech, BookWorld, LuxWeb, WizaMart etc.) still only have a single-language description. Fashion Group was seeded; rest can be done by hand via admin UI as merchants come online, or batch-updated later. No code work needed.

Prod data reset

Wiped and reseeded — used the corrected sequence from deployment/hetzner-server-setup.md section 12. Two doc gaps found and patched in the same pass:

  • Reset procedure called scripts/seed/seed_email_templates.py (doesn't exist) — now calls both real scripts
  • Reset procedure was missing seed_demo.py at the end of step 8 — now included

After reset, admin credentials are back to the defaults from init_production.py (admin / Ollama@8044, etc.); platform admin SMTP overrides in /admin/settings need to be re-applied (port 587, STARTTLS, support@wizard.lu).

Status board delta

  • Step 1 (email templates seeded) — re-seeded post-reset, still
  • Step 3 (migrations) — now at tenancy_005, still
  • Step 6 (web user-journey E2E tests) — Test 1 round 2 pending on clean DB; the bugs found in round 1 are no longer blockers

Next session

Session paused 2026-05-16 evening. To resume Test 1 round 2:

  1. Re-apply SMTP overrides under /admin/settings (port 587, STARTTLS, support@wizard.lu) — the reset wiped them.
  2. Confirm /admin/loyalty/programs shows the Fashion Group program (should be seeded by seed_demo.py).
  3. Tail api and celery-worker logs live, then enroll at https://fashionhub.rewardflow.lu/loyalty/join with a fresh email. The point of the live tail is to catch where B1-F actually dies — at dispatch, at SMTP, or somewhere else.

2026-05-17 update — B1-F resolved (chain of 4 nested bugs)

End-to-end enrollment → Celery dispatch → email_logs status=sent → real emails arriving in inbox. Verified with the FR locale: enrollment ("Bienvenue chez Fashion Group S.A. Loyalty !") and welcome-bonus ("Vous avez gagné 50 points bonus !") both send within ~4s of submit.

The "no welcome email" symptom hid four layered bugs; each silently masked the next, which is why early diagnostics looked clean:

# Bug Fix
1 @shared_task defaulted to amqp://localhost// because celery_app.set_default() was never called AND the api process never imported celery_config. .delay() raised kombu.OperationalError: Connection refused. 44c42909set_default() + early import in main.py (with # isort: split so ruff doesn't reorder it).
2 on_failure log handler crashed on reserved LogRecord attribute name argsKeyError masked every real task exception. 3e650ff8 — rename to task_args / task_kwargs.
3 loyalty.send_notification_email wasn't in worker's task registry — notifications.py wasn't imported by loyalty/tasks/__init__.py. Worker received the message, couldn't find the task, ACKed silently. 2a216101 — add the import + __all__ entry.
4 Celery worker process never imported all models. First DB query failed InvalidRequestError: expression 'ContentPage' failed to locate a name. 5b21908b_preload_all_module_models() walks the registry and force-imports each module's models package at celery_config load.

Three earlier same-session commits also shipped: SMTP password eye toggle (64a178f4), JS error on /admin/loyalty/programs (8d6830fc), 422 on ProgramCreate (120532e6).

Audit finding

app/modules/prospecting/tasks/__init__.py has the same shape as bug #3 above — scan_tasks.py exists but isn't imported. Not blocking anything today (no prospecting Celery dispatch is wired up yet), but should be fixed alongside the unit-test pass below.

Follow-ups (queued for next session)

  1. Two Test 1 nits — date format mm/dd/yyyy on FR storefront enrollment form (verify the <html lang> deploy actually landed; if it did, the user's browser doesn't honor lang for <input type="date"> and we need a JS date-picker swap); "Continuer mes achats" CTA on enroll-success.html:118 is wrong for loyalty-only storefronts with no catalog.
  2. Test 2 — cross-store re-enrollment at FASHIONOUTLET with the email from Test 1.
  3. Hetzner doc check — verify whether docs/deployment/hetzner-server-setup.md needs any new step from tonight's fixes. Most likely no (the fixes are in-code, not deployment), but worth a glance.
  4. Unit tests — none of the four B1-F bugs were caught by the existing suite. Add at minimum:
    • Assert celery_app.conf.broker_url is redis://... after importing main (catches future set_default() ordering regressions).
    • Assert loyalty.send_notification_email is in celery_app.tasks after importing app.modules.loyalty.tasks (catches future missing imports in task package __init__.py).
    • Assert configure_mappers() succeeds after importing app.core.celery_config (catches future missing-models regressions in celery).
    • Either assert task_base.on_failure doesn't crash on a synthetic failure, or standardize an extra= sanitiser that strips reserved LogRecord attribute names.
  5. Fix prospecting/tasks/__init__.py — add the missing import.
  6. Audit every other module's email path — are billing's trial-expiration emails really dispatched via Celery? Messaging's password-reset emails? If yes, same silent-failure risk exists until a real send hits prod. Add an integration test that triggers a representative email from every module and asserts an email_logs row appears within N seconds.

2026-05-18 update — Test 1 round 2 cleanup + admin polish

Both follow-up #1 items from yesterday shipped + verified by user. Plus a bonus polish on the admin program form, surfaced when the user spotted a non-clickable "Conditions Générales" link on the storefront and asked why.

Test 1 nits resolved

Bug Diagnosis Fix Commit
Birthday picker shows mm/dd/yyyy on FR even though <html lang="fr"> IS in the page source Firefox-specific: it ignores the lang attribute for <input type="date"> (Mozilla bug #1344625, open since 2017). Chrome/Safari/Edge respect it. Swap to flatpickr on both loyalty/storefront/enroll.html and loyalty/store/enroll.html. Configured dateFormat: 'Y-m-d' (ISO to API) + altInput: true + altFormat: 'd/m/Y' (dd/mm/yyyy visible) + maxDate: 'today' + locale: '{{ current_language }}' for month/day name translations. Loaded via extra_head + extra_scripts blocks. ab3e133a
"Continuer mes achats" CTA on enrollment success page makes no sense for a loyalty-only storefront with no catalog Same destination is fine ({{ base_url }}), only the label needed to change. Renamed i18n key continue_shoppingback_to_home in all four locales: EN "Back to Home" / FR "Retour à l'accueil" / DE "Zurück zur Startseite" / LB "Zréck op d'Haaptsäit". 236fee01

Admin program-form polish (bonus)

User noticed the storefront's "Conditions Générales" link wasn't clickable. Root cause: the program had both terms_text and terms_cms_page_slug empty. The storefront then renders a plain <span> instead of an <a> (enroll.html:122-124) — intentional, so the link doesn't open an empty modal, but easy to accidentally publish a program in this broken state.

Two changes to loyalty/shared/program-form.html:

  1. Yellow warning banner inside the Terms section, visible only when both fields are empty. Tells the admin exactly what the storefront will look like and what to fix.
  2. Save button disabled until at least one of the two terms fields is filled. The button gets a localised :title tooltip explaining why it's disabled, with disabled:cursor-not-allowed so the disabled state is obvious on hover.

Three new i18n keys (terms_required_warning, terms_text_hint, terms_required_tooltip) added in en/fr/de/lb. Shipped as 5f288502.

Test 1 status

All 6 originally-reported bugs (B1-A through B1-F) plus the 2 follow-up nits are now resolved end-to-end on prod. Test 1 is fully done.

Remaining follow-ups (carry over to next session)

From the 2026-05-17 list, items #2 (Test 2), #3 (Hetzner doc check), #4 (unit tests for the B1-F chain), #5 (prospecting tasks/__init__.py missing import), and #6 (other-module email audit) are still queued. Items #1 (Test 1 nits) are now closed by this session's commits.

2026-05-19 update — Test 2 complete

Cross-store re-enrollment at FASHIONOUTLET (same email as Test 1's successful +17mayf@gmail.com) walked cleanly. Behavioral checks all green, one copy bug fixed.

Test 2 verification

Check Result
Exactly 1 loyalty_cards row for customer / merchant — no duplicate
Zero new email_logs rows — no duplicate welcome email
Cross-location locations block lists both Fashion Hub + Fashion Outlet
Title renders "Vous êtes déjà membre !" (already conditional from prior fix)

Bug fixed: contradicting subtitle on success page (dee2eab2)

The title at enroll-success.html:21 was already x-text-conditional on enrollContext.already_enrolled — so it correctly switched between "Welcome!" and "You're already a member!". But the subtitle just below at line 24 was a static {{ _('success.message') }}, always rendering "Vous êtes maintenant membre..." even on the already-enrolled branch. Two contradicting messages stacked.

Added an already_enrolled_message i18n key in en/fr/de/lb and made the subtitle conditional the same way as the title:

  • EN: "Welcome back — your card is ready whenever you are."
  • FR: "Heureux de vous revoir — votre carte est prête à l'emploi."
  • DE: "Willkommen zurück — Ihre Karte ist einsatzbereit."
  • LB: "Wëllkomm zréck — Är Kaart ass prett wann Dir et sidd."

Status board delta

  • Step 6 (web user-journey E2E tests) — Tests 1 and 2 done. Tests 38 still ahead.

Carry over for next session

  • Test 3 — Staff stamps/points at the terminal (/store/FASHIONHUB/loyalty/terminal)
  • Items #3 (Hetzner doc check), #4 (unit tests for the B1-F chain), #5 (prospecting tasks/__init__.py missing import), #6 (other-module email audit) still queued from the 2026-05-17 follow-up list.

2026-05-23 update — Test 3 done + cooldown bug + routing investigation

Test 3 (staff stamps/points at terminal) — all 6 sub-steps verified

Lookup by card-number AND by email both work; phone + birthday show correctly on the card detail (B1-D regression check passed); points earning credits; cooldown rejection fires (after the fix below).

Cooldown bug fixed (93ab072f)

stamp_service.add_stamp properly checks cooldown before crediting. The parallel points_service.earn_points wrote card.last_points_at but never read it — so the program's cooldown_minutes was silently ignored for points-based programs. Mirrored the stamp check in points_service after the row lock; added PointsCooldownException with error_code POINTS_COOLDOWN.

Cooldown toast localised (aa8ca594)

After the cooldown fix shipped, the FR-locale toast still showed the raw English from the backend. Three small changes:

  • static/shared/js/api-client.js — propagate error.details (alongside errorCode) so callers can render localised toasts.
  • loyalty-terminal.js:277 — in the transaction-dispatch catch, branch on errorCode === 'POINTS_COOLDOWN' | 'STAMP_COOLDOWN' and render loyalty.store.terminal.cooldown_wait_minutes with {minutes} from error.details.cooldown_minutes; toast type switches to warning since the rejection is soft.
  • New cooldown_wait_minutes key in en/fr/de/lb under loyalty.store.terminal.*.

Routing investigation — 4 distinct bugs in path/host handling (not yet fixed)

User hit a 404 on https://fashionhub.rewardflow.lu/platforms/loyalty/store/fashionhub/dashboard after login, then noticed several other oddities. Diagnostics found four distinct routing-implementation bugs, all from the same architectural drift (path-based dev → subdomain/custom-domain prod):

  1. Mount 1 store-resolution broken on subdomain/store/login returns "Failed to load store information" even though the route is mounted at main.py:449-458 and the host should resolve the store via middleware. Workaround: use Mount 2 /store/{STORE_CODE}/login.
  2. Server-side post-login redirect leaks dev prefixapp/modules/tenancy/routes/pages/store.py:86 builds /platforms/{platform_code}/store/{store_code}/dashboard on a subdomain hit (should pick the :88 branch). Same pattern as B1-B but for redirects.
  3. JS post-login redirect uses wrong heuristicapp/modules/tenancy/static/store/js/login.js:155-158 treats "platform_code is set" as "we're in path-based mode" and prepends /platforms/{code}/ always. Should check window.location.pathname.startsWith('/platforms/') instead.
  4. Sidebar URL builder uses code-bearing form on subdomain — works (Mount 2 also matches) but inconsistent with the canonical platform-debug pattern; adds visible cruft to URLs.

Why didn't tests catch this?

Ran the full middleware suite (185 tests, 29s, all green). Confirmed thorough coverage of inbound resolution (host → platform/store, request.state population). Zero coverage on outbound URL construction — no test asserts post-login Location header, sidebar URLs, or Mount 1 actually serving on subdomain. The bugs exist precisely because nothing red-flags them.

Platform-debug enhancement scoped (not implemented)

User suggested enhancing /admin/platform-debug to test redirects. My scope: add a 5th panel called "Redirect Trace" alongside Platform Trace, Domain Health, Permissions Audit, Tenant Isolation. Auto-runs the 12-row (host × URL-pattern) matrix the page already enumerates, simulates each via httpx.AsyncClient(transport=ASGITransport(app=app)), asserts the redirect Location vs the expected canonical. The same backing endpoint becomes the harness for tests/integration/test_redirect_trace.py so the 4 routing bugs would surface in red.

Status board delta

  • Step 6 (web user-journey E2E tests) — Tests 1 , 2 , 3 done. Tests 48 ahead.

Carry over for next session

  • Test 4 — Cross-store redemption at FASHIONOUTLET with the card from Tests 1-3
  • Routing pass (after Test 8 finishes so we don't churn mid-walkthrough): fix the 4 routing bugs in one focused commit, add the RedirectTrace admin tool + the corresponding integration test, update hetzner doc + user-journeys doc Case 3 to match the canonical platform-debug pattern.
  • Existing follow-ups still queued: Hetzner doc check, B1-F unit tests, prospecting tasks/__init__.py missing import, other-module email audit.

2026-05-24 update — Test 4 done + storefront auth body-schema fix

Test 4 (cross-store redemption) — verified

Card #5 has its full earning history at FASHIONHUB (store_id=4): welcome bonus 50 + three points_earned totalling 218 = 268 total earned. Today's points_redeemed -100 @ store_id=5 (FASHIONOUTLET) succeeded cleanly, producing the mixed-store transaction history the cross-location flow is supposed to deliver. Balance = 168 pts.

Storefront forgot/reset password endpoints now accept JSON body (478c3a9c)

Both POST /api/v1/storefront/auth/forgot-password and .../reset-password were declared with bare email: str / reset_token: str, new_password: str parameters. FastAPI treats unannotated str params as query parameters, so the storefront's JSON request body was ignored and the endpoint 422'd with {"loc":["query","email"],"msg":"Field required"}. The endpoint docstrings even said "Request Body: email" — intent was clear, the implementation drifted.

Added two body schemas in app/modules/tenancy/schemas/auth.py (PasswordResetRequest, PasswordResetConfirm), re-exported via __init__.py, and switched both endpoint signatures to body: <Schema>.

Surfaced when the user tried to test Test 5 (customer storefront login) and needed to set a password on the customer that self-enrolled with just email + name + birthday.

Skill created: /loyalty-wrap (d03b96da)

Mechanises the end-of-day routine that's been manual every session. Lives at .claude/skills/loyalty-wrap/SKILL.md. Triggers on phrases like "call it a night", "save memory and docs", "wrap up", etc. Skills load at session start, so the first session where the user can actually invoke it as /loyalty-wrap is the next one after the one that committed it.

Status board delta

  • Step 6 (web user-journey E2E tests) — Tests 1 , 2 , 3 , 4 done. Test 5 in progress (blocked tonight on password-reset flow; now unblocked by the 478c3a9c fix, verification pending next session).

Carry over for next session

  1. Test 5 — password-reset end-to-end (new top priority): with the 478c3a9c fix deployed, retry the forgot-password flow → confirm an email_logs row appears with template_code='password_reset', status='sent' → click the link in the email → set a password → login → continue from step 5.3 (visit /account/loyalty dashboard + history).
  2. Transaction categories — permissions audit (new item raised by user): today only admin can create transaction categories. Merchants and store owners should be able to. Investigate the existing endpoint in app/modules/loyalty/services/category_service.py + app/modules/loyalty/routes/api/admin.py, decide the right scope (merchant-level? store-level?), wire up the merchant + store UIs, add the appropriate RBAC permissions.
  3. Routing pass still queued (after Test 8): fix the 4 routing bugs
    • Redirect Trace admin tool + integration tests + doc updates.
  4. Existing follow-ups: Hetzner doc check, B1-F unit tests, prospecting tasks/__init__.py missing import, other-module email audit.

Status board

# Pre-launch step State Notes
1 Seed loyalty email templates on prod 20 rows (5 templates × 4 locales) all is_active=true
2 Google Wallet config on Hetzner Wallet config validator green: credentials valid, issuer 3388000000023089598, origin https://rewardflow.lu, default logo reachable
3 Database migrations All four module heads current incl. loyalty_011 (acting-device audit) on prod
4 FR/DE/LB translations for analytics i18n keys 🟡 8 keys still EN-only. Cosmetic, doesn't block soft launch
5 messaging.manage_templates permission for store owners 🟡 Only matters if merchants self-edit templates. Admin can edit centrally. Defer
6 8 web user-journey E2E tests The remaining gate — user does this with a real test customer
6b 6 Android terminal E2E tests Pairing, PIN, daily flows, offline queue, auto-lock, device revoke — gated on user obtaining a tablet
7 Google Wallet real-device pass test Already confirmed earlier — cards register, points/redeem visible on personal Google Wallet
8 Go live Gated by #6. Cleanup test data + enable platform feature flags for FASHIONHUB
9 Google Wallet production access Post-launch, 13 day Google review. App-side change is zero; same issuer + service account, passes become public-visible once approved

What got sorted tonight

SMTP wired to a self-hosted mail server

Started here:

  • prod .env had EMAIL_PROVIDER=sendgrid + a SendGrid API key
  • SendGrid free trial (60 days) had expired
  • SMTP_* env vars were placeholders pointing at smtp.example.com

Discovered that /admin/settings lets you store SMTP config in the DB (table admin_settings, category email) and those values win over .env. User had already configured:

  • email_provider=smtp
  • smtp_host=mail1.myservices.hosting
  • smtp_port=465 ← problematic
  • smtp_user=support@wizard.lu / encrypted password
  • smtp_use_ssl=true, smtp_use_tls=false

Diagnosis from the prod container:

Check Result
DNS resolves mail1.myservices.hosting 185.26.107.245
TCP mail1.myservices.hosting:465 timed out
TCP mail1.myservices.hosting:587 open

Either Hetzner blocks 465 outbound for this VPS or the provider firewalls Hetzner's IP range on 465. Either way, port 587 (submission

  • STARTTLS) is the modern path and works.

Fix: changed /admin/settings to port 587, SSL off, TLS on. Test email landed in inbox immediately, sender header Support Wizard <support@wizard.lu> — proving the DB override was being used.

Cosmetic bug found and fixed

The test email's body claimed the configuration that would have been used if .env were authoritative — i.e. it said Provider: sendgrid and From: noreply@wizard.lu even though the actual send went via SMTP from support@wizard.lu. Two places in the code:

  1. app/modules/core/routes/api/admin_settings.py::send_test_email — body template hardcoded app_settings.email_provider and app_settings.email_from_address
  2. app/modules/messaging/services/email_service.py — the "template not found" EmailLog branch recorded settings.email_provider / settings.email_from_address instead of the effective config

Both now read from get_effective_email_config(db) / self._platform_config, so the test email page and audit logs reflect what was actually used.

Commit: f2d1bdcd on master, deployed via Gitea Actions.

What the user does next

In priority order:

  1. Tonight or tomorrow — review email copy. Open /admin/email-templates and skim the 5 loyalty templates (EN locale). loyalty_enrollment, loyalty_welcome_bonus and loyalty_reward_ready are the customer-visible ones — adjust subject lines + body copy if anything reads off-brand.
  2. Walk the 8 web user-journey E2E tests — checklist at the bottom of app/modules/loyalty/docs/user-journeys.md. Use a personal email as the test customer. 2b. Once a tablet is on hand: walk the 6 Android terminal tests — same doc, "Android Terminal Tests" section (Tests 914). Covers pairing (QR + manual), offline PIN bcrypt verify, daily flows (stamp/earn/redeem/enroll), offline queue drain, idle auto-lock, and device revocation cutoff.
  3. Flip live for FASHIONHUB — clean any test data, double-check Celery (docker compose ps | grep celery), enable loyalty feature on FASHIONHUB's stores via the admin UI.
  4. In parallel, file Google Wallet production accesspay.google.com/business/console → Wallet API → Manage → Request production access. Use sample pass screenshots from FASHIONHUB. Google reviews the Issuer, not individual merchants — once approved all merchants on the platform are covered.

Open follow-ups (non-blocking)

These can wait but are worth tracking:

  • FR/DE/LB translations for the 8 analytics i18n keys (store.analytics.revenue_title, store.analytics.cohort_title, etc.). EN shows through; cosmetic only.
  • messaging.manage_templates permission discovery for merchant_owner role — needed if/when merchants self-edit templates. Admin can edit centrally for v1.
  • Failed-PIN-attempt reporting from Android tablet → server lockout counter — tablet bcrypts locally and silently fails; a stolen tablet's brute-forcer doesn't trip server-side lockout. Add a tiny POST /pins/{id}/record-failed-attempt endpoint plus a call from the PinViewModel's failure branch.
  • Splash screen + per-action success animation for the Android tablet — Phase F polish that was intentionally deferred.

Reference