progress/features.md

System Features

Overview of all features in the booking system. For development history, see changelog.md.

Epic 1: Tenant & Onboarding ✅

  • Admin tạo tenant (name, slug, industryType, settings)
  • Owner customize branding (logo, cover, colors)
  • Owner quản lý settings (booking mode, auto-confirm, business hours, cancellation)
  • Description (rich text via Tiptap), address, location map (pigeon-maps + Nominatim)
  • Onboarding wizard — 7-step full-page flow (Welcome, Salon info, Business hours, Services, Staff, Booking policy, Review & Launch) với OnboardingGuard redirect OWNER chưa onboardedAt, stepper tracking completed/reachable steps monotonic, step 6 skippable, address fields disabled (force fill via search/map), deposit→payment-config cross-check warning

Epic 2: Resource Management ✅

  • CRUD resources (staff)
  • Assign/remove skills (services per staff)
  • Weekly recurring schedule (multi-slot per day)
  • Schedule overrides (vacation, sick, special hours)
  • Time-off (multi-day, partial-day, multi-per-day)
  • Tạo staff kèm login account (email/phone + password + role OWNER|STAFF) — 1 transaction tạo User + Resource linked qua userId
  • Staff xem lịch cá nhân (mobile)
  • Staff invite link (email + self-service password) — thay cho admin-set password hiện tại

Epic 3: Service Catalog ✅

  • CRUD services (name, duration, price)
  • Service categories (CRUD, sort order)
  • Toggle active/inactive

Epic 4: Booking Engine ✅

  • Customer booking online (public page)
  • Available time slots (schedule + overrides + time-off + conflicts)
  • Owner/Staff tạo booking thủ công (admin)
  • Walk-in handling
  • Unassigned booking (allow_unassigned mode)
  • Status management (PENDING → CONFIRMED → ARRIVED → IN_PROGRESS → COMPLETED, admin free transition)
  • OWNER/ADMIN force override — cancel-window + deposit-required guards bypassed with audit reason (status-matrix P0-1 + P0-2)
  • Walk-in paymentMode: IN_PERSON — Payment listener skips PSP init for walk-ins (status-matrix P0-3)
  • Customer self-cancel — POST /public/tenants/:slug/bookings/:id/cancel + CTA on customer account portal with ConfirmDialog (status-matrix P1-1)
  • Conflict detection (double booking prevention)
  • Multi-service booking (multiple items per booking)
  • Booking audit log (before/after JSON, performer, action history)
  • Guest vs auth booking separation (guest = no customer record, auth = customerId + contact snapshot)
  • Staff self-pick (tự nhận booking unassigned)

Epic 5: Customer Management ✅

  • Auto-create customer on booking (match by phone/email)
  • CRUD customers (name, phone, email, notes)
  • Booking history per customer (cross-tenant, customer portal)
  • Customer check-in (ARRIVED status + admin free transition)
  • TenantCustomer — per-tenant customer metrics (visitCount, lastVisit, totalSpent), auto-backfill from bookings

Epic 6: Payment (DDD Bounded Context) 🚧 Track D2 + post-D2 UX polish done

Full DDD + Hexagonal + CQRS Payment Context, provider-agnostic, merchant-of-record model. See docs/architecture/payment-architecture.md.

Phase 0–5 — DONE (branch feat/payment-foundation, 24 commits, 817 tests):

  • Foundation primitives — CQRS (Command/Query/Event buses), DomainEvent, AggregateRoot, Clock port, AES-256-GCM cipher, UUID v7, Outbox port
  • Domain model — Money, PaymentId, IdempotencyKey, ProviderRef VOs; Payment aggregate with full state machine (INITIATED → AUTHORIZED → CAPTURED → PARTIALLY_REFUNDED / REFUNDED / VOIDED / FAILED / EXPIRED); PaymentConfig aggregate; stable error codes
  • Policies — CancellationRefundPolicy (VOID/FORFEIT/FULL_REFUND/NO_ACTION), AuthorizationExpiryPolicy, FeeCalculationPolicy (PERCENT + FIXED)
  • Persistence — Prisma schema (payments, payment_events, payment_webhook_inbox, domain_event_outbox, tenant_payment_configs); dual-write outbox inside single tx; rollback keeps events on aggregate
  • Provider port + FakePaymentProvider + ProviderRegistry
  • Commands — Initiate / Capture / Void / Refund (idempotent, tenant-scoped)
  • Queries — GetPayment / ListPayments / GetPaymentsByBooking
  • Integration listeners — BookingCreated / Confirmed / Cancelled / MarkedNoShow / Completed → auto-dispatch Payment commands via CancellationRefundPolicy
  • HTTP — admin /admin/payments (list/get/refund/void/capture), /admin/payment-configs (CRUD + rotate + activate + health-check), public /public/payments/:tenantId/status (tenant-scoped)
  • PaymentDomainError → HTTP filter (codes → 400/404/409/422/502/504)
  • Enum drift guards (runtime validation Prisma ↔ domain)
  • Tenant-scoped repository contract (every find* requires tenantId, incl. findByProviderRef)
  • Bambora adapter (Worldline Connect) — credentials, HMAC auth, http-client, errors, mapper, retry; replaces FakePaymentProvider in prod (kept for tests)
  • Webhook pipeline — POST /api/webhooks/payments/:provider/:tenantId → HMAC-SHA256 verify (constant-time + 5-min replay window) → payment_webhook_inbox ON CONFLICT DO NOTHING → BullMQ payment-webhook job → ProcessWebhookInboxService applies transition
  • BullMQ root config + BullBoard at AppModule; modules register queues without duplicating config
  • rawBody: true in main.ts for webhook RawBodyRequest

Phase 6 Track A — DONE (branch feat/payment-foundation, +4 commits, 848 tests):

  • Shared primitives moved to neutral src/shared/ (events, clock, ids) so Booking consumes without depending on Payment
  • Prisma migration add_outbox_last_attempt_atlast_attempt_at + composite index for backoff-aware polling
  • OutboxRepositoryPort extended — findById, listStuck(beforeAt, limit), markFailed(id, error, attemptedAt), deletePublishedOlderThan(cutoff), append returns generated UUID v7 IDs for enqueue-after-commit
  • OutboxModule — BullMQ outbox-publisher queue with hybrid delivery: hot-path OutboxQueue.enqueuePublish(id) after dual-write commits, repeatable janitor (30s) scans stuck rows and re-enqueues, repeatable cleanup (1h) enforces 30-day retention, dead-letter at 10 attempts
  • OutboxPublisherService — idempotent re-hydration (eventId = outbox.id), publishes to EventBus, marks published/failed
  • OutboxPublisherProcessor — BullMQ WorkerHost dispatches by job name (publish/janitor/cleanup)
  • Prometheus metrics — payment_outbox_published_total{event_type,tenant_id}, _retries_total, _dead_letter_total counters + payment_outbox_unpublished gauge; registry injectable for test isolation
  • BullBoard registers outbox-publisher queue at /api/queues
  • Resilience — queue.on('error') + @OnWorkerEvent('error') on both queues & processors (Outbox + Payment Webhook) prevent Node process crashes on Redis flap; OnModuleDestroy closes queue for clean SIGTERM shutdown

Phase 6 Track B — DONE (branch feat/payment-foundation, +10 commits, 900 unit tests + 5 e2e):

  • Booking event catalog at core/booking/domain/events/booking-events.ts (source of truth). Payment context imports from here; the old duplicate in payment/application/integration/ removed
  • Payload builders buildBookingCreatedPayload / Confirmed / Cancelled / NoShow / Completed — pure functions with 16 tests covering deposit rounding (percentage + fixed), clamping to total, customer snapshot, ISO serialization
  • Prisma migration add_booking_idempotency_flagsprocessed_for_tenant_customer + processed_for_loyalty BOOLEAN DEFAULT false (safe ADD COLUMN for existing rows)
  • BookingService refactor — create() / updateStatus() / walkIn() wrapped in prisma.$transaction: booking row + domainEventOutbox.createMany commit atomically; enqueue publish job after commit (janitor re-enqueues on Redis failure); Clock port injected for testable occurredAt timestamps
  • updateStatus() emits exactly one event per terminal transition (Confirmed/Cancelled/NoShow/Completed); IN_PROGRESS/PENDING/ARRIVED intentionally no-emit; cancelledBy resolved from performer.role (CUSTOMER vs SALON); cancellationWindowHours from tenant settings
  • Inline tenantCustomerService.onBookingCompleted + loyaltyService.autoStamp/autoEarnPoints calls removed from BookingService; LoyaltyService injection dropped from BookingModule
  • OnBookingCompletedTenantCustomerListener (tenant-customer module) — CAS claim-first + rollback-on-failure idempotency via processedForTenantCustomer; Prisma upsert + guarded updateMany for lastVisit to avoid race with Loyalty listener's upsert
  • OnBookingCompletedLoyaltyListener (loyalty module) — resolves tenantCustomerId via upsert, calls LoyaltyService.autoStamp + autoEarnPoints, same CAS pattern via processedForLoyalty
  • URL convention for BookingCreated payload: returnUrl = {PUBLIC_WEB_URL}/b/{slug}/bookings/{id}, cancelUrl = {PUBLIC_WEB_URL}/b/{slug}, webhookUrl = {API_BASE_URL}/api/webhooks/payments (env vars via ConfigService; Payment adapters append provider+tenantId)
  • Lint cleanup — 11 errors + 63 warnings on branch reduced to 0/0 (e2e test typing, Prisma proper types, typed Request for auth decorators/guards, ms.StringValue for JWT expiresIn)
  • E2E test test/booking-outbox.e2e-spec.ts (5 cases) — BookingCreated outbox row shape + URLs, publishOne marks published, Completed triggers TC+Loyalty DB effects, idempotent re-publish, guest booking leaves flags false
  • E2E suite back to green (2026-04-18) — fix 12 preexisting failures (customer-auth 8 + public-booking 4). Customer-auth helper includes v: tokenVersion for guard check; refresh test asserts Set-Cookie headers (HttpOnly, no body tokens). Public-booking tests realigned to the post-1ec327c guest flow (snapshot on booking, no Customer auto-create, no phone dedupe for guests). 900 unit + 57 e2e all green.
  • Authorization expiry cron (2026-04-18) — repeatable BullMQ job payment-expiry:sweep every 15 min scans Payment rows where status ∈ {INITIATED, AUTHORIZED} AND expiresAt ≤ now(). For AUTHORIZED + txnId + supportsVoid: best-effort provider.void with reason AUTHORIZATION_EXPIRED and per-payment idempotency key; failure is logged + counted but does NOT block the domain transition. Always calls payment.markExpired(now) → outbox event → listeners. Per-payment errors isolated (one failure doesn't abort batch); idempotent because findExpirable filters non-terminal. Metrics: payment_expiry_swept_total{provider_key,from_status}, payment_expiry_void_failed_total{provider_key}, payment_expiry_skipped_total{reason}. Registered on BullBoard. New findExpirable(asOf, limit) port method is cross-tenant (system-level exception — documented). 26 new tests (9 service + 6 queue + 3 processor + 5 metrics + 2 repo + 1 module wiring), 926/926 green.

Phase 7 Track D1 — Admin UI: Provider Config (IN PROGRESS 2026-04-18):

  • Backend PaymentConfigDto converted interface → class with @ApiProperty + @ApiOkResponse on every controller endpoint → OpenAPI response schema no longer empty, api.generated.ts now has full type
  • Frontend test infra: Vitest + @testing-library/react + jsdom (scripts test, test:watch); Playwright 1.59 + chromium (scripts test:e2e, test:e2e:integration, test:e2e:ui); QueryClient test wrapper helper
  • Provider split — Bambora Classic vs Worldline Direct (2026-04-18). Discovered that the initial "bambora/" adapter was actually coded against Worldline Direct API (preprod.worldline-solutions.com), while Norwegian SMB merchants sign up for Bambora Europe Checkout (Classic) — the two products are distinct, and Worldline doesn't onboard directly in NO (that's why they acquired Bambora in 2020). Split cleanly: renamed existing code to providers/worldline-direct/ + ProviderKey.WORLDLINE (kept for future enterprise migration, disabled in UI), new providers/bambora/ adapter implements Bambora Europe Checkout (Classic) with Basic-auth(access:secret), MD5 callback signatures, 4-URL endpoint map (transaction/merchant/checkout-api/login). Prisma enum + domain enum gain WORLDLINE (additive migration).
  • Bambora Classic adapter (~1640 LOC + 72 tests): credentials parse (merchantNumber T/P + accessToken + secretToken + md5Key, derives isTest from prefix), endpoints, MD5 callback hash (compute + verify, constant-time, case-insensitive), HTTP client (copied from worldline-direct — lift to shared in Phase 5+), error mapper (meta.result=false treated as failure, insufficient-funds codes mapped), transaction-shape mapper, full 7-method PaymentProviderPort: createSession (POST /checkout) / capture / void (/delete) / refund (/credit) / fetchStatus / verifyWebhook / healthCheck (GET /merchant/functionpermissionsandfeatures)
  • Frontend Zod schema rewritten: 4 fields (merchantNumber optional + 3 required secrets: accessToken, secretToken, md5Key). deriveBamboraCredentials derives isTest from T/P prefix (defaults true when absent). No Test-mode toggle anywhere in the UI — single source of truth is the merchant number.
  • API client hooks in usePaymentConfigs.ts — list + create + update + rotate + activate + deactivate + health-check; all invalidate the shared payment-configs query key; mutations pipe through useFormMutation (toast + error-code translation)
  • Components in components/settings/payment/: ProviderCard (3 status states + disabled "Coming soon" for Vipps + Worldline), HealthCheckBadge (OK / FAILED / not-yet with hover timestamp), BamboraConfigForm (FormField + 3 × PasswordField, live MerchantModeBadge flips Test/Produksjon as owner types, Wrapper/Inner drawer pattern via showMerchantNumber/showDisplayName), ConnectedProviderActions (Verify/Activate/Deactivate/Rotate), PaymentSettings (grid page)
  • FormField + new PasswordField now emit proper htmlFor/id labels for a11y + getByLabelText test ergonomics; PasswordField has show/hide toggle with localized aria-labels
  • Settings sidebar tab "Betaling / Payment" wired into SettingsContent with CreditCard icon, URL ?tab=payment; i18n keys under settings.payment.* (nb.json + en.json) covering providers, status, health check, actions, Bambora form fields (merchantNumber/accessToken/secretToken/md5Key + test/produksjon labels), messages
  • Provider selector: Bambora (enabled); Vipps MobilePay + Worldline (enterprise) shown as "Kommer snart" / "Coming soon" with disabled CTA. Stripe/Nets/Adyen kept in provider enum but not surfaced in UI
  • "Delete config" intentionally omitted (phase D1 scope) — Deactivate covers pausing, matches backend which has no DELETE endpoint
  • E2E: e2e/payment-settings.spec.ts (3 smoke cases — cards render, Vipps disabled, drawer opens); e2e/payment-settings-integration.spec.ts (@integration — real Bambora sandbox health check, auto-skip when E2E_BAMBORA_{ACCESS_TOKEN,SECRET_TOKEN,MD5_KEY} envs missing; optional E2E_BAMBORA_MERCHANT_NUMBER). Playwright auth fixture logs in as seed OWNER (owner1@gmail.com / 123456)
  • Provider-card + drawer UX polish (2026-04-18)ProviderCard: health badge moved right of title and relabeled "Connect failed!" (not "Failed") when the provider rejects creds; Test/Production mode badge now gated behind lastHealthCheckStatus === 'OK' so the owner never sees an unverified mode claim; Verify/Manage/Connect/Coming-soon buttons shrunk to a compact !px-2.5 !py-1.5 !text-xs style with h-3.5 w-3.5 icons. PaymentSettings.handleBamboraCreate refactored to async/mutateAsynccreate no longer closes the drawer on success; it awaits health-check first and only closes when OK. On FAILED the drawer swaps to manage mode (configId preserved) so the retry goes through rotate instead of 409-ing a second POST /payment-configs. handleManageSubmit likewise waits on health-check before closing when a rotate happened. Activate now auto-fires healthCheckMutation on success so stale-OK claims after a deactivate/reactivate round-trip can't fool the UI. Drawer chrome (ProviderConfigDrawer, EditConfigDrawer, BamboraConfigForm) restructured to flex max-h-[90vh] flex-col → body scrolls (flex-1 min-h-0 overflow-y-auto), footer sticks (shrink-0 border-t); Cancel disabled while submitting. Tests updated (ProviderCard +2 cases for mode-badge gating = 17; HealthCheckBadge text change; 58 Vitest green overall).
  • Tests green: API 999 Jest (was 927, +72 Bambora adapter suite — 12 credentials + 9 signature + 7 errors + 10 http-client + 12 mapper + 15 adapter + 7 retry) · Web 58 Vitest · Playwright 4 tests listed (3 smoke + 1 @integration)
  • Bambora Classic webhook → Payment.authorize end-to-end LIVE (2026-04-20) — three sequential fixes unblocked deposit flow after customer completes Bambora test checkout: (1) PaymentWebhookController had only @Post but Bambora Classic dispatches callback as GET per docs — added @Get(':provider/:tenantId') reading raw query string from req.url (preserves Bambora MD5 signing order), shared process() with POST path; (2) MD5 verify failed on arrival — dev-only forensic log ([Bambora verify mismatch] md5KeyLen=9 md5KeyPrefix=UOc8... charCodes=... provided=... expected=... concat=...) showed the admin-form paste had dropped the trailing char, owner re-entered via Rotate credentials; retained Incoming GET webhook + Webhook verified / Webhook verify FAILED structured logs for future triage; (3) ProcessWebhookInboxService was Worldline-shaped (payload.payment.id, payment.authorized eventType) and treated Bambora's flat {txnid, orderid} as "unhandled" — fixed by: Bambora adapter.createSession.providerSessionId = toBamboraOrderNumber(paymentId) (merchant order reference = what Bambora echoes as orderid on callback, enables findByProviderSessionId(orderid) without extra API call; Bambora session token stays in redirectUrl, not needed for capture/void/refund/status which all use txnid), Bambora adapter.verifyWebhook.eventType = 'payment.authorized' default (docs: callback only fires on successful auth), processor extracts providerTransactionId = payload.payment?.id ?? payload.txnid and falls back to findByProviderSessionId(orderid) when findByProviderRef(txnid) misses, transitionAggregate for authorize safely picks txnid from either shape. Verified live: callback arrives → MD5 passes → Payment found by orderid → Payment.authorize(txnid)PaymentAuthorized event → OnPaymentAuthorizedListener flips booking PENDING → CONFIRMED. Return page polling sees AUTHORIZED → "Deposit secured" card. Backward-compat note: Payments created before this commit stored Bambora session token as providerSessionId (not orderid), their pending callback retries won't match via findByProviderSessionId — only new bookings are fixed. 505/507 payment tests green (2 skipped, pre-existing).
  • Spec conformance + public provider branding (2026-04-19) — adapter alignment with Worldline Online Checkout v1 docs (cached in Obsidian Bambora/ vault): endpoint /checkout/sessions, url.declineurl.cancel, removed url.immediateredirecttoaccept: false (field is integer seconds, boolean caused 40400/50000 Serialization error: 'false' cannot be parsed as Int32), language relocated from top-level → paymentwindow.language, order.ordernumberorder.id. Public GET /public/tenants/:slug gains settings.paymentProvider (BAMBORA | null) from the first active PaymentConfig; PublicBookingModule imports PaymentModule. BookingPage CTA branded: "Pay with Bambora · 500 kr" + "Secured by Bambora" footer (ShieldCheck icon) via provider-metadata.ts — future Vipps/Stripe is a one-line metadata enable. Hydration fix: initialDate = todayInZone(tenant.settings.timezone) computed on the server component and threaded down as prop to BookingPage + DateStrip (previously each called new Date() client-side, racing SSR around midnight). Public cache: fetchPublic flipped from next.revalidate: 60cache: 'no-store' — tenant settings / services / availability are live edit targets, 60s ISR window produced SSR/hydrate payload drift. Polling safety: pollForCheckoutUrl now exits on status ∈ {FAILED, EXPIRED} throwing PaymentCheckoutFailedError instead of spamming the poll endpoint until 15s timeout; BookingPage surfaces book.errorPaymentFailed (nb + en). Redirect race: redirectToCheckout replaced throw new Error('Redirect did not happen') with new Promise<never>(() => {}) — avoids red error flash during navigation. 14/14 adapter + 25/25 public-booking controller specs green. External blocker surfaced: Bambora test merchant returned 40401 currency not supported for NOK — owner must enable NOK in the Bambora backoffice or switch tenant currency to DKK/EUR for testing.

Phase 7+ — Planned:

  • Track C — Public deposit flow (IN PROGRESS). Authorize-on-book, capture-on-complete.
    • C1 — Public booking ↔ Payment plumbing (2026-04-18). InitiatePaymentHandler persists checkoutUrl in Payment.metadata (previously transient). resolveInitialStatus(settings, { depositRequired }) forces PENDING when deposit > 0 regardless of autoConfirm — staff never see a confirmed-but-unpaid booking. computeDepositAmount() extracted into booking-settings.helper so BookingService (status) and buildBookingCreatedPayload (event) share one implementation. New GET /public/tenants/:slug/bookings/:bookingId/payment returns { status, checkoutUrl, amount, ... } or null; FE polls until the async onBookingCreated → InitiatePaymentCommand listener lands. POST /public/tenants/:slug/bookings response gains requiresPayment + paymentPollUrl. 1019 unit (+19) + 59 e2e (+2) green.
    • C2 — IN PROGRESS. Webhook POST /api/webhooks/payments/bambora (MD5 verify) → markAuthorized → listeners transition booking.
      • C2 backend (2026-04-18)OnPaymentAuthorizedListener (PENDING → CONFIRMED on PaymentAuthorized) + OnPaymentSettledNegativeListener (PENDING → CANCELLED on PaymentFailed/PaymentExpired). bookingId enriched on the three payment payloads. BookingService.updateStatus gains a role: 'SYSTEM' bypass for the cancellation-window rule so event-driven cancels always fire. Idempotent (skip non-PENDING), race-safe (swallow INVALID_STATUS_TRANSITION), tenant-guarded. 19 new unit tests. Webhook endpoint + ProcessWebhookInboxServicePayment.authorize pipeline was already wired from Phase 5, so only the Booking-side subscribers were missing.
      • C2 FE (2026-04-18)buildBookingUrls now points returnUrl at /b/{slug}/bookings/{id}/payment/return (poll-until-status landing) and cancelUrl at /b/{slug}/bookings/{id}/payment/cancelled (user-cancelled landing). Shared lib/payment/public-payment-api.ts adds fetchBookingPayment, classifyOutcome(status) (pending/success/failed/cancelled), pollForCheckoutUrl(slug, bookingId, { intervalMs, timeoutMs }) (500ms default, 15s cap) and redirectToCheckout(slug, bookingId) wrapper. Two new App Router pages: /b/[slug]/bookings/[id]/payment/return (client-side polling with 2s interval + 30s timeout, tone cards for each outcome) and /b/[slug]/bookings/[id]/payment/cancelled (static "you cancelled" with retry). BookingPage.onSubmit branches on requiresPayment: when true it awaits redirectToCheckout (browser leaves page); on PaymentCheckoutTimeoutError it surfaces a friendly errorPaymentTimeout message so the customer can retry. i18n keys added under paymentReturn.* (nb + en parity) + new book.errorPaymentTimeout. Build clean, vitest 64/64, lint 0/0.
    • C3 (pre-existing from Phase 6, verified 2026-04-18)PaymentIntegrationService already subscribed to the booking lifecycle: onBookingCompletedCapturePaymentCommand (MANUAL + AUTHORIZED); onBookingCancelleddecideCancellationRefund policy → VoidPaymentCommand / CapturePaymentCommand (forfeit) / RefundPaymentCommand / no-op based on cancel window + cancelledBy; onBookingNoShowCapturePaymentCommand (no-show fee default). 10 unit tests in payment-integration.service.spec.ts. Only the listeners that TRANSITION the booking on payment events (C2.1 + C2.2) were missing.
    • C4 UI — DONE (2026-04-20)
      • C4.3 Customer booking-form deposit preview — public GET /public/tenants/:slug surfaces depositEnabled/depositType/depositValue; new lib/payment/deposit-calc.ts mirrors the backend math (7 vitest). BookingPage shows amber notice + swaps CTA to "Continue to payment · X". i18n book.{continueToPayment,depositNotice,redirecting} nb + en.
      • C4.1 Admin booking-drawer payment summary — new BookingPaymentSummary (Total / Deposit+status badge / Paid / Remaining, failure banner when latest FAILED) powered by useBookingPayments(bookingId)GET /admin/payments/by-booking/:bookingId. Sums captured-minus-refunded across retries. FE Payment type in types/payment.ts pending OpenAPI regen. i18n bookingPayment.* nb + en.
      • C4.2 Admin booking-list deposit badge (2026-04-20)PaymentRepositoryPort gains findLatestStatusByBookingIds(bookingIds, tenantId): Promise<Map<string, PaymentStatus>> (batched, tenant-scoped, returns the most-recent by createdAt so a retry-after-FAILED naturally wins). BookingController.findAll imports PAYMENT_REPOSITORY (via BookingModule → PaymentModule) and decorates each list item with paymentStatus: PaymentStatus \| null — one extra query per page, no N+1. Shared PaymentStatusBadge extracted from BookingPaymentSummary into components/payment/ and reused on the list table; absent-status renders em-dash. New booking list column "Depositum/Deposit" via bookings.deposit i18n key. +5 tests (1 controller merge + 4 Prisma repo: empty array, tenant+IN scope, latest-wins dedupe, null-bookingId skip). Results: 1044 API unit + 57 e2e · 71 web vitest · lint 0/0 · both typechecks clean.
  • Track L — Loyalty discount applied to booking amount (IN PROGRESS). Gap: customer can redeem stamp/point rewards (FREE_SERVICE / DISCOUNT_AMOUNT / DISCOUNT_PERCENT) but redemption does NOT reduce booking.total or Payment.amount — customer still pays full. Phased rollout:
    • L1 — Data model + backfill (2026-04-21). Prisma migration add_loyalty_discount_fields: Booking gains discountAmount (Int?) + appliedRedemptionId (String? UNIQUE FK → LoyaltyRedemption ON DELETE SET NULL); LoyaltyRedemption gains new enum LoyaltyRedemptionStatus (RESERVED | CONSUMED | CANCELLED) + redeemedAt + cancelledAt nullable timestamps + index on status. Legacy admin-created redemptions backfilled status=CONSUMED (DB default) + redeemed_at = created_at. Unique constraint on applied_redemption_id so one redemption can only back one booking. LoyaltyService.redeemStampCard now explicitly sets status=CONSUMED, redeemedAt=new Date() on create so the admin-manual path keeps coherent state; redeemPoints doesn't create LoyaltyRedemption rows (points-burn is via LoyaltyPointTransaction ledger) so no change needed there. No behaviour change, no API contract change, zero new tests — pure data model preparation unblocking L2–L6.
    • L2 — Discount compute helper (2026-04-21). Pure computeLoyaltyDiscount(input): LoyaltyDiscountResult at core/loyalty/compute-loyalty-discount.ts — no Prisma access, caller hydrates input. Returns {discountAmount, freeServiceItemId?, eligibleSubtotal}. Rules: FREE_SERVICE auto-picks the sole eligible item; on multi-eligible requires selectedServiceItemId (throws LOYALTY_SERVICE_PICK_REQUIRED) — no auto-pick most-expensive. applicableServiceIds narrows the candidate set; a pick outside throws LOYALTY_PICKED_ITEM_NOT_ELIGIBLE. DISCOUNT_AMOUNT subtracts fixed øre, clamped to min(eligibleSubtotal, rawTotal). DISCOUNT_PERCENT round(eligibleSubtotal × value / 100) then clamp — accepts >100 (clamps to total, supports premium-tier "120% off" edge cases). When applicableServiceIds non-empty the reward discounts only the matching-items subtotal (Stripe/Booksy default). Errors: 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 (TDD: spec-first, 19/19 green on GREEN phase). 1089 API unit total.
    • L3 — DDD layering + reserve on booking create (2026-04-21). Loyalty reorganized into domain/ (pure) + application/ (use cases + ports) + infrastructure/ (Prisma adapter), mirroring the Payment context. computeLoyaltyDiscount moved to loyalty/domain/; new redemption-policy.ts (pure guards: assertStampRedeemable, assertPointsRedeemable, pointsToDiscountAmount). New application/loyalty-redemption.service.ts exposes preflight(cmd) (read-only validation + discount compute) + reserveInTx(tx, bookingId, cmd, preflight) (RESERVED row for VISIT_BASED, points ledger REDEEM for POINTS_BASED); persistence hidden behind LOYALTY_REDEMPTION_REPOSITORY port → PrismaLoyaltyRedemptionRepository in infrastructure/. BookingService.create pipeline: (1) preflight before tx, (2) compute payableTotal = rawTotal − discountAmount, (3) inside $transaction: booking row with discountAmountreserveInTxbooking.update({ appliedRedemptionId }) when VISIT_BASED → outbox row. BookingCreatedPayload extended with optional originalAmount + discountAmount (backward-compat); totalAmount = payableTotal; deposit % computed on discounted total. DTO adds BookingRedemptionInputDto { cardId, selectedServiceItemIndex?, pointsToRedeem? }selectedServiceItemIndex is 0-based into items[] (FE can't know server-side booking-item UUIDs at submit time). Guest bookings with redemption rejected pre-preflight (LOYALTY_GUEST_NOT_ALLOWED). Docs: docs/flows/loyalty-flow.md — layering rules, sequence diagram, error-code catalog, backward-compat notes. +39 tests (19 policy + 13 application service + 4 payload builder + 3 booking-service integration). 1127 API unit + 59 e2e green (public-booking multi-day-timeoff spec fixed: getNextWeekday(3) produced endDate < startDate when today is Tuesday → replaced with nextTuesday + 1 day; 2 e2e cases were silently passing on non-Tuesdays) · 1 pre-existing e2e flake in public-booking.e2e-spec › multi-day time-off documented as unrelated (fails on main before L3)**. Booking-outbox e2e updated: captureMode: 'AUTO''MANUAL' for DEPOSIT intent (stale assertion since 2026-04-20 deposit hotfix). Weekday-flake fix in public-booking.e2e-spec › multi-day time-off: root cause was test helper getNextWeekday(3) returning Wed tomorrow while getNextWeekday(2) returned Tue next week when run on a Tuesday — nextWed now derived from nextTuesday + 1 day so the time-off span is always valid (availability service itself was correct).
    • L4 — Lifecycle listeners. COMPLETED → status=CONSUMED + redeemedAt=now. CANCELLED pre-capture → status=CANCELLED + restore stamps/points. Post-capture → forfeit (no restore), mirrors payment-cancellation policy.
    • L5 — Public API + customer UI. GET /public/tenants/:slug/customer/rewards auth-gated list, booking payload accepts redemption, BookingPage "Apply reward" section with preview. Guest bookings blocked from redeem (no TenantCustomer record).
    • L6 — Admin UX + E2E. Booking drawer + list show discount breakdown, Payment Detail breakdown original / discount / payable.
  • Track E1 — Remaining payment via in-salon QR (2026-04-20). PaymentIntent.REMAINING_PAYMENT added (domain enum + additive Prisma migration). New hexagonal BookingLookupPort + PrismaBookingLookupAdapter (1 query: Booking + items + tenant.settings) so Payment context reads booking summary without reaching into Booking internals. New InitiateRemainingPaymentCommand + Handler: validates booking status ∈ {ARRIVED, IN_PROGRESS, COMPLETED}, computes remaining = total − Σ captured + Σ refunded (skipping FAILED/VOIDED/EXPIRED Payments since no money moved), clamps command.amount ≤ remaining (defaults to full when omitted), idempotency-by-intent reuses an unexpired INITIATED REMAINING_PAYMENT row so the owner can close/reopen the QR modal without zombie sessions, then delegates to InitiatePaymentHandler with captureMode=AUTO. Four new domain errors: PAYMENT_BOOKING_NOT_FOUND, PAYMENT_INVALID_BOOKING_STATE, PAYMENT_NO_REMAINING_AMOUNT, PAYMENT_REMAINING_AMOUNT_EXCEEDED. Admin POST /admin/payments/remaining endpoint (Roles: OWNER, STAFF). Frontend: qrcode.react QR (240px), CollectRemainingModal 3-step state machine (input → qr → success) driven by derived step (React-Compiler-safe, no setState-in-effect), usePayment(id, { pollIntervalMs }) with terminal-status auto-stop, booking drawer CTA Krev resterende · X kr gated by remaining > 0 + allowed statuses, i18n collectRemaining.* nb + en parity + 4 new payment error-code translations. +16 API handler tests + 2 controller tests = 18 backend · +7 schema + 2 hook = 9 frontend. 1073 API unit + 57 e2e · 112 web vitest · lint 0/0 · both builds clean. Track L loyalty integration moved to backlog.
  • Second checkout for remainingAmount (Bambora session #2 online, triggered at completion) — superseded by Track E1 above
  • POS integration — card-reader terminal collects remainingAmount at salon
  • Per-service deposit override (currently per-tenant only)
  • Cancellation fee policy — partial capture on late-cancel instead of full void
  • Track D2 — Admin payment list + detail drawer + refund/void/capture dialogs (2026-04-20). /admin/payments page with filter toolbar (status, provider, bookingId search, date range) + paginated table (createdAt, provider, intent, status badge, amount, captured, refunded, booking link). PaymentDetailDrawer framer-motion slide-in with meta / amounts breakdown / provider refs / timeline / failure panel. Action bar (Capture/Refund/Void) gated by status + captureMode + user.role === 'OWNER'. RefundDialog two-step (form → ConfirmDialog danger) — Zod caps amount at captured − refunded, reason required. VoidDialog single-step with optional reason. CaptureDialog two-step — amount optional (null = full), bounded by authorized amount. Hooks usePayments / usePayment / useRefundPayment / useVoidPayment / useCapturePayment + generateIdempotencyKey() via crypto.randomUUID(). Sidebar entry with CreditCard icon. i18n nb + en (payments.* + 12 payment error codes + validation keys). +23 vitest, +2 Playwright smoke, 0 backend changes.
  • Post-D2 UX polish (2026-04-20).
    • 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. Switched to startsWith (Prisma where.bookingId = { startsWith }; in-memory r.bookingId?.startsWith(prefix)); full UUIDs still match via starts-with-itself. +2 query tests.
    • Backend — 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. 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. FE mirror: BookingDrawer.onSubmit edit-mode checks formState.isDirty and calls forceClose() instead of dispatching the mutation on no-op.
    • Backend — snapshot tenant display settings at creation time (422fc12): BookingService.create now writes a frozen copy of tenant display settings (timezone, currency, locale) into booking.metadata.displaySnapshot via new buildDisplaySnapshot() helper. FE renders booking time/money from this snapshot so a later tenant timezone change doesn't retroactively shift historical bookings (e.g. admin account page + web booking list render via displaySnapshot.timezone, not the live tenant setting). +10 tests (5 helper + 5 service integration).
    • Backend — customer portal timezone flatten (3c487c1): GET /customer/me/bookings was exposing nested tenant.settings.timezone that the FE had to dig for; service now flattens to booking.tenantTimezone at the DTO boundary so the account-page booking card can render formatDateTimeInZone(startTime, tenantTimezone) without re-fetching the tenant. +2 service tests.
    • FE — shared DatePicker overhaul (components/form/date-picker.tsx): altInput d/m/Y dd/mm/yyyy display (wire format stays Y-m-d), compact input h-10, Ant-Design-style range mode="range" with showMonths: 2 + separator , injected "Today" footer button (single-date mode only), hover-to-clear icon (Calendar → X on hover when value present), partial-range revert via lastValidRangeRef on close with 1-date, lucide Calendar icon (fixes clipped SVG bottom). globals.css flatpickr rounded-md day cells + bg-brand-100 inRange band. PaymentList filter toolbar swapped two <input type="date"> → single DatePicker mode="range".
    • FE — cross-page Payment → Booking drawer: PaymentDetailDrawer booking-id <Link> replaced with button firing onViewBooking(id)PaymentsContent fetches via new useBooking(id) hook (enabled on open) → renders nested BookingDrawer. Closing the booking drawer leaves the payment drawer on screen so owner keeps payment context. Booking-id cell gets a CopyButton (icon-only, 1.5s check flash, stopPropagation).
    • FE — BookingHistory diff modal rewrite: was single-column "changed fields only"; now full-snapshot side-by-side table (Field / Before / After). Per-field rows classified as CHANGED (tinted red-50/60 strikethrough left, emerald-50/60 bold right), UNCHANGED (single muted cell pulled from live booking prop), or NOT_SET (filtered out). Separate amber row for items: 'replaced' since audit doesn't store old/new item arrays. Fields: status, startTime, endTime, resourceId, customerId, notes, isPaid, source. Prop signature changed (bookingId, resources)(booking, resources); new i18n bookings.{diffField, diffBefore, diffAfter, historyNoDiff, historyItemsReplaced, fieldCustomer} nb + en.
    • FE — shared formatDateTimeInZone(iso, tz) + createDateTimeFormatterInZone(tz) in lib/timezone.ts — canonical dd/mm/yyyy HH:mm (en-GB + hour12: false + formatToParts assembly). Replaces duplicated Intl.DateTimeFormat('nb-NO', {...}) in PaymentList + PaymentDetailDrawer. +6 timezone.test.ts cases (Europe/Oslo DST, UTC, midnight normalise, single-digit pad).
    • Results: 1047 API unit (was 1044, +3: 2 query + 1 service) + 57 e2e · 100 web vitest (was 94, +6 DatePicker/timezone) · lint 0/0 · both builds clean.
  • Capture trigger move — Confirmed → Arrived (2026-04-21). Previously onBookingConfirmed captured MANUAL+AUTHORIZED deposits, but PaymentAuthorized flips booking PENDING→CONFIRMED within seconds of authorize, so Void window shrank to ~zero. Moved capture to onBookingArrived (primary) + kept onBookingCompleted (fallback — state machine allows CONFIRMED→IN_PROGRESS directly). Matches industry standard (Booksy / Timely / Vagaro / Phorest). New event + payload + builder; 4 integration-service tests.
  • Lead-time cap maxBookingDaysInAdvance (2026-04-21). New TenantSettings field, default 30 (range 1–365), enforced in BookingService create + update via validateBookingLeadTime() returning BOOKING_TOO_FAR_IN_ADVANCE. Admin does NOT get an override — hard cap for both public and admin create. Public endpoint exposes the cap; FE pre-validates and clamps admin DateField max. Settings form shows deposit warning when cap > 7 days (Bambora hold = 7 days). Industry defaults (BEAUTY_SETTINGS, BARBERSHOP_SETTINGS) realigned to currency NOK + maxBookingDaysInAdvance: 30; prisma/seed.ts updated. 5 helper tests (inside-cap / past-cap / boundary / past-booking / legacy-missing-setting). Customer DateStrip clamp (follow-up 2026-04-21)DateStrip accepts maxDaysInAdvance?: number, days array length = min(28, cap + 1) so today + the final allowed day are both selectable; BookingPage threads settings.maxBookingDaysInAdvance. Closes a gap where the strip hard-coded length: 28 and offered dates the API would reject on submit. E2E lockbooking-web/e2e/public-booking.spec.ts (2 Playwright cases): default seed (cap=30 → 28 cells) + owner PATCH cap=5 → 6 cells with today / today+5 boundary check, finally-block restores seed. Compact salon header — BookingPage gains inline logo/avatar + salon name + back-link row so deep-linked customers never lose tenant context.
  • Admin UI polish — unified SearchSelect + sortable BookingList + profile link fix (2026-04-21). All remaining native <select> across admin pages (BookingList status filter, PaymentList status + provider, Settings General Currency + Timezone, Settings Booking bookingMode + depositType, Accounting VAT rate) swapped to SearchSelect so chevrons/borders/heights match. Required-by-zod fields pass required prop so the clear-X button is hidden and auto-asterisk renders. BookingList gains "Created" column + sortable startTime/createdAt headers with 3-state toggle persisted to localStorage; backend adds sortBy/sortOrder whitelist in BookingService.findAllByTenant (calendar mode still forces startTime asc). UserDropdown "Edit profile" href /profile → /admin/profile.
  • Deposit capture-mode hotfix + Payment detail AMOUNTS redesign (2026-04-20). Two intertwined bugs caused every Bambora deposit to silently capture server-side while our DB still showed AUTHORIZED (owner Void → Bambora 134: No approved Authorize available for Delete). (A) buildBookingCreatedPayload hardcoded captureMode: 'AUTO' — MANUAL branch in PaymentIntegrationService + provider adapters was unreachable from booking-created path. Fixed by deriving from intent: intent === 'DEPOSIT' ? 'MANUAL' : 'AUTO' so deposit bookings hold and capture on Confirmed/Completed; full-prepay (future path) keeps AUTO. (B) Bambora Classic's /checkout/sessions treats presence of instantcaptureamount (not its value) as "capture on authorize" — adapter was sending instantcaptureamount: 0 for MANUAL, which silently captured the full amount server-side. Fixed by omitting the field entirely unless captureMode === AUTO. FE — Payment detail AMOUNTS section redesign: new On hold row (tone pending, hint "Reserved on the card") when status === AUTHORIZED, rendering amount − capturedAmount; zero Captured/Refunded render (muted) instead of 0 kr matching Stripe/Adyen convention; Up to X kr refundable → X kr confusing duplication renamed to clean Refundable → X kr; AmountRow gains optional hint slot + 'muted' tone. i18n payments.detail.{held, heldHint, refundable} nb + en. Tests replace "AUTO captureMode by default" with two explicit cases (FULL_PAYMENT → AUTO, DEPOSIT → MANUAL); Bambora adapter spec now asserts expect(body).not.toHaveProperty('instantcaptureamount') for MANUAL (the original toBe(0) was exactly the bug). No schema/migration, no API contract change — pre-fix payments still show diverged state (Bambora already captured them; use Refund instead of Void). Follow-ups queued: (1) move deposit capture from onBookingConfirmedonBookingArrived primary + onBookingCompleted fallback ✅ shipped 2026-04-21, (2) booking auto-cancel on Payment auth-expiry ✅ already shipped with Track C2 (OnPaymentSettledNegativeListener subscribes both PaymentFailed + PaymentExpired); audit 2026-04-21 added a contract test to authorization-expiry.service.spec.ts asserting the sweep emits PaymentExpired with bookingId + tenantId on the envelope so the listener invariant can't regress silently, (3) tenant setting maxBookingDaysInAdvance (default 30, hard-capped for both public + admin create) ✅ shipped 2026-04-21.
  • Lift http-client.ts + retry.ts → shared infrastructure/http/ (2026-04-20). Before: identical files duplicated in providers/bambora/ + providers/worldline-direct/ (~127 LOC × 2) — any fix to retry/backoff had to be patched twice. After: one canonical copy at core/payment/infrastructure/http/{http-client,retry}.ts, adapters + specs import from the shared path. provider-bootstrap.ts now uses a single FetchHttpClient import instead of two aliased copies. Tests de-duplicated (http-client.spec.ts + retry.spec.ts kept only in http/), worldline-direct/*.ts duplicates git rm'd. Lint 0/0, build clean, 1060 API unit (was 1073; -13 from removing the duplicate spec files, zero real test coverage lost).
  • Stripe / Vipps / Nets adapters (drop-in, zero domain change)

Epic 7: Notifications ❌ Not started

  • SMS booking confirmation (Norwegian template)
  • SMS status change notification
  • Owner/Staff push notifications (new booking, walk-in)
  • BullMQ queue processor (infrastructure ready, not wired)

Epic 8: Admin Portal ❌ Not started

  • Admin dashboard (all tenants overview)
  • Admin tenant management (search, filter)
  • Login as tenant (impersonate) + audit log

Epic 9: Customer Portal ✅

  • Salon profile page (/b/[slug])
  • Service catalog (category tabs, Fresha-style pill tabs)
  • Opening hours (timezone-aware, status badge, today highlight)
  • About section (HTML description)
  • Location (address + interactive map)
  • Public booking page (single-page, multi-service, Calendly-style)
  • Public booking E2E (2026-04-22) — 4 tranches / 11 new Playwright tests covering service+staff selection, settings enforcement (closed day, business-hours, deposit redirect), validation (blank name, skill filter, invalid deep-link) and rebook — full 13/13 suite green. See testing.md for per-test inventory.
  • Customer auth (Google OAuth, separate JWT, separate cookies)
  • Customer account page (/account — profile edit, booking history, loyalty, URL-based tabs)
  • Pre-fill booking form when logged in (profile name/phone/email auto-fill)
  • Auto-link customerId on booking when authenticated
  • Header avatar dropdown menu (My Bookings link → /account?tab=bookings)
  • Footer language switcher (auto-detect browser, localStorage persist)
  • My Bookings list: server-side pagination (page+limit, keepPreviousData)
  • My Bookings: sort by createdAt DESC (most-recent first); show Appointment + Created at times
  • My Bookings: View modal (reuses BookingTicket — salon/items/QR) + Book again deep-link (?from=<bookingId>)
  • Book again server-side clone (multi-service, staff, notes) — validates service/resource still active
  • Booking confirmation ticket (post-payment) — salon info + items w/ price + QR code for staff check-in
  • QR encodes public booking URL (/b/:slug/bookings/:id) — mobile staff app parses to open in-app
  • Google-auth profiles: email field read-only (managed by Google, backend drops dto.email)
  • Subdomain routing ({slug}.app.no)
  • SEO optimization

Epic 10: Loyalty System ✅

  • CRUD loyalty cards (admin — name, type stamp/points, reward type, thresholds)
  • Visit-based stamp cards (cycle tracking, auto-stamp on COMPLETED)
  • Points-based ledger (auto-earn on COMPLETED, adjustments, clawback)
  • Reward redemption (discount, free service)
  • Admin loyalty management UI (/admin/loyalty)
  • Customer loyalty dashboard (stamp progress dots + point balance per salon)
  • Auto-stamp on booking completion
  • Prepaid packages

Web Admin Features

Feature Status
Auth (login, signup, forgot/reset password) ✅ Done
Dashboard (overview, today's timeline) ✅ Done
Staff Management (CRUD, skills, tabs) ✅ Done
Staff Work Schedule (multi-slot grid, TimeOff) ✅ Done
Service Catalog (categories, CRUD) ✅ Done
Booking Calendar (day/week view, drag-drop, status filter) ✅ Done
Customer Management ✅ Done
Settings — General, Booking, Business Hours ✅ Done
Settings — Branding, Location, About ✅ Done
Settings — Tax, Accounting ✅ Done
Settings — Deposit (enabled, type, value, input group style) ✅ Done
Loyalty Programs (cards, conditional form validation) ✅ Done
Settings URL-based tabs (?tab=location) ✅ Done
Bookings view mode persist (localStorage) ✅ Done

Static Pages (Customer-facing)

Page Route Status
About /about ✅ Placeholder
Contact /contact ✅ Placeholder
Privacy Policy /privacy ✅ Placeholder
Terms of Service /terms ✅ Placeholder
Pricing /pricing ✅ Placeholder
Help Center /help ✅ Placeholder

Content sẽ được quản lý bởi Superadmin (xem Roadmap bên dưới).

Non-functional Requirements

Requirement Status
JWT auth + refresh tokens + token versioning ✅ Done
Security hardening (helmet, sameSite strict, MaxLength, timing-safe) ✅ Done
Tenant isolation (tenantId filter) ✅ Done
Rate limiting (100 req/min) ✅ Done
Norwegian localization (nb-NO) ✅ Done
API response envelope ✅ Done
Error codes (domain prefix) ✅ Done
OpenAPI spec + type codegen ✅ Done
Role-based access control (ADMIN/OWNER/STAFF/CUSTOMER) Done (API + Web + E2E) (xem section dưới)
Multi-tenant sign-in (same email across salons) ✅ Done (2026-04-24) — password verified before tenant list disclosure, TenantPicker v2 groups salons by role (OWNER / STAFF) in separate tinted sections with 56×56 avatars + unified brand-accent hover; bcrypt compare loop parallelised so login latency stays at ~one compare regardless of tenant count; login endpoint on a stricter 5/60s/IP throttle
Admin reset staff password + edit login phone in Edit-staff drawer ✅ Done (2026-04-23) — login block on PATCH /resources/:id (add login / reset password / change phone); email + role immutable; tokenVersion bumped on password reset to kill stale sessions
RLS (Row Level Security) ❌ Not started
Offline mode (mobile) ❌ Not started

Role-based Access Control — Audit & Test ✅ DONE (API + Web + E2E)

Why: trước khi build mobile app (booking-mobile) em cần role switch giữa OWNER/STAFF đã verify end-to-end. Ma trận quyền chính thức: docs/architecture/role-matrix.md.

Hiện trạng (2026-04-23 — All Phases DONE)

Role hierarchy đã implement (booking-api/src/auth/guards/roles.guard.ts):

  • ADMIN(4) > OWNER(3) > STAFF(2) > CUSTOMER(1), so sánh >=
  • @Roles('OWNER') ngầm cho phép ADMIN
  • Endpoint không @Roles() → allow tất cả authenticated admin user (ADMIN/OWNER/STAFF); CUSTOMER JWT bị reject ở JwtAuthGuard (line 44)
Layer Trạng thái
API — RolesGuard global + @Roles() tường minh trên mọi admin controller ✅ Done (2026-04-23)
API — JwtAuthGuard load resourceId vào RequestUser ✅ Done — populate từ DB mỗi request, không bump tokenVersion
API — BookingService resource-scoping 6 method (findAll, findById, create, walkIn, update, updateStatus) + selfPick ✅ Done — STAFF thấy own + unassigned, throw 403 nếu cross-resource
API — TenantCustomerService.update() whitelist notes/tags/metadata ✅ Done — metrics field locked
API — ResourceService time-off self-scoping cho STAFF ✅ Done — STAFF POST/PATCH/DELETE time-off của mình khi isApproved=false
API — Upload mở cho STAFF POST (avatar), DELETE vẫn OWNER-only ✅ Done
API — Payment admin by-booking/remaining/:id scope theo booking ownership cho STAFF ✅ Done
API — Tests ✅ 14 test mới (booking 8, resource 5, tenant-customer 1, payment 3) — tổng 1185 pass, 0 fail
Web — /admin layout allowedRoles={["ADMIN","OWNER","STAFF"]} ✅ Done (2026-04-23)
Web — sidebar filter theo role (ẩn Services/Resources/Payments/Settings cho STAFF) ✅ Done (2026-04-23)
Web — BookingCalendar default filter STAFF = self (one-shot via localStorage flag) ✅ Done (2026-04-23)
Web — BookingDrawer hide staff selector + force resourceId cho STAFF ✅ Done (2026-04-23)
Web — OwnerOnlyGuard wrapper trên các trang sensitive (direct-URL guard) ✅ Done (2026-04-23)
Test — E2E Playwright với STAFF fixture (7 scenarios) ✅ Done (2026-04-23) — booking-web/e2e/staff-role.spec.ts, 7/7 green
Ma trận chính thức docs/architecture/role-matrix.md (2026-04-23 draft)

Đã ship trong Phase 2 (2026-04-23)

  • RequestUser.resourceId — thêm field, populate từ DB trong JwtAuthGuard (mỗi request 1 query nhẹ qua User unique index).
  • Performer.resourceId (BookingService) — performer interface mở rộng, controller truyền user.resourceId vào mọi call.
  • STAFF scoping rules (BookingService):
    • findAllByTenantWHERE resourceId IN (staff.resourceId, NULL); reject khi query resourceId khác.
    • findById — 403 BOOKING_NOT_IN_STAFF_SCOPE nếu booking.resourceId ≠ staff và ≠ null.
    • create — block 403 nếu dto.resourceId hoặc bất kỳ items[].resourceId khác staff.resourceId.
    • walkInWALK_IN_RESOURCE_NOT_ALLOWED_FOR_STAFF nếu dto.resourceId ≠ staff.
    • updateBOOKING_REASSIGN_NOT_ALLOWED_FOR_STAFF nếu đổi sang resource khác; cấm items[] target khác.
    • updateStatus — reuse findById guard, auto 403 nếu out-of-scope.
    • selfPickSELF_PICK_RESOURCE_MUST_BE_SELF nếu caller pass resourceId ≠ mình.
  • TenantCustomer whitelistupdate() chỉ persist notes/tags/metadata; mọi field khác silent drop.
  • ResourceService time-off — STAFF create/update/delete chỉ trên :id = staff.resourceId; edit/delete bị block nếu isApproved=true.
  • Payment admin scopebyBooking, getOne, initiateRemaining validate booking.resourceId cho STAFF.
  • Upload — POST mở cho STAFF (@Roles('STAFF','OWNER','ADMIN')); DELETE vẫn @Roles('OWNER','ADMIN').
  • @Roles() tường minh — tất cả admin controller đã khai báo rõ role list (không dựa hierarchy implicit).

Checklist còn lại

Phase 3 — Web UI (DONE 2026-04-23)

Đã ship:

  • AuthContext.User.resourceId (từ /auth/me backend vừa thêm) — STAFF có resourceId, OWNER/ADMIN = null.
  • /admin/(dashboard)/layout.tsx mở allowedRoles={["ADMIN","OWNER","STAFF"]}.
  • AuthGuard: nếu user đã login nhưng role không đủ → redirect /admin thay vì /admin/signin.
  • OwnerOnlyGuard wrapper mới (thin AuthGuard với ADMIN/OWNER) gắn vào: Settings, Payments, Services, Staff, Work-schedule pages — STAFF direct-URL cũng bị chặn.
  • AppSidebar: NavItem.allowedRoles? field + filterNavByRole() helper. STAFF chỉ thấy Dashboard, Bookings, Customers, Loyalty. Parents toàn sub-items hidden cũng drop khỏi menu.
  • BookingCalendar: one-shot STAFF default filter — tất cả resource column trừ own ẩn trên lần load đầu (localStorage flag calendar:staffDefaultsApplied). User có thể unhide colleagues để xem overview; API vẫn scope bookings → column người khác sẽ empty.
  • BookingDrawer: STAFF dropdown resource chỉ list self; handleAddService force resourceId = staffResourceId khi thêm item mới, bỏ qua "last item/calendar click" defaults. Duplicate useAuth() call dọn sạch.

Result: yarn lint 0/0 · yarn build clean.

Phase 3 — Web UI (legacy plan dưới đây giữ lại để tham chiếu)

  • Đổi AuthGuard allowedRoles={["ADMIN","OWNER","STAFF"]}/admin layout.
  • AppSidebar filter menu theo role — STAFF ẩn: Settings, Staff management, Services CRUD, Tax, Payment config, Loyalty CRUD.
  • BookingCalendar — STAFF mặc định filter staff column = chính mình, có thể chuyển sang unassigned queue.
  • Button/action guard trong booking drawer theo role (Delete chỉ OWNER, Refund chỉ OWNER...).

Phase 4 — E2E Tests (DONE 2026-04-23)

  • Unit tests (booking-api) — 14 test mới:
    • BookingService.findById 4 test (own / unassigned / foreign-403 / OWNER bypass).
    • BookingService.findAllByTenant 3 test (STAFF scope / STAFF cross-query / OWNER bypass).
    • BookingService mutations 5 test (create-resourceId-403, create-items-403, update-reassign-403, updateStatus-403, walkIn-403).
    • BookingService.selfPick 1 test (cross-resource 403).
    • ResourceService time-off 5 test (cross-resource 403, self OK, approved-edit-403, approved-delete-403, OWNER bypass).
    • TenantCustomerService.update() 1 test (silent drop metrics field).
    • PaymentController 3 test (getOne STAFF 403, byBooking STAFF 403, initiateRemaining STAFF 403).
  • E2E tests (booking-web/e2e/staff-role.spec.ts) — 7 scenarios:
    • STAFF signin qua form /admin/signin → redirect /admin, /auth/me trả role=STAFF + resourceId ≠ null.
    • STAFF sidebar chỉ hiện 4 menu (Dashboard, Bookings, Customers, Loyalty); ẩn Services, Resources (Staff+Work Schedule), Payments, Settings.
    • STAFF vào được /admin/bookings trực tiếp (không bị guard redirect).
    • STAFF gõ URL /admin/staffOwnerOnlyGuard bounce về /admin.
    • STAFF gõ URL /admin/settings → bounce về /admin.
    • STAFF gõ URL /admin/services → bounce về /admin.
    • OWNER → logout → STAFF login cùng browser session: /auth/me flip role, giữ nguyên tenantId, trả resourceId mới.
  • Fixture fixtures/auth.ts dùng storageState pattern (login 1 lần/worker/role) — tránh rate-limit khi full suite.

Phase 5 — Done criteria

  • docs/architecture/role-matrix.md merged.
  • 0 endpoint còn chặn STAFF nhầm theo matrix — API harden complete.
  • Web /admin login được STAFF, sidebar + booking filter đúng.
  • ≥ 7 E2E scenarios pass — 7/7 green trong 6s solo, full suite 24/25 green + 1 fixme trong 34s.
  • ≥ 5 unit scenarios pass cho BookingService scoping — 14 pass.
  • OpenAPI spec regen, booking-web types sync (mobile types sẽ sync khi scaffold mobile app).
  • Update docs/progress/changelog.md + chuyển dòng này sang ✅ Done.

Unblocks: mobile scaffold (booking-mobile) — API + Web + E2E role matrix đã lock, mobile có thể clone pattern mà không lo regression.

📌 Blocker cho: mobile app (mọi phase), Epic "Staff self-pick" trong Roadmap.

Architecture Decisions

  • Tenant = Location — 1 tenant = 1 salon. Multi-location via Organization layer planned.
  • Resource abstraction — Core uses "Resource", beauty layer maps to "Staff".
  • Schedule — Recurring weekly (ResourceSchedule) + date overrides (ResourceScheduleOverride).
  • Timezone — UTC in DB, salon tz in TenantSettings, Intl.DateTimeFormat for conversion.
  • Auth (admin) — httpOnly cookies (sameSite strict), access 30m, refresh 30d, token versioning, helmet security headers.
  • Auth (customer) — Separate JWT (type: customer), separate cookies, Google OAuth, token versioning.
  • Token versioningtokenVersion on User + Customer. JWT contains v. Guards compare v vs DB. Increment on password change/reset → invalidates all sessions. Handles DB reset (user not found → 401).
  • Auth separation — Admin guard rejects customer tokens (type claim check). Separate refresh endpoints + 401 redirect paths (admin → /admin/signin, customer → /account/login). Public API paths (/public/*) bypass auth entirely.
  • Booking guest vs auth — Guest: contact info snapshot on booking only, no customer record created. Auth: customerId from JWT + contact snapshot. No auto-merge.
  • TenantCustomer — Bridge table (customer × tenant) for per-salon visit stats. Auto-created on booking.
  • Loyalty — Visit-based (stamp cards with cycles) + points-based (ledger). Auto on COMPLETED.
  • Map — pigeon-maps (free), geocoding via Nominatim (OpenStreetMap).
  • Editor — Tiptap for rich text, sanitize-html on API.

Roadmap

Near-term

  • Role-based access control audit — blocker cho mobile, xem section "Role-based Access Control" ở trên
  • Payment tracking — mark paid, revenue dashboard
  • SMS notifications — Twilio/local provider, Norwegian templates

Mid-term (1-2 months)

  • Mobile app — React Native Expo, owner + staff screens
  • Push notifications — Expo push, real-time booking alerts
  • WebSocket — live calendar updates across devices
  • Subdomain routing — {slug}.app.no
  • Staff self-pick — claim unassigned bookings
  • Offline sync — WatermelonDB for mobile
  • Staff attendance — check-in/out, timesheet

Long-term (3-6 months)

  • Online payment — Stripe/Vipps integration
  • Multi-location — Organization layer above Tenant
  • Analytics — booking trends, revenue reports, staff utilization
  • Waitlist — join when fully booked
  • Calendar sync — Google Calendar / Apple Calendar
  • Review system — post-booking customer reviews
  • Marketing — email campaigns, promotions, gift cards
  • POS integration — physical point-of-sale
  • Multi-industry expansion — fitness, clinic, spa
  • RLS — PostgreSQL Row Level Security
  • Product sales — retail inventory, attach to booking invoice
  • Style gallery — photo portfolio for public page

Superadmin — System Management

  • Superadmin dashboard — overview all tenants, system health, user stats
  • Tenant management — search, filter, suspend/activate tenants
  • Login as tenant (impersonate) — debug/support with audit log
  • Static page editor — manage content for About, Privacy, Terms, Help, Pricing (rich text, per-language)
  • System settings — default configs for new tenants, supported industries, currencies
  • User management — admin accounts, roles, permissions
  • Billing & subscription — tenant plans, usage tracking, invoicing
  • Audit log — full system-wide mutation history
  • Feature flags — toggle features per tenant or globally
  • Email templates — manage notification templates (booking confirm, reminder, etc.)
  • Analytics & reports — platform-wide metrics (total bookings, active tenants, revenue)