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 customeraccountportal 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_inboxON CONFLICT DO NOTHING → BullMQpayment-webhookjob → ProcessWebhookInboxService applies transition - BullMQ root config + BullBoard at AppModule; modules register queues without duplicating config
-
rawBody: truein 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_at—last_attempt_at+ composite index for backoff-aware polling -
OutboxRepositoryPortextended —findById,listStuck(beforeAt, limit),markFailed(id, error, attemptedAt),deletePublishedOlderThan(cutoff),appendreturns generated UUID v7 IDs for enqueue-after-commit -
OutboxModule— BullMQoutbox-publisherqueue with hybrid delivery: hot-pathOutboxQueue.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 toEventBus, 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_totalcounters +payment_outbox_unpublishedgauge; registry injectable for test isolation - BullBoard registers
outbox-publisherqueue at/api/queues - Resilience —
queue.on('error')+@OnWorkerEvent('error')on both queues & processors (Outbox + Payment Webhook) prevent Node process crashes on Redis flap;OnModuleDestroycloses 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 inpayment/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_flags—processed_for_tenant_customer+processed_for_loyaltyBOOLEAN DEFAULT false (safe ADD COLUMN for existing rows) -
BookingServicerefactor —create() / updateStatus() / walkIn()wrapped inprisma.$transaction: booking row +domainEventOutbox.createManycommit atomically; enqueue publish job after commit (janitor re-enqueues on Redis failure);Clockport injected for testableoccurredAttimestamps -
updateStatus()emits exactly one event per terminal transition (Confirmed/Cancelled/NoShow/Completed); IN_PROGRESS/PENDING/ARRIVED intentionally no-emit;cancelledByresolved fromperformer.role(CUSTOMER vs SALON);cancellationWindowHoursfrom tenant settings - Inline
tenantCustomerService.onBookingCompleted+loyaltyService.autoStamp/autoEarnPointscalls removed from BookingService; LoyaltyService injection dropped from BookingModule -
OnBookingCompletedTenantCustomerListener(tenant-customer module) — CAS claim-first + rollback-on-failure idempotency viaprocessedForTenantCustomer; Prisma upsert + guarded updateMany forlastVisitto avoid race with Loyalty listener's upsert -
OnBookingCompletedLoyaltyListener(loyalty module) — resolves tenantCustomerId via upsert, calls LoyaltyService.autoStamp + autoEarnPoints, same CAS pattern viaprocessedForLoyalty - 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: tokenVersionfor guard check; refresh test assertsSet-Cookieheaders (HttpOnly, no body tokens). Public-booking tests realigned to the post-1ec327cguest 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:sweepevery 15 min scansPaymentrows wherestatus ∈ {INITIATED, AUTHORIZED} AND expiresAt ≤ now(). For AUTHORIZED + txnId +supportsVoid: best-effortprovider.voidwith reasonAUTHORIZATION_EXPIREDand per-payment idempotency key; failure is logged + counted but does NOT block the domain transition. Always callspayment.markExpired(now)→ outbox event → listeners. Per-payment errors isolated (one failure doesn't abort batch); idempotent becausefindExpirablefilters 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. NewfindExpirable(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
PaymentConfigDtoconverted interface → class with@ApiProperty+@ApiOkResponseon every controller endpoint → OpenAPI response schema no longer empty,api.generated.tsnow has full type - Frontend test infra: Vitest + @testing-library/react + jsdom (scripts
test,test:watch); Playwright 1.59 + chromium (scriptstest: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), newproviders/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).
deriveBamboraCredentialsderives 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 sharedpayment-configsquery key; mutations pipe throughuseFormMutation(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/Innerdrawer pattern viashowMerchantNumber/showDisplayName),ConnectedProviderActions(Verify/Activate/Deactivate/Rotate),PaymentSettings(grid page) -
FormField+ newPasswordFieldnow emit properhtmlFor/idlabels for a11y +getByLabelTexttest ergonomics; PasswordField has show/hide toggle with localized aria-labels - Settings sidebar tab "Betaling / Payment" wired into
SettingsContentwithCreditCardicon, URL?tab=payment; i18n keys undersettings.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 whenE2E_BAMBORA_{ACCESS_TOKEN,SECRET_TOKEN,MD5_KEY}envs missing; optionalE2E_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 behindlastHealthCheckStatus === '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-xsstyle withh-3.5 w-3.5icons.PaymentSettings.handleBamboraCreaterefactored toasync/mutateAsync— create 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 throughrotateinstead of 409-ing a secondPOST /payment-configs.handleManageSubmitlikewise waits on health-check before closing when a rotate happened. Activate now auto-fireshealthCheckMutationon success so stale-OK claims after a deactivate/reactivate round-trip can't fool the UI. Drawer chrome (ProviderConfigDrawer,EditConfigDrawer,BamboraConfigForm) restructured toflex 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)
PaymentWebhookControllerhad only@Postbut Bambora Classic dispatches callback asGETper docs — added@Get(':provider/:tenantId')reading raw query string fromreq.url(preserves Bambora MD5 signing order), sharedprocess()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; retainedIncoming GET webhook+Webhook verified/Webhook verify FAILEDstructured logs for future triage; (3)ProcessWebhookInboxServicewas Worldline-shaped (payload.payment.id,payment.authorizedeventType) and treated Bambora's flat{txnid, orderid}as "unhandled" — fixed by: Bamboraadapter.createSession.providerSessionId = toBamboraOrderNumber(paymentId)(merchant order reference = what Bambora echoes asorderidon callback, enablesfindByProviderSessionId(orderid)without extra API call; Bambora session token stays inredirectUrl, not needed for capture/void/refund/status which all use txnid), Bamboraadapter.verifyWebhook.eventType = 'payment.authorized'default (docs: callback only fires on successful auth), processor extractsproviderTransactionId = payload.payment?.id ?? payload.txnidand falls back tofindByProviderSessionId(orderid)whenfindByProviderRef(txnid)misses,transitionAggregatefor authorize safely picks txnid from either shape. Verified live: callback arrives → MD5 passes → Payment found by orderid →Payment.authorize(txnid)→PaymentAuthorizedevent →OnPaymentAuthorizedListenerflips booking PENDING → CONFIRMED. Return page polling sees AUTHORIZED → "Deposit secured" card. Backward-compat note: Payments created before this commit stored Bambora session token asproviderSessionId(not orderid), their pending callback retries won't match viafindByProviderSessionId— 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.decline→url.cancel, removedurl.immediateredirecttoaccept: false(field isintegerseconds, boolean caused40400/50000 Serialization error: 'false' cannot be parsed as Int32),languagerelocated from top-level →paymentwindow.language,order.ordernumber→order.id. PublicGET /public/tenants/:sluggainssettings.paymentProvider(BAMBORA | null) from the first activePaymentConfig;PublicBookingModuleimportsPaymentModule. BookingPage CTA branded: "Pay with Bambora · 500 kr" + "Secured by Bambora" footer (ShieldCheck icon) viaprovider-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 toBookingPage+DateStrip(previously each callednew Date()client-side, racing SSR around midnight). Public cache:fetchPublicflipped fromnext.revalidate: 60→cache: 'no-store'— tenant settings / services / availability are live edit targets, 60s ISR window produced SSR/hydrate payload drift. Polling safety:pollForCheckoutUrlnow exits onstatus ∈ {FAILED, EXPIRED}throwingPaymentCheckoutFailedErrorinstead of spamming the poll endpoint until 15s timeout; BookingPage surfacesbook.errorPaymentFailed(nb + en). Redirect race:redirectToCheckoutreplacedthrow new Error('Redirect did not happen')withnew Promise<never>(() => {})— avoids red error flash during navigation. 14/14 adapter + 25/25 public-booking controller specs green. External blocker surfaced: Bambora test merchant returned40401 currency not supportedfor 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).
InitiatePaymentHandlerpersistscheckoutUrlinPayment.metadata(previously transient).resolveInitialStatus(settings, { depositRequired })forces PENDING when deposit > 0 regardless ofautoConfirm— staff never see a confirmed-but-unpaid booking.computeDepositAmount()extracted intobooking-settings.helperso BookingService (status) andbuildBookingCreatedPayload(event) share one implementation. NewGET /public/tenants/:slug/bookings/:bookingId/paymentreturns{ status, checkoutUrl, amount, ... }or null; FE polls until the asynconBookingCreated → InitiatePaymentCommandlistener lands.POST /public/tenants/:slug/bookingsresponse gainsrequiresPayment+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 onPaymentAuthorized) +OnPaymentSettledNegativeListener(PENDING → CANCELLED onPaymentFailed/PaymentExpired).bookingIdenriched on the three payment payloads.BookingService.updateStatusgains arole: 'SYSTEM'bypass for the cancellation-window rule so event-driven cancels always fire. Idempotent (skip non-PENDING), race-safe (swallowINVALID_STATUS_TRANSITION), tenant-guarded. 19 new unit tests. Webhook endpoint +ProcessWebhookInboxService→Payment.authorizepipeline was already wired from Phase 5, so only the Booking-side subscribers were missing. - C2 FE (2026-04-18) —
buildBookingUrlsnow 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). Sharedlib/payment/public-payment-api.tsaddsfetchBookingPayment,classifyOutcome(status)(pending/success/failed/cancelled),pollForCheckoutUrl(slug, bookingId, { intervalMs, timeoutMs })(500ms default, 15s cap) andredirectToCheckout(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.onSubmitbranches onrequiresPayment: when true it awaitsredirectToCheckout(browser leaves page); onPaymentCheckoutTimeoutErrorit surfaces a friendlyerrorPaymentTimeoutmessage so the customer can retry. i18n keys added underpaymentReturn.*(nb + en parity) + newbook.errorPaymentTimeout. Build clean, vitest 64/64, lint 0/0.
- C2 backend (2026-04-18) —
- C3 (pre-existing from Phase 6, verified 2026-04-18) —
PaymentIntegrationServicealready subscribed to the booking lifecycle:onBookingCompleted→CapturePaymentCommand(MANUAL + AUTHORIZED);onBookingCancelled→decideCancellationRefundpolicy →VoidPaymentCommand/CapturePaymentCommand (forfeit)/RefundPaymentCommand/ no-op based on cancel window +cancelledBy;onBookingNoShow→CapturePaymentCommand(no-show fee default). 10 unit tests inpayment-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/:slugsurfacesdepositEnabled/depositType/depositValue; newlib/payment/deposit-calc.tsmirrors the backend math (7 vitest). BookingPage shows amber notice + swaps CTA to "Continue to payment · X". i18nbook.{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 byuseBookingPayments(bookingId)→GET /admin/payments/by-booking/:bookingId. Sums captured-minus-refunded across retries. FEPaymenttype intypes/payment.tspending OpenAPI regen. i18nbookingPayment.*nb + en. - C4.2 Admin booking-list deposit badge (2026-04-20) —
PaymentRepositoryPortgainsfindLatestStatusByBookingIds(bookingIds, tenantId): Promise<Map<string, PaymentStatus>>(batched, tenant-scoped, returns the most-recent bycreatedAtso a retry-after-FAILED naturally wins).BookingController.findAllimportsPAYMENT_REPOSITORY(viaBookingModule → PaymentModule) and decorates each list item withpaymentStatus: PaymentStatus \| null— one extra query per page, no N+1. SharedPaymentStatusBadgeextracted fromBookingPaymentSummaryintocomponents/payment/and reused on the list table; absent-status renders em-dash. New booking list column "Depositum/Deposit" viabookings.depositi18n 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.
- C4.3 Customer booking-form deposit preview — public
- C1 — Public booking ↔ Payment plumbing (2026-04-18).
- 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.totalor Payment.amount — customer still pays full. Phased rollout:- L1 — Data model + backfill (2026-04-21). Prisma migration
add_loyalty_discount_fields: Booking gainsdiscountAmount(Int?) +appliedRedemptionId(String? UNIQUE FK → LoyaltyRedemption ON DELETE SET NULL);LoyaltyRedemptiongains new enumLoyaltyRedemptionStatus(RESERVED | CONSUMED | CANCELLED) +redeemedAt+cancelledAtnullable timestamps + index onstatus. Legacy admin-created redemptions backfilledstatus=CONSUMED(DB default) +redeemed_at = created_at. Unique constraint onapplied_redemption_idso one redemption can only back one booking.LoyaltyService.redeemStampCardnow explicitly setsstatus=CONSUMED, redeemedAt=new Date()on create so the admin-manual path keeps coherent state;redeemPointsdoesn't create LoyaltyRedemption rows (points-burn is viaLoyaltyPointTransactionledger) 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): LoyaltyDiscountResultatcore/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 requiresselectedServiceItemId(throwsLOYALTY_SERVICE_PICK_REQUIRED) — no auto-pick most-expensive.applicableServiceIdsnarrows the candidate set; a pick outside throwsLOYALTY_PICKED_ITEM_NOT_ELIGIBLE. DISCOUNT_AMOUNT subtracts fixed øre, clamped tomin(eligibleSubtotal, rawTotal). DISCOUNT_PERCENTround(eligibleSubtotal × value / 100)then clamp — accepts >100 (clamps to total, supports premium-tier "120% off" edge cases). WhenapplicableServiceIdsnon-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.computeLoyaltyDiscountmoved toloyalty/domain/; newredemption-policy.ts(pure guards:assertStampRedeemable,assertPointsRedeemable,pointsToDiscountAmount). Newapplication/loyalty-redemption.service.tsexposespreflight(cmd)(read-only validation + discount compute) +reserveInTx(tx, bookingId, cmd, preflight)(RESERVED row for VISIT_BASED, points ledger REDEEM for POINTS_BASED); persistence hidden behindLOYALTY_REDEMPTION_REPOSITORYport →PrismaLoyaltyRedemptionRepositoryininfrastructure/. BookingService.create pipeline: (1) preflight before tx, (2) computepayableTotal = rawTotal − discountAmount, (3) inside$transaction: booking row withdiscountAmount→reserveInTx→booking.update({ appliedRedemptionId })when VISIT_BASED → outbox row.BookingCreatedPayloadextended with optionaloriginalAmount+discountAmount(backward-compat);totalAmount = payableTotal; deposit % computed on discounted total. DTO addsBookingRedemptionInputDto { cardId, selectedServiceItemIndex?, pointsToRedeem? }—selectedServiceItemIndexis 0-based intoitems[](FE can't know server-side booking-item UUIDs at submit time). Guest bookings withredemptionrejected 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 withnextTuesday + 1 day; 2 e2e cases were silently passing on non-Tuesdays) · 1 pre-existing e2e flake inpublic-booking.e2e-spec › multi-day time-offdocumented 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 inpublic-booking.e2e-spec › multi-day time-off: root cause was test helpergetNextWeekday(3)returning Wed tomorrow whilegetNextWeekday(2)returned Tue next week when run on a Tuesday —nextWednow derived fromnextTuesday + 1 dayso 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/rewardsauth-gated list, booking payload acceptsredemption, 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.
- L1 — Data model + backfill (2026-04-21). Prisma migration
- Track E1 — Remaining payment via in-salon QR (2026-04-20).
PaymentIntent.REMAINING_PAYMENTadded (domain enum + additive Prisma migration). New hexagonalBookingLookupPort+PrismaBookingLookupAdapter(1 query: Booking + items + tenant.settings) so Payment context reads booking summary without reaching into Booking internals. NewInitiateRemainingPaymentCommand + Handler: validates booking status ∈ {ARRIVED, IN_PROGRESS, COMPLETED}, computesremaining = total − Σ captured + Σ refunded(skipping FAILED/VOIDED/EXPIRED Payments since no money moved), clampscommand.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 toInitiatePaymentHandlerwithcaptureMode=AUTO. Four new domain errors:PAYMENT_BOOKING_NOT_FOUND,PAYMENT_INVALID_BOOKING_STATE,PAYMENT_NO_REMAINING_AMOUNT,PAYMENT_REMAINING_AMOUNT_EXCEEDED. AdminPOST /admin/payments/remainingendpoint (Roles: OWNER, STAFF). Frontend:qrcode.reactQR (240px),CollectRemainingModal3-step state machine (input → qr → success) driven by derivedstep(React-Compiler-safe, no setState-in-effect),usePayment(id, { pollIntervalMs })with terminal-status auto-stop, booking drawer CTAKrev resterende · X krgated byremaining > 0 + allowed statuses, i18ncollectRemaining.*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
remainingAmountat 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/paymentspage with filter toolbar (status, provider, bookingId search, date range) + paginated table (createdAt, provider, intent, status badge, amount, captured, refunded, booking link).PaymentDetailDrawerframer-motion slide-in with meta / amounts breakdown / provider refs / timeline / failure panel. Action bar (Capture/Refund/Void) gated by status + captureMode + user.role === 'OWNER'.RefundDialogtwo-step (form → ConfirmDialog danger) — Zod caps amount atcaptured − refunded, reason required.VoidDialogsingle-step with optional reason.CaptureDialogtwo-step — amount optional (null = full), bounded by authorized amount. HooksusePayments / usePayment / useRefundPayment / useVoidPayment / useCapturePayment+generateIdempotencyKey()viacrypto.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 bothPrismaPaymentRepository.listByTenantandInMemoryPaymentRepository.listByTenantdid exact-match. Switched tostartsWith(Prismawhere.bookingId = { startsWith }; in-memoryr.bookingId?.startsWith(prefix)); full UUIDs still match via starts-with-itself. +2 query tests. - Backend — no-op
Saveguard (BookingService.update): owners who open the booking drawer and reflexively hit Save were generatingUPDATEDaudit rows even when nothing had changed, because the items array is always sent on update. NewitemsEqual(existing, resolved)helper deep-compares length + per-item (serviceId, resourceId, startTime in ms) after sort-by-sortOrder; when identical,items: 'replaced'stays out ofchangesand the service short-circuits withreturn existingbefore touchingprisma.booking.update— avoids both theupdatedAtbump and the noise audit row. Also added the missingcustomerIddiff to the scalar compare block. +1 service test. FE mirror:BookingDrawer.onSubmitedit-mode checksformState.isDirtyand callsforceClose()instead of dispatching the mutation on no-op. - Backend — snapshot tenant display settings at creation time (
422fc12):BookingService.createnow writes a frozen copy of tenant display settings (timezone, currency, locale) intobooking.metadata.displaySnapshotvia newbuildDisplaySnapshot()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 viadisplaySnapshot.timezone, not the live tenant setting). +10 tests (5 helper + 5 service integration). - Backend — customer portal timezone flatten (
3c487c1):GET /customer/me/bookingswas exposing nestedtenant.settings.timezonethat the FE had to dig for; service now flattens tobooking.tenantTimezoneat the DTO boundary so the account-page booking card can renderformatDateTimeInZone(startTime, tenantTimezone)without re-fetching the tenant. +2 service tests. - FE — shared
DatePickeroverhaul (components/form/date-picker.tsx): altInputd/m/Ydd/mm/yyyy display (wire format staysY-m-d), compact inputh-10, Ant-Design-style rangemode="range"withshowMonths: 2+ separator→, injected "Today" footer button (single-date mode only), hover-to-clear icon (Calendar → X on hover when value present), partial-range revert vialastValidRangeRefon close with 1-date, lucideCalendaricon (fixes clipped SVG bottom).globals.cssflatpickr rounded-md day cells +bg-brand-100inRange band.PaymentListfilter toolbar swapped two<input type="date">→ singleDatePicker mode="range". - FE — cross-page Payment → Booking drawer:
PaymentDetailDrawerbooking-id<Link>replaced with button firingonViewBooking(id)→PaymentsContentfetches via newuseBooking(id)hook (enabled on open) → renders nestedBookingDrawer. Closing the booking drawer leaves the payment drawer on screen so owner keeps payment context. Booking-id cell gets aCopyButton(icon-only, 1.5s check flash, stopPropagation). - FE —
BookingHistorydiff 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 livebookingprop), or NOT_SET (filtered out). Separate amber row foritems: '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 i18nbookings.{diffField, diffBefore, diffAfter, historyNoDiff, historyItemsReplaced, fieldCustomer}nb + en. - FE — shared
formatDateTimeInZone(iso, tz)+createDateTimeFormatterInZone(tz)inlib/timezone.ts— canonicaldd/mm/yyyy HH:mm(en-GB+hour12: false+formatToPartsassembly). Replaces duplicatedIntl.DateTimeFormat('nb-NO', {...})inPaymentList+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.
- Backend — bookingId prefix filter (
- Capture trigger move — Confirmed → Arrived (2026-04-21). Previously
onBookingConfirmedcaptured MANUAL+AUTHORIZED deposits, but PaymentAuthorized flips booking PENDING→CONFIRMED within seconds of authorize, so Void window shrank to ~zero. Moved capture toonBookingArrived(primary) + keptonBookingCompleted(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 viavalidateBookingLeadTime()returningBOOKING_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.tsupdated. 5 helper tests (inside-cap / past-cap / boundary / past-booking / legacy-missing-setting). Customer DateStrip clamp (follow-up 2026-04-21) —DateStripacceptsmaxDaysInAdvance?: number, days array length =min(28, cap + 1)so today + the final allowed day are both selectable; BookingPage threadssettings.maxBookingDaysInAdvance. Closes a gap where the strip hard-codedlength: 28and offered dates the API would reject on submit. E2E lock —booking-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 passrequiredprop so the clear-X button is hidden and auto-asterisk renders. BookingList gains "Created" column + sortablestartTime/createdAtheaders with 3-state toggle persisted to localStorage; backend addssortBy/sortOrderwhitelist inBookingService.findAllByTenant(calendar mode still forcesstartTime 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)buildBookingCreatedPayloadhardcodedcaptureMode: 'AUTO'— MANUAL branch inPaymentIntegrationService+ 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/sessionstreats presence ofinstantcaptureamount(not its value) as "capture on authorize" — adapter was sendinginstantcaptureamount: 0for MANUAL, which silently captured the full amount server-side. Fixed by omitting the field entirely unlesscaptureMode === AUTO. FE — Payment detail AMOUNTS section redesign: newOn holdrow (tone pending, hint "Reserved on the card") whenstatus === AUTHORIZED, renderingamount − capturedAmount; zero Captured/Refunded render—(muted) instead of0 krmatching Stripe/Adyen convention;Up to X kr refundable → X krconfusing duplication renamed to cleanRefundable → X kr;AmountRowgains optionalhintslot +'muted'tone. i18npayments.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 assertsexpect(body).not.toHaveProperty('instantcaptureamount')for MANUAL (the originaltoBe(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 fromonBookingConfirmed→onBookingArrivedprimary +onBookingCompletedfallback ✅ shipped 2026-04-21, (2) booking auto-cancel on Payment auth-expiry ✅ already shipped with Track C2 (OnPaymentSettledNegativeListenersubscribes bothPaymentFailed+PaymentExpired); audit 2026-04-21 added a contract test toauthorization-expiry.service.spec.tsasserting the sweep emitsPaymentExpiredwithbookingId+tenantIdon the envelope so the listener invariant can't regress silently, (3) tenant settingmaxBookingDaysInAdvance(default 30, hard-capped for both public + admin create) ✅ shipped 2026-04-21. - Lift
http-client.ts+retry.ts→ sharedinfrastructure/http/(2026-04-20). Before: identical files duplicated inproviders/bambora/+providers/worldline-direct/(~127 LOC × 2) — any fix to retry/backoff had to be patched twice. After: one canonical copy atcore/payment/infrastructure/http/{http-client,retry}.ts, adapters + specs import from the shared path.provider-bootstrap.tsnow uses a singleFetchHttpClientimport instead of two aliased copies. Tests de-duplicated (http-client.spec.ts+retry.spec.tskept only inhttp/),worldline-direct/*.tsduplicatesgit 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.mdfor 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 trongJwtAuthGuard(mỗi request 1 query nhẹ qua User unique index).Performer.resourceId(BookingService) — performer interface mở rộng, controller truyềnuser.resourceIdvào mọi call.- STAFF scoping rules (BookingService):
findAllByTenant—WHERE resourceId IN (staff.resourceId, NULL); reject khi query resourceId khác.findById— 403BOOKING_NOT_IN_STAFF_SCOPEnếu booking.resourceId ≠ staff và ≠ null.create— block 403 nếudto.resourceIdhoặc bất kỳitems[].resourceIdkhác staff.resourceId.walkIn—WALK_IN_RESOURCE_NOT_ALLOWED_FOR_STAFFnếudto.resourceId≠ staff.update—BOOKING_REASSIGN_NOT_ALLOWED_FOR_STAFFnếu đổi sang resource khác; cấm items[] target khác.updateStatus— reusefindByIdguard, auto 403 nếu out-of-scope.selfPick—SELF_PICK_RESOURCE_MUST_BE_SELFnếu caller pass resourceId ≠ mình.
- TenantCustomer whitelist —
update()chỉ persistnotes/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ếuisApproved=true. - Payment admin scope —
byBooking,getOne,initiateRemainingvalidatebooking.resourceIdcho 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/mebackend vừa thêm) — STAFF córesourceId, OWNER/ADMIN = null./admin/(dashboard)/layout.tsxmởallowedRoles={["ADMIN","OWNER","STAFF"]}.AuthGuard: nếu user đã login nhưng role không đủ → redirect/adminthay vì/admin/signin.OwnerOnlyGuardwrapper mới (thinAuthGuardvớ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 flagcalendar: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;handleAddServiceforceresourceId = staffResourceIdkhi thêm item mới, bỏ qua "last item/calendar click" defaults. DuplicateuseAuth()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"]}ở/adminlayout. -
AppSidebarfilter 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.findById4 test (own / unassigned / foreign-403 / OWNER bypass). -
BookingService.findAllByTenant3 test (STAFF scope / STAFF cross-query / OWNER bypass). -
BookingServicemutations 5 test (create-resourceId-403, create-items-403, update-reassign-403, updateStatus-403, walkIn-403). -
BookingService.selfPick1 test (cross-resource 403). -
ResourceServicetime-off 5 test (cross-resource 403, self OK, approved-edit-403, approved-delete-403, OWNER bypass). -
TenantCustomerService.update()1 test (silent drop metrics field). -
PaymentController3 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/metrả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/bookingstrực tiếp (không bị guard redirect). - STAFF gõ URL
/admin/staff→OwnerOnlyGuardbounce 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/meflip role, giữ nguyên tenantId, trả resourceId mới.
- STAFF signin qua form
- Fixture
fixtures/auth.tsdùngstorageStatepattern (login 1 lần/worker/role) — tránh rate-limit khi full suite.
Phase 5 — Done criteria
-
docs/architecture/role-matrix.mdmerged. - 0 endpoint còn chặn STAFF nhầm theo matrix — API harden complete.
- Web
/adminlogin đượ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-webtypes 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 versioning —
tokenVersionon User + Customer. JWT containsv. Guards comparevvs DB. Increment on password change/reset → invalidates all sessions. Handles DB reset (user not found → 401). - Auth separation — Admin guard rejects customer tokens (
typeclaim 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:
customerIdfrom 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)