# 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`](../modules/loyalty/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).

## 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 `.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 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.
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 access** —
   [pay.google.com/business/console](https://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_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

- Canonical plan: [`app/modules/loyalty/docs/production-launch-plan.md`](../modules/loyalty/production-launch-plan.md)
- Hetzner runbook: [`deployment/hetzner-server-setup.md`](../deployment/hetzner-server-setup.md)
- Wallet diagnostics page: `/admin/loyalty/wallet-debug` (super admin only)
- Recent commits relevant to this session:
  - `f2d1bdcd` fix(messaging): test email + EmailLog show effective config
