Commit Graph

172 Commits

Author SHA1 Message Date
10a99f98fe fix(storefront): i18n sweep + locale-aware reset-password and welcome email
Some checks failed
CI / ruff (push) Successful in 19s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Test 5 (storefront password reset + customer dashboard) surfaced five
issues that all traced back to missing i18n plumbing:

- Forgot-password email arrived in EN regardless of storefront locale —
  handler now prefers request.state.language over customer.preferred_language,
  and loyalty self-enrollment backfills preferred_language for new + returning
  customers so future locale-sensitive flows hit the right language without
  being told twice.
- reset-password.html rendered "undefined" icon boxes because $icon magic
  wasn't loaded in the standalone page — replaced with inline SVGs matching
  the forgot-password.html convention.
- reset-password.html was hardcoded English: added lang attr, full _()
  sweep (22 new auth.* keys × 4 locales), language selector, and JS
  validation strings exposed via tojson.
- "Continue shopping" CTA renamed to "Back to Home" (auth.back_to_home,
  4 locales) on login + forgot + reset — loyalty storefronts have no
  catalog to continue to, mirroring the earlier enroll-success rename.
- /account dashboard, profile, addresses were hardcoded English in the
  body (menu was FR because base layout uses _()). New customers.storefront
  .pages.{dashboard,profile,addresses}.* namespace (~80 keys × 4 locales),
  templates updated, Alpine JS strings injected via window.__*I18n.

18 files, 18 changed; arch validation: 126 warnings before = 126 after,
mkdocs --strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:06:11 +02:00
bb4c400436 fix(loyalty): sweep remaining hardcoded 'en-US' in persona JS files
Follow-up to 06e59f73 which swept non-loyalty modules. The earlier
loyalty fix (dd1f9af8) only touched the shared/ factories; persona-
specific JS files in loyalty's admin/, merchant/, store/, and
storefront/ dirs were missed and still hardcoded 'en-US'.

13 occurrences across 8 files now use I18n.locale:
- admin: loyalty-analytics.js, loyalty-merchant-detail.js,
  loyalty-programs.js
- merchant: loyalty-analytics.js
- store: loyalty-analytics.js, loyalty-terminal.js
- storefront: loyalty-dashboard.js, loyalty-history.js

After this commit grep -rn "'en-US'" --include=*.js across the whole
repo returns nothing. Clearing the deck so the JS-016 rule can ship
at error severity in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:51:24 +02:00
dd1f9af811 fix(i18n): locale-aware date/number formatting in loyalty factories
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
Dates rendered in English even when the dashboard language was set to
French (or any other locale). The 5 shared loyalty Alpine factories
hardcoded 'en-US' in every toLocaleDateString / toLocaleString /
Intl.NumberFormat call, ignoring the user's selected language.

- Add `I18n.locale` getter to static/shared/js/i18n.js that returns
  the current dashboard language code (en/fr/de/lb). Falls back to
  'en' if I18n isn't initialised yet.
- Replace 'en-US' with I18n.locale in 5 loyalty shared factories:
  loyalty-cards-list, loyalty-card-detail-view, loyalty-transactions-
  list, loyalty-pins-list, loyalty-devices-list.
- Also fix a latent bug in loyalty-transactions-list.formatDateTime
  that called toLocaleDateString with hour/minute opts (silently
  ignored — same bug previously fixed in loyalty-card-detail-view).

Scoped to loyalty per session decision; other modules with the same
hardcoded 'en-US' pattern (catalog, billing, etc.) are tracked as a
follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:03:03 +02:00
d32c1fd545 fix(loyalty): show category column on card-detail for all 3 personas
The transaction-history table on the card-detail page rendered a
Category column only on the store frontend. Merchant and admin saw
five columns instead of six, even though the merchant report
prompted the audit (rewardflow.lu/merchants/loyalty/cards/6 vs
fashionhub.rewardflow.lu/store/.../cards/6).

Root cause was two layers:
- API: only store's GET /cards/{id}/transactions enriched
  tx.category_names from tx.category_ids; merchant's and admin's
  endpoints returned raw rows with category_names=null.
- Template: the shared partial's show_category_column flag was set
  to true only on the store wrapper.

Backfill the same `category_service.validate_category_for_store`
lookup loop into merchant.py::get_card_transactions and
admin.py::get_merchant_card_transactions, accepting Request to read
request.state.language for localised category names. Add
`{% set show_category_column = true %}` to the merchant and admin
card-detail wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:02:07 +02:00
aa8ca59493 fix(loyalty-terminal): localise cooldown toast (was raw English)
Some checks failed
CI / ruff (push) Successful in 17s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
When earn-points or add-stamp was rejected by the new cooldown
enforcement, the terminal showed the raw English error message from
the backend in the toast, even on FR / DE / LB locales:
  "Transaction failed: Please wait 15 minutes between point-earning..."

Two-part fix:

1. static/shared/js/api-client.js — when raising apiError on non-OK
   responses, also propagate the `details` payload from the response
   body (alongside the existing errorCode). Without this the catch
   sites had no structured access to e.g. cooldown_minutes.

2. loyalty-terminal.js — in the catch around the transaction dispatch,
   when error.errorCode is POINTS_COOLDOWN or STAMP_COOLDOWN, render a
   new localised key loyalty.store.terminal.cooldown_wait_minutes with
   {minutes} interpolated from error.details.cooldown_minutes (with a
   fallback to this.program.cooldown_minutes). Toast type switches to
   'warning' since the rejection is soft (try again later) rather than
   a hard failure. Other errors keep the existing 'transaction_failed'
   path so nothing else regresses.

Added the new key in en / fr / de / lb under the existing
loyalty.store.terminal.* namespace (sibling of the existing
cooldown_active label).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:16:32 +02:00
c661c1e394 refactor(loyalty): migrate store cards + card-detail to shared partials
The store frontend was inlining two CRUD bodies that already had shared
equivalents under loyalty/templates/loyalty/shared/. Migrate them to the
established pattern (thin per-persona wrapper + shared body partial).

- store/cards.html: 171 -> 56 LOC. Now sets cards_api_prefix /
  cards_base_url / show_store_filter=false and includes
  shared/cards-list.html (same partial merchant already uses).
- store/card-detail.html: 205 -> 55 LOC. Includes
  shared/card-detail-view.html with new flags show_copy_buttons,
  show_category_column, show_pagination so its extras survive.
- shared/card-detail-view.html: gain those three boolean flags plus
  reads txLabels/txNotes from the Alpine factory (empty defaults so
  admin/merchant callers still get raw values).
- shared/loyalty-card-detail-view.js: factory accepts txLabels, txNotes,
  paginate config; exposes pagination state unconditionally so the
  partial's pagination macro resolves; fix latent bug where
  formatDateTime called toLocaleDateString with ignored hour/minute
  opts.
- store/loyalty-cards.js + loyalty-card-detail.js: now thin wrappers
  calling the shared factories.
- locales/{en,fr,de,lb}.json: add loyalty.shared.card_detail.col_category
  for the new optional column.
- Add `noqa: TPL-016` on the 5 legit-exception loyalty templates
  (admin/programs aggregator, admin/merchant-settings, admin/wallet-debug,
  store/enroll, store/terminal) ahead of the rule landing in a follow-up
  commit. Note the per-file reason inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:09:29 +02:00
93ab072f55 fix(loyalty): enforce cooldown on earn-points (was silently skipped)
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
stamp_service.add_stamp checks card.last_stamp_at + cooldown_minutes
before crediting and raises StampCooldownException if too soon. The
parallel points_service.earn_points writes card.last_points_at but
never reads it for enforcement — so cooldown_minutes was silently
ignored for points-based programs.

Mirror the stamps check in points_service.earn_points: after acquiring
the row lock, compare now vs last_points_at + cooldown_minutes and
raise the new PointsCooldownException if the cashier is inside the
window. Add PointsCooldownException alongside StampCooldownException
in exceptions.py with parity wording / error code POINTS_COOLDOWN.

Surfaced during Test 3 step 3.6 — repeated earn-points calls for the
same card kept crediting the customer with no rate limit even though
the program's cooldown_minutes was set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:28:23 +02:00
dee2eab266 fix(loyalty): conditional subtitle on enrollment success page
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
When re-enrolling (already a member), the success page showed two
contradicting messages stacked:
  Title:    "Vous êtes déjà membre !"          (correct, conditional)
  Subtitle: "Vous êtes maintenant membre..."   (wrong — static)

The title was already x-text-conditional based on
enrollContext.already_enrolled, but the subtitle was a server-side
{{ _('success.message') }} so it always rendered the "you're now a
member" copy regardless of branch.

Make the subtitle conditional the same way:
- new i18n key already_enrolled_message in en/fr/de/lb
  ("Welcome back — your card is ready whenever you are." and
  locale-appropriate equivalents)
- expose success_message + already_enrolled_message in i18nStrings
- subtitle becomes x-text="already_enrolled ? msg2 : msg1"

Found during Test 2 round 2 — cross-store re-enrollment at
FASHIONOUTLET with the email from Test 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:15:15 +02:00
5f2885023c fix(loyalty-admin): require at least one terms field on program form
Some checks failed
CI / ruff (push) Successful in 19s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
If a merchant saves a loyalty program with both terms_text and
terms_cms_page_slug empty, the storefront enrollment page renders the
"Terms & Conditions" link as a non-clickable <span> (see enroll.html
template branch) — customers can't read what they're agreeing to.

Two changes to the shared program-form to make this impossible to ship
by accident:

1. Yellow warning banner inside the Terms section, visible only when
   both fields are empty. Tells the admin what the storefront will
   look like and what to fix.
2. Save button is disabled until at least one of the two terms
   fields is filled. The button gets a localised :title tooltip
   explaining why it's disabled, and disabled:cursor-not-allowed so
   the disabled state is obvious on hover.

Added three i18n keys (terms_required_warning, terms_text_hint,
terms_required_tooltip) in en/fr/de/lb, plus a small "either this or
the slug above is required" hint under the textarea so each field is
self-explanatory in isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:36:14 +02:00
ab3e133af7 fix(loyalty): use flatpickr for birthday so Firefox honors dd/mm/yyyy on FR
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Native <input type="date"> defers display format to the browser's
locale, which most engines pick up from the <html lang> attribute.
Firefox is the exception — it ignores lang and uses the OS locale
instead (Mozilla bug #1344625, still open). So FR users on Firefox
still saw mm/dd/yyyy even after the lang fix from earlier this week.

Swap to flatpickr for both the customer storefront enrollment page
and the staff terminal enrollment page. Configure:
  - dateFormat: 'Y-m-d'   (what gets sent to the API — ISO, what
                           Pydantic's date field expects)
  - altInput: true        (flatpickr creates a separate visible input)
  - altFormat: 'd/m/Y'    (what the user sees — universal in Europe)
  - locale: current_language (FR/DE/LB month + day names)
  - maxDate: 'today'      (no future birthdays)

Load flatpickr core + the optional locale JS via the existing
{% block extra_head %} / {% block extra_scripts %} hooks. The
loyalty/store/enroll.html template didn't have those blocks before,
added them in the same commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:55:35 +02:00
54247ca4f0 feat(static-assets): cache-bust JS/CSS via ?v=<commit-sha>, immutable in prod
All checks were successful
CI / ruff (push) Successful in 18s
CI / pytest (push) Successful in 2h50m43s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Successful in 50s
CI / deploy (push) Successful in 1m15s
Adds a `static_v(request, name, path=...)` Jinja helper that appends
?v=<commit-sha> from app.core.build_info, plus a CachedStaticFiles
subclass that serves Cache-Control: public, max-age=31536000, immutable
in production and no-cache in development. Browsers refetch JS/CSS
automatically on every deploy without the user having to hard-reload.

- New: app/core/static_files.py (CachedStaticFiles)
- Updated: app/templates_config.py (static_v helper)
- Updated: main.py (use CachedStaticFiles for *_static mounts)
- Codemod: 143 url_for('*_static', path='*.js'|'*.css') → static_v(...)
  across 123 templates. Images/fonts/JSON locales intentionally
  unchanged (out of scope).
- Arch rule: FE-024 (warning) flags raw url_for on JS/CSS to prevent
  drift. Note: FE-008 was already taken by the number_stepper rule.
- docs/proposals/static-asset-cache-busting.md marked Done.

Closes plan from docs/proposals/static-asset-cache-busting.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:35:59 +02:00
236fee015e fix(loyalty): rename enrollment-success CTA from "continue shopping" to "back to home"
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-18 19:25:48 +02:00
2a21610128 fix(loyalty): register send_notification_email with celery worker
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-17 22:50:44 +02:00
120532e63f fix(loyalty): ProgramCreate accepts null for minimum_purchase_cents
All checks were successful
CI / ruff (push) Successful in 16s
CI / pytest (push) Successful in 2h53m13s
CI / validate (push) Successful in 35s
CI / dependency-scanning (push) Successful in 35s
CI / docs (push) Successful in 57s
CI / deploy (push) Successful in 1m18s
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>
2026-05-17 15:08:29 +02:00
8d6830fc97 fix(loyalty-admin): existing-program warning crashes when no merchant selected
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
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>
2026-05-17 14:25:34 +02:00
caf1361291 fix(loyalty): admin/store/merchant card detail return phone + birthday
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>
2026-05-16 19:45:25 +02:00
a3fb7029bd docs(loyalty): add Android terminal E2E tests to user-journeys checklist
All checks were successful
CI / ruff (push) Successful in 14s
CI / pytest (push) Successful in 2h25m39s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Successful in 50s
CI / deploy (push) Successful in 1m11s
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>
2026-05-10 22:05:00 +02:00
d3b1670623 docs(loyalty): go-live readiness snapshot — 2026-05-10
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-10 22:00:43 +02:00
c1bb225228 fix(loyalty): paired-device JWT bypasses program.require_staff_pin
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>
2026-05-06 23:16:33 +02:00
3bf23c1b23 feat(android-terminal): Phase C — PIN screen with offline bcrypt verify
Some checks failed
CI / ruff (push) Successful in 15s
CI / pytest (push) Failing after 2h27m32s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
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>
2026-05-05 22:58:18 +02:00
a0e3461c48 feat(android-terminal): Phase B — setup screen with QR scanner
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-05 22:32:25 +02:00
90b5b3d135 fix(loyalty): drop unregistered device-tablet icon, guard QR template against null
Some checks failed
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
- 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>
2026-05-05 21:31:12 +02:00
cb8e6a0ec3 fix(loyalty): accept store_id in body for merchant PIN create
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-05 21:25:01 +02:00
573b0ef483 fix(loyalty): wire merchant staff PIN form to team-member autocomplete
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / ruff (push) Successful in 16s
CI / pytest (push) Has been cancelled
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>
2026-05-05 21:20:07 +02:00
eaffc764ec i18n(loyalty): translate terminal_devices block to FR/DE/LB
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-05 21:05:58 +02:00
d99633345f feat(loyalty): attribute transactions to the acting POS tablet
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>
2026-05-05 21:04:56 +02:00
c267452dc6 fix(loyalty): align /locations endpoint shape with template bindings
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>
2026-05-05 21:03:33 +02:00
cdacc8bc0d fix(loyalty): make device tokens authenticate via require_module_access too
Some checks failed
CI / ruff (push) Successful in 15s
CI / docs (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
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>
2026-05-05 20:39:58 +02:00
6276e9e3ac feat(loyalty): pair POS terminal devices with one-time setup QR
Some checks failed
CI / ruff (push) Successful in 47s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-05 20:18:57 +02:00
516141b41d fix(loyalty): add Category column to card detail transaction history
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-04-27 22:35:47 +02:00
4f70290af5 fix(loyalty): translate transaction types + notes on card detail page
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-04-27 22:23:02 +02:00
3fa159ff2a fix(loyalty): sanitize earn amount input on every keystroke
Some checks failed
CI / ruff (push) Successful in 15s
CI / pytest (push) Failing after 2h19m25s
CI / validate (push) Successful in 29s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
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>
2026-04-26 17:58:42 +02:00
143248ff0f fix(loyalty): restrict earn points input to digits and decimal only
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-04-26 16:11:10 +02:00
56c94ac2f4 fix(loyalty): use confirm modal for category deletion
Some checks failed
CI / ruff (push) Successful in 17s
CI / pytest (push) Failing after 2h21m18s
CI / validate (push) Successful in 29s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
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>
2026-04-25 14:40:29 +02:00
255ac6525e fix(loyalty): translate category names in transaction history
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-04-25 14:17:43 +02:00
10e37e749b fix(loyalty): show translated category names on terminal
Some checks failed
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
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>
2026-04-25 13:15:47 +02:00
f23990a4d9 fix(loyalty): add red star (*) to mandatory category fields
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:47:12 +02:00
62b83b46a4 feat(loyalty): category view mode + mandatory translations
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / ruff (push) Successful in 14s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
- 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>
2026-04-25 12:34:37 +02:00
f8b2429533 fix(loyalty): rename category label "Name (default)" to "English (EN)"
Some checks failed
CI / validate (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / ruff (push) Successful in 18s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:28:00 +02:00
3883927be0 fix(loyalty): disable confirm button until category selected
Some checks failed
CI / ruff (push) Successful in 17s
CI / pytest (push) Failing after 2h35m37s
CI / deploy (push) Has been skipped
CI / validate (push) Successful in 41s
CI / dependency-scanning (push) Successful in 44s
CI / docs (push) Has been skipped
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>
2026-04-19 22:33:26 +02:00
39e02f0d9b fix(loyalty): terminal icons, server-side i18n, category in transactions
Some checks failed
CI / ruff (push) Successful in 22s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
- 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>
2026-04-19 22:20:52 +02:00
29593f4c61 feat(loyalty): multi-select categories on transactions
Some checks failed
CI / ruff (push) Successful in 24s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / pytest (push) Has been cancelled
Switch from single category_id to category_ids JSON array on
transactions. Sellers can now select multiple categories (e.g.,
Men + Accessories) when entering stamp/points transactions.

- Migration loyalty_009: drop category_id FK, add category_ids JSON
- Schemas: category_id → category_ids (list[int] | None)
- Services: stamp_service + points_service accept category_ids
- Terminal UI: pills are now multi-select (toggle on/off)
- Transaction response: category_names (list[str]) resolved from IDs
- Recent transactions table: new Category column showing comma-
  separated names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 21:36:49 +02:00
220f7e3a08 fix(loyalty): replace $t() with server-side _() in program-view
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / pytest (push) Has been cancelled
Convert 6 client-side $t() calls to server-rendered _() in the
shared program-view template to eliminate i18n timing warnings.
Uses .replace() for dynamic parameters (count, days).

Fixes warnings: loyalty.common.active, inactive, none, never,
loyalty.shared.program_view.x_points, x_days_inactivity, x_minutes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:58:03 +02:00
258aa6a34b fix(loyalty): missing i18n keys, wrong icon names in admin
Some checks failed
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 22s
CI / validate (push) Has been cancelled
- 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>
2026-04-19 19:52:39 +02:00
51bcc9f874 feat(loyalty): inline edit for transaction categories in admin
Some checks failed
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
CI / ruff (push) Successful in 21s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
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>
2026-04-19 19:27:55 +02:00
eafa086c73 feat(loyalty): translatable categories + mandatory on earn points
Some checks failed
CI / pytest (push) Failing after 2h47m45s
CI / validate (push) Successful in 39s
CI / dependency-scanning (push) Successful in 47s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 21s
- 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>
2026-04-19 14:12:55 +02:00
ab2daf99bd feat(loyalty): transaction categories — admin UI + web terminal
Some checks failed
CI / ruff (push) Successful in 27s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
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>
2026-04-19 12:28:55 +02:00
1cf9fea40a feat(loyalty): transaction categories (what was sold)
Some checks failed
CI / ruff (push) Successful in 20s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Merchants can configure per-store product categories (e.g., Men,
Women, Accessories, Kids) that sellers select when entering loyalty
transactions. Enables per-category sales analytics.

Backend:
- New model: StoreTransactionCategory (store-scoped, max 10 per store)
- Migration loyalty_007: creates table + adds category_id FK on
  loyalty_transactions
- New category_service.py with CRUD + validation
- New schemas/category.py (Create, Update, Response, ListResponse)
- Admin CRUD: GET/POST/PATCH/DELETE /admin/loyalty/stores/{id}/categories
- Store CRUD: GET/POST/PATCH/DELETE /store/loyalty/categories
- Stamp/Points request schemas accept optional category_id
- Stamp/Points services pass category_id to transaction creation
- TransactionResponse includes category_id + category_name

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:23:17 +02:00
21e4ac5124 docs(loyalty): update launch plan — Google Wallet already deployed
Some checks failed
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 25s
CI / pytest (push) Failing after 2h55m43s
CI / validate (push) Successful in 52s
CI / dependency-scanning (push) Successful in 56s
CI / docs (push) Has been skipped
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>
2026-04-17 23:04:22 +02:00
3ade1b9354 docs(loyalty): rewrite launch plan with step-by-step pre-launch checklist
Some checks failed
CI / pytest (push) Failing after 2h31m6s
CI / validate (push) Successful in 29s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 13s
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>
2026-04-16 22:34:57 +02:00