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>
28 KiB
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 (1–3 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-workerlogs live, checkemail_logsfor a row. If still no email, then there's a real bug in the dispatch path —notification_service.send_enrollment_confirmationis called fromcard_service.enroll_customer:636and wraps the call in a try/except that only logs warnings (card_service.py:631-645), so a silent failure in_resolve_contextor 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_urlinto theloyalty_enrollmenttemplate, generate QR server-side (Pythonqrcode), update the HTML body in all 4 locales inscripts/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.pyat 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:
- Re-apply SMTP overrides under
/admin/settings(port 587, STARTTLS,support@wizard.lu) — the reset wiped them. - Confirm
/admin/loyalty/programsshows the Fashion Group program (should be seeded byseed_demo.py). - Tail
apiandcelery-workerlogs live, then enroll athttps://fashionhub.rewardflow.lu/loyalty/joinwith 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. |
44c42909 — set_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 args → KeyError 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)
- 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 honorlangfor<input type="date">and we need a JS date-picker swap); "Continuer mes achats" CTA onenroll-success.html:118is wrong for loyalty-only storefronts with no catalog. - Test 2 — cross-store re-enrollment at FASHIONOUTLET with the email from Test 1.
- Hetzner doc check — verify whether
docs/deployment/hetzner-server-setup.mdneeds any new step from tonight's fixes. Most likely no (the fixes are in-code, not deployment), but worth a glance. - Unit tests — none of the four B1-F bugs were caught by the existing
suite. Add at minimum:
- Assert
celery_app.conf.broker_urlisredis://...after importingmain(catches futureset_default()ordering regressions). - Assert
loyalty.send_notification_emailis incelery_app.tasksafter importingapp.modules.loyalty.tasks(catches future missing imports in task package__init__.py). - Assert
configure_mappers()succeeds after importingapp.core.celery_config(catches future missing-models regressions in celery). - Either assert
task_base.on_failuredoesn't crash on a synthetic failure, or standardize anextra=sanitiser that strips reservedLogRecordattribute names.
- Assert
- Fix
prospecting/tasks/__init__.py— add the missing import. - 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_logsrow 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_shopping → back_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:
- 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.
- Save button disabled until at least one of the two terms fields is
filled. The button gets a localised
:titletooltip explaining why it's disabled, withdisabled:cursor-not-allowedso 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 3–8 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__.pymissing 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— propagateerror.details(alongsideerrorCode) so callers can render localised toasts.loyalty-terminal.js:277— in the transaction-dispatch catch, branch onerrorCode === 'POINTS_COOLDOWN' | 'STAMP_COOLDOWN'and renderloyalty.store.terminal.cooldown_wait_minuteswith{minutes}fromerror.details.cooldown_minutes; toast type switches towarningsince the rejection is soft.- New
cooldown_wait_minuteskey in en/fr/de/lb underloyalty.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):
- Mount 1 store-resolution broken on subdomain —
/store/loginreturns "Failed to load store information" even though the route is mounted atmain.py:449-458and the host should resolve the store via middleware. Workaround: use Mount 2/store/{STORE_CODE}/login. - Server-side post-login redirect leaks dev prefix —
app/modules/tenancy/routes/pages/store.py:86builds/platforms/{platform_code}/store/{store_code}/dashboardon a subdomain hit (should pick the:88branch). Same pattern as B1-B but for redirects. - JS post-login redirect uses wrong heuristic —
app/modules/tenancy/static/store/js/login.js:155-158treats "platform_code is set" as "we're in path-based mode" and prepends/platforms/{code}/always. Should checkwindow.location.pathname.startsWith('/platforms/')instead. - 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 4–8 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__.pymissing 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
478c3a9cfix, verification pending next session).
Carry over for next session
- Test 5 — password-reset end-to-end (new top priority): with the
478c3a9cfix deployed, retry the forgot-password flow → confirm anemail_logsrow appears withtemplate_code='password_reset',status='sent'→ click the link in the email → set a password → login → continue from step 5.3 (visit/account/loyaltydashboard + history). - 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. - Routing pass still queued (after Test 8): fix the 4 routing bugs
- Redirect Trace admin tool + integration tests + doc updates.
- Existing follow-ups: Hetzner doc check, B1-F unit tests,
prospecting
tasks/__init__.pymissing 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, 1–3 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
.envhadEMAIL_PROVIDER=sendgrid+ a SendGrid API key - SendGrid free trial (60 days) had expired
SMTP_*env vars were placeholders pointing atsmtp.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=smtpsmtp_host=mail1.myservices.hostingsmtp_port=465← problematicsmtp_user=support@wizard.lu/ encrypted passwordsmtp_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:
app/modules/core/routes/api/admin_settings.py::send_test_email— body template hardcodedapp_settings.email_providerandapp_settings.email_from_addressapp/modules/messaging/services/email_service.py— the "template not found"EmailLogbranch recordedsettings.email_provider/settings.email_from_addressinstead 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:
- Tonight or tomorrow — review email copy. Open
/admin/email-templatesand skim the 5 loyalty templates (EN locale).loyalty_enrollment,loyalty_welcome_bonusandloyalty_reward_readyare the customer-visible ones — adjust subject lines + body copy if anything reads off-brand. - 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 9–14). Covers pairing (QR + manual), offline PIN bcrypt verify, daily flows (stamp/earn/redeem/enroll), offline queue drain, idle auto-lock, and device revocation cutoff. - 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. - In parallel, file Google Wallet production access — pay.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_templatespermission 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-attemptendpoint 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
- Canonical plan:
app/modules/loyalty/docs/production-launch-plan.md - Hetzner runbook:
deployment/hetzner-server-setup.md - Wallet diagnostics page:
/admin/loyalty/wallet-debug(super admin only) - Recent commits relevant to this session:
f2d1bdcdfix(messaging): test email + EmailLog show effective config