The loyalty storefront is a registration / wallet endpoint, not a
catalog — there's nothing to continue shopping toward. The button
already navigates to {{ base_url }} (the homepage), so the
destination was correct; only the wording was wrong.
Rename the i18n key continue_shopping -> back_to_home in
loyalty/enroll-success.html and all four locale files (en/fr/de/lb):
EN: "Continue Shopping" -> "Back to Home"
FR: "Continuer mes achats" -> "Retour à l'accueil"
DE: "Weiter einkaufen" -> "Zurück zur Startseite"
LB: "Weider akafen" -> "Zréck op d'Haaptsäit"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day 2026-05-17 update to the go-live readiness doc.
Welcome email B1-F is now fully resolved end-to-end (enrollment ->
celery dispatch -> email_logs status=sent -> emails landing). The
issue was a chain of four nested bugs each masking the next — the
doc lists all four with commit hashes (44c42909, 3e650ff8, 2a216101,
5b21908b) plus the three earlier same-session fixes for the SMTP
password eye toggle, the JS error on /admin/loyalty/programs, and
the 422 on ProgramCreate.
Also captured:
- Audit finding: prospecting/tasks/__init__.py has the same bug as
bug #3 (scan_tasks.py exists but not imported).
- Six queued follow-ups for next session: two Test 1 storefront nits
(date format on FR, "Continuer mes achats" CTA), Test 2 cross-
store re-enrollment, Hetzner doc check, a concrete unit-test list
that would have caught each of the four B1-F bugs, the prospecting
__init__.py fix, and a wider audit of every module's email path
to find any other silently-broken @shared_task registration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Celery workers were failing every task that touched the DB with
InvalidRequestError: "expression 'ContentPage' failed to locate a name".
Root cause: the worker process only loads task modules during startup,
not routes or services. Models reached only via SQLAlchemy
string-based relationships (e.g. Platform.relationship("ContentPage"))
were never imported, so the mapper couldn't resolve the name when the
first task tried to open a DB session. The FastAPI process avoids this
because api_router transitively imports the world; the worker doesn't.
Add _preload_all_module_models() in celery_config.py that walks the
module registry and importlib.import_module's each "app.modules.<code>.models"
package. Called at module import time, before any task runs.
Surfaced while finally getting loyalty.send_notification_email registered
on the worker — the task ran, hit the DB to load email settings, and
exploded on the unresolved Platform -> ContentPage relationship.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The notifications task module was never imported by the loyalty.tasks
package __init__, so celery's discovery walk loaded the package but
never executed the @shared_task decorator on send_notification_email.
The task was missing from the worker's [tasks] registry, so every
.delay() call resulted in NotRegistered on the worker side — message
ACKed, task silently dropped, no email_logs row written.
Add the import (and update the module docstring / __all__) so the
task is registered alongside the other loyalty background tasks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a Celery task failed, on_failure passed extra={"args": ..., "kwargs": ...}
to logger.error. Python's logging.makeRecord rejects any extra key that
collides with a built-in LogRecord attribute, and "args" is one (used for
printf formatting). The KeyError raised inside the error handler then
cascaded through Celery's trace.handle_failure, masking the real task
exception entirely.
Rename the keys to task_args / task_kwargs.
Surfaced while debugging why the loyalty welcome email never got sent —
the underlying task was failing, but the on_failure handler crashed before
logging the real cause, leaving nothing in worker logs to investigate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Proposes a one-liner Jinja helper that appends ?v=<commit-sha> to
static JS/CSS URLs, leveraging the existing build_info pipeline so
users no longer need to hard-reload after every deploy. Documents the
codemod scope (143 callsites), open decisions, and the server-side
Cache-Control: immutable follow-up that makes the version flip pay off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'clipboard' name is not registered in static/shared/js/icons.js;
'clipboard-copy' is. Browser logged "Icon \"clipboard\" not found".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the FastAPI process called send_notification_email.delay() it
crashed with kombu OperationalError "[Errno 111] Connection refused"
because the published task was going to amqp://localhost// instead
of our configured redis broker.
Root cause: tasks decorated with @shared_task bind to whatever
Celery considers the "current default app" at decoration time. Our
celery_app (with redis broker) was never imported during FastAPI
startup, so the loyalty / billing / etc. task modules registered
their @shared_task functions against Celery's built-in default —
which has broker_url=amqp://localhost// and no RabbitMQ deployed.
Two-part fix:
1. app/core/celery_config.py — call celery_app.set_default() so
any @shared_task evaluated after this module loads binds to our
app rather than the built-in default.
2. main.py — import app.core.celery_config near the top, BEFORE
api_router (which transitively imports the task modules). The
side-effect of the import (creating celery_app + set_default())
must run before any @shared_task decorator fires. An # isort:
split below the import stops the import-sorter from alphabetising
it back behind app.api.main and re-introducing the bug.
User-visible effect: loyalty welcome emails (and every other
@shared_task-based notification) now actually queue + send.
Surfaced this while debugging why enrollment on prod produced a
loyalty_card row but no email_logs row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The admin program-edit form sends null for empty number inputs.
ProgramCreate had minimum_purchase_cents declared as int (default
0, ge=0), which rejected null with 422 — even though the DB column
is NOT NULL with default 0 and "0 means no minimum" is the
documented semantics.
Add a field_validator(mode="before") that coerces None to 0 so
the admin form (and any other client that sends null for an empty
optional number) goes through cleanly. The other tolerant fields
in the schema (stamps_reward_value_cents, points_expiration_days)
are already int | None; ProgramUpdate already accepts null here.
User hit this after a clean-DB reset prevented falling back to a
pre-existing program; the merchant area form happens to send 0
instead of null, masking the bug there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The warning panel was wrapped in x-show, which only toggles
display:none — child :href bindings still evaluate, so the
'selectedMerchant.id' access inside the link threw
'TypeError: can't access property "id", selectedMerchant is null'
on every reactive update before a merchant was picked.
Switch to <template x-if> so the element is removed from the DOM
entirely when the condition is false; child bindings then never
run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI: add Clear and Copy-to-clipboard (TSV) buttons, an in-page Recent
Queries pane (localStorage, capped at 20, de-duped) and a pencil-edit
flow for saved queries with a dedicated SQL field in the modal.
Bind Ctrl/Cmd+S to open the save modal (or edit the active saved query).
Backend: harden validate_query with a multi-statement guard that
respects string literals + comments. Stop swallowing record_query_run
errors silently — log via logger.exception so failures show up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap the SMTP password input in a relative container with an
eye / eye-off toggle button so admins can reveal the value while
typing — helps catch copy-paste artifacts and typos when
re-applying credentials after a reset.
State scoped to the field's own x-data block so it doesn't leak
into the parent Alpine context. autocomplete=new-password
prevents browsers from autofilling stale values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a short "Next session" subsection to the 2026-05-16 update
spelling out the three steps to pick up Test 1 round 2 (SMTP
re-apply post-reset, program sanity check, live log tail + fresh
enrollment).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs/proposals/loyalty-go-live-readiness.md — record the
2026-05-16 session: 7 bugs found during Test 1 round 1
(TimestampMixin, CardDetailResponse, storefront i18n triple,
Makefile, meta_keywords), 6 fixed and deployed with commit
hashes, B1-F still pending repro on the clean DB.
- docs/deployment/hetzner-server-setup.md — fix section 12
step 8 to call seed_email_templates_core.py +
seed_email_templates_loyalty.py instead of the non-existent
seed_email_templates.py, and add seed_demo.py at the end.
Append a note about post-reset state (.build-info + admin
settings).
- mkdocs.yml — sweep 14 previously-unlinked proposals and 2
module docs (loyalty/production-readiness.md,
prospecting/batch-scanning.md) into the nav so the strict
build no longer warns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration cms_003 dropped meta_keywords from content_pages but the
default-pages seed script still passed it to ContentPage(...), so
make db-reset / fresh seeding bombed on the first platform.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
init-prod and db-reset both referenced scripts/seed/seed_email_templates.py
which doesn't exist — the real seeders are seed_email_templates_core.py and
seed_email_templates_loyalty.py. db-reset would fail mid-way and skip the
demo seed entirely.
Replace the missing call with both real scripts in both targets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small storefront i18n improvements found during the FR
pre-launch walkthrough on FASHIONHUB:
- Store description (e.g. "Trendy clothing and accessories") was a
single English string rendering in the footer regardless of locale.
Added a description_translations JSON column on Store with the same
shape used elsewhere (CMS, Platform, Subscription), exposed via
get_translated_description(lang), and updated the footer + meta tag
to use it. Seeded FR/DE/LB/EN for FASHIONHUB and FASHIONOUTLET so
Fashion Group renders correctly out of the box. Other stores still
show the single description field as fallback.
- "Home" was a hardcoded English literal in both desktop and mobile
nav, even though the FR translation already existed at nav.home in
static/locales/fr.json. Now uses _('nav.home').
- <html lang="en"> was hardcoded, which made <input type="date"> show
in mm/dd/yyyy on the FR storefront. Now driven by current_language
so the browser's locale-aware date picker matches the page locale.
Migration tenancy_005 adds the description_translations column;
nullable, no backfill needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared card-detail-view template already renders
card.customer_phone and card.customer_birthday, but CardDetailResponse
was missing both fields, so every consumer (admin, store, merchant)
silently returned them as undefined and the UI showed "-".
Added the two fields to the schema and populated them from
customer.phone / customer.birth_date in all three endpoints. Data was
persisting correctly all along — purely a serialization gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TimestampMixin was setting default=datetime.now(UTC) — calling the
function once at module import time. Every INSERT (and every UPDATE
via onupdate) since the app last restarted got stamped with the same
process-start timestamp, silently breaking created_at / updated_at on
every table that uses the mixin (customers, stores, cards, users,
orders, etc.).
Pass the _utc_now callable instead so SQLAlchemy invokes it per row.
Forward fix only — historical rows on prod since the most recent app
restart all read as the start-time microsecond and will need a
separate decision on whether to backfill.
Found while debugging why a customer + their loyalty card had
microsecond-identical created_at timestamps despite being created
through separate service calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Practical field-by-field intake doc for the first merchants — maps
each question to its DB column so the call → admin UI is 1:1.
Includes a section on marketing-material reuse aimed at franchisees,
with written-authorization clauses for the future marketing module.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The web user-journey checklist (Tests 1–8) only covers human-using-loyalty
flows from a browser. The cashier-facing Android tablet built in Phases
A–F goes through a different surface and has its own failure modes that
won't surface in any web test. Adding 6 dedicated Android tests so a
tablet-in-hand verification has the same level of structure as the web
side.
- Test 9: Tablet pairing — QR scan + manual entry fallback, with the
audit (paired-device row appears, last_seen_at populated)
- Test 10: PIN screen — wrong/right PIN, offline-capable bcrypt verify,
locked-PIN rejection
- Test 11: Daily flows — search, scan, enroll, stamp, earn, redeem,
with the acting_terminal_device_id audit column check at the end
- Test 12: Offline queue + sync — airplane mode → queued → re-online →
drain; redeem is hard-disabled offline per spec
- Test 13: Auto-lock + manual lock — 2 min idle, immediate lock button,
the known caveat that AlertDialog pointer events don't bubble
- Test 14: Device revocation — revoke on web → 401 on tablet next call
Updated the go-live readiness snapshot to reference these as Step 6b
(gated on user obtaining a tablet, not on schedule).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures where the loyalty pre-launch checklist actually stands after
tonight's prod readiness pass:
- Step 1 (email templates seeded) ✅
- Step 2 (Google Wallet config) ✅ validated via wallet-debug
- Step 3 (migrations) ✅ all module heads incl. loyalty_011 on prod
- Step 7 (Wallet real-device test) ✅
- Steps 4, 5 (FR/DE/LB analytics keys, store-owner template
permission) deferred — cosmetic / non-blocking
- Step 6 (8 user-journey E2E tests) is the remaining human gate
- Step 9 (Google Wallet production access) post-launch
Also records the SMTP path-change diagnosis (own mail server on port
465 blocked outbound from Hetzner; switched to 587 STARTTLS via
/admin/settings DB overrides) and the cosmetic fix shipped in
f2d1bdcd so the test email reports the *effective* config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two display bugs where admin-facing email diagnostics reported the
.env-level config even when database overrides were in effect — so a
prod admin who switched from SendGrid (.env) to SMTP via /admin/settings
would receive a test email whose body still said "Provider: sendgrid"
and "From: noreply@wizard.lu", even though the actual From header used
the DB-overridden address. Confusing, and erodes trust in the page that
exists specifically to verify the setup.
- admin_settings.send_test_email: read get_effective_email_config(db)
and display its provider + from_email in the test email's body. Also
use the effective provider when stamping the audit-log row.
- email_service: in the "template not found" EmailLog branch, record
the effective from_email / from_name / provider from
self._platform_config (already populated correctly by __init__),
instead of falling back to the env-only settings.email_*.
Verified by importing both modules; pure plumbing fix, no schema or
behavior change for callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two stale unit-test groups left over from earlier intentional changes:
1. tests/unit/api/test_deps.py — TestGetCurrentStoreApi
get_current_store_api gained a leading `request: Request` param in
commit 6276e9e3 (terminal-device pairing) so the device-token path
could record last_seen IP. The three tests still passed `(creds, db)`
positionally, so `creds` bound to `request` and the function blew up
with `Session has no attribute 'credentials'`. Pass a mock request
first using the existing `_make_request` helper.
2. tests/unit/utils/test_page_context.py — TestBaseUrlCalculation
d591200d switched the storefront base_url builder to use
`store.subdomain or store.store_code` (lowercase slug) because the
store-context middleware resolves URLs by the lowercase slug; the
uppercase `store_code` is for internal use. Tests still asserted the
old uppercase shape. Update the two URL assertions to the lowercase
subdomain, invert `test_base_url_uses_store_code_not_subdomain` into
`test_base_url_uses_subdomain_not_store_code`, and add a fallback
test that exercises the `or store.store_code` path when subdomain
is None.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Window.setStatusBarColor was deprecated in API 35; the recommended path
is enableEdgeToEdge() (already called in MainActivity) which lets the
theme drive the color. Keep the isAppearanceLightStatusBars line so the
icon tint stays correct on the brief moments immersive mode reveals the
bars on swipe.
Silences the last Kotlin compile warning. Build is clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes out the Android terminal plan (Phases A → F).
- MainActivity:
* Immersive mode via WindowInsetsControllerCompat — hides status +
navigation bars, swipe-to-reveal with auto-hide
(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE). Re-applied in onResume so
a transient overlay can't leave the bars visible.
* Lock Task Mode — startLockTask() in onResume. The manifest already
declares lockTaskMode="if_whitelisted", so this enters kiosk on a
properly MDM-provisioned tablet and is a silent no-op (caught
SecurityException) on dev / unprovisioned devices.
- TerminalScreen:
* SnackbarHost pinned at the bottom; LaunchedEffect(actionResult)
shows "Queued — will sync when back online" whenever an offline
action gets queued, so the cashier has explicit feedback beyond the
top-bar pending pill.
* 4 new locale strings (en/fr/de/lb) for the snackbar copy.
- Manifest cleanup: dropped redundant tools:replace from the debug
AndroidManifest overlay (networkSecurityConfig isn't set in the main
manifest, so the replace directive was a no-op + emitted a warning).
Skipped from the plan: a custom splash screen (the existing theme
background renders for the cold-start frame; adding the splashscreen
library is polish for a follow-up) and per-action success toasts
(the action sheet closing + balance refresh + recent-feed update are
adequate confirmation).
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stamp / earn / enroll actions performed without connectivity now persist
in the existing pending_transactions Room table and drain via a
SyncWorker as soon as the network constraint is satisfied. Redemption
stays online-only (server needs the authoritative balance — queueing
would let cashiers redeem rewards customers have already spent).
Pieces:
- Hilt-Work plumbing: androidx.hilt:hilt-compiler KSP processor wired,
RewardFlowApp now implements Configuration.Provider with the injected
HiltWorkerFactory, AndroidManifest disables the WorkManager auto-init
via the AndroidX startup tools:node="remove" pattern. Bumped Dagger
to 2.55 so its compiler can read Kotlin 2.1 metadata — 2.51 crashed
with "Unable to read Kotlin metadata due to unsupported metadata
version" once we added @Inject lateinit var on the Application class.
- data/sync/SyncWorker.kt — @HiltWorker CoroutineWorker that drains the
queue FIFO. Per row: HTTP error → permanent markFailed; IOException
→ Result.retry(); success → markSynced + sweep at end of run.
- data/repository/QueueRepository.kt — single entrypoint for "queue
this offline action". queueStamp / queuePointsEarn / queueEnroll
Moshi-serialize the request body and enqueueUniqueWork(KEEP) the
worker under a NetworkType.CONNECTED constraint with 30s exponential
backoff. scheduleSync() is idempotent.
- TerminalViewModel: runAction(block, queueOnNetworkFailure) — on
IOException AND a queue lambda is provided, queue + emit
ActionResult.Queued. HttpException paths still surface the server's
message inline. Stamp / Earn / Enroll go through the queue path;
redeem actions pass null. setStaffPinId records who initiated each
queued row. init schedules a sync so anything left from a previous
session drains on screen entry. pendingSyncCount is combined into
state via Flow<Int> from the DAO; when it drops to 0 we refresh the
recent-transactions feed so the UI shows what just synced.
- TerminalScreen: PendingSyncPill in the top bar (only visible when
count > 0) gives the cashier feedback that a queued action is
waiting + that the queue is draining live.
Cleartext + readable HTTP errors from yesterday remain in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small dev-quality fixes shaken out by manual testing:
- Add a debug-only network_security_config.xml that whitelists 10.0.2.2,
localhost and 127.0.0.1 for cleartext HTTP. Without this, the dev
emulator can't reach the Python dev server because targetSdk 35
forbids cleartext HTTP by default. Lives under app/src/debug/ so it
ships only in debug APKs — release builds keep the platform default
(no cleartext at all).
- TerminalViewModel.runAction now extracts the JSON {message: ...} field
from HttpException response bodies instead of just showing "HTTP 400".
Cashiers (and developers) now see "Staff PIN is required for this
operation" / "Daily stamp limit of N reached" inline in the failed
action sheet, surfacing the same business-error text the server
already returns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the request principal is a paired POS terminal device
(current_user.terminal_device_id is not None), the staff PIN is
considered already-verified — the cashier bcrypt-verified locally on
the tablet's lock screen against the cached hashes from
/pins/for-device. Web-terminal user JWTs still require the per-action
PIN as before; the strict fraud-prevention path is unchanged.
Threat-model note: the device JWT is itself proof of authentication.
The merchant owner paired the device, the cashier verified locally,
and the JWT is revocable from /merchants/loyalty/devices. The 2-min
idle auto-lock + acting_terminal_device_id audit column give us the
attribution we'd otherwise get from a per-action PIN.
Applied to: stamp_service.add_stamp / redeem_stamps / void_stamps;
points_service.earn_points / redeem_points / void_points. adjust_points
was already permissive on missing PIN. New tests in TestDevicePinBypass
lock both the bypass behavior and the still-strict web-terminal path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the terminal screen in an IdleTracker that observes pointer-down
events and fires onLockScreen after 2 min of silence (per spec). Each
tap restarts the timer via a LaunchedEffect re-launch.
awaitFirstDown(requireUnconsumed = false) lets us observe touches
without intercepting them, so children (text fields, buttons, lists,
keypad) keep working normally. Body extracted into a private
TerminalContent so the wrapper stays tidy.
Caveat: Compose AlertDialogs render in a separate window, so the timer
keeps ticking through an open dialog. This is correct behavior — a
cashier who walks away mid-dialog should be locked out — and the
dialog disposes when the NavHost pops the terminal route on lock.
Documented in IdleTracker.kt.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both left-pane buttons now functional. Cashier can find a customer by
scanning their loyalty QR or enroll a brand-new customer from the
tablet — closing the last gap in the everyday POS flow.
- TerminalViewModel: scannerOpen / enrollDialogOpen / enrolling /
enrollError state. submitEnroll posts to /cards/enroll, then re-fetches
the lookup shape so the customer pane renders fully (rewards,
cooldown). On lookup-after-enroll failure (rare) the new card_number
is pre-filled in the search field as a fallback.
- EnrollDialog.kt (new): AlertDialog with name + email (required), phone
+ birthday (optional; birthday is plain YYYY-MM-DD text — date picker
is a polish task). Inline error surface for backend rejections.
- QrScannerOverlay.kt (new): fullscreen overlay reusing QrScannerView
from Phase B. Cancel button top-right. Decoded value is treated as a
card_number and feeds the lookup flow.
- TerminalScreen: scan/enroll buttons are no longer disabled; the two
new composables render conditionally on top of the main layout.
The tablet now supports every everyday flow: lookup, scan, enroll,
stamp, earn points, redeem stamps, redeem reward, recent feed.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Left pane below the search/scan/enroll buttons now shows the last ~10
transactions for the merchant, refreshed on init and after every
successful action sheet submission.
- ApiModels: TransactionListResponse + TransactionItem mirroring the
store-API shape (deltas, customer/staff names, purchase amount,
reward description, ISO transaction_at).
- LoyaltyApi.listRecentTransactions(skip, limit) → GET /transactions.
- TerminalViewModel.refreshRecentTransactions called on init and from
runAction's success path so balances + feed stay in lockstep.
- TerminalScreen: RecentTransactionsList composable. Each row renders
the type (translated by transactionLabel), customer name when known,
signed delta (+50 pts / +1 ★ / -100 pts), and HH:mm timestamp
localized to the device timezone via java.time (Android 26+).
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four right-pane buttons now work end-to-end (online happy path).
Each opens an AlertDialog wired to a ViewModel action; on success the
sheet closes and the customer card refreshes from the server.
- TerminalViewModel: + categories cache load (via CategoryRepository),
+ activeAction / actionInProgress / actionResult on the state. Action
methods (submitStamp, submitEarnPoints, submitRedeemStamps,
submitRedeemReward) all funnel through a runAction helper that toggles
in-progress, calls the LoyaltyApi, refreshes the customer on success,
surfaces failures inline.
- ActionSheets.kt (new): one ActionSheet entrypoint that dispatches by
ActionKind. Category multi-select via FlowRow + FilterChip pills.
EarnPoints accepts "12.50" / "12,50" → cents. Reward picker shows
available_rewards from the lookup response.
- TerminalScreen: action buttons enabled per state — stamp/earn stay
active offline (Phase E will queue them), redeem is hard-disabled when
offline per the plan ("redemption requires an internet connection").
Categories list comes from the cache filled at pairing; fresh refresh
runs in the background so newly-added categories show up next launch
without a manual sync.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The terminal screen is now functional for card lookup and customer
display. Phase D.2 will fill in the action sheets (stamp / earn /
redeem) on top of this.
- TerminalViewModel: state machine with program (from cache), customer,
search/error/online state. onSearchSubmit hits /cards/lookup;
refreshCurrentCustomer re-fetches after actions land.
- TerminalScreen rewrite: top bar with staff name + online pill + Lock;
left pane with search field + buttons; right pane shows the empty
state or a customer panel (name/email/card number, points + stamps
card, four placeholder action buttons greyed out for D.2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-pane landscape: scrollable staff list on the left, PIN dots + numeric
keypad on the right. Footer shows online/offline + pending-sync count.
Going with cached-hashes for offline-capable PIN verify (decision logged
in chat). The threat model already accepts the device — a stolen tablet
holds a 1-year store-scoped JWT, so leaking 4-digit bcrypt hashes is
incremental. Hashes only ever leave the server when the requester is a
paired POS tablet, gated by the new endpoint refusing user JWTs.
Backend:
- GET /api/v1/store/loyalty/pins/for-device — returns PINs WITH pin_hash
for terminal-device JWTs only; user JWTs receive 403.
- PinForDeviceResponse / PinForDeviceListResponse schemas.
- 2 integration tests in TestPinsForDevice (10/10 pass total).
Android:
- PinForDeviceItem / PinForDeviceListResponse Moshi models.
- LoyaltyApi.listPinsForDevice().
- StaffPinRepository.verifyPin(plain) — at.favre.lib bcrypt verify
against cached hashes; filters active + unlocked rows in one pass.
- PendingTransactionDao.getPendingCount() switched to Flow<Int> so the
badge auto-updates when transactions sync.
- PinViewModel state machine — loads pins on init, accumulates digits,
bcrypt-verifies on length >= 4, fires verified/errorMessage. Combines
pending-sync count + online state into the same StateFlow.
- PinScreen rewrite: avatar-circle staff list, 6-dot PIN display,
spinner during verify, error label on wrong PIN, status footer.
Open follow-up (intentional, post-launch): tablet doesn't yet report
failed attempts back to the server's lockout counter. Path is clear —
small POST /pins/{id}/record-failed-attempt endpoint plus a call from
attemptVerify's failure branch.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end pairing flow:
1. SetupScreen renders a CameraX preview on the left, a manual-entry
form on the right (dev fallback). Camera permission is requested
in-place — no accompanist dep.
2. QrScannerView uses ML Kit's barcode scanner (QR format only),
single-shot fires the decoded JSON to the ViewModel and stops
analysing.
3. SetupViewModel.pairFromQr decodes via Moshi, persists the pairing
in DataStore, then verifies by hitting /api/v1/store/loyalty/program
through the AuthInterceptor (which now sees the new url + token).
On 200 it warms the staff PIN and category caches and emits Success;
on failure it rolls back via DeviceConfigRepository.resetDevice() so
the user is back at a clean Setup with an error.
4. The NavHost watches is_device_set_up and forwards to PIN once Success
fires. The DataStore key was aligned to "is_device_set_up" so this
reactive switch keeps working.
Backend: the QR payload generated by POST /merchants/loyalty/devices
now includes store_id and store_name in addition to api_url, store_code
and auth_token, so the tablet doesn't have to resolve them later via a
separate call. Old QRs (which only had three fields) won't decode — the
merchant has to revoke and re-pair, which is the same flow they'd run
anyway after losing a tablet.
Files:
- ui/scanner/QrScannerView.kt (new) — CameraX + ML Kit composable
- ui/setup/SetupViewModel.kt (rewrite) — pair flow + state machine
- ui/setup/SetupScreen.kt (rewrite) — two-pane layout, status overlay
- data/model/ApiModels.kt — SetupPayload model
- data/repository/DeviceConfigRepository.kt — IS_SET_UP key alignment
- app/modules/loyalty/services/terminal_device_service.py — richer QR payload
Verified by ./gradlew assembleDebug — clean build, all warnings address
in this commit (LocalLifecycleOwner moved to lifecycle.compose, OptIn on
ExperimentalGetImage removed since it's no longer @RequiresOptIn).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation work for the Android tablet POS app. Adds the singletons that
every screen needs (config persistence, network awareness, auth) without
touching the existing screen stubs yet — Phases B–F will build on these.
- gradle: bcrypt 0.10.2 pinned (used in Phase C for offline PIN verify)
- LoyaltyApi: + GET /api/v1/store/loyalty/categories endpoint
- ApiModels: + category_ids on StampRequest / PointsEarnRequest,
+ CategoryItem / CategoryListResponse
- DeviceConfigRepository: DataStore wrapper for the paired-tablet state
(api_url, auth_token, store_id/code/name, cached program/pins/categories
JSON, per-seller language, resetDevice())
- AuthInterceptor: rewrites every request's host to the paired api_url
and adds Bearer auth — Retrofit keeps a placeholder baseUrl since the
real URL only exists post-pair
- NetworkMonitor: Flow<Boolean> isOnline from ConnectivityManager
- StaffPinRepository / CategoryRepository: cache-or-refresh pattern,
Moshi-serialized to DataStore
- AppModule: wires AuthInterceptor before the logging interceptor
- strings.xml: ~50 strings × 4 locales (en authoritative; fr/de/lb
translated, mirroring the loyalty backend's i18n approach)
Verified by ./gradlew assembleDebug — clean build, only pre-existing
warnings (Theme statusBarColor deprecation, Moshi-kapt deprecation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>