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>
- Fix "Enrolled at: Unknown" by resolving enrolled_at_store_name from
the store service and adding it to CardDetailResponse schema.
- Add clipboard-copy buttons next to card number, customer name,
email, and phone fields using the shared Utils.copyToClipboard()
utility with toast feedback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch from pagination_simple to pagination — the same macro used on
the cards list page, with page number buttons and "Showing X-Y of Z".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace custom pagination with the shared pagination_simple macro
to match the cards list page pattern. Always shows "Showing X-Y of Z"
with Previous/Next — no longer hidden when only 1 page. Uses standard
Alpine.js pagination interface (pagination.page, totalPages, startIndex,
endIndex, pageNumbers, previousPage, nextPage, goToPage).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The store card detail page now shows paginated transaction history
instead of a flat list of 50. Uses PlatformSettings.getRowsPerPage()
for the page size (default 20), with Previous/Next navigation and
"Page X of Y" indicator using server-rendered i18n.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The terminal redeem failed with "card not found: unknown" because
CardLookupResponse used card_id while CardDetailResponse (from
refreshCard) used id. After refresh, selectedCard.card_id was
undefined.
Fix: standardize on 'id' everywhere (the universal convention):
- CardLookupResponse: card_id → id
- _build_card_lookup_response: card_id= → id=
- loyalty-terminal.js: selectedCard.card_id → selectedCard.id (5 refs)
- Removed the card_id/model_validator approach as unnecessary
Also fixes Chart.js recursion error on analytics page (inline CDN
script instead of optional-libs.html include which caused infinite
template recursion in test context).
342 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire the Phase 7 analytics API endpoints into the store analytics
page with interactive visualizations:
- Revenue chart (Chart.js bar+line combo): monthly points earned as
bars + active customers as line overlay with dual Y axes.
- At-risk members panel: ranked list of churning cards showing
customer name and days inactive, with count badge.
- Cohort retention table: enrollment month rows × M0-M5 retention
columns with color-coded percentage cells (green >60%, yellow
>30%, red <30%).
Chart.js loaded on-demand via existing CDN loader with local fallback.
Data fetched in parallel via Promise.all for the 3 analytics endpoints.
All sections gracefully degrade to "not enough data" message when empty.
7 new i18n keys (EN only — FR/DE/LB translations to be added).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New analytics_service.py with three analytics features:
- Cohort retention: groups cards by enrollment month, tracks % with
any transaction in each subsequent month. Returns matrix suitable
for Chart.js heatmap. GET /analytics/cohorts?months_back=6
- Churn detection: flags cards as "at risk" when inactive > 2x their
average inter-transaction interval (default 60d for new cards).
Returns ranked list. GET /analytics/churn?limit=50
- Revenue attribution: monthly and per-store aggregation of point-
earning transactions. GET /analytics/revenue?months_back=6
Endpoints added to both admin API (/admin/loyalty/merchants/{id}/
analytics/*) and store API (/store/loyalty/analytics/*) so merchants
can see their own analytics.
342 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Admin operations for production management:
- GDPR anonymization: DELETE /admin/loyalty/cards/customer/{id}
Nulls customer_id, deactivates cards, scrubs PII from transaction
notes. Keeps aggregate data for reporting.
- Bulk deactivate: POST /admin/loyalty/merchants/{id}/cards/bulk/deactivate
and POST /store/loyalty/cards/bulk/deactivate (merchant_owner only).
Deactivates multiple cards with audit trail.
- Point restore: POST /admin/loyalty/cards/{id}/restore-points
Creates ADMIN_ADJUSTMENT transaction with positive delta. Reuses
existing adjust_points service method.
- Cascade restore: POST /admin/loyalty/merchants/{id}/restore-deleted
Restores all soft-deleted programs and cards for a merchant.
Service methods: anonymize_cards_for_customer, bulk_deactivate_cards,
restore_deleted_cards, restore_deleted_programs.
342 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add support for linking a loyalty program's Terms & Conditions to a
CMS page, replacing the simple terms_text textarea with a scalable
content source that supports rich HTML, multi-language, and store
overrides.
- Migration loyalty_006: adds terms_cms_page_slug column to
loyalty_programs (nullable, String 200).
- Model + schemas: new field on LoyaltyProgram, ProgramCreate,
ProgramUpdate, ProgramResponse.
- Program form: new "CMS Page Slug" input field with hint text,
placed above the legacy terms_text (now labeled as "fallback").
- Enrollment page: when terms_cms_page_slug is set, JS fetches the
CMS page content via /storefront/cms/pages/{slug} and displays
rendered HTML in the modal. Falls back to terms_text when no slug.
- i18n: 3 new keys in 4 locales (terms_cms_page, terms_cms_page_hint,
terms_fallback_hint).
Legacy terms_text field preserved as fallback for existing programs.
342 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 3 of the production launch plan: task reliability improvements
to prevent DB lock issues at scale and handle transient wallet API
failures gracefully.
- 3.1 Batched point expiration: rewrite per-card Python loop to chunked
processing (LIMIT 500 FOR UPDATE SKIP LOCKED). Each chunk commits
independently, releasing row locks before processing the next batch.
Notifications sent after commit (outside lock window). Warning emails
also chunked with same pattern.
- 3.2 Wallet sync exponential backoff: replace time.sleep(2) single
retry with 4 attempts using [1s, 4s, 16s] backoff delays. Per-card
try/except ensures one failing card doesn't block the batch.
Failed card IDs logged for observability.
342 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add async email notifications for 5 loyalty lifecycle events, using
the existing messaging module infrastructure (EmailService, EmailLog,
store template overrides).
- New seed script: scripts/seed/seed_email_templates_loyalty.py
Seeds 5 templates × 4 locales (en/fr/de/lb) = 20 rows. Idempotent.
Renamed existing script to seed_email_templates_core.py.
- Celery task: loyalty.send_notification_email — async dispatch with
3 retries and 60s backoff. Opens own DB session.
- Notification service: LoyaltyNotificationService with 5 methods
that resolve customer/card/program into template variables and
enqueue via Celery (never blocks request handlers).
- Enrollment: sends loyalty_enrollment + loyalty_welcome_bonus (if
bonus > 0) after card creation commit.
- Stamps: sends loyalty_reward_ready when stamp target reached.
- Expiration task: sends loyalty_points_expiring 14 days before expiry
(tracked via new last_expiration_warning_at column to prevent dupes),
and loyalty_points_expired after points are zeroed.
- Migration loyalty_005: adds last_expiration_warning_at to cards.
- 8 new unit tests for notification service dispatch.
- Fix: rate limiter autouse fixture in integration tests to prevent
state bleed between tests.
Templates: loyalty_enrollment, loyalty_welcome_bonus,
loyalty_points_expiring, loyalty_points_expired, loyalty_reward_ready.
All support store-level overrides via the existing email template UI.
Birthday + re-engagement emails deferred to future marketing module
(cross-platform: OMS, loyalty, hosting).
342 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix duplicate card creation when the same email enrolls at different
stores under the same merchant, and implement cross-location-aware
enrollment behavior.
- Cross-location enabled (default): one card per customer per merchant.
Re-enrolling at another store returns the existing card with a
"works at all our locations" message + store list.
- Cross-location disabled: one card per customer per store. Enrolling
at a different store creates a separate card for that store.
Changes:
- Migration loyalty_004: replace (merchant_id, customer_id) unique
index with (enrolled_at_store_id, customer_id). Per-merchant
uniqueness enforced at application layer when cross-location enabled.
- card_service.resolve_customer_id: cross-store email lookup via
merchant_id param to find existing cardholders at other stores.
- card_service.enroll_customer: branch duplicate check on
allow_cross_location_redemption setting.
- card_service.search_card_for_store: cross-store email search when
cross-location enabled so staff at store2 can find cards from store1.
- card_service.get_card_by_customer_and_store: new service method.
- storefront enrollment: catch LoyaltyCardAlreadyExistsException,
return existing card with already_enrolled flag, locations, and
cross-location context. Server-rendered i18n via Jinja2 tojson.
- enroll-success.html: conditional cross-store/single-store messaging,
server-rendered translations and context, i18n_modules block added.
- dashboard.html, history.html: replace $t() with server-side _() to
fix i18n flicker across all storefront templates.
- Fix device-mobile icon → phone icon.
- 4 new i18n keys in 4 locales (en, fr, de, lb).
- Docs: updated data-model, business-logic, production-launch-plan,
user-journeys with cross-location behavior and E2E test checklist.
- 12 new unit tests + 3 new integration tests (334 total pass).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1 of the loyalty production launch plan: config & security
hardening, dropped-data fix, DB integrity guards, rate limiting, and
constant-time auth compare. 362 tests pass.
- 1.4 Persist customer birth_date (new column + migration). Enrollment
form was collecting it but the value was silently dropped because
create_customer_for_enrollment never received it. Backfills existing
customers without overwriting.
- 1.1 LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON validated at startup (file
must exist and be readable; ~ expanded). Adds is_google_wallet_enabled
and is_apple_wallet_enabled derived flags. Prod path documented as
~/apps/orion/google-wallet-sa.json.
- 1.5 CHECK constraints on loyalty_cards (points_balance, stamp_count
non-negative) and loyalty_programs (min_purchase, points_per_euro,
welcome_bonus non-negative; stamps_target >= 1). Mirrored as
CheckConstraint in models. Pre-flight scan showed zero violations.
- 1.3 @rate_limit on store mutating endpoints: stamp 60/min,
redeem/points-earn 30-60/min, void/adjust 20/min, pin unlock 10/min.
- 1.2 Constant-time hmac.compare_digest for Apple Wallet auth token
(pulled forward from Phase 9 — code is safe whenever Apple ships).
See app/modules/loyalty/docs/production-launch-plan.md for the full
launch plan and remaining phases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Menu tests (6): Tests expected merchant menu item id "loyalty-program"
but the actual definition in loyalty/definition.py uses "program".
Updated assertions to match the actual menu item IDs.
Wallet test (1): test_enrollment_succeeds_without_wallet_config didn't
mock the Google Wallet config, so is_configured returned True when
GOOGLE_ISSUER_ID is set in .env. Added @patch to mock config as
unconfigured.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Delete program tests now verify soft-delete (deleted_at set, record
hidden from normal queries) instead of expecting hard deletion.
Uses db.query() instead of db.get() since the soft-delete filter
only applies to ORM queries, not identity map lookups.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The first-letter avatar adds visual noise on a dense transactions table
without meaningful value. Simplified to plain text customer name.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TransactionResponse doesn't include card_number, so the template was
showing '-' under every customer name. Removed the nonexistent field.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When editing a PIN, only the PIN code should be changeable. Staff name,
staff ID, and store are now displayed as read-only fields. This prevents
accidentally reassigning a PIN to a different staff member.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PIN create/edit modals were showing "Customer not found" (terminal
message) when no staff members matched. Now shows "No staff members
found" with a proper locale key.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace custom inline autocomplete HTML in both create and edit PIN
modals with the shared search_autocomplete macro from inputs.html.
Refactored JS to use staffSearchResults array populated by searchStaff()
(client-side filter) matching the macro's conventions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
list_cards() was calling Python .replace() on a SQLAlchemy column
object instead of SQL func.replace(), causing AttributeError when
searching cards by card number.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace custom inline autocomplete HTML with the shared
search_autocomplete macro from inputs.html. Same behavior (debounced
search, dropdown with name + email, loading/no-results states) but
using the established reusable component.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Terminal search now shows live autocomplete suggestions as the user
types (debounced 300ms, min 2 chars). Dropdown shows matching customers
with avatar, name, email, card number, and points balance. Uses the
existing GET /store/loyalty/cards?search= endpoint (limit=5).
Selecting a result loads the full card details via the lookup endpoint.
Enter key still works for exact lookup. No new dependencies — uses
native Alpine.js dropdown, no Tom Select needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The autocomplete dropdown appeared immediately when the name field
gained focus (even when empty). Now only shows when there's text to
filter by.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a staff member was selected and then the name field was edited or
cleared, the staff_id (email) remained set. Now tracks the selected
member name and clears staff_id when the search text diverges.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The pins list template included the pagination macro but the JS has no
pagination state (PINs are few and don't need pagination). The empty
macro rendered a broken pagination bar.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Template called openEditPin() and confirmDeletePin() but JS methods
are openEditModal() and openDeleteModal(). Buttons were silently
failing on click.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When creating or editing a staff PIN in the store context, the name
field now shows an autocomplete dropdown with the store's team members
(loaded from GET /store/team/members). Selecting a member auto-fills
name and staff_id (email). The dropdown filters as you type.
Only active in store context (where staffApiPrefix is configured).
Merchant and admin PIN views are unaffected — merchant has no
staffApiPrefix, admin is read-only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The terminal's selectedCard comes from CardLookupResponse which uses
card_id field, but the JS was referencing selectedCard.id (undefined).
This caused all terminal transactions to fail with "LoyaltyCard with
identifier 'unknown' not found" instead of processing the transaction
or showing proper PIN validation errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The store and merchant init-alpine.js derive currentPage from the URL's
last segment (e.g., /loyalty/program -> 'program'). Loyalty menu items
used prefixed IDs like 'loyalty-program' which never matched, so sidebar
items never highlighted.
Fixed by renaming all store/merchant menu item IDs and JS currentPage
values to match URL segments: program, cards, analytics, transactions,
pins, settings — consistent with how every other module works.
Also reverted the init-alpine.js guard that broke storeCode extraction,
and added missing loyalty.common.contact_admin_setup translation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Store templates (cards, card-detail, terminal) reference col_member,
col_date etc. but locale files had table_member, table_date. Renamed
16 keys across all 4 locale files (en/fr/de/lb) to match.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The tenancy merchant detail page now reads an optional ?back= query
parameter to determine the back button destination. Falls back to
/admin/merchants when no param is present (default behavior preserved).
The loyalty merchant detail "View Merchant" link now passes
?back=/admin/loyalty/merchants/{id} so clicking back from the tenancy
page returns to the loyalty context instead of the merchants list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "View Merchant" quick action on the loyalty merchant detail hub
links to the tenancy merchant page, which has its own back button going
to /admin/merchants. Opening in a new tab prevents losing the loyalty
context. Added external link icon as visual indicator.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Load merchant name in page route handlers and pass to template context.
Headers now render as "Cards: Fashion Group S.A." using server-side
Jinja2 variables instead of relying on JS program.merchant_name which
was not in the ProgramResponse schema.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch admin sub-pages (cards, pins, transactions) from page_header_flex
to detail_page_header with merchant name context, matching the settings
page pattern. Headers now show "MerchantName — Cards" with back button
to merchant detail hub.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The shared JS modules (cards-list, pins-list, transactions-list) all
call {apiPrefix}/program to load the program before fetching data. For
admin on-behalf pages, this resolved to GET /admin/loyalty/merchants/
{id}/program which only had a POST endpoint, causing 405 Method Not
Allowed errors on all admin on-behalf pages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix template references to match existing locale key names (11 renames
in pins-list.html and settings.html) and add 29 missing keys to all 4
locale files (en/fr/de/lb). All 299 template keys now resolve correctly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Align loyalty pages across admin, merchant, and store personas so each
sees the same page set scoped to their access level. Admin acts as a
superset of merchant with "on behalf" capabilities.
New pages:
- Store: Staff PINs management (CRUD)
- Merchant: Cards, Card Detail, Transactions, Staff PINs (CRUD), Settings (read-only)
- Admin: Merchant Cards, Card Detail, Transactions, PINs (read-only)
Architecture:
- 4 shared Jinja2 partials (cards-list, card-detail, transactions, pins)
- 4 shared JS factory modules parameterized by apiPrefix/scope
- Persona templates are thin wrappers including shared partials
- PinDetailResponse schema for cross-store PIN listings
API: 17 new endpoints (11 merchant, 6 admin on-behalf)
Tests: 38 new integration tests, arch-check green
i18n: ~130 new keys across en/fr/de/lb
Docs: pages-and-navigation.md with full page matrix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add wallet diagnostics page at /admin/loyalty/wallet-debug (super admin only)
with explorer-sidebar pattern: config validation, class status, card inspector,
save URL tester, recent enrollments, and Apple Wallet status panels
- Fix Google Wallet fat JWT: include both loyaltyClasses and loyaltyObjects in
payload, use UNDER_REVIEW instead of DRAFT for class reviewStatus
- Fix StorefrontProgramResponse schema: accept google_class_id values while
keeping exclude=True (was rejecting non-None values)
- Standardize all module configs to read from .env file directly
(env_file=".env", extra="ignore") matching core Settings pattern
- Add MOD-026 architecture rule enforcing env_file in module configs
- Add SVC-005 noqa support in architecture validator
- Add test files for dev_tools domain_health and isolation_audit services
- Add google_wallet_status.py script for querying Google Wallet API
- Use table_wrapper macro in wallet-debug.html (FE-005 compliance)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Security:
- Fix TOCTOU race conditions: move balance/limit checks after row lock in redeem_points, add_stamp, redeem_stamps
- Add PIN ownership verification to update/delete/unlock store routes
- Gate adjust_points endpoint to merchant_owner role only
Data integrity:
- Track total_points_voided in void_points
- Add order_reference idempotency guard in earn_points
Correctness:
- Fix LoyaltyProgramAlreadyExistsException to use merchant_id parameter
- Add StorefrontProgramResponse excluding wallet IDs from public API
- Add bounds (±100000) to PointsAdjustRequest.points_delta
Audit & config:
- Add CARD_REACTIVATED transaction type with audit record
- Improve admin audit logging with actor identity and old values
- Use merchant-specific PIN lockout settings with global fallback
- Guard MerchantLoyaltySettings creation with get_or_create pattern
Tests: 27 new tests (265 total) covering all 12 items — unit and integration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix rate limiter to extract real client IP and handle sync/async endpoints
- Rate-limit public enrollment (10/min) and program info (30/min) endpoints
- Add 409 Conflict to non-retryable status codes in retry decorator
- Cache private key in get_save_url() to avoid re-reading JSON per call
- Make update_class() return bool success status with error-level logging
- Move Google Wallet config from core to loyalty module config
- Document time.sleep() safety in retry decorator (threadpool execution)
- Add per-card retry (1 retry, 2s delay) to wallet sync task
- Add logo URL reachability check (HEAD request) to validate_config()
- Add 26 comprehensive unit tests for GoogleWalletService
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix IPv6 host parsing with _strip_port() utility
- Remove dangerous StorePlatform→Store.subdomain silent fallback
- Close storefront gate bypass when frontend_type is None
- Add custom subdomain management UI and API for stores
- Add domain health diagnostic tool
- Convert db.add() in loops to db.add_all() (24 PERF-006 fixes)
- Add tests for all new functionality (18 subdomain service tests)
- Add .github templates for validator compliance
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two bugs causing "Program Not Available" on storefront enrollment:
1. extract_store_from_referer() was not platform-aware — used
settings.main_domain (wizard.lu) instead of platform.domain
(rewardflow.lu) for subdomain detection, and restricted path-based
extraction to localhost only. Now mirrors the platform-aware logic
from _detect_store_from_host_and_path(): checks platform.domain for
subdomain detection (fashionhub.rewardflow.lu → fashionhub) and
allows path-based extraction on platform domains
(rewardflow.lu/storefront/FASHIONHUB/... → FASHIONHUB).
2. Storefront JS scripts (enroll, dashboard, history) were missing
defer attribute, causing them to execute before log-config.js and
crash on window.LogConfig access. Also fix quote escaping in
server-side rendered x-text expressions for French translations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>