- The icon registry has no 'device-tablet' (closest available is 'phone',
which is already used for similar device-mobile contexts). Replace
uses I added in the merchant menu item and the empty state.
- The pairing-QR modal uses x-show on the QR/payload blocks, but x-show
only toggles display while Alpine still evaluates child expressions.
pairingResult is null on first render, so the template threw "can't
access property 'qr_png_base64'/'setup_payload', pairingResult is
null" until pairing actually fired. Wrap the block in
<template x-if="pairingResult"> so the bindings only mount when the
data exists.
(There is a third 'device-tablet' reference in store/analytics.html
that predates this work — leaving it for a separate cleanup since
it's not on the Android-rollout path.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The merchant /pins POST was reading store_id as a query parameter, but
the shared loyalty pins JS factory sends the form (including store_id)
as a JSON body — matching the store-side endpoint, which gets store_id
from the JWT and ignores any body field. Result: a 422 "Field
required" on every PIN create from /merchants/loyalty/pins.
Add PinCreateForMerchant (PinCreate + store_id) and switch the
endpoint to it. Validation that the store belongs to the merchant is
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared loyalty pins list factory has an autocomplete-from-team
flow gated behind config.staffApiPrefix (loaded once into a list, then
filtered client-side). The merchant entry in static/merchant/js/loyalty-pins.js
never set staffApiPrefix, so the loadStaffMembers branch never ran and
the "Staff member name" field on /merchants/loyalty/pins fell back to
free text instead of suggesting actual team members.
Wire the merchant config to /merchants/account, and add a flat
GET /merchants/account/team/members alias next to the existing
/merchants/account/team that returns just {members: [...]} — matching
the shape the shared autocomplete factory already expects at
${prefix}/team/members.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the EN placeholders that were seeded with the feature with
proper FR, DE and LB translations. Same scope: terminal_devices.* and
the menu.terminal_devices label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds acting_terminal_device_id to loyalty_transactions so the audit
log can distinguish between operations performed via the web terminal
(human user JWT) and operations performed via a paired tablet (device
JWT). The principal-of-record stays the pairing user — existing
reports keep working — and this column adds "which tablet did it"
alongside.
Threaded through every store-API endpoint that creates a transaction
(stamp add/redeem/void, points earn/redeem/void/adjust, enrollment +
welcome bonus, card deactivate/reactivate). The route reads
current_user.terminal_device_id, which the bearer-auth dep populates
when a device JWT is presented. User-token requests leave the column
NULL, as covered by the new test.
Bulk admin operations (GDPR anonymization, bulk deactivate) and Celery
tasks (point expiration) are not threaded — they always come from a
human admin or the scheduler, never a tablet.
- Migration loyalty_011 + LoyaltyTransaction.acting_terminal_device_id
- 9 service signatures gain the optional kwarg
- 8 store-API routes pass it through
- Integration tests: device JWT populates the column, user JWT leaves
it NULL
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared loyalty list partials (pins, cards, transactions, devices,
admin merchant detail) bind store filter dropdowns to
loc.store_id/loc.store_name, but the /merchants/loyalty/locations and
/admin/loyalty/merchants/{id}/locations endpoints were returning
{id, name, code}. Result: every store-filter dropdown was silently
empty across the loyalty module.
Switch both endpoints to {store_id, store_name, store_code}, matching
the shape used everywhere else (analytics, location stats). Storefront
locations come from a different code path and are unaffected.
Drop the temporary normalizer in the devices Alpine factory now that
the endpoint speaks the right shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups from the live smoke test:
1. The store router has two auth gates: its own get_current_store_api
(already taught about device tokens) and router-level
require_module_access("loyalty", FrontendType.STORE), which goes
through get_current_store_from_cookie_or_header. That cookie-or-header
variant didn't know about device tokens, so live curl with a paired
device JWT was rejected with 401 "Authentication required". Tests
passed only because dependency overrides bypass the module-access dep.
Add the same _try_authenticate_terminal_device branch there.
2. Normalize the /merchants/loyalty/locations response in the devices
Alpine factory: the endpoint returns {id, name, code} but the
templates bind to loc.store_id/loc.store_name. Map both shapes so
the pair-tablet store dropdown populates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the backend half of the Android tablet rollout. Merchants can
pair tablets to specific stores from /merchants/loyalty/devices (or
admins can pair on behalf from the merchant detail page). Each
pairing issues a long-lived JWT shown ONCE in the response with a
server-rendered QR PNG containing {api_url, store_code, auth_token} —
the tablet scans it on first boot and persists the three fields.
The store API (/api/v1/store/loyalty/*) now accepts these device JWTs
alongside user JWTs. Revoking a device row immediately rejects its
token (401 TERMINAL_DEVICE_REVOKED). Tokens expire after 1 year;
re-pair to renew.
- Migration loyalty_010 + TerminalDevice model
- create_device_token / verify_device_token JWT helpers
- 5 endpoints x 2 portals (merchant + admin on-behalf)
- Bearer-auth wiring in app/api/deps.py
- Pages, shared list partial with one-time pairing-QR modal,
Alpine.js factories
- Locale strings (en authoritative; fr/de/lb seeded with EN copy
for translation)
- 6 integration tests covering pair, list, revoke, idempotency,
cross-merchant rejection, store-API auth via device JWT
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The project has multiple migration branch heads (one per module:
softdelete, cms, customers, loyalty, ...) so \`migrate-up\` and
\`db-reset\` need \`heads\` (plural). The \`head\` (singular) form
hit "Multiple head revisions are present" once the first module
branch was created.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full implementation plan for the RewardFlow Terminal Android app:
4 screens (Setup, PIN, Terminal, Enrollment), 6 phases (~5 days),
ASCII wireframes, data layer design, offline queue strategy,
multi-language support, and API model changes needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
JSON arrays like [11, 4, 10] were rendered as "11,4,10" because
String() on a JS array drops brackets. Now uses JSON.stringify()
for object/array values.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added missing category column between Points and Location. Shows
translated category names (comma-separated for multi-select), or
"-" for transactions without categories.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Card detail transaction history now shows translated transaction type
labels and system-generated notes. Uses server-rendered labels object
(same pattern as terminal) to avoid async i18n flicker.
- Transaction types: server-rendered txLabels lookup (all 11 types)
- Notes: txNotes lookup maps English DB strings to translated text
(e.g., "Welcome bonus on enrollment" → "Bonus de bienvenue...")
- Added welcome_bonus_note key to all 4 locales
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch from type=number (leaks special chars via compose keys) to
type=text with @input sanitizer that strips non-digit/non-dot
characters and prevents multiple dots. Handles accented characters,
paste, and compose key sequences.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add keypress filter to block non-numeric characters (e, +, -) and
inputmode="decimal" for mobile keyboard.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace browser confirm() dialog with the shared confirm_modal
macro for category deletion. Matches the existing program delete
pattern. Shows warning about impact on existing transactions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Category names in transaction tables now resolve to the current
page language instead of always showing English. Updated:
- category_service.validate_category_for_store: accepts lang param,
uses get_translated_name()
- Store transactions list route: passes request.state.language
- Card detail transactions route: passes request.state.language
- card_service.get_customer_transactions_with_store_names: accepts
lang param for storefront route
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Category pills in the PIN modal now display the translated name
based on the page's current_language, falling back to the default
name (English) if no translation exists.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add eye icon to expand read-only view of all translations
- View panel shows EN/FR/DE/LB values with "Edit" button to switch
to edit mode
- All 4 language fields (EN/FR/DE/LB) now mandatory — Save button
disabled until all are filled (both add and edit forms)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PIN modal confirm button stays disabled when categories exist and
the action is stamp/earn but no category is selected yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix icons: plus-circle → plus, backspace → arrow-left
- Convert terminal $t() calls to server-side _() for card_label,
stamps_until_reward, reward_label, not_enough_stamps
- Inject transaction type labels as server-rendered window._txLabels
object (eliminates all async i18n warnings on terminal page)
- Resolve category_names in store transactions list endpoint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add missing common keys: add, activate, copy, deactivate
- Fix icon: building-office → office-building (2 templates)
- Fix icon: pause → ban (pause not in icon set, ban used for deactivate)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Category list now has a pencil edit button that expands inline with
name + FR/DE/LB translation fields. Save updates via PATCH API.
View mode shows translations summary next to the name.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add name_translations JSON column to StoreTransactionCategory
(migration loyalty_008). Stores {"en": "Men", "fr": "Hommes", ...}.
Model has get_translated_name(lang) helper.
- Admin CRUD form now has FR/DE/LB translation inputs alongside the
default name.
- Points earn: category_id is now mandatory when the store has
active categories configured. Returns CATEGORY_REQUIRED error.
- Stamps: category remains optional (quick tap workflow).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Admin merchant detail page:
- New "Transaction Categories" section with store selector
- Inline add form, activate/deactivate toggle, delete button
- Categories CRUD via /admin/loyalty/stores/{id}/categories API
Web terminal:
- Loads categories on init via /store/loyalty/categories
- Category pill selector shown in PIN modal before stamp/earn actions
- Selected category_id passed to stamp and points API calls
- Categories are optional (selector hidden when none configured)
4 new i18n keys (EN).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Client requirement: sellers must select a product category (e.g.,
Men, Women, Accessories, Kids) when entering loyalty transactions.
Categories are per-store, configured via admin/merchant CRUD.
Proposal covers: data model (StoreTransactionCategory + FK on
transactions), CRUD API for admin + store, web terminal UI, Android
terminal integration, and analytics extension path.
Priority: urgent for production launch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rename apps/ → clients/ for clarity:
- app/ (singular) = Python backend (FastAPI, server-rendered web UI)
- clients/ (plural) = standalone client applications (API consumers)
The web storefront/store/admin stays in app/ because it's server-
rendered Jinja2, not a standalone frontend. clients/ is for native
apps that connect to the API externally.
Updated:
- docs/architecture/overview.md — added clients/ to project structure
- clients/terminal-android/SETUP.md — updated path references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Native Android tablet app for loyalty POS terminal. Replaces web
terminal for merchants who need device lockdown, camera QR scanning,
and offline operation.
Project at apps/terminal-android/ — Kotlin, Jetpack Compose, calls
the same /api/v1/store/loyalty/* API (no backend changes).
Scaffold includes:
- Gradle build (Kotlin DSL) with version catalog (libs.versions.toml)
- Hilt DI, Retrofit + Moshi networking, Room offline DB
- CameraX + ML Kit barcode scanning dependencies
- DataStore for device config persistence
- WorkManager for background sync
- Three-screen navigation: Setup → PIN → Terminal
- Stub screens with layout structure (ready to implement)
- API models matching all loyalty store endpoints
- PendingTransaction entity + DAO for offline queue
- RewardFlow brand theme (purple, light/dark)
- Landscape-only, fullscreen, Lock Task Mode ready
- SETUP.md with Android Studio installation guide
- .gitignore for Android build artifacts
Tech decisions:
- Min SDK 26 (Android 8.0, 95%+ tablet coverage)
- Firebase App Distribution for v1, Play Store later
- Staff PIN auth (no username/password on POS)
- One-time device setup via QR code from web settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move store theme admin pages, templates, and JS from tenancy module
to CMS module where the data layer (model, service, API, schemas)
already lives. Eliminates split ownership.
Moved:
- Route handlers: GET /store-themes, GET /stores/{code}/theme
- Templates: store-theme.html, store-themes.html
- JS: store-theme.js, store-themes.js
- Updated static references: tenancy_static → cms_static
Deleted old tenancy files (no remaining references).
Menu item in CMS definition already pointed to correct route.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clarify Step 2: Google Wallet service account, Docker mount, and env
vars are already deployed on Hetzner (per Step 25 of server setup doc).
Only verification needed at deploy time.
Add Step 9 (post-launch): Google Wallet production access request.
Passes work in demo mode for test accounts at launch. Production
approval is a Google console step (1-3 business days, no code changes).
Google reviews the Issuer (platform), not individual merchants.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Content page editor improvements:
- Page type selector: Content Page / Landing Page dropdown (sets template)
- Title language tabs: translate page titles per language (same pattern as sections)
- Content language tabs: translate page content per language
- Meta description language tabs: translatable SEO descriptions
- Template-driven section palette: template defines which sections are available
(store landing pages hide Pricing, platform homepages show all)
- Hide content editor when Landing Page selected, hide sections when Content Page
Schema changes (migration cms_003):
- Add meta_description_translations column (JSON) to content_pages
- Drop meta_keywords column (obsolete, ignored by all search engines since 2009)
- Remove meta keywords tag from storefront and platform base templates
API + service updates:
- title_translations, content_translations, meta_description_translations
added to create/update schemas, route handlers, and service methods
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>