Changelog
Development history and test coverage. For feature overview, see features.md.
Test Coverage
API (booking-api)
| Module | Unit Tests | E2E Tests |
|---|---|---|
| Auth | 38 | — |
| Tenant & Onboarding | 26 | — |
| Resource Management | 32 | — |
| Service Catalog | 26 | — |
| Booking Engine | 50 | — |
| Availability | 20 | — |
| Public Booking API | 25 | 33 |
| Customer Auth | — | 19 |
| TenantCustomer | 12 | — |
| Loyalty System | 28 | — |
| Payment Foundation (DDD) | 469 | — |
| Booking → Outbox (Phase 6B) | 27 | 5 |
| Booking Event Listeners | 19 | — |
| Authorization Expiry Cron | 26 | — |
| Worldline Direct adapter (renamed) | — | — |
| Bambora Classic adapter (Phase 7 Track D1 swap) | 72 | — |
| BookingId prefix filter + no-op update guard (post-D2) | 3 | — |
| Remaining payment handler + controller (Track E1) | 18 | — |
| HTTP shared lift (dedupe worldline-direct copy) | −13 | — |
| Capture-mode hotfix (intent-derived + Bambora instantcapture) | 0 | — |
| Capture trigger move (Confirmed → Arrived + Completed fallback) | 3 | — |
| Lead-time validator (maxBookingDaysInAdvance) | 5 | — |
| Auth-expiry → listener contract test | 1 | — |
| Track L1 — loyalty discount schema (migration + backfill, no tests) | 0 | — |
Track L2 — computeLoyaltyDiscount pure helper |
19 | — |
| Track L3 — DDD layering + reserve on booking create | 39 | 0 |
Multi-day time-off weekday-flake fix (getNextWeekday helper) |
0 | 2 |
| Role-based audit Phase 2 — STAFF scoping (booking/resource/tenant-customer/payment) | 14 | — |
| Role-based audit Phase 3 — web STAFF support + /auth/me resourceId | 1 | — |
| Role-based audit Phase 4 — Add-staff login + auth multi-tenant + cache leak fix | 9 | — |
| Status-matrix P0-1/P0-2/P0-3 — force cancel + deposit guard + walk-in IN_PERSON | 17 | — |
| Status-matrix P1-1 — customer self-cancel endpoint | 6 | — |
| Test type drift fix — 8 specs + ts-jest full diagnostics | 0 | — |
| Total | 1218 | 59 |
Track D2 (admin payments list/detail/refund/void/capture) is frontend-only — no backend changes, 5 existing admin endpoints reused. Post-D2 additions:
ListPaymentsQuery.bookingIdswitched from exact match tostartsWithso 8-char pasted prefixes match;BookingService.updateshort-circuits when diff is empty so quick-tap "Save" no longer bumpsupdatedAtnor writes a noise audit row.
Web (booking-web)
| Module | Unit Tests | E2E Tests |
|---|---|---|
| Bambora Zod schema (Classic, 4 fields) | 17 | — |
| Payment config hooks | 8 | — |
| Provider metadata | 4 | — |
| HealthCheckBadge | 3 | — |
| ProviderCard | 7 | — |
| BamboraConfigForm | 5 | — |
| Vitest infra smoke | 2 | — |
| Payment settings page | — | 3 smoke + 1 @integration |
| Admin payment hooks (D2) | 10 | — |
| Refund schema (D2) | 8 | — |
| Capture schema (D2) | 5 | — |
Shared formatDateTimeInZone helper (D2) |
6 | — |
| Admin payment list page | — | 2 smoke |
| Collect remaining schema + hook (Track E1) | 9 | — |
| Public booking DateStrip (Playwright) | — | 2 |
| Public booking flow E2E (Tranches 1–4, Playwright) | — | 11 |
| Force-override modal (P0-1/P0-2 FE wiring) | 0 | — |
| Customer self-cancel CTA (P1-1 FE wiring) | 0 | — |
| Total | 112 | 18 + 1 |
For a per-file breakdown of every test (unit + integration + E2E) across all repos, see
testing.md.
Timeline
| Date | Highlights |
|---|---|
| 2026-04-06 | Project scaffold, auth, response envelope, Prisma setup, Docker |
| 2026-04-07 | Auth upgrade, Epic 1-4 (tenant, resource, service, booking + availability) |
| 2026-04-12 | Web admin: dashboard, staff, services, bookings calendar, settings |
| 2026-04-13 | Multi-service booking, calendar drag-drop, customer booking page |
| 2026-04-14 | Public page redesign, settings sidebar, location/map, work schedule |
| 2026-04-14 | Multi-slot business hours (Fresha-style), schedule editor modal, timezone fix |
| 2026-04-14 | TimeOff feature (API+UI), availability indicators, TimePicker/TimeField |
| 2026-04-15 | Public booking page (single-page, multi-service, Calendly-style), error codes cleanup, 33 e2e + 45 unit tests |
| 2026-04-15 | Tax/accounting settings, ARRIVED status, staff skill tabs, calendar status filter, booking audit log, DTO audit |
| 2026-04-16 | Customer auth (Google OAuth), customer portal (profile, bookings), 19 e2e tests |
| 2026-04-16 | TenantCustomer bridge, loyalty system (stamps + points), admin loyalty UI, customer loyalty dashboard, form validation audit |
| 2026-04-16 | UI polish: header glass effect, hero gradient, info pages layout (sidebar+tabs), admin footer (locale/theme), static pages, settings/account URL-based tabs, seed location fix, React Compiler lint fixes |
| 2026-04-16 | Guest vs auth booking: contact snapshot fields on Booking, no auto-merge guest→customer. Booking list: status dropdown, SearchSelect resource filter, DatePicker, view mode localStorage persist. Week view scroll. |
| 2026-04-16 | Security hardening: token versioning (User+Customer), changePassword/resetPassword invalidate sessions, helmet, sameSite strict, MaxLength password, admin guard rejects customer tokens, Bull Board timing-safe auth, response body no longer exposes tokens. Separate admin/customer refresh + 401 redirect. |
| 2026-04-17 | Payment Phase 0–4 foundation (DDD + Hexagonal + CQRS): domain + policies + Prisma persistence + dual-write outbox + FakeProvider + commands/queries + integration listeners + admin/public HTTP + PaymentDomainError filter + enum drift guards + tenant-scoped repos. 21 commits, 715 tests. |
| 2026-04-17 | Payment Phase 5: Bambora adapter (Worldline Connect — credentials, HMAC, http-client, errors, mapper, retry), webhook pipeline (HMAC-SHA256 verify + 5-min replay window + payment_webhook_inbox dedup + BullMQ payment-webhook processor), BullMQ root at AppModule + BullBoard, rawBody: true bootstrap, enum guards cleanup, admin controller tests. +102 tests. |
| 2026-04-17 | Deposit settings UI (booking-web): deposit enabled/type/value, input group style. |
| 2026-04-17 | Docs reorganization: split docs/ into product/, architecture/, flows/, rules/, operations/, progress/ subfolders + index README.md. |
| 2026-04-17 | Add development-rules.md §0 Engineering Mindset: senior-developer stance by default + zero-shortcut discipline for payment/money/credentials code (no TODO, no "fix later", observability ships with feature). |
| 2026-04-22 | Public booking E2E coverage — 4 tranches / 11 new Playwright tests: happy path + multi-service + unassigned vs assigned-only (Tranche 1), closed-day grey-out + business-hours slot cap + deposit redirect to Bambora (Tranche 2), blank-name + skill filter + invalid serviceId (Tranche 3), rebook via ?from= (Tranche 4). BookingPage / ServiceItem / ServicePicker gain data-testids; ownerApi Playwright fixture switched to worker-scoped to stop the login rate-limiter; changelog Timeline refactored to keep short entries in the table and push long entries to a new "Detailed Entries" section; new testing.md doc tracks per-file test counts across all repos. 13/13 public-booking Playwright green in 18.5s; lint 0/0; build clean. |
| 2026-04-23 | Role-based audit Phase 5 — Playwright E2E staff scenarios: 7 new tests (staff-role.spec.ts) covering STAFF signin via form, sidebar filter (only Dashboard/Bookings/Customers/Loyalty visible), OwnerOnlyGuard bounces (/admin/staff, /admin/settings, /admin/services all redirect to /admin), /admin/bookings stays accessible, and OWNER→logout→STAFF role switch keeping tenantId while flipping role + resourceId. Fixture refactored to storageState pattern: owner + staff each log in once per worker, every test loads saved cookies — kills the auth rate-limiter cascade that was bringing down the full suite. Two pre-existing spec fixes: payment-settings.spec.ts "Bambora" card assertions moved to getByRole('heading') (description text also contained "Bambora", tripping strict mode) + Merchant label updated to placeholder-based locator (form labels now use <span> not <label htmlFor>). Public-booking 1.3 unassigned marked test.fixme — genuine API regression where unassigned + first-slot time lands on BOOKING_OUTSIDE_BUSINESS_HOURS while 1.1 (same slot, staff assigned) passes; tracked for separate fix. Results: 24/25 E2E green + 1 fixme in 34s · lint 0/0 · build clean. Unblocks mobile scaffold start. |
| 2026-04-23 | Auth + profile UX polish (same day, follow-up to multi-tenant sign-in). (1) Tenant picker media grid — TenantPicker rendered as single-column list; swapped to grid-cols-1 sm:grid-cols-2 with max-w-2xl, avatar bumped to h-14 w-14 rounded-xl, tenant name to text-base font-semibold, and the user's role in that tenant now shown below the name (uppercase + tracking-wide) instead of the slug — owners/staff with the same email across salons immediately see which login they're picking. API: AuthService.login includes role: m.role on every entry of the AUTH_TENANT_REQUIRED.tenants[] payload; frontend TenantChoice.role required + normalizeTenants() drops rows missing role. (2) Admin Profile page — Profile card + Personal information card now share a row on lg+ (grid-cols-1 lg:grid-cols-2); Change password stays full-width below. (3) Dead code removal — deleted unused ChangePasswordForm.tsx (superseded by ChangePasswordCard but never un-imported). (4) Auth-refresh cookie loop hardening — POST /auth/refresh now clears auth cookies in the response on any failure (missing token / revoked / expired) so the browser stops re-sending a dead cookie on every F5, breaking the refresh → /me 401 → redirect → same cookie → 401 loop; AuthGuard + UserDropdown.handleSignOut + ChangePasswordCard.onSuccess switched from router.replace → window.location.href = '/admin/signin' to avoid the RSC round-trip fetching with the stale cookie before the redirect lands; AuthContext.fetchUser returns the resolved user, logout no longer pushes internally — callers own the redirect for explicit mid-session clear. Tests: API auth.service spec asserts role in tenants payload (1 updated assertion) · web SignInForm tests include role on 3 mocked tenant payloads (7 pass). Lint 0/0, build clean both repos. |
| 2026-04-23 | Admin UI polish tranche: (1) Team toolbar <select> → SearchSelect for style parity with other dropdowns (new feedback_no_native_select memory), toolbar widths pinned so the status filter no longer stretches to fill the card. (2) Global search box in AppHeader replaced with the current tenant name (useTenant) — truncates to … with title={tenant.name} tooltip, max-w-xs/xl:max-w-sm + lg:block responsive, removed the dead ⌘K focus listener. (3) Theme persistence fixed across reloads: inline <head> script sets html.dark from localStorage BEFORE React hydrates, ThemeContext reads via useSyncExternalStore(document.documentElement.classList) + one-shot mount reconcile effect covers CSP/HMR edges; AdminFooter Light/Dark buttons each target an explicit setTheme(next) instead of both calling toggleTheme; <html suppressHydrationWarning> silences the expected class-attribute diff. (4) Dark-mode modal separation fix: every modal surface (shared Modal wrapper + 12 hand-rolled dialogs — Confirm, Refund/Capture/Void/CollectRemaining, Account/Tax, BookingDetailModal, BookingDrawer inline confirm, BookingHistory, UserInfoCard, UserMetaCard) now border border-transparent dark:border-gray-600 dark:shadow-2xl — earlier attempts used dark:border alone which doesn't emit width in Tailwind v4 (border-color applied, border-width stayed at 0). Saved feedback_inspect_before_guess memory so future UI-regression debugging starts in DevTools Computed tab before cycling class variants. No new tests (presentation-only); lint 0/0, build clean. |
| 2026-04-24 | Build fix for 4 GB production VPS — iteration 2: env-flag opt-out + husky pre-push gate. The typescript.ignoreBuildErrors switch was flipped from "always on" to process.env.BUILD_SKIP_TYPECHECK === '1', so local dev + CI build with the full safety net and only the memory-tight server trims the check. NODE_OPTIONS dropped from the build script — it's now an opt-in second-line fallback documented in .env.example. New .husky/pre-push hook runs yarn lint && yarn typecheck && yarn test (~12 s, 137 tests) so the server opt-out never lands a type error in prod. husky 9.1.7 added as devDep; prepare: husky wires the hook up on yarn install. Default build 14.4 s, skip-TS build 10.5 s with "Skipping validation of types" banner. |
| 2026-04-24 | Build fix for 4 GB production VPS — iteration 1: hardcoded ignoreBuildErrors: true + NODE_OPTIONS='--max-old-space-size=3072' in build script. Superseded the same day by iteration 2 (env-flag gate). |
| 2026-04-24 | Status-matrix P1-1 · customer self-cancel endpoint. New POST /public/tenants/:slug/bookings/:id/cancel gated by @CustomerAuth() — tenant scoped via slug (404 when booking belongs to another salon), ownership check booking.customerId === customerUser.customerId (guest bookings + mismatches get 403 BOOKING_NOT_IN_CUSTOMER_SCOPE), then delegates to BookingService.updateStatus with { role: 'CUSTOMER', userId: customerId } so the existing cancellation-window guard runs (!isSystem && !isAdmin branch) and BookingCancelled emits cancelledBy=CUSTOMER. Web: api-client taught to treat /public/tenants/.../bookings/.../cancel as a customer-authed public path (new CUSTOMER_AUTHED_PUBLIC_PATHS pattern list — still resolves as public for routing but triggers ensureValidCustomerToken + retry-with-customer-refresh on 401). account/BookingsSection renders a red "Cancel" CTA on PENDING/CONFIRMED/ARRIVED rows, gated behind ConfirmDialog; on success invalidates ['customer','bookings'] so the list re-fetches with the new status. 6 new i18n keys (en + nb). Tests: 6 new unit tests on PublicBookingController (happy path, guest booking, owner mismatch, tenant not found, window-guard passthrough, slug not found). 1233 API pass (+6) · 137 web pass · lint 0/0 · build clean both repos. Out-of-window UX (P1-2 "Contact salon" CTA) still open. |
| 2026-04-24 | Seed consolidation: seed-multistore.ts folded into seed.ts as section 13 so yarn seed boots with the full multi-tenant login fixture (multistore@gmail.com OWNER at studio-nordic + owner2, STAFF at owner3 + owner4 with linked Resource + Mon-Fri schedule). Extra tenants use getDefaultSettings('beauty') + DEFAULT_BRANDING to stay compliant with the no-fallback-settings rule. Standalone seed-multistore.ts deleted. Lint + build clean. |
| 2026-04-24 | Tenant picker v2 + multi-tenant login hardening. UX (booking-web) TenantPicker rewritten to group tenants by role (Salons you own / Salons you work at), each group framed in its own rounded-2xl tinted section (brand indigo for OWNER, blue-light sky for STAFF), 56×56 gradient-tinted avatars, name/slug stacked, unified brand-accent hover (-translate-y-0.5 lift + ring-2 + bg contrast — earlier attempt used group-matched tint which collapsed into the section gradient in dark mode), neutral gray count chip shared across groups, Users → Briefcase icon for STAFF, header icons go flat (no 40px filled chip) per user feedback. 3 new i18n keys: chooseTenantGroupOwner/Staff/Other (en + nb). API (booking-api) AuthService.login bcrypt loop parallelised — for (const candidate of users) { await bcrypt.compare(...) } → Promise.all(users.map(...)). Native bcrypt runs on the libuv thread pool so this is real parallelism, cutting multi-tenant login latency from O(N × ~100ms) to ~one compare regardless of how many salons the email belongs to. AuthController.login moved off the shared AUTH_THROTTLE (10/60s/IP) onto a dedicated LOGIN_THROTTLE = 5/60s/IP — login is the only bcrypt-heavy hot path and the main brute-force surface; register/refresh stay at 10/60s. Customer /auth/customer/google unchanged (OAuth, no bcrypt path). Tests: 87/87 auth (API unit + e2e) pass · 7/7 web SignInForm.test.tsx pass. Lint 0/0, build clean both repos. |
| 2026-04-23 | Edit-staff follow-ups: role change (OWNER/ADMIN only, self-demotion blocked), Login Access polish + required Role select, Commission+Color 2-col layout, Email column on Team list, filter by status on Team list. Backend: PATCH /resources/:id now forwards login.role → bumps tokenVersion + refuses when performer.userId === resource.userId (STAFF_ROLE_SELF_CHANGE_NOT_ALLOWED); ResourceQueryDto gains `status: 'active' |
| 2026-04-23 | Edit-staff login panel (admin can reset password + edit phone in the same drawer). ResourceService.update gains a login block: resource without a linked User + {email, password, role} → creates + links the User in a single $transaction (reuses the Phase 4 create-with-login pattern); resource with a linked User + {password} → hashes + bumps tokenVersion so every stale JWT the staff still holds is rejected on the next /auth/me; + {phone} diff → updates with tenant-scoped phone-conflict check (new excludeUserId arg on assertNoUserConflict so re-saving without edits never trips STAFF_PHONE_CONFLICT against self). Email and role are immutable on the existing-user path — admin must delete+recreate the staff to reshape identity. findById + findAllByTenant now include user: { id, email, phone, role, isActive } (password hash explicitly excluded). New error codes STAFF_LOGIN_PASSWORD_REQUIRED, STAFF_LOGIN_ROLE_REQUIRED. Frontend StaffFormModal branches by staff.user: linked User → "Login access" panel (readonly email + role dl-grid, editable PhoneField prefilled from user, Change-password switch that reveals a password field when toggled); no linked User → the existing "Create login account" form (now shown in edit mode too, previously hidden). Zod schema changePassword flag gates the password-length-6 check so unrelated edits don't fail validation. Submit builder diffs phone and only attaches login.phone when it actually changed; login.password only when the Change-password switch is on. i18n resources.loginAccessTitle/loginAccessHint/changePassword/changePasswordHint/newPassword (en + nb). Tests: +8 API (resource.service): add-login-on-update happy path + missing password/role rejection + password-reset + phone update + phone-unchanged no-op + email/role-ignored guard + phone-conflict rejection; +3 web (StaffFormModal): add-login branch in edit, Login-Access panel render, password-reset submit, no-login-object on pure resource edit. 1204 API pass (+8) / 134 web pass (+3). Lint 0/0, build clean, staff-role E2E still 7/7. |
| 2026-04-23 | Multi-tenant sign-in picker (AUTH_TENANT_REQUIRED UX). AuthService.login now verifies the submitted password against every User record matching the email BEFORE disclosing tenant membership (stops email-enumeration via AUTH_TENANT_REQUIRED that previously leaked "email X is registered at salon A/B/C" on any wrong password). Match=0 collapses to AUTH_INVALID_CREDENTIALS; match=1 auto-logs-in regardless of how many tenants the email exists at; match≥2 + no tenantSlug throws AUTH_TENANT_REQUIRED with details.tenants: [{slug, name, logoUrl}] sourced from tenant.settings.branding.logoUrl. ApiError + ApiResponse gained an optional context: Record<string, unknown> field so error handlers can carry structured payload beyond the standard FieldError[] shape; HttpExceptionFilterGlobal.extractContext forwards any keys on the thrown object beyond the Nest envelope (statusCode/message/error/details) as context. Frontend: SignInForm holds an in-memory pickerState = {tenants, email, password} — on AUTH_TENANT_REQUIRED it swaps the form for a new TenantPicker component (logo/name/slug cards, ArrowLeft back button, per-card spinner during submission); clicking a card calls login(email, password, tenantSlug) a second time, success lands on /admin normally, failure clears the picker. Password is never written to storage — F5 at the picker drops back to the form (rare case, acceptable). Cookie set after successful picker pick means F5 after auth works unchanged (JWT carries the chosen tenantId). i18n: auth.chooseTenantTitle/Subtitle/Back (en + nb). Tests: +3 API (password matches 0/1/≥2 tenants) · +3 web (picker renders / click calls login w/ slug / Back returns to form) → 1196 API pass (+2 new - 0 removed, note: 1 test enhanced with tenants assertion) · 131 web pass (+3). lint 0/0 both repos; build clean. |
| 2026-04-22 | Tenant Onboarding wizard (7 steps full-page, Tenant.onboardedAt gate + OnboardingGuard + monotonic stepper) + foundational form-infra fixes: Zod v4 .min(n, "key") syntax (v3 { message } was silently ignored), FormField migrated to useController (React Compiler was eliding formState.errors proxy subscription → inline errors invisible, setValue→DOM lost), city/postal/country disabled across Onboarding + Settings with consistent gray styling, search dropdown reuses handleMapClick (nominatim forward search is sparse), deposit-requires-payment cross-check warnings (admin BookingPolicyEditor amber Alert + customer BookingPage red banner + disabled submit). |
Detailed Entries
Long changelog entries from 2026-04-17 onwards are tracked as individual subsections to keep the Timeline table readable. Entries are ordered by date ascending.
2026-04-17 — Payment Phase 6 WIP
Payment Phase 6 WIP — extract shared primitives (domain-event, event-bus, outbox port + in-memory, uuid-v7, clock ports/impls) out of payment/shared/ into neutral src/shared/ (events, ids, clock); 24 consuming files re-pathed. Prisma migration add_outbox_last_attempt_at adds nullable last_attempt_at + composite index for backoff-aware polling. Booking context will now share these primitives without depending on Payment. 48 suites / 439 tests still green.
2026-04-17 — Payment Phase 6 WIP
Payment Phase 6 WIP — extend OutboxRepositoryPort: append returns generated UUID v7 IDs (enables enqueue-after-commit), new findById for processor re-hydration, listStuck(beforeAt, limit) for janitor backoff filter, markFailed(id, error, attemptedAt) records lastAttemptAt, deletePublishedOlderThan(cutoff) for 30-day retention. In-memory + Prisma impls updated, 16 new tests (20 in-memory + expanded Prisma spec). 455 tests green.
2026-04-17 — Payment Phase 6 — Outbox BullMQ pipeline COMPLETE
Payment Phase 6 — Outbox BullMQ pipeline COMPLETE. New OutboxModule wires a hybrid publisher: (1) OutboxQueue.enqueuePublish(id) hot path called after dual-write commits, (2) repeatable janitor job scans stuck rows every 30s and re-enqueues (bypasses rows at max attempts — dead-letter), (3) repeatable cleanup job deletes published rows older than 30 days. OutboxPublisherService re-hydrates DomainEvent from row (eventId = outbox.id) and dispatches to EventBus, marking published/failed idempotently. OutboxMetrics adds Prometheus counters (payment_outbox_published_total{event_type,tenant_id}, _retries_total, _dead_letter_total) + gauge (payment_outbox_unpublished); BullBoard registers the outbox-publisher queue. Registry is injectable so tests isolate cross-contamination. PaymentModule now re-exports OutboxModule — Booking will depend on the neutral module directly. 84 suites / 848 tests green.
2026-04-17 — Payment Phase 6 resilience
Payment Phase 6 resilience — add 'error' event listeners on BullMQ Queues (OutboxQueue, PaymentWebhookController) + @OnWorkerEvent('error') on both processors. Before: a transient Redis blip would emit an unhandled 'error' event and crash the Node process in production. Now: logged-and-continue. OutboxQueue.onModuleDestroy closes the queue explicitly for clean SIGTERM/SIGINT shutdown (verified: dev server exits instantly on Ctrl+C, no stack trace). Module spec points at the real Docker Redis (localhost:6389) so BullMQ lifecycle runs for-real instead of against a fake port. 848/848 tests still green.
2026-04-18 — Authorization expiry cron
Authorization expiry cron — production-readiness piece for Bambora's 7-day authorization hold. New repeatable BullMQ job payment-expiry:sweep (15-min cadence) scans Payment rows with status ∈ {INITIATED, AUTHORIZED} and expiresAt ≤ now(). For each: if AUTHORIZED + has providerTransactionId + adapter advertises supportsVoid + active PaymentConfig present, make a best-effort provider.void({ reason: "AUTHORIZATION_EXPIRED", idempotencyKey: "expire-{paymentId}-{uuid}" }) — failure does NOT block the domain transition. Always calls payment.markExpired(now) → event emitted via existing outbox → EventBus → listeners. Per-payment try/catch isolates failures (race → InvalidStateTransitionError → recorded as skipped:INVALID_STATE; repository.save failure → skipped:SAVE_FAILED; batch continues). Idempotent at the query level — findExpirable filters non-terminal statuses, so a retried sweep only sees payments that still need work. New PaymentRepositoryPort.findExpirable(asOf, limit) is a cross-tenant system scan (documented exception to the tenant-scope rule — not exposed to tenant callers). Prometheus metrics: payment_expiry_swept_total{provider_key,from_status}, payment_expiry_void_failed_total{provider_key}, payment_expiry_skipped_total{reason} — registry is DI-injected for test isolation (follows OutboxMetrics pattern). Queue wiring mirrors OutboxQueue: queue.on('error') listener prevents Node crashes on Redis blip, onModuleDestroy closes queue for clean SIGTERM, scheduler registration in try/catch so transient Redis failures don't block app startup. BullBoardModule.forFeature registers payment-expiry alongside the webhook queue. 9 service tests (happy paths for AUTHORIZED+void, INITIATED no-void, void failure best-effort, inactive config skip, unsupported capability skip, race isolation, save failure isolation, clock respect, empty batch), 6 queue tests, 3 processor tests, 5 metrics tests, 2 Prisma repo tests, 1 PaymentModule wiring test. 92 suites / 926 tests green (+26). E2E still 57/57.
2026-04-18 — E2E green — fix 12 preexisting failures flagged after Phase 6B
E2E green — fix 12 preexisting failures flagged after Phase 6B. customer-auth (8): JWT test helper now fetches customer.tokenVersion and includes v in payload so the CustomerJwtAuthGuard tokenVersion check passes; POST /auth/customer/refresh test asserts HttpOnly Set-Cookie headers instead of expecting accessToken/refreshToken in body (endpoint returns { expiresAt } only for XSS protection). public-booking (4): tests realigned to the post-1ec327c guest flow where contact info is snapshotted on the Booking row only — guest booking must NOT auto-create a Customer record, multiple guest bookings with same phone do not dedupe, customerEmail-only booking snapshots email without creating Customer, customerName may be null (no "Guest" server-side default). Test helper rename should create booking with customer auto-create → should snapshot guest customer contact on booking; should reuse existing customer by phone → should allow multiple guest bookings with same phone (no dedupe for guests); should default customer name to Guest when not provided → should allow booking without customerName (null snapshot). Full suite: 900 unit + 57 e2e all green (0 failures, 0 skipped). Also added .DS_Store to booking-api, booking-mobile, docs/ gitignores.
2026-04-18 — Track D1 — Provider switch: Worldline Direct → Bambora Europe Checkout (Classic)
Payment Phase 7 Track D1 — Provider switch: Worldline Direct → Bambora Europe Checkout (Classic). Discovered mid-track that the existing "bambora/" adapter was coded against Worldline Direct API (preprod.worldline-solutions.com) but Norwegian SMB merchants sign up for Bambora Europe Checkout, a distinct product — Worldline doesn't onboard directly in NO, which is why they acquired Bambora in 2020. Split cleanly into two adapters: (1) providers/worldline-direct/ (git mv from bambora/ + rename BamboraAdapter → WorldlineDirectAdapter + BamboraCredentials → WorldlineDirectCredentials + mapBamboraErrorResponse → mapWorldlineDirectErrorResponse + BAMBORA_*BASE_URL → WORLDLINE*BASE_URL + adapter.key = ProviderKey.WORLDLINE) registered under new ProviderKey.WORLDLINE, kept for future enterprise migration but hidden from UI as "Coming soon"; (2) new providers/bambora/ implementing Bambora Classic: Basic-auth(accessToken:secretToken) in Authorization header, 4 base URLs shared between test and production (api.v1.checkout.bambora.com + transaction-v1 + merchant-v1 + login-v1), test-vs-production determined by merchant-number T/P prefix (server-side), MD5 callback signature (compute + constant-time verify, hash field stops concat per PHP spec), meta.result-based success check (Bambora returns 200 with meta.result=false on business failures — adapter maps those to ProviderError), endpoint map: createSession → POST /checkout, capture → POST /transactions/{id}/capture, refund → POST /transactions/{id}/credit, void → POST /transactions/{id}/delete, fetchStatus → GET merchant/transactions/{id}, verifyWebhook → parse query string + MD5 check, healthCheck → GET login/merchant/functionpermissionsandfeatures. Prisma enum PaymentProvider gains WORLDLINE via additive ALTER TYPE ADD VALUE migration (DB had no rows yet — safe repurpose). Enum-drift guard test auto-picks up WORLDLINE via dynamic Object.values. Adapter file sizes: 326 LOC adapter + 66 credentials + 73 signature + 69 errors + 84 mapper + 4 endpoint consts. Test count: +72 (12 credentials incl. base64 Authorization header + 9 MD5 signature + 7 errors incl. insufficient-funds codes 1220/1221/1230/1235/1240 + 10 http-client + 12 mapper + 7 retry copy + 15 adapter contract). Frontend adjusts in the same branch: credentials form rewritten to 4 fields (merchantNumber optional T/P + accessToken + secretToken + md5Key) + live MerchantModeBadge that flips Test (amber) / Production (green) as owner types. No more useSandbox toggle anywhere — single source of truth is the T/P prefix. i18n keys renamed (nb + en): form.bambora.{merchantNumber, accessToken, secretToken, md5Key, testModeLabel, productionModeLabel} + removed Worldline-shaped keys (merchantId/apiKeyId/secretApiKey/webhookKeyId/webhookSecret/useSandbox). E2E integration spec env vars swapped to E2E_BAMBORA{MERCHANT_NUMBER?, ACCESS_TOKEN, SECRET_TOKEN, MD5_KEY} — auto-skip when missing. Provider list on /admin/settings?tab=payment shows Bambora (enabled) + Vipps MobilePay (Coming soon) + Worldline enterprise (Coming soon). HTTP client + retry utility duplicated into bambora/ for now — follow-up: lift to shared infrastructure/http/. Results: 999 API unit (was 927, +72 Bambora) · 46 web Vitest (was 42, +4 metadata test) · 4 Playwright tests listed · lint 0/0 · yarn build clean in both repos.
2026-04-18 — Track D1 — Admin UI: Provider Config (WIP)
Payment Phase 7 Track D1 — Admin UI: Provider Config (WIP) — first booking-web Payment Context screen. Backend: PaymentConfigDto converted from TS interface → class with @ApiProperty decorators + @ApiOkResponse/ApiCreatedResponse on every /admin/payment-configs endpoint; regenerated OpenAPI spec + api.generated.ts so the frontend consumes a fully-typed response shape (previously content?: never). Frontend test infrastructure bootstrapped from zero: Vitest 4 + @testing-library/react + jsdom (scripts test, test:watch) and Playwright 1.59 + chromium (scripts test:e2e — skips @integration, test:e2e:integration — only @integration, test:e2e:ui); shared createQueryWrapper() helper in src/test-utils/react-query.tsx; ESLint overrides react-hooks/rules-of-hooks for Playwright use fixtures; eslint globalIgnores test-results/ + playwright-report/. Schema layer: lib/payment/bambora-schema.ts — Zod form schema with 5 trimmed-required credential fields + optional displayName (≤100 chars) + useSandbox boolean default true; deriveBamboraCredentials maps the single toggle to both credentials.environment ('sandbox' | 'production') and PaymentConfig.isTest so Owner only sees one control; provider-metadata.ts constants order Bambora first, Vipps gated as disabled + "Coming soon". API client: hooks/usePaymentConfigs.ts exports usePaymentConfigList / useCreatePaymentConfig / useUpdatePaymentConfig / useRotatePaymentCredentials / useActivatePaymentConfig / useDeactivatePaymentConfig / useHealthCheckPaymentConfig — every mutation invalidates PAYMENT_CONFIGS_KEY = ['payment-configs'] and routes through useFormMutation for toast + error-code translation. Components in components/settings/payment/: HealthCheckBadge (OK/FAILED/not-yet with hover timestamp), ProviderCard (status badge + CTA: Connect when no config, Manage when configured; Vipps always "Coming soon" + disabled), BamboraConfigForm (FormProvider + react-hook-form + zodResolver; FormField for text fields, new PasswordField for secrets with Eye/EyeOff toggle + localized aria-labels, SwitchField for sandbox toggle; Create vs Rotate modes via showDisplayName/showSandboxToggle props), ProviderConfigDrawer (Wrapper/Inner reset-on-open pattern via key, switches to Bambora form; Vipps placeholder), ConnectedProviderActions (Verify/Activate/Deactivate/Rotate buttons, disabled while mutating). PaymentSettings orchestrates: fetch list, 2-column grid, open drawer on card click (create if no config, rotate if existing), auto-trigger health-check after create/rotate success. FormField + PasswordField updated to emit htmlFor/id labels for a11y + getByLabelText test ergonomics (no regression — no existing tests relied on missing labels). SettingsContent tab list grew to 8: general/booking/businessHours/branding/location/tax/payment/about with CreditCard icon, URL ?tab=payment; i18n keys under settings.payment.* covering providers, status, health check, actions, form field labels + hints, success messages; nb + en parity. E2E: e2e/payment-settings.spec.ts (3 smoke cases — renders cards, Vipps disabled with Coming soon, Bambora card opens drawer) and e2e/payment-settings-integration.spec.ts (@integration tag; auto-skips when E2E_BAMBORA_{MERCHANT_ID,API_KEY_ID,SECRET_API_KEY,WEBHOOK_KEY_ID,WEBHOOK_SECRET} envs missing; fills real Worldline preprod creds, waits for auto-verify badge, activates + deactivates, confirms state via /api/admin/payment-configs API). Playwright fixture logs in as seed OWNER (owner1@gmail.com/123456). Scope intentionally narrow for D1: only Bambora provider + verify API key flow; no payment list, no refund/void UI, no booking-detail integration, no delete config (deactivate covers pausing). Results: lint 0/0, build passes, 42 Vitest + 3 Playwright smoke + 1 Playwright integration tests.
2026-04-18 — Track B
Payment Phase 6 Track B COMPLETE — Booking context now publishes domain events through the outbox → EventBus → listeners pipeline. Source-of-truth event catalog moved from Payment to core/booking/domain/events/ (payload interfaces + stable string constants). Pure payload builders handle deposit rounding/clamping + ISO serialization (16 tests). Prisma migration adds processed_for_tenant_customer + processed_for_loyalty idempotency flags on bookings. BookingService.{create, updateStatus, walkIn} refactored to prisma.$transaction dual-write (booking row + domainEventOutbox.createMany commit atomically) and enqueue publish after commit — Redis unreachable is not fatal, janitor re-enqueues. Clock port injected for testable occurredAt. Inline tenantCustomerService.onBookingCompleted + loyaltyService.autoStamp/autoEarnPoints calls removed; LoyaltyService dependency dropped from BookingModule. Two new EventBus subscribers replace them: OnBookingCompletedTenantCustomerListener (upsert TC stats — atomic Postgres upsert + guarded updateMany for lastVisit to co-exist with Loyalty's upsert) and OnBookingCompletedLoyaltyListener (resolve tc.id via upsert, delegate to LoyaltyService). Both use CAS claim-first idempotency via the new flags and roll flags back on failure so outbox redelivery retries cleanly. URL convention in BookingCreatedPayload: returnUrl = {PUBLIC_WEB_URL}/b/{slug}/bookings/{id}, cancelUrl = {PUBLIC_WEB_URL}/b/{slug}, webhookUrl = {API_BASE_URL}/api/webhooks/payments (provider adapters append provider+tenantId). Lint cleanup: 11 errors + 63 warnings on the branch → 0/0 (e2e res.body.data typing, Prisma Where/InputJsonValue instead of as any, typed Request & { user/customerUser? } in auth decorators + guards, ms.StringValue cast for JWT expiresIn). New e2e test/booking-outbox.e2e-spec.ts drives the full pipeline synchronously (BullMQ short-circuited) — 5 cases cover outbox row shape + URLs, publish marks row published, Completed transitions trigger TC + Loyalty DB effects, idempotent re-publish, guest bookings leave flags false. 900 unit tests + 5 e2e green. 12 preexisting e2e failures (customer-auth 8, public-booking 4) present on branch before Phase 6B — flagged for a follow-up PR.
2026-04-18 — Track C1 — Public deposit plumbing (backend)
Payment Phase 7 Track C1 — Public deposit plumbing (backend). Four sub-steps, all TDD. C1.1: InitiatePaymentHandler now persists CreateSessionResult.redirectUrl into Payment.metadata.checkoutUrl — previously the provider's checkout URL was only returned once to the command caller and lost, which blocked mobile-backgrounded/refresh resume. Existing caller-supplied metadata is preserved via spread. +2 handler tests (persists checkoutUrl + preserves caller-supplied metadata). C1.2: resolveInitialStatus(settings, { depositRequired? }) — new options arg; when depositRequired=true we force PENDING regardless of autoConfirm. Rationale: staff must not see a green CONFIRMED card before the PSP actually holds the deposit; C2 OnPaymentAuthorized listener is what flips Pending → Confirmed once the hold lands. Deposit math extracted into computeDepositAmount(totalAmount, policy) (typed to a narrow DepositPolicy interface satisfied by both TenantSettings and the payload-builder input), used by both BookingService.create (for status resolution) and buildBookingCreatedPayload (for the outbound event). BookingService.create now sums resolvedItems[].price BEFORE the tx to know depositAmount, then passes { depositRequired: depositAmount > 0 } to resolveInitialStatus. +11 new helper tests (4 × resolveInitialStatus + 7 × computeDepositAmount including clamp-to-total guards). C1.3: new PublicBookingPaymentController at GET /public/tenants/:slug/bookings/:bookingId/payment returning { id, status, amount, capturedAmount, currency, checkoutUrl, expiresAt, updatedAt } or null when the onBookingCreated listener hasn't fired yet (async via outbox → EventBus). Resolves slug → tenantId via TenantService.findBySlug for tenant isolation (a caller cannot read another tenant's payment with a guessed bookingId). Multi-row bookings (retry after void) return the freshest by updatedAt. Empty bookingId normalised to 404. +6 controller tests. PaymentModule imports TenantModule to satisfy the DI. C1.4: POST /public/tenants/:slug/bookings response gains requiresPayment: boolean + paymentPollUrl: string \| null so the FE knows whether to redirect to the PSP. paymentPollUrl points at the new C1.3 endpoint. Unit-level spec mock needed items: [{ price }] shim (previously mockBooking was minimal); +2 e2e cases (deposit enabled → PENDING + poll URL; deposit disabled → CONFIRMED + null). Results: 101 suites / 1019 unit + 3 suites / 59 e2e all green · lint 0/0 · yarn build clean. Next: Track C2 (Bambora webhook → markAuthorized → OnPaymentAuthorized listener flips booking Pending → Confirmed).
2026-04-18 — Track C3 + C4 (verified + built)
Payment Phase 7 Track C3 + C4 (verified + built). C3 was pre-existing from Phase 6: PaymentIntegrationService already subscribed to the booking lifecycle — onBookingCompleted → CapturePaymentCommand (MANUAL + AUTHORIZED); onBookingCancelled → decideCancellationRefund policy → Void / Forfeit (capture) / FullRefund / no-op based on cancel window + cancelledBy; onBookingNoShow → capture as no-show fee. 10 integration-service tests cover every branch. Only the reverse direction (payment → booking) was missing, which C2.1/C2.2 filled. C4.3 customer booking-form deposit preview: public GET /public/tenants/:slug now surfaces depositEnabled/depositType/depositValue via sanitizeSettings (the three are useless individually so they ship as a triple). New lib/payment/deposit-calc.ts mirrors the backend helper so the UI preview matches the API charge — a rule change must land in both (7 vitest covering percentage/fixed/clamp edge cases). BookingPage: amber notice "A deposit of {amount} will be held on your card when you confirm" appears when depositAmount > 0; submit CTA swaps to "Continue to payment · {amount}" and the spinner text changes to "Redirecting to secure checkout…" while redirectToCheckout works its poll-until-checkoutUrl loop. i18n book.{continueToPayment,depositNotice,redirecting} nb + en. C4.1 admin booking-drawer payment summary: new BookingPaymentSummary component inside BookingDrawer (edit mode only) shows Total / Deposit + live status badge / Paid (sum of capturedAmount − refundedAmount across retries) / Remaining (total − paid, clamped to ≥0), plus a failure banner when the latest Payment is FAILED. Powered by useBookingPayments(bookingId) → GET /admin/payments/by-booking/:bookingId (enabled:false when no bookingId). BookingDrawer imports useCurrency() alongside useFormatMoney() to hand the currency code to the summary. FE Payment type mirrored into types/payment.ts pending a downstream OpenAPI regen. i18n bookingPayment.{title,total,deposit,paid,remaining,status.*} nb + en. C4.2 deferred — admin booking-list deposit-badge column needs the backend to fold paymentStatus into the booking-list DTO to avoid N+1 per-row fetches; logged on the roadmap. Results: 1036 unit + 59 e2e · 71 vitest (64 → 71, +7 deposit-calc) · lint 0/0 · both builds clean.
2026-04-18 — Track C2 FE — public deposit redirect + return landing pages
Payment Phase 7 Track C2 FE — public deposit redirect + return landing pages. Backend: BookingService.buildBookingUrls now routes returnUrl to /b/{slug}/bookings/{id}/payment/return (poll-until-status) and cancelUrl to /b/{slug}/bookings/{id}/payment/cancelled (user-cancelled landing); the outbox e2e test still passes because it asserts with toContain, only the unit spec's URL regex needed tightening. Frontend: shared lib/payment/public-payment-api.ts exports fetchBookingPayment(slug, bookingId) hitting the C1.3 endpoint, classifyOutcome(status) (pending/success/failed/cancelled), pollForCheckoutUrl(slug, bookingId, { intervalMs=500, timeoutMs=15000, signal }) with transient-error tolerance, and a redirectToCheckout wrapper that does window.location.href = url. New route /b/[slug]/bookings/[id]/payment/return mounts a client component polling every 2s (30s cap) and renders tone cards: success → deposit secured + "back to salon"; failed → retry CTA; cancelled → "you cancelled" + retry; timeout → "status pending, check later"; spinner while INITIATED/null. Polling survives transient HTTP errors — only the timeout guard ends the loop so the PSP's 10–20s tail case doesn't abort prematurely. Sister route /b/[slug]/bookings/[id]/payment/cancelled is a static "you cancelled" page (no polling — PSP routed there on customer click-cancel; the domain-side expiry cron handles any dangling auth later). BookingPage.onSubmit is now async-aware of the requiresPayment flag: when true, it awaits redirectToCheckout (browser navigates away); on PaymentCheckoutTimeoutError it surfaces book.errorPaymentTimeout so the customer gets a clean retry path instead of a dead-spinning form. i18n keys: paymentReturn.{pending,success,failed,cancelled,timeout,common} + book.errorPaymentTimeout, nb + en parity. Tests: +6 vitest covering classifyOutcome + isTerminalStatus. Results: 64 vitest green · 1036 unit + 59 e2e backend green · lint 0/0 · both builds clean. Track C2 complete end-to-end — webhook → Payment.authorize → OnPaymentAuthorized listener → booking CONFIRMED → customer landing page sees "deposit secured" card. Next: C3 (capture on Completed, void on Cancelled/NoShow).
2026-04-18 — Track C2 backend — Payment ↔ Booking lifecycle wiring
Payment Phase 7 Track C2 backend — Payment ↔ Booking lifecycle wiring. Two new EventBus subscribers live under core/booking/: OnPaymentAuthorizedListener (subscribes to PAYMENT_EVENT_TYPES.Authorized → flips booking PENDING → CONFIRMED via BookingService.updateStatus); OnPaymentSettledNegativeListener (subscribes to .Failed + .Expired → flips booking PENDING → CANCELLED). Both resolve the booking via bookingId now carried on the enriched payloads — PaymentAuthorizedPayload, PaymentFailedPayload, PaymentExpiredPayload each gain bookingId: string \| null and Payment.{authorize, markFailed, markExpired} read it off this._bookingId when emitting. Idempotency: each listener refuses to act on non-PENDING bookings so outbox redelivery is a no-op; unknown-booking events log + skip; cross-tenant event.tenantId !== booking.tenantId refuses + logs. Race handling: concurrent transitions (admin cancels while the webhook lands) can race — the loser's updateStatus throws INVALID_STATUS_TRANSITION, which the listener catches and treats as already-handled (re-queueing would loop forever). Unknown errors re-throw so the outbox retries them. SYSTEM performer: updateStatus gets a narrow performer.role === 'SYSTEM' bypass for the customer-protection cancellation-window validation — event-driven cancels from payment failures must always fire (refusing would strand the booking). System-role transitions use the admin transition matrix so PENDING → CANCELLED + PENDING → CONFIRMED always allowed regardless of the configured 24-hour cancellation rule. Audit log still records performedByRole: 'SYSTEM'. Tests: +19 unit (9 OnPaymentAuthorized + 10 OnPaymentSettledNegative covering happy path, ad-hoc payment, cross-tenant, non-PENDING skip, race swallow, unexpected rethrow) + 1 domain assertion (Payment.authorize payload shape). Results: 103 suites / 1036 unit + 3 suites / 59 e2e all green · lint 0/0 · yarn build clean. Next: FE C2.3 (/b/[slug]/bookings/[id]/payment/{success,failed,cancelled} landing pages + poll-until-status) and C2.4 (post-POST redirect from the booking form to checkoutUrl).
2026-04-20 — Track D1 — Bambora Classic webhook → Payment.authorize end-to-end working (live test)
Payment Phase 7 Track D1 — Bambora Classic webhook → Payment.authorize end-to-end working (live test). Three sequential fixes unblocked the deposit flow; diagnosed with dev-only forensic logging that was removed after root cause found. (1) GET endpoint: PaymentWebhookController only had @Post, Bambora Classic dispatches callback as GET per Worldline Online Checkout docs — every Bambora retry got 404/405 and payment stuck at INITIATED. Added @Get(':provider/:tenantId') alongside @Post; GET reads raw query string from req.url (preserves Bambora's MD5 signing order), POST reads body; shared process() runs provider lookup → verify → inbox → queue. +2 tests. (2) MD5 mismatch: callback landed and was rejected with signature mismatch. Computed the expected hash from the concat + stored key and discovered the owner's MD5 key in DB was 9 chars where Bambora signs with 10 — a truncated-paste in the admin form. No code bug; owner re-entered the full key via Rotate credentials and verify passed on the next retry. Retained minimal per-webhook logging (Incoming GET webhook: provider=X tenantId=Y payloadLen=Z + Webhook verified: / Webhook verify FAILED:) for future triage. (3) Webhook processor shape mismatch: ProcessWebhookInboxService was coded against Worldline Direct nested payload (payload.payment.id, eventType payment.authorized), Bambora Classic ships a flat query-string (txnid, orderid) with no event-type field. Processor treated Bambora as "unhandled" → payment stayed INITIATED even with verified callback. Fixed with: (a) Bambora adapter createSession.providerSessionId = merchant order reference (same value sent as order.id + returned in callback orderid), enabling findByProviderSessionId(orderid) lookup without an extra API call; Bambora's internal session token stays inside redirectUrl and isn't needed for downstream ops (capture/void/refund/status all use txnid). (b) Bambora adapter verifyWebhook.eventType = 'payment.authorized' default — Bambora only fires callback on successful auth per docs. (c) ProcessWebhookInboxService.applyEvent reads providerTransactionId from payload.payment?.id ?? payload.txnid, and when findByProviderRef(txnid) misses falls back to findByProviderSessionId(orderid) — covers both Worldline and Bambora shapes. (d) transitionAggregate for payment.authorized now resolves txnid from either shape and returns false if missing (removed the unsafe non-null assertion). Verified end-to-end live: owner completes Bambora test checkout → callback GET arrives → MD5 verify passes → ProcessWebhookInboxService finds Payment by orderid → Payment.authorize(txnid) → PaymentAuthorized event → OnPaymentAuthorizedListener flips booking PENDING → CONFIRMED. 505/507 payment tests green (2 skipped, pre-existing). Note on backward-compat: payments created before this commit have providerSessionId = Bambora token (not orderid) so their pending callbacks will not be matched by findByProviderSessionId — new bookings only.
2026-04-19 — Track D1 — Bambora Classic spec conformance + provider-aware public CTA + hydration fix
Payment Phase 7 Track D1 — Bambora Classic spec conformance + provider-aware public CTA + hydration fix. Adapter fixes aligned with the Worldline Online Checkout v1 docs (vault Bambora/): (1) endpoint POST /checkout → POST /sessions — /checkout was Worldline Direct carryover; (2) url.decline → url.cancel per spec; (3) removed url.immediateredirecttoaccept: false — the field is integer (seconds, default 0), sending boolean false caused Bambora 40400 / 50000 Serialization error: 'false' cannot be parsed as Int32; (4) language: 'nb-NO' moved from top-level → paymentwindow.language where the spec expects it; (5) order.ordernumber → order.id (20-char merchant reference). 14/14 adapter specs green after the rewrite. Public tenant API gains settings.paymentProvider (BAMBORA | null) derived from the first PaymentConfig with isActive=true — PublicBookingModule imports PaymentModule for PAYMENT_CONFIG_REPOSITORY. Booking form CTA consumes the new field via provider-metadata.ts: button flips to "Pay with Bambora · 500 kr" with a "Secured by Bambora" footer line (ShieldCheck icon), falls back to "Continue to payment" when no provider configured — future Vipps/Stripe enable is a one-liner in metadata. Hydration mismatch fix: BookingPage + DateStrip were calling todayInZone(new Date()) client-side, which races with SSR clock around midnight and with any dev-server fetch cache producing drift. Moved initialDate = todayInZone(tenant.settings.timezone) into the server component (page.tsx) and threaded it down as a prop — server and client now agree on "today" deterministically. Public fetch cache: fetchPublic flipped from next.revalidate: 60 → cache: 'no-store'; tenant settings / services / availability are real-time edit targets, a 60s ISR window was the root cause of SSR rendering paymentProvider: null against the cached payload while a fresh client render hit the updated API. Polling safety: pollForCheckoutUrl now detects status ∈ {FAILED, EXPIRED} and throws PaymentCheckoutFailedError instead of spamming the poll endpoint until the 15s timeout — BookingPage surfaces book.errorPaymentFailed (nb + en) on this error. Redirect race fix: redirectToCheckout was throwing new Error('Redirect did not happen') to satisfy Promise<never>, which briefly flashed a red error toast during the navigation tick; replaced with new Promise<never>(() => {}) — the browser unloads the page and the await blocks indefinitely. Tests: 14 bambora adapter + 25 public-booking controller (prev +1 for paymentProvider assertion) green. Known external blocker surfaced during testing: Bambora test merchant returned 40401: The requested payment type or currency is not supported for NOK — merchant agreement needs NOK enabled in the Bambora backoffice, or the tenant must switch to DKK/EUR for testing. Next: D2 (payment list admin UI + booking-detail deposit badge column).
2026-04-18 — Track D1 — Admin UI polish pass (2026-04-18)
Payment Phase 7 Track D1 — Admin UI polish pass (2026-04-18). ProviderCard: (1) health badge moved from left-of-title to the right-hand slot and relabeled "Connect failed!" (en) / "Tilkobling mislyktes!" (nb) instead of the ambiguous "Failed" / "Feilet" — owners needed a clear signal that the provider rejected the credentials, not the local form; (2) Test/Production mode badge now gated behind lastHealthCheckStatus === 'OK' — before verification we have no proof the T/P prefix claim is real, so surfacing it was misleading; (3) Verify / Manage / Connect / "Coming soon" buttons shrunk to a compact !px-2.5 !py-1.5 !text-xs form with h-3.5 w-3.5 icons to match the lighter card aesthetic in the reference design. PaymentSettings flow: handleBamboraCreate rewritten to async/mutateAsync — create no longer closes the drawer on success; it awaits health-check and only closes when OK. On FAILED the drawer swaps into manage mode with the just-created configId preserved, so the owner retries through the rotate path instead of 409-ing a duplicate POST /payment-configs. handleManageSubmit similarly waits on health-check before closing when a rotate happened. activateMutation.onSuccess now auto-fires healthCheckMutation — a reactivated provider's secrets may have been rotated or expired while deactivated, so a stale OK from before the toggle-off cannot be trusted. isSubmitting on both drawers includes healthCheckMutation.isPending so the Save button keeps its spinner through verify. Drawer chrome (ProviderConfigDrawer, EditConfigDrawer, BamboraConfigForm) restructured to flex max-h-[90vh] flex-col → body flex-1 min-h-0 overflow-y-auto, footer shrink-0 border-t — the Cancel/Save bar is now sticky at the bottom regardless of scroll, and Cancel is disabled while a mutation is in flight. Tests updated: ProviderCard.test.tsx (+2 cases to cover mode-badge gating before-OK / on-FAILED, 17 total), HealthCheckBadge.test.tsx (text assertion flip). Results: 58 web Vitest green, lint 0/0, build clean.
2026-04-20 — Post-D2 UX polish. Backend
Post-D2 UX polish. Backend — bookingId prefix filter (ListPaymentsQuery): admin UI shows an 8-char UUID prefix in the bookingId column, but pasting that prefix into the filter returned empty because both PrismaPaymentRepository.listByTenant and InMemoryPaymentRepository.listByTenant did exact-match on bookingId. Switched to startsWith (Prisma where.bookingId = { startsWith }; in-memory r.bookingId?.startsWith(prefix)). Full UUIDs still match since a string starts-with itself. +2 query tests: "filters by bookingId prefix" + "filters by bookingId exact (full UUID still matches via startsWith)". Backend — booking no-op Save guard (BookingService.update): owners who open the booking drawer and reflexively hit Save were generating UPDATED audit rows even when nothing had changed, because the items array is always sent on update and the old guard logged {items: 'replaced'} unconditionally. New itemsEqual(existing, resolved) helper deep-compares length + per-item (serviceId, resourceId, startTime in ms) after sort-by-sortOrder; when identical, items: 'replaced' stays out of changes and the service short-circuits with return existing before touching prisma.booking.update — avoids both the updatedAt bump and the noise audit row. Also added the missing customerId diff to the scalar compare block. +1 service test "should skip update + audit log when nothing changed (no-op Save)"; updated 3 existing tests to submit 2 items (so the diff actually fires) + added a 2nd findFirst mock for the second-item conflict check. FE mirrors: BookingDrawer.onSubmit in edit mode checks formState.isDirty — when false, calls forceClose() without dispatching the mutation so we don't even hit the API with a no-op payload. Shared DatePicker overhaul (components/form/date-picker.tsx): (a) altInput: true + altFormat: 'd/m/Y' so every consumer shows dd/mm/yyyy while the wire format stays Y-m-d; altInputClass inlined to keep the field style consistent with the other form inputs. (b) Compact input (h-11 → h-10 + pl-3 pr-10). (c) Ant-Design-style mode: 'range': adds showMonths: 2 + locale.rangeSeparator: ' → ' so the popup is two months side-by-side and the closed input reads 20/04/2026 → 25/04/2026 instead of … to …. (d) Inject a "Today" footer button via onReady for single-date mode (skipped in range — setDate(today) would collapse the range). (e) Hover-to-clear: icon button stays a non-clickable Calendar until the picker has a value; on hover it swaps to X and the click runs instance.clear() → consumer's onChange fires with [] so state resets. (f) Partial-range revert: onClose with 1 selected date rolls back to lastValidRangeRef (last complete range) via setDate(..., triggerChange=false) — a half-picked range is an intermediate state, not a filter value. (g) onChange in range mode swallows the intermediate [start] fire so consumers only ever see final 2-item ranges or explicit [] clears. (h) Icon changed from project CalenderIcon (SVG with empty bottom space in viewBox) to lucide Calendar at h-4 w-4 — fixes the "icon clipped at bottom" visual. (i) Button shell absolute right-0 top-0 h-10 w-10 flex items-center justify-center border-0 bg-transparent — pointer-events-none when empty lets flatpickr open on input click; interactive once a value exists. globals.css flatpickr tweaks: rounded-md default day cells (not circle); inRange uses bg-brand-100 (stronger than brand-50) + rounded-none so connected in-range cells form a continuous band; startRange/endRange stay rounded-md + solid brand-500 fill; compact p-3 + smaller header margins; removed duplicate .flatpickr-calendar / .flatpickr-weekdays rules that were overriding the new compact values. PaymentList: replaced two <input type="date"> From/To fields with a single DatePicker mode="range" at w-64; handleRangeChange only apples the filter on 2-date arrays (intermediate [start] is swallowed by the picker; [] resets both ends). Label Period / Periode, placeholder Start date — End date / Startdato — Sluttdato. Cross-page open — PaymentDetailDrawer → BookingDrawer: replaced the booking-id <Link href=/admin/bookings?bookingId=…> with a button that fires onViewBooking(id) → PaymentsContent calls useBooking(id) (new hook, fetch-on-open via enabled: !!id) → renders a nested BookingDrawer with the freshly-loaded booking. Closing the booking drawer leaves the payment drawer on screen so the owner keeps payment context. Added CopyButton next to the id (icon-only, 1.5s check-flash on success, stopPropagation so it doesn't trigger parent click). BookingHistory diff modal rewrite: was single-column "show only changed fields"; now full-snapshot side-by-side table. Header row "Field / Before / After"; per-field rows classified as CHANGED (2 tinted cells: red-50/60 strikethrough left, emerald-50/60 bold right), UNCHANGED (single muted cell spanning both Before/After columns, pulled from live booking prop so owner sees surrounding context), or NOT_SET (filtered out). Separate amber items: 'replaced' row since audit doesn't store old/new item arrays. Fields: status, startTime, endTime, resourceId, customerId, notes, isPaid, source. Values resolved via i18n (tStatus, SOURCE_LABEL_KEYS), resourceMap, and new resolveCustomer(id) callback — matches live booking.customer.name for the current id, falls back to 8-char UUID for swapped-out customers (no extra fetch). Prop signature changed from (bookingId, resources) → (booking, resources). +i18n bookings.{diffField, diffBefore, diffAfter, historyNoDiff, historyItemsReplaced, fieldCustomer} (nb + en). Shared formatDateTimeInZone(iso, tz) + createDateTimeFormatterInZone(tz) in lib/timezone.ts — canonical dd/mm/yyyy HH:mm date-time format (en-GB + hour12: false + formatToParts assembly) for admin tables and audit timelines. Replaces duplicated Intl.DateTimeFormat('nb-NO', {...}) in PaymentList + PaymentDetailDrawer. 6 new timezone.test.ts cases (Europe/Oslo DST, UTC, midnight normalise, single-digit pad). Results: 1047 API unit (+3) + 57 e2e · 100 web vitest (+6) · lint 0/0 · both builds clean.
2026-04-20 — Track D2 — Admin payments list + detail drawer + refund/void/capture dialogs
Payment Phase 7 Track D2 — Admin payments list + detail drawer + refund/void/capture dialogs. Frontend-only; the five /admin/payments* endpoints were already shipped in Phase 5 waiting for a UI. Hooks (usePayments.ts): usePayments(filter) with typed query-string builder (status + provider + bookingId + fromDate + toDate + limit + offset, each omitted when empty), usePayment(id) gated on id via enabled: !!id, three mutation hooks (useRefundPayment, useVoidPayment, useCapturePayment) carrying matching RefundPaymentResult / VoidPaymentResult / CapturePaymentResult types mirrored from the backend commands. Each mutation captures the { id, bookingId } pair on input and invalidates PAYMENTS_KEY, PAYMENT_KEY(id), BOOKING_PAYMENTS_KEY(bookingId), and ['bookings'] on success — no reliance on the response shape (backend returns command results, not full Payment DTOs). generateIdempotencyKey() wraps crypto.randomUUID() (36-char v4, well above the backend minLength: 16). Sidebar adds "Betalinger" / "Payments" with CreditCard icon at /admin/payments, matched with sidebar.payments i18n (nb + en). List page (/admin/payments) = filter toolbar (status, provider, bookingId search with 300ms debounce, from/to date range, Clear-filters CTA) + table columns [createdAt, provider, intent, status, amount, captured, refunded, booking] + Pagination component (limit options 10/20/50/100, default 20). Timestamps format via cached Intl.DateTimeFormat in salon timezone. PaymentDetailDrawer (framer-motion slide-in with backdrop, AnimatePresence) reads the selected payment via usePayment(id), renders four sections: meta (id/intent/provider/booking link), amounts (total/captured/refunded/remaining — pending-toned), provider references (txn + session), timeline (created/authorized/captured/expires/updated), plus a failure panel when FAILED or failureMessage present. Sticky action bar at the bottom shows Capture / Refund / Void buttons gated by state machine: Refund only when status ∈ {CAPTURED, PARTIALLY_REFUNDED} and providerTransactionId is set; Void only when status === AUTHORIZED; Capture only when status === AUTHORIZED AND captureMode === MANUAL. Action bar hidden entirely when user.role !== 'OWNER' (backend @Roles('OWNER') on refund/void/capture) — STAFF/ADMIN get read-only access. RefundDialog: two-step flow (form → ConfirmDialog danger variant). Zod schema (refund-schema.ts) — amount is positive integer ≤ capturedAmount−refundedAmount, reason trimmed min 1 max 500. MoneyField pre-fills with max refundable; remaining hint below. Reason textarea routes errors through useValidationMessage so keys like required render as translated copy. Idempotency key generated once per dialog mount (retries of the same submission reuse it; close/re-open mints a new one). VoidDialog: single-step modal with optional reason textarea — backend accepts null reason, UI trims empty strings before sending. CaptureDialog: two-step flow like Refund; Zod schema (capture-schema.ts) amount is nullable (null ⇒ full capture) with same positive/exceeds-max bounds. All three dialogs wire useFormMutation → success toast + error-code translation; on success the drawer and dialog both close. i18n (nb + en parity): payments.* (title, subtitle, filter.{anyStatus, anyProvider, bookingId*, fromDate, toDate, clear}, columns., intent.{DEPOSIT, FULL_PAYMENT, CANCELLATION_FEE, NO_SHOW_FEE}, captureMode.{AUTO, MANUAL}, empty, viewBooking, detail., actions., refund., void., capture.); validation.{positive, refundExceedsMax, captureExceedsMax}; 12 payment domain error codes under errors.PAYMENT_* (state transition, refund exceeded, capture exceeded, invalid amount, invalid idempotency key, invalid provider ref, provider error, provider invalid creds, provider insufficient funds, provider timeout, provider unsupported, not found). Tests: +23 vitest (10 hook tests covering query-string builder, empty-filter short-circuit, default unwrap, disabled-when-null id, refund/void/capture POST shape + invalidation; 8 refund-schema cases covering zero/negative/over-cap/exact-cap/empty/whitespace/non-integer; 5 capture-schema cases covering null/partial/zero/over-cap/exact-cap) + 2 Playwright smoke (page heading + filter controls render; sidebar shows Payments link). Results: 94 vitest (was 71, +23) · lint 0/0 · yarn build clean · backend untouched (1044 unit + 57 e2e still green). Scope intentionally excludes bulk actions, payment-level notes, and CSV export — deferred to D2.1+.
2026-04-20 — Track C4.2 — Admin booking-list deposit badge + FE api-url refactor
Payment Phase 7 Track C4.2 — Admin booking-list deposit badge + FE api-url refactor. Backend: PaymentRepositoryPort extended with findLatestStatusByBookingIds(bookingIds, tenantId): Promise<Map<string, PaymentStatus>> — batched (one query per page, no N+1), tenant-scoped, returns the latest Payment per booking by createdAt DESC so a retry-after-FAILED naturally wins and shows the current state. Implemented on PrismaPaymentRepository (bookingId IN + in-memory dedupe; the composite @@index([bookingId]) keeps the scan trivial) and InMemoryPaymentRepository (sort-then-dedupe mirror). BookingModule imports PaymentModule to gain access to PAYMENT_REPOSITORY; BookingController.findAll is now async, calls findLatestStatusByBookingIds on the page + calendar-mode result and decorates each booking with paymentStatus: PaymentStatus | null. process-webhook-inbox.service.spec mock repo extended with the new method + the missing findExpirable stub (pre-existing port gap). Frontend: Booking type in types/booking.ts gains paymentStatus?: PaymentStatus | null; new shared components/payment/PaymentStatusBadge extracted from BookingPaymentSummary (removed its inline copy — same Record<PaymentStatus, classes> map, same i18n key under bookingPayment.status.*). BookingList renders a new "Depositum/Deposit" column that uses the badge when paymentStatus is present and an em-dash otherwise — no fallback to a default status (absent = no Payment row yet = correct). Zero N+1 calls — the FE list request triggers exactly one additional findLatestStatusByBookingIds. FE api-url refactor (bonus): booking-api.ts had been calling http://localhost:3010/api/... directly (NEXT_PUBLIC_API_URL), which was cross-origin to the browser and stripped the customerAccessToken cookie (sameSite=strict) — every authenticated customer booking was silently recorded as guest. Created lib/api-url.ts with two exports: SERVER_API_URL (absolute, from env — for src/proxy.ts middleware + server-component public-api.ts) and CLIENT_API_URL = "/api" (relative → Next.js rewrite, same-origin, cookies travel). Refactored booking-api.ts, public-payment-api.ts, public-api.ts, proxy.ts to use the shared constants — no more 4× duplicated env-default expressions. Also fixed two pre-existing FE test errors: usePaymentConfigs.test.tsx missing merchantNumber: in fakeConfig fixture (the PaymentConfigDto schema added the field in D1); bambora-schema.test.ts validForm retyped from BamboraFormInput → BamboraFormValues since deriveBamboraCredentials runs after Zod parse (output type has displayName: string via .default(), not the optional input type). Results: 1044 API unit (+5) + 57 e2e · 71 web vitest · lint 0/0 (both) · tsc clean (both) · both builds green. Scope: Track C4 closed; D2 (admin payment list + refund/void UI) and beyond deferred.
2026-04-20 — Payment shared HTTP infrastructure lift
Payment shared HTTP infrastructure lift. http-client.ts + retry.ts were duplicated in providers/bambora/ + providers/worldline-direct/ (identical content, 127 LOC × 2) — a known tech-debt flagged since the Worldline split. Moved canonical copy to src/core/payment/infrastructure/http/{http-client.ts,http-client.spec.ts,retry.ts,retry.spec.ts}, updated 6 callers (providers/bambora/{adapter.ts,adapter.spec.ts,adapter.integration.spec.ts}, providers/worldline-direct/{adapter.ts,adapter.spec.ts}, infrastructure/provider/provider-bootstrap.ts) to import from ../../http/. provider-bootstrap.ts collapsed BamboraFetchHttpClient + WorldlineFetchHttpClient aliased imports into one FetchHttpClient. 4 duplicate spec files git rm'd from worldline-direct/. Zero real test coverage lost — tests were identical on both sides; deduped count 1073 → 1060 API unit. Lint 0/0, build clean. Benefit: next provider (Stripe / Vipps / Nets) reuses the same HTTP client + retry semantics out-of-the-box.
2026-04-21 — Track D3 — capture trigger move + maxBookingDaysInAdvance setting
Payment Phase 7 Track D3 — capture trigger move + maxBookingDaysInAdvance setting. Industry-standard fix for MANUAL capture flow: previously onBookingConfirmed captured MANUAL+AUTHORIZED payments, but PaymentAuthorized event flips booking PENDING→CONFIRMED within seconds of authorize — so Void window was effectively zero and MANUAL was indistinguishable from AUTO. Matched Booksy / Timely / Vagaro / Phorest pattern: capture at ARRIVED (primary) + COMPLETED (fallback when staff skip arrival tap since state machine allows CONFIRMED→IN_PROGRESS directly). Changes: added BOOKING_INTEGRATION_EVENTS.Arrived + BookingArrivedPayload + buildBookingArrivedPayload; BookingService.buildStatusTransitionEvent emits Arrived on transition; PaymentIntegrationService swaps Confirmed subscription → Arrived, keeps Completed as safety net; 4 integration-service tests cover arrived-captures / confirmed-no-op / completed-fallback / AUTO-no-op. Deposit now truly held through the service window — cancel in cancellation window = Void (cheap) instead of Refund (fee on both ways). Lead-time cap: new TenantSettings.maxBookingDaysInAdvance (default 30, range 1–365) enforced in BookingService.create AND BookingService.update via validateBookingLeadTime() → throws BOOKING_TOO_FAR_IN_ADVANCE. Admin does NOT get an override per explicit product decision — salon services rarely booked >30 days ahead; hard cap also sidesteps Bambora's 7-day authorization hold. Public endpoint GET /public/tenants/:slug exposes the cap via sanitizeSettings so FE pre-validates. BookingPage.handleSubmit guards with errorTooFarInAdvance (nb + en); admin BookingDrawer.DateField.max clamps at now + cap. Settings form surfaces a warning when depositEnabled && cap > 7: "card authorizations only hold for 7 days — bookings farther out may lose their deposit hold". Industry defaults realigned: BEAUTY_SETTINGS + BARBERSHOP_SETTINGS currency USD → NOK, new tenants get maxBookingDaysInAdvance: 30 out of the box; prisma/seed.ts updated too. TenantSettingsDto gains the new field with @IsInt @Min(1) @Max(365) — the forbidNonWhitelisted validator would otherwise reject the PATCH. OpenAPI + api.generated.ts regenerated. +5 helper tests covering inside-cap / past-cap / exact-boundary / past-booking / legacy-missing-setting. No schema/migration — settings are JSONB.
2026-04-21 — Admin UI polish
Admin UI polish — unify all <select> to SearchSelect across BookingList, PaymentList, and Settings pages (General / Booking / Accounting). Each page had a mix of native <select> and SearchSelect, so chevrons, border-radius, focus rings and heights didn't line up between filter rows. All now render through SearchSelect (wrapped in react-hook-form Controller where applicable). Required-by-zod fields (Currency, Timezone, bookingMode, depositType, VAT rate) pass required prop so the clear-X button is hidden + auto-asterisk appears; optional filters (booking status / resource, payment status / provider) keep the X clear button. BookingList sortable + Created column: new "Created" column rendered via formatDateTimeInZone in booking timezone; sortable headers on startTime + createdAt with 3-state toggle (null → asc → desc → null) persisted to localStorage. Backend: BookingQueryDto inherits sortBy/sortOrder from PaginationDto (narrow-override triggered TS2612 under strictPropertyInitialization), BookingService.findAllByTenant guards with ALLOWED_SORT_FIELDS = ['startTime', 'createdAt'] whitelist, calendar mode (dateTo set) ignores sort params and always startTime asc. +1 controller-spec arg, +0 service-spec changes. Header dropdown fix: "Edit profile" link pointed at /profile but the admin layout hosts the page at /admin/profile — updated href.
2026-04-20 — Track D1 hotfix — deposit was silently captured server-side on Bambora
Payment Phase 7 Track D1 hotfix — deposit was silently captured server-side on Bambora. Two intertwined bugs made every deposit go straight from INITIATED to the money-moved state on Bambora's side while our DB still showed AUTHORIZED: owner clicking Void hit 134: No approved Authorize available for Delete, and the balance mismatched if they waited. Root cause A: buildBookingCreatedPayload hardcoded captureMode: 'AUTO' at core/booking/domain/events/build-booking-event-payload.ts:54 — every onBookingCreated path routed into Bambora with the AUTO capture flag regardless of intent. The MANUAL branch inside PaymentIntegrationService + provider adapters was literally unreachable from this flow. Fixed by deriving captureMode from intent: intent === 'DEPOSIT' ? 'MANUAL' : 'AUTO' so deposit bookings hold (capture later on Confirmed/Completed via listener), full-prepay bookings (future path when depositAmount === totalAmount) keep AUTO. Root cause B: Bambora Classic's /checkout/sessions treats the presence of instantcaptureamount (not its value) as "capture on authorize". providers/bambora/adapter.ts:createSession was sending instantcaptureamount: 0 for MANUAL (thinking 0 = no-op), which silently captured the full authorized amount server-side — verified live against the T-merchant sandbox on 2026-04-20. Fixed by omitting the field entirely when captureMode !== AUTO. Added comment with the incident date so the next engineer doesn't retry the 0-means-off trick. FE — Payment detail AMOUNTS section redesign (components/payments/PaymentDetailDrawer.tsx): (a) new On hold row (tone pending, brand-600) rendering amount − capturedAmount when status === AUTHORIZED, with sub-hint "Reserved on the card" — owners no longer have to remember what Amount 5 kr / Captured 0 kr means. (b) Zero captured/refunded now render — (muted gray) instead of 0 kr, matching the Stripe/Adyen convention: "—" = "never happened", "0 kr" would look like an active zero-value. (c) Old refund-leftover row relabeled from the confusing Up to X kr refundable → X kr duplication to a clean Refundable → X kr (still only shown when capturedAmount > 0). (d) AmountRow component extended with optional hint slot + new 'muted' tone. i18n keys payments.detail.{held, heldHint, refundable} added to nb + en. Tests updated: build-booking-event-payload.spec.ts replaces single "AUTO captureMode by default" assertion with two scenarios (FULL_PAYMENT → AUTO, DEPOSIT → MANUAL); bambora/adapter.spec.ts MANUAL case now asserts expect(body).not.toHaveProperty('instantcaptureamount') (the original toBe(0) was exactly the bug we just fixed). 92 suites / 92 tests green across the four touched files. Results: hotfix only — no schema/migration, no API contract change. Owner-observed behavior: fresh deposit bookings now truly hold on Bambora + Void in the drawer succeeds; pre-fix payments still show the diverged state and need Refund (Bambora captured them already).
2026-04-20 — Track E1 — Remaining payment via in-salon QR
Payment Phase 7 Track E1 — Remaining payment via in-salon QR. Fills the last gap in the payment lifecycle: after the deposit-held flow (C1–C4 + D1–D2), owner can now collect booking.total − captured on the spot by generating a QR the customer scans. Chose the QR pattern because it's the Nordic default (Vipps/BankID), avoids SMS fees + wrong phone numbers, and the customer is already at the salon at checkout time. Enum: PaymentIntent.REMAINING_PAYMENT added to domain + Prisma via additive ALTER TYPE ADD VALUE migration (safe — no enum-drift guard changes needed since it uses Object.values). New port + adapter: core/payment/domain/ports/booking-lookup.port.ts (BookingLookupPort.getSummary(bookingId, tenantId) → BookingSummary | null returning status, totalAmount, currency, pre-built return/cancel/webhook URLs, customer snapshot). PrismaBookingLookupAdapter reads Booking + items + tenant.settings in a single findFirst with select so Payment context never reaches into Booking internals — keeps the bounded context clean. URLs mirror BookingService.buildBookingUrls (PUBLIC_WEB_URL + API_BASE_URL env). Command + handler (InitiateRemainingPaymentCommand + Handler): (1) guards booking.status ∈ {ARRIVED, IN_PROGRESS, COMPLETED} → PAYMENT_INVALID_BOOKING_STATE; (2) computes paid = Σ Payment.capturedAmount − Σ Payment.refundedAmount across all rows for the booking, skipping FAILED / VOIDED / EXPIRED since those never touched the customer's money — mirrors what BookingPaymentSummary shows the owner; (3) remaining = booking.totalAmount − paid, rejects remaining ≤ 0 with PAYMENT_NO_REMAINING_AMOUNT; (4) resolves amount = command.amount ?? remaining, clamps 0 < amount ≤ remaining (InvalidAmountError / PAYMENT_REMAINING_AMOUNT_EXCEEDED); (5) idempotency-by-intent: finds any unexpired INITIATED REMAINING_PAYMENT for this booking via findByBookingId and reuses the metadata.checkoutUrl so owner reopening the modal doesn't stack zombie Payment rows; (6) delegates to InitiatePaymentHandler with intent=REMAINING_PAYMENT, captureMode=AUTO — remaining charges don't hold + capture separately, money arrives in one step. Four domain errors added (BookingNotFoundForPaymentError, InvalidBookingStateForRemainingPaymentError, NoRemainingAmountError, RemainingAmountExceededError) each carrying a stable code property that the existing PaymentDomainErrorFilter maps to HTTP 404/409/409/422 respectively. Admin endpoint POST /admin/payments/remaining (Roles: OWNER, STAFF) with InitiateRemainingPaymentDto (bookingId + optional amount + idempotencyKey ≥ 16 chars). Wired through PaymentModule (new bookingLookupProvider, PrismaBookingLookupAdapter + handler registered) and PaymentHandlersBootstrap.onModuleInit. Frontend: qrcode.react@4.2.0 added (~3 KB). usePayment(id, { pollIntervalMs }) extended with a terminal-aware refetchInterval that stops when status ∈ {CAPTURED, AUTHORIZED, FAILED, VOIDED, EXPIRED, REFUNDED, PARTIALLY_REFUNDED} so the React Query poll doesn't spin forever. useInitiateRemainingPayment(opts) mutation invalidates PAYMENTS_KEY, PAYMENT_KEY(id), BOOKING_PAYMENTS_KEY(bookingId), ['bookings'] on success. CollectRemainingModal (3-step FSM: input → qr → success) derives step via useMemo on {session, polledPayment} so React Compiler is happy — no setState-in-effect lint violation. Step input shows total/paid/remaining breakdown + editable MoneyField (default = full remaining) with Zod schema buildCollectRemainingSchema(maxRemaining) (positive int ≤ remaining). Step qr renders 240px QRCodeCanvas with the redirectUrl from the initiate call, a countdown timer derived from expiresAt via useCountdown(iso) (state holds now, label derives purely — Compiler-safe), Loader2 spinner + "Venter på at kunden betaler…", and error banner when polling sees FAILED/EXPIRED. Step success auto-closes 2s after detection via single setTimeout-in-effect (guarded). Modal close ≠ void — idempotency-by-intent reuses the Payment next open; owner voids explicitly from Payment detail drawer if needed. BookingPaymentSummary takes new bookingStatus prop and renders a brand-500 "Krev resterende · X kr" button below the summary when remaining > 0 && status ∈ {ARRIVED, IN_PROGRESS, COMPLETED}. i18n (nb + en parity): collectRemaining.* (ctaLabel, title, total, paid, remaining, amountLabel, amountHint, continueToCheckout, qrTitle, qrHint, waiting, expiresIn, paymentFailed, paymentExpired, successTitle, successMessage, sessionCreated); validation.remainingExceedsMax; 4 new error codes under errors.PAYMENT_BOOKING_NOT_FOUND / PAYMENT_INVALID_BOOKING_STATE / PAYMENT_NO_REMAINING_AMOUNT / PAYMENT_REMAINING_AMOUNT_EXCEEDED. Tests: +18 API (16 handler cases covering happy / explicit amount / remaining math with partial refunds / skip FAILED·VOIDED·EXPIRED / idempotency-by-intent reuse + expired-skip / idempotency key replay / BookingNotFound / cross-tenant null / PENDING guard / CANCELLED guard / all 3 allowed statuses / NoRemaining / AmountExceeds / InvalidAmount / URLs from lookup; 2 controller POST shape) + 9 web Vitest (7 collect-remaining-schema + 2 useInitiateRemainingPayment hook). Results: 1065 API unit (+18) + 57 e2e · 109 web vitest (+9) · lint 0/0 (both) · builds clean (both) · OpenAPI + api.generated.ts regenerated. Payment lifecycle is now end-to-end: deposit → confirm → arrive → remaining → completed. Track L (loyalty discount applied to booking/payment) deferred to backlog.
2026-04-21 — Track L2 — computeLoyaltyDiscount pure helper (+19 tests, TDD RED→GREEN)
Track L2 — computeLoyaltyDiscount pure helper (+19 tests, TDD RED→GREEN). New file core/loyalty/compute-loyalty-discount.ts turns a loyalty reward + booking items into a concrete discount amount without touching Prisma — caller (L3 BookingService.create) loads LoyaltyCard + items and passes plain data. Signature: computeLoyaltyDiscount(input): {discountAmount, freeServiceItemId?, eligibleSubtotal}. Rules: FREE_SERVICE auto-picks the sole eligible item; when >1 eligible requires selectedServiceItemId (throws LOYALTY_SERVICE_PICK_REQUIRED — no auto-pick most-expensive, respects user intent for mixed bookings like cắt tóc + ráy tai). applicableServiceIds narrows the candidate set; a pick outside the set throws LOYALTY_PICKED_ITEM_NOT_ELIGIBLE. DISCOUNT_AMOUNT subtracts fixed øre, clamped to min(eligibleSubtotal, rawTotal) so total can never go negative. DISCOUNT_PERCENT rounds eligibleSubtotal × value / 100 then clamps — accepts >100 without throwing so owners can configure premium-tier "110% off" edge cases. Scoped rewards (non-empty applicableServiceIds) apply to the matching-items subtotal only; unscoped apply to rawTotal. Error codes follow LOYALTY_ prefix convention: LOYALTY_NO_ITEMS, LOYALTY_NO_APPLICABLE_ITEMS, LOYALTY_INVALID_REWARD_VALUE, LOYALTY_PICKED_ITEM_NOT_FOUND, LOYALTY_PICKED_ITEM_NOT_ELIGIBLE, LOYALTY_SERVICE_PICK_REQUIRED. 19 unit tests cover all 3 RewardTypes × {scoped, unscoped} × {auto-pick, picker, clamp, reject} plus 4 guards. 1088 API unit + 57 e2e · lint 0/0 · build clean. Next: L3 (wire helper into BookingService.create transactional path + redemption lifecycle).
2026-04-21 — Track L1 — loyalty discount schema groundwork (Track L1/6)
Track L1 — loyalty discount schema groundwork (Track L1/6). Prisma migration 20260421082757_add_loyalty_discount_fields preps DB for the end-to-end loyalty-discount flow (L2–L6 coming). Changes: (a) bookings gains discount_amount INTEGER NULL + applied_redemption_id TEXT NULL UNIQUE with FK → loyalty_redemptions(id) ON DELETE SET NULL — unique means one redemption row backs at most one booking; (b) new enum LoyaltyRedemptionStatus { RESERVED, CONSUMED, CANCELLED }; (c) loyalty_redemptions gains status (default CONSUMED so legacy admin-created rows implicitly sit in the terminal state), redeemed_at TIMESTAMP(3) NULL, cancelled_at TIMESTAMP(3) NULL + index on status; (d) backfill UPDATE loyalty_redemptions SET redeemed_at = created_at WHERE redeemed_at IS NULL so pre-L1 rows look as if they had always tracked consumption time. Schema-side: Booking.appliedRedemption relation (via named BookingAppliedRedemption) + reverse LoyaltyRedemption.appliedToBooking; Booking.loyaltyRedemptions (legacy loose FK) kept for audit trail. LoyaltyService.redeemStampCard explicitly sets status: 'CONSUMED', redeemedAt: new Date() on the admin-manual path — comment clarifies that the RESERVED → CONSUMED / CANCELLED state machine only activates for the customer-initiated path wired in L3. Design choices locked with user: (1) FREE_SERVICE on multi-service bookings requires customer to pick which service to apply free (no auto-pick most-expensive — respects customer intent), (2) deposit % computed off payableTotal = rawTotal − discountAmount not raw total (fair to customer, matches Stripe/Booksy default), (3) guest bookings blocked from redeem (no TenantCustomer → no loyalty identity). No behaviour change, no API contract change, zero new tests — pure data model prep. 1070/1072 unit (2 skipped pre-existing) · build clean · lint 0/0.
2026-04-21 — Auth-expiry → booking-cancel audit (no code change, +1 contract test)
Auth-expiry → booking-cancel audit (no code change, +1 contract test). D1-hotfix follow-up queue item (2) was "add OnPaymentExpiredListener to sync booking on auth expiry". Audit of existing code found it had already shipped in Track C2 backend (2026-04-18): OnPaymentSettledNegativeListener.onModuleInit() subscribes BOTH PAYMENT_EVENT_TYPES.Failed AND PAYMENT_EVENT_TYPES.Expired, with full cross-tenant guard + PENDING-only transition + INVALID_STATUS_TRANSITION swallow for concurrent transitions + 8 unit tests (including explicit PaymentExpired PENDING→CANCELLED and skip-CANCELLED-terminal). Cron side (AuthorizationExpiryService.sweepAndExpire) calls payment.markExpired(now) which records a PaymentExpired domain event into the aggregate; PrismaPaymentRepository.save() dual-writes it into domain_event_outbox inside the same transaction; the outbox publisher (janitor every 30s + hot-path enqueue) dispatches it to the EventBus → listener fires. One invariant wasn't locked by tests: nothing asserted the sweep emits the event with bookingId + tenantId populated — if makeAuthorized factory or markExpired() ever stopped threading bookingId, the listener would silently no-op on if (!payload.bookingId) return and every stuck PENDING booking would persist. Added one contract test to authorization-expiry.service.spec.ts (“emits PaymentExpired event carrying bookingId + tenantId (listener contract)”) that calls payment.pullPendingEvents() after sweep and asserts the envelope + payload shape. Features doc updated — follow-up queue items (1)/(2)/(3) from D1 hotfix are now all marked shipped. Results: 1069 API unit (+1) · no web changes · no schema / migration / API contract change.
2026-04-23 — Role-based audit Phase 4 — Add staff with login + auth multi-tenant + TanStack cache leak fix
Feature chính: OWNER/ADMIN từ /admin/staff có thể tick "Create login account" và nhập email/phone + password (≥6) + role (OWNER|STAFF) trong cùng form Add staff — backend wraps User.create + Resource.create vào cùng $transaction (resource.service.ts), email/phone check conflict trước tx để surface STAFF_EMAIL_CONFLICT / STAFF_PHONE_CONFLICT trước khi bcrypt. DTO mới CreateResourceLoginDto nested optional — onboarding wizard không break vì payload cũ (không có login) vẫn hoạt động. Form web bỏ section khi ở edit mode (reset-password từ form để sau). Hardening song song: (a) Cache leak cross-tenant — AuthContext.login/register/logout giờ gọi queryClient.clear() để wipe TanStack cache; trước đó logout owner1 rồi login owner2 vẫn thấy customer của owner1 cho tới khi F5 (query keys ['customers']/['staff']/['bookings'] không chứa tenantId, QueryClient là singleton). (b) Auth login multi-tenant — auth.service.ts đổi findFirst({email}) → findMany({email, tenantId?}) với optional tenantSlug trong LoginDto; throw AUTH_TENANT_REQUIRED khi email trùng ở nhiều tenant và không truyền slug (trước đó chọn ngẫu nhiên — potential data leak). (c) SignUp/SignIn refactor — 2 form này đang xài useState thủ công + banner đỏ chung chung errors.VALIDATION_ERROR khi lỗi; refactor sang react-hook-form + Zod + FormField/PasswordField inline errors, map AUTH_USER_EXISTS → setError('email') và AUTH_INVALID_CREDENTIALS → setError('password'), fallback toast cho unknown errors (bỏ banner luôn); password min 6 sync với backend MinLength(6) + đã có ở reset-password/ChangePassword. i18n key mới: validation.passwordTooShort, validation.loginRequiresEmailOrPhone, auth.emailAlreadyExists/invalidCredentials/accountInactive, resources.hasLoginAccount/loginAccountHint/loginEmail/loginPhone/loginPassword/loginRole/loginRoleStaff/loginRoleOwner, 4 error codes (STAFF_LOGIN_REQUIRES_EMAIL_OR_PHONE, STAFF_EMAIL_CONFLICT, STAFF_PHONE_CONFLICT, AUTH_TENANT_REQUIRED) cả en + nb. Tests: +9 API (+6 resource.service.create login flows + 3 auth.service.login multi-tenant) · +16 web (3 AuthContext cache clear + 4 SignInForm + 4 SignUpForm + 6 StaffFormModal login section + 1 existing SignUpForm empty check covered by the 4 above — tất cả đều pass với 128 total). role-matrix POST /resources row cập nhật với payload login. Chưa làm: frontend "Salon slug" field khi nhận AUTH_TENANT_REQUIRED (Phase 2 — hiện chỉ cần API trả đúng code); Layer 2 hardening (thêm tenantId vào query keys) defer. Results: booking-api lint/build/test 0/0/0 · 1194 unit pass (+9) · booking-web lint/build/test 0/0/0 · 128 pass (+16) · OpenAPI + api.generated.ts regenerated · features.md + role-matrix.md cập nhật.
2026-04-23 — Role-based audit Phase 3 — Web UI for STAFF (sidebar + calendar + drawer)
Pair with Phase 2 API harden (same day). Opens /admin to STAFF without exposing management surfaces or letting them interact with bookings cross-resource. Mobile role-switch app can now lean on a known-good web surface for parity. Backend follow-up — /auth/me + /auth/profile return resourceId so the web knows the performer's linked Resource (null for OWNER/ADMIN, set for STAFF); spec updated and openapi.json regenerated. Web AuthContext — User.resourceId: string | null field; AuthGuard now bounces role-insufficient users to /admin (dashboard root) rather than the sign-in page — users already logged in shouldn't be thrown back to login when they type an OWNER-only URL. Dashboard layout — allowedRoles opens {"ADMIN", "OWNER", "STAFF"}. OwnerOnlyGuard — thin new wrapper (= AuthGuard with ["ADMIN", "OWNER"]) dropped onto the 5 OWNER-only page.tsx files (Settings, Payments, Services, Staff, Work schedule). Dashboard-level guard lets STAFF in; per-page guard catches direct-URL navigation to management surfaces. AppSidebar — NavItem.allowedRoles? + NavSubItem.allowedRoles? optional fields, filterNavByRole(items, role) helper drops forbidden items and any parent whose sub-items are all hidden. STAFF ends up with just Dashboard, Bookings, Customers, Loyalty; Services, Resources (staffList/workSchedule), Payments, Settings all gone. BookingCalendar — one-shot STAFF default filter: on first load, all resource columns except the staffer's own hide themselves via calendar:staffDefaultsApplied localStorage flag. User can unhide colleagues later for self-pick coverage context — the API still scopes bookings to own+unassigned (role-matrix §2.4), so other columns stay empty; this is purely UX noise reduction. resources = resourcesData?.data ?? [] wrapped in useMemo to satisfy React Compiler's exhaustive-deps rule that now applies to the new effect's deps. BookingDrawer — useAuth() consolidated (duplicate call removed), isStaff / staffResourceId derived. handleAddService forces resourceId = staffResourceId when STAFF adds a service, bypassing the "last item resourceId → defaults → empty" fallback that made sense for OWNER. getResourceOptions() filters to [self] only for STAFF so the dropdown never offers a value the API would reject — mirrors BookingService.create / update / walkIn guards from Phase 2. Results: booking-web yarn lint 0/0 · yarn build clean · pending Playwright smoke test with STAFF account · docs (features.md) updated with Phase 3 done + Phase 4 E2E listed as remaining work.
2026-04-23 — Role-based audit Phase 2 — API harden for STAFF scoping
Mobile-prep audit sprint: STAFF resource-scoping shipped across booking-api. Phase 1 (matrix draft) landed in prior commit; Phase 2 closes the API surface so mobile can start without role-regression thrash. RequestUser.resourceId — added a 4th field populated by JwtAuthGuard from DB every request (extended the existing User.findUnique select with resource: { select: { id: true } }); no token-version bump required, no forced re-login, stays in sync if OWNER reassigns STAFF → Resource. BookingService scoping — Performer now carries resourceId?, and a new isStaffPerformer helper gates every read/write path: findAllByTenant filters resourceId IN [own, null] (and short-circuits to IN [] if STAFF queries someone else's column so API surfaces a consistent empty list instead of an unscoped one), findById throws ForbiddenException('BOOKING_NOT_IN_STAFF_SCOPE') for out-of-scope ids (403 not 404 — the row exists, just not in the staffer's slice), create rejects any dto.resourceId or items[].resourceId pointing elsewhere (BOOKING_ITEM_RESOURCE_NOT_ALLOWED_FOR_STAFF), walkIn rejects forged resourceId (WALK_IN_RESOURCE_NOT_ALLOWED_FOR_STAFF), update blocks reassignment to a different staff (BOOKING_REASSIGN_NOT_ALLOWED_FOR_STAFF) and item-level cross-reassignment, updateStatus piggybacks on findById so status transitions inherit the same 403, selfPick refuses if caller passes a resourceId ≠ performer.resourceId (SELF_PICK_RESOURCE_MUST_BE_SELF). BookingController wires user.resourceId into performer on every endpoint including getAuditLog (new) — audit service has no performer concept so the controller gates via bookingService.findById before calling auditService.findByBooking. TenantCustomerService.update — rewrote the data: dto as any smell into an explicit whitelist (notes/tags/metadata); metrics fields (visitCount/totalSpent/firstVisit/lastVisit) are now structurally inaccessible even if a future DTO extension adds them. ResourceService time-off — ResourcePerformer interface added, createTimeOff/updateTimeOff/deleteTimeOff enforce performer.resourceId === :id and block edit/delete when timeOff.isApproved === true (TIME_OFF_EDIT_LOCKED_AFTER_APPROVAL / TIME_OFF_DELETE_LOCKED_AFTER_APPROVAL). Controller decorators moved from @Roles('OWNER','ADMIN') to @Roles('STAFF','OWNER','ADMIN') so STAFF self-service reaches the guard. PaymentController (admin) — circular-import-safe (injected PrismaService, not BookingService) assertStaffOwnsBooking(tenantId, bookingId, user) helper gates getOne (scoped by payment.bookingId lookup), byBooking, and initiateRemaining; remaining @Roles extended to include ADMIN for consistency. UploadController — POST opened to @Roles('STAFF','OWNER','ADMIN') (avatar self-upload); DELETE kept OWNER/ADMIN (no ownership metadata on the URL, avoiding arbitrary-file-deletion risk). @Roles() tường minh audit — all admin controllers verified to have explicit role lists per matrix §1.3 convention (no implicit-hierarchy reliance). Tests: +14 — booking.service.spec.ts gets a new findById block (own/unassigned/foreign-403/OWNER bypass), a STAFF scoping — findAllByTenant describe (3 scenarios incl. cross-query empty-set), and a STAFF scoping — mutations describe (5 scenarios: create/items/update-reassign/updateStatus/walkIn). resource.service.spec.ts adds timeOff to the prisma mock + 5 STAFF self-scoping tests. tenant-customer.service.spec.ts asserts visitCount/totalSpent get silently dropped. payment.controller.spec.ts adds prisma DI + staffUser/ownerUser fixtures + 3 STAFF 403 scenarios. Existing tests updated for the new controller signatures (mockUser.resourceId: null, mockPerformer helper). Results: 1185/1187 tests pass (2 pre-existing skipped) · yarn lint clean · yarn build clean · 0 new TS errors (26 pre-existing untouched) · features.md + role-matrix.md updated · docs/progress/features.md row now 🟡 API harden done — web + E2E pending · still blocks mobile start until Phase 3 (web layer + E2E) ships.
2026-04-22 — Tenant Onboarding wizard + form-infra fixes
Tenant Onboarding wizard (7 steps) + foundational form-infra fixes. Shipped Epic 1's onboarding wizard end-to-end plus two form-library fixes that were blocking inline validation under React 19 + React Compiler. Onboarding — /admin/onboarding full-page wizard with left stepper + main content + sticky footer, 7 steps: Welcome (industry confirm), Salon info (org/name/phone/email + locale + address with AddressSearch + Nominatim reverse-geocode + LocationMap), Business hours (per-day slots with copy-to-all prompt), Services (industry preset tick/xoá/thêm), Staff (≥1 required), Booking policy (SKIPPABLE — industry default), Review & Launch. Backend: Tenant.onboardedAt: DateTime? (null = pending) + onboardingStep: String? cursor, migration backfills onboardedAt = createdAt for existing tenants. Three API endpoints /api/tenants/me/onboarding (GET state) + /step (PATCH save, reuses settings service) + /complete (POST finalize, validates ≥1 Resource, ≥1 Service, businessHours 7 days). OnboardingGuard middleware: OWNER + onboardedAt === null → redirect /admin/onboarding. After launch, redirect /admin and /admin/onboarding bounces back to dashboard. Auto-apply business hours → staff WorkingHours on step save. Tenant signup auto-provisions 3 tax rates (0/15/25%) + matching accounting codes (Norway salon defaults). Stepper completedSteps bug — backend onboardingStep cursor regresses when user hits Review, clicks Edit on a prior step, re-saves it (server moves cursor to step-after-edited, losing ✓ for later steps). Client now tracks a monotonic maxReachedIdx state that only grows, so stepper ✓ never regresses during in-session edits. Zod v4 syntax fix — schema used v3 { message: "key" } params, silently ignored by Zod v4 .min()/.email() which expect plain string or { error: "key" }. Inline error messages fell back to default English "Too small..." instead of resolving i18n keys. Fixed across SalonInfoStep schema; saved memory feedback_zod_v4_syntax.md so future sessions don't repeat. React-Hook-Form + React Compiler subscription fix — three chained bugs: (1) inline errors not rendering after submit (destructured formState: { errors } from useFormContext — Compiler elides the proxy getter), (2) errors not clearing on valid type (useFormState({ control, name }) name-filter only fires on value change, misses error-clear event), (3) autofill via setValue not updating DOM (register-based uncontrolled inputs lose ref integrity when FormField re-renders on every form state change). Migrated FormField + PhoneField to useController → controlled inputs driven by field.value, guaranteed reactive fieldState.error. Memory feedback_rhf_react_compiler.md documents all 3 symptoms + canonical pattern. Address UX — city/postalCode/country fields disabled (force fill via search or map click) across Onboarding + Settings LocationSection, plus consistent gray disabled styling (bg-gray-100 border-gray-200) across 8 form components (FormField, PhoneField, MoneyField, DateField, SearchSelect, TimeField, PasswordField, InputField, TextArea). Search dropdown result often sparse (county-only) — instead of adding county fallback parsing, handleSearchSelect now extracts lat/lng and triggers handleMapClick → reverseGeocode path, single code path for both entry modes (memory feedback_reuse_existing_flow.md). Deposit requires active payment cross-check — admin BookingPolicyEditor shows amber Alert warning ("Configure payments") when depositEnabled && !hasActivePaymentConfig; customer BookingPage disables "Continue to payment" button + shows red warning banner when requiresPayment && !settings.paymentProvider so customers don't waste time on a broken checkout. i18n — 8 new keys (en + nb): onboarding wizard strings, validation error keys (nameTooShort/addressRequired/cityRequired/postalCodeRequired/invalidEmail/etc), settings.depositNoPaymentTitle/Message/Link, booking.depositUnavailable. Results: no API changes · booking-web build clean (0 lint, 0 tsc) · 19 new files, 20 modified · memory updated with 3 new feedback notes.
2026-04-24 — Status-matrix P0 combined PR + test-type hygiene
Force cancel + deposit guard + walk-in IN_PERSON (P0-1/P0-2/P0-3). Closes the three production blockers called out in docs/flows/status-matrix/gaps-and-plan.md with a single combined PR across booking-api and booking-web.
Backend (booking-api, 9 files, +540/-12). BookingService.updateStatus(id, tenant, status, performer, options?) now takes an options: { force?, reason? } bag. OWNER/ADMIN can bypass two guards, both of which land as distinct audit actions so support can answer "why did the salon skip policy?" without decoding JSON: (a) CANCELLED outside cancellationHours — normal path still 422s for STAFF and for admins without force; isAdmin + force + trim(reason).length > 0 bypasses the window and writes STATUS_CHANGE_FORCED with note = reason; STAFF passing force=true is outright rejected with BOOKING_FORCE_NOT_ALLOWED_FOR_ROLE to keep the escape hatch scope obvious in audit trails. (b) PENDING → CONFIRMED with settings.depositEnabled — BookingService now injects PaymentRepositoryPort (module-level import of PaymentModule already existed) and rejects the transition unless at least one Payment row for that booking is AUTHORIZED or CAPTURED; OWNER can force with reason → audit CONFIRMED_WITHOUT_DEPOSIT. payableTotal = 0 (full loyalty discount) short-circuits the guard before any Payment read — this keeps P2-9 (future) simple while not blocking free bookings today. BookingCreatedPayload gains optional paymentMode: 'ONLINE' | 'IN_PERSON' (omitted = ONLINE, so every existing consumer keeps working); BookingService.walkIn() stamps IN_PERSON; PaymentIntegrationService.onBookingCreated skips InitiatePaymentCommand when paymentMode === 'IN_PERSON' so walk-ins don't spin up a PSP session the customer can't complete at the counter. UpdateBookingStatusDto { force?, reason? } added to DTOs; controller forwards the body. +17 unit tests (force cancel 7 cases including whitespace-reason + STAFF-force rejection + SYSTEM bypass + in-window-force-inert; deposit guard 8 cases including CAPTURED-path, discount-zero short-circuit, OWNER-force with reason, SYSTEM bypass; walk-in IN_PERSON event emit; payment integration IN_PERSON skip + ONLINE default). Total 1210 → 1227.
Frontend (booking-web, 5 modified + 2 new, +293/-13). useUpdateBookingStatus now takes { force?, reason? } and uses raw useMutation so the caller picks toast vs modal on error (keeps useFormMutation semantics unchanged for everyone else). New useForceOverridableStatus hook wraps it: tracks lastInputRef, detects the two overridable error codes (BOOKING_CANCELLATION_TOO_LATE, BOOKING_DEPOSIT_REQUIRED), stores override: { input, kind } when admin hits the guard, and exposes submitOverride(reason) / dismissOverride(). A force-retry failing falls through to toast (no modal loop). New ForceOverrideModal component is wrapper/inner split: when closed it unmounts so the next open resets reason state without setState in useEffect (React Compiler happy). Both BookingDrawer and BookingList swap to the new hook + render the modal. i18n: 3 error codes (BOOKING_DEPOSIT_REQUIRED, BOOKING_FORCE_REASON_REQUIRED, BOOKING_FORCE_NOT_ALLOWED_FOR_ROLE) + 8 modal strings (en + nb). 137 tests still pass, Next build clean, lint 0/0.
Test-type hygiene (bonus). yarn tsc --noEmit surfaced 26 pre-existing TS errors across 8 spec files that yarn test silently ignored because tsconfig.json had isolatedModules: true, putting ts-jest in transpile-only mode. Fixed: auth.controller.spec (missing expiresAt in mockTokens), customer.{controller,service}.spec (dropped tenantId args since Customer went global), loyalty.{controller,service}.spec (switched string literals to LoyaltyCardTypeDto / RewardTypeDto enums + numeric requiredVisits), payment-webhook.controller.spec (Parameters<> → ConstructorParameters<> + explicit FakeProvider type so verifyCalls stays visible after the setup helper), service.{controller,service}.spec (dropped stray undefined arg + added accountingAccount to prisma mock type). Added ts-jest transformer options { diagnostics: true, isolatedModules: false } to package.json's jest block so the runner now catches type drift the same as the standalone tsc gate — closes the "tests pass but types fail" gap.
Results: 1227 API unit (+17 P0) · 137 web unit · yarn tsc --noEmit 0 errors (was 26) · nest build + Next build clean · lint 0/0 · three repos committed separately (booking-api chore/tests + feat, booking-web feat, booking-system docs).
2026-04-21 — Customer-portal end-to-end polish
Customer-portal end-to-end polish — booking ticket, rebook, My Bookings, inline validation, error-code i18n, past-time guard. Shipped one tranche covering eight user-facing improvements on the booking flow. 1. Pagination (/customer/me/bookings) — CustomerPortalService.getBookings now takes PaginationDto, returns {data, meta} via shared paginateParams/paginateResult; CustomerPortalController.getBookings wires @Query() pagination. BookingsSection.tsx adds page+limit state, placeholderData: keepPreviousData so the list doesn't collapse to skeletons between pages, Pagination component reused from admin. +3 unit + 1 e2e test ("honors page + limit query params"), existing empty-state assertions updated for the envelope. 2. Sort by createdAt — swapped orderBy: { startTime: 'desc' } → { createdAt: 'desc' } so the most-recently booked appointment is always on top (receipt-style), matching what customers expect from "My bookings". 3. Public booking detail endpoint (GET /public/tenants/:slug/bookings/:id) returns id/status/startTime/endTime/customerName/notes/items (service+resource+duration+price)/tenant (name+slug+timezone+address) + payment snapshot. No auth (bookingId is a UUID — sufficient entropy), tenant-scoped so a leaked id can't probe other salons. 4 unit + 2 e2e tests (detail + 404 per-tenant + 404 per-slug). 4. Booking success ticket + QR — PaymentReturnClient extracted into a shared components/bookings/BookingTicket (screenshot-friendly: salon → booking id + date/time → services (name·staff·duration·price) → total/paid → QR). QR payload is the canonical public URL ${origin}/b/:slug/bookings/:id (was /admin/bookings/:id) — mobile staff app parses the pattern to open in-app; a route redirect at /b/[slug]/bookings/[id] forwards a generic camera scan to /payment/return so customers get the ticket instead of a dead end. When logged-in, "My bookings" secondary button appears above "Back to salon". 5. My Bookings modal + Book again — card gets dedicated buttons: outline "View" (Eye icon) opens BookingDetailModal which fetches via publicApi.getBooking and renders the same BookingTicket headerless; solid brand "Book again" (CalendarPlus icon) links to /b/:slug/book?from=<bookingId>. Rebook flow rewritten server-side — was packing serviceId/resourceId/notes into the query string which doesn't scale past a few services. book/page.tsx now reads ?from=<id>, calls publicApi.getBooking + publicApi.getResources, validates each item (drops service if deactivated, clears resourceId if staff removed — NOT by skill filter, so the customer sees "their" staff selected and fails at availability rather than silently reverting), then passes preSelectedItems: BookingItemData[] to BookingPage. Multi-service bookings clone all items in one URL. Card also surfaces Appointment + Created at times side-by-side using formatDateTimeInZone (same dd/mm/yyyy HH:mm format as Payments) so customers can tell booking time from booking record time. 6. Past-time booking guard — validateStartTimeNotInPast(startTime, now) added to booking-settings.helper.ts, called in BookingService.create for all roles (admins with legitimate past walk-ins use the dedicated /walk-in endpoint that starts IN_PROGRESS). Error code BOOKING_START_TIME_IN_PAST. AvailabilityService also filters slot.getTime() <= now in the slot loop — UI no longer shows slots the user couldn't book anyway. AvailabilityService now takes @Inject(CLOCK) so tests can pin time. +4 helper unit + 2 service unit ("reject past, reject now-edge"), availability spec gets a pinned 2026-04-07T00:00Z clock. 7. Inline validation + error-code audit — BookingPage.handleSubmit replaced the single bottom banner with per-item itemErrors: Record<index, {staff?, time?}>; ServiceItem forwards into the existing SearchSelect.error prop (red border + message). Errors auto-clear on field change + re-index correctly when the customer removes a service between others. booking-api.ts upgraded to throw a typed PublicBookingApiError {code, message, status} so useErrorMessage can look up the backend code; the hook itself went duck-typed (accepts any error with .code: string) so non-ApiError sources translate too. 38 missing error-code keys added to errors.* (en + nb): all CUSTOMER_AUTH_* + CUSTOMER_PORTAL_*, all LOYALTY_*, PAYMENT_CONFIG_* / PAYMENT_CURRENCY_* / PAYMENT_NO_ACTIVE_CONFIG / PAYMENT_WEBHOOK_VERIFICATION_FAILED, SCHEDULE_OVERRIDE_NOT_FOUND, TENANT_CUSTOMER_NOT_FOUND, REQUIRED_VISITS_MISSING, UNASSIGNED_BOOKING_STAFF, AUTH_TOKEN_REVOKED. Raw backend codes no longer leak to customers. 8. Admin check-in page + Google-email protection — /admin/bookings/[id] is a standalone route outside the (dashboard) group so its own AuthGuard can allow STAFF (dashboard layout is ADMIN+OWNER only); minimalist page shows customer + services + date/time and a prominent "Mark as arrived" button (success green). canMarkArrived(booking) helper returns {ok, reason?} with structure ready for future time-window gating (TOO_EARLY, TOO_LATE) but MVP only checks status === 'CONFIRMED'. Customer profile form: Customer.googleId surfaced as isGoogle: boolean on /auth/customer/me + /customer/me (raw googleId never leaks); ProfileSection disables the email input + shows "Email is managed by your Google account." hint when isGoogle; updateProfile silently drops dto.email for Google customers (no 400 — prevents stale forms breaking the update). Also dozens of smaller UX polish items landed in the same pass: QR card overflow-hidden fix (2 góc dưới bo tròn), mobile padding pass on the ticket + account sidebar, max-w-xl+pt-4 pb-10 wrapper on mobile, formatDateTimeInZone applied to BookingsSection for consistency with Payments, back-button bg-gray-100 default, CustomerHeader dropdown Profile/My Bookings deep-links with correct tab param, services row in ticket shows price too. Results: 1144 API unit (+4 past-time helper, +3 customer-portal isGoogle, …) · 63 e2e (+3 public-booking detail) · OpenAPI + api.generated.ts regenerated · lint 0/0 · both builds clean.