progress/features.md

Tổng quan Features

Tổng quan các feature trong hệ thống Booking. Lịch sử phát triển: xem changelog.md. Defer roadmap: xem gaps-and-plan.md.

Status-matrix dependency (P0/P1 series shipped)

flowchart LR
    P01[P0-1<br/>Force cancel<br/>OWNER override] --> P18[P1-8<br/>Cancel refund preview]
    P02[P0-2<br/>Deposit guard] --> P15[P1-5<br/>autoConfirm + deposit<br/>combo block]
    P03[P0-3<br/>Walk-in IN_PERSON] --> P13[P1-3<br/>Phone booking IN_PERSON]
    P11[P1-1<br/>Customer self-cancel] --> P12[P1-2<br/>Out-of-window dialog]
    P11 --> P18
    P19[P1-9<br/>Deposit-status projection] --> P110[P1-10<br/>Refund/void SMS]
    P19 --> P14[P1-4<br/>Retry classification<br/>+ email nudge]
    P111[P1-11<br/>7-day auth-hold cap] --> P14
    P14 --> P16[P1-6<br/>Loyalty L4 — pending]

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
  • Onboarding footer — locale + theme switchers (2026-05-07) — wizard footer now shows English / Norsk · Light / Dark on a settings strip below the Back/Skip/Next nav row, separated by a hairline divider, right-aligned. Reuses customer-footer FooterLocaleSwitcher / FooterThemeSwitcher so non-English salon owners can switch to nb-NO before reading any wizard copy.
  • Tenant temporary close (2026-05-15) — owner pause kill switch tại /admin/settings?tab=temporaryClose để chặn customer đặt mới khi salon quá tải. Schema TenantSettings.temporaryClose { enabled, until, message, startedAt } + backfill migration 20260515021622. 3 endpoints OWNER+ADMIN: PATCH /tenants/:id/temporary-close (7 preset: 30m/1h/2h/end-of-current-slot/next-open/custom/indefinite, server resolve theo BusinessHours + tenant tz), PATCH .../message (chỉ sửa wording, giữ deadline), DELETE (reopen/clear). Public guard POST /public/tenants/:slug/bookings reject 409 TENANT_TEMPORARILY_CLOSED; admin/walk-in tạo bypass theo spec. Customer UI: red banner dưới cover + ServiceList Book disabled + OpenStatusTrigger/OpeningHoursCard pill flip "Paused". Admin UI 3-state (open/active/expired) với inline edit message + clear-stale-config CTA, modal vertical preset list với per-option time preview salon-tz + custom datetime-local min=now+1. Side fix: ScheduleCell add-shift popover migrated to portal + position:fixed + auto-flip-up — table không còn scroll khi click "+" ở row cuối. Tests api 1933/1933, web 170/170.

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
  • Resource permanent delete vs pause (2026-05-10)DELETE /resources/:id + "Delete permanently" button on StaffFormModal (separate from the isActive pause toggle). Always soft-deletes: stamps deletedAt, clears userId (frees @unique so the User can be re-linked later), flips isActive=false. Child rows (ResourceSkill/Schedule/ScheduleOverride/TimeOff/PortfolioItem) stay queryable for audit. See docs/architecture/soft-delete-pattern.md.
  • Staff xem lịch cá nhân (mobile)
  • Staff invitation flow (Phase 1–5 shipped 2026-05-14) — email invite + self-service password setup thay owner-set-password. Đóng lỗ hổng cross-tenant password-reuse: owner đoán password staff → tenant picker lộ mọi tenant staff đang làm. Phase 1 (schema StaffInvitation + migration 20260514033919), Phase 2 (admin API POST/list/resend/revoke + public verify/accept + 29/29 tests + rate limit + tenant isolation + orphan-User re-invite recovery), Phase 3 (InviteStaffModal + StaffList split CTA Invite vs Add Resource + Pending badge + password-free StaffFormModal + 3 regression tests), Phase 4 (/admin/accept-invite page + identity-swap warning banner + httpOnly cookie set qua setAuthCookies shared helper), Phase 5 (i18n acceptInvite + inviteStaff + pendingInvite namespaces en/nb + 9 INVITATION_* error codes). Side fixes: HttpExceptionFilter map 429 → RATE_LIMIT_EXCEEDED, ResourceService.findById/findAllByTenant* eager-load user.staffInvitations[0] để render Pending state không cần extra fetch. Tests api 1882/1884, web 170/170. Remaining roadmap: Phase 6 (xoá hẳn Resource.password field + clean up legacy owner-set-password code), Phase 7 (Playwright E2E happy path + edge cases), Phase 8 (branded email template qua queue + Norwegian i18n + ops runbook). Plan: docs/plans/staff-invitation-plan.md.

Epic 3: Service Catalog ✅

  • CRUD services (name, duration, price)
  • Service categories (CRUD, sort order)
  • Toggle active/inactive
  • Service permanent delete (2026-05-10)Service.delete() always soft-deletes (deletedAt = now()) and the service disappears from every list/picker while BookingItem snapshots keep rendering paid history. New "Delete permanently" button on ServiceFormModal (the previous tenant UI was missing — only the isActive toggle existed). See docs/architecture/soft-delete-pattern.md.
  • Salon landing service list: show-more description (2026-06-08)ServiceList/b/[slug] đồng bộ stepper: description clamp 3 dòng + Show more/less (ExpandableText), row items-start.
  • Description textarea + giới hạn 200 từ (2026-06-08) — field Description trên ServiceFormModal đổi từ <input> sang textarea (TextAreaField mới, react-hook-form useController) với bộ đếm từ trực tiếp n/200 words (đỏ khi vượt) + Zod .refine(countWords <= 200, 'maxWords'). Helper countWords()lib/utils.ts. i18n validation.maxWords + common.words.

Epic 4: Booking Engine ✅

  • Customer booking online (public page)
    • V2 Stepper (industry-standard 4-step)Services → Staff → Date+Time → Confirm, mobile-first, lazy chain availability, ?step= URL state, EmptyDayBanner + "Jump to next available date" (POST /availability/next-available-date). Replaces V2 1-page. Gated by bookingUiVersion (V1 single-page giữ nguyên). Shipped 2026-05-29.
    • Booking draft / shareable cart (?sessionId=, 2026-05-29)BookingDraft server-side cart (TTL 7d) giữ nguyên service+staff+date+time+voucher qua F5 + link chia sẻ được; không lưu PII (re-check slot lúc submit). POST/GET/PATCH /public/tenants/:slug/bookings/draft, debounced persist (600ms) → ?sessionId=, hydrate precedence sessionId > from > services > serviceId, nút "Copy booking link" trong stepper header. Cron dọn draft defer. Test plan: docs/testing/v2-booking-conflict-scenarios.md.
    • Toggle step chọn staff (allowStaffSelection, 2026-06-02) — setting tenant bật/tắt step "Choose professional" trong V2 stepper. Tắt → stepper bỏ step staff (3 bước, counter "N/3" qua getStepOrder), mọi item mặc định "Any available" (resourceId=null). Chỉ hợp lệ cùng bookingMode: allow_unassigned (guard validateSettingsCombination + zod superRefine + TENANT_SETTINGS_STAFF_SELECTION_REQUIRES_UNASSIGNED). Default true. Backfill migration 20260602041500 (strict parser REQUIRED_KEY). Settings toggle khoá ON + auto-bật khi đổi sang assigned_only. Enforce cả API + UI. Xem booking-flow.md.
    • Expand/collapse service description (2026-06-08)ServiceCard ở step chọn dịch vụ clamp description 3 dòng + nút Show more/less khi tràn (component tái dùng ExpandableText, đo overflow thật qua ResizeObserver). Thumbnail + check căn trên đầu (items-start + mt-1) cho card nhiều dòng. i18n publicBooking.stepper.showMore/showLess.
    • Hover preview ảnh service ở stepper (2026-06-08) — hover thumbnail service có ảnh trong ServiceCard → popover phóng to (tái dùng ImageHoverPreview, thêm prop className). Cùng pattern với salon landing.
  • 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)
  • Out-of-window cancel dialog — OutOfWindowDialog replaces red toast with policy explanation + booking reference card + salon page link (status-matrix P1-2)
  • Deposit-status projection — OnPaymentStateProjectionListener keeps Booking.depositStatus in sync with Payment lifecycle (8 events → 8 states), idempotent + cross-tenant guarded (status-matrix P1-9)
  • Refund / void customer notifications — SMS on PaymentRefunded / PaymentPartiallyRefunded / PaymentVoided via OnPaymentNotificationListener with Norwegian templates (status-matrix P1-10)
  • autoConfirm + depositEnabled mutual-exclusion — API rejects the combo with TENANT_SETTINGS_AUTOCONFIRM_DEPOSIT_CONFLICT, settings UI disables each toggle while the other is on (status-matrix P1-5)
  • Cancel refund preview — buildCancellationPreview + GET /bookings/:id/cancel-preview (admin) + GET /public/tenants/:slug/bookings/:id/cancel-preview (customer) drive a CancelPreviewDialog that shows refund / void / forfeit / no-action amount before the cancel mutation fires (status-matrix P1-8)
  • Phone-booking paymentMode=IN_PERSON derivation — source=PHONE in admin create flow tells OnBookingCreated to skip PSP init (status-matrix P1-3)
  • Bambora 7-day auth-hold safety net — settings hard cap (depositEnabledmaxBookingDaysInAdvance ≤ 7) + auto-cancel CONFIRMED on PaymentExpired with AUTHORIZATION_EXPIRED audit reason (status-matrix P1-11)
  • Payment retry on failure — PaymentFailureKind (TRANSIENT vs PERMANENT) on PaymentFailedPayload; PERMANENT counted toward 3-attempt cap before auto-cancel PAYMENT_RETRY_EXHAUSTED, TRANSIENT never cancels; POST /public/tenants/:slug/bookings/:id/payment/retry (CustomerAuth) mints fresh PSP session; SMS + email nudge with deep-link via new OnPaymentFailedRetryNotificationListener + pluggable EmailProvider port (status-matrix P1-4)
  • 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)
  • BookingItem hybrid snapshot (2026-05-10) — frozen Service/Resource state on each line at creation: hot fields flatten (serviceName / serviceCurrency / resourceName / duration / price) for indexing + JSONB serviceSnapshot / resourceSnapshot for receipt-grade detail (description, imageKey, taxRate, categoryName, metadata, capturedAt). 3-step online-safe migration (nullable cols → backfill 109 legacy rows from live joins → NOT NULL). Receipt/invoice/customer-history/email switched to snapshot reads; calendar/dashboard/admin drawer keep live join (current state). 8 new tests including the regression guard "snapshot stays frozen if Service is later renamed". Closes the long-standing data-integrity gap where catalog edits retroactively rewrote paid history. See docs/flows/booking-flow.md § Snapshot Pattern.
  • 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
  • Customer tenant-scoped unlink (2026-05-11)DELETE /customers/:id (admin OWNER/ADMIN) removes the customer from this tenant's list only by upserting TenantCustomer.deletedAt = now(). Does NOT touch the global Customer row: the customer keeps their login and can still book at other salons. Booking history at the salon stays intact via customerName/customerPhone/customerEmail snapshots. UI: "Delete" button in CustomerFormModal edit mode + ConfirmDialog with bold-name copy. Global GDPR right-to-be-forgotten is a separate concern (customer self-service / platform admin) — deferred. See docs/architecture/soft-delete-pattern.md.

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.
      • C4.4 Collect-remaining when deposit OFF (2026-04-26)BookingPaymentSummary previously returned null whenever payments.length === 0, so the Collect-remaining CTA never rendered on bookings created with depositEnabled=false. Now the early-return guard is isLoading \|\| (no payments && no active PaymentConfig) — cash-only salons still hide the card, but tenants with an active PSP see Total / Paid 0 / Remaining + Collect button on ARRIVED/IN_PROGRESS/COMPLETED. bookingProvider falls back to activeConfig.provider when no payment row exists yet so the modal routes correctly.
  • 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.
  • 2026-04-27 bundle — Calendar UX + multi-provider Pay + bulk-day schedule + invoice settled bug fix. (1) Calendar grid polishTimeColumn + week-view header time-spacer now sticky left-0 (column stays visible on horizontal scroll); WeekOverview lost its inner overflow-auto h-full wrapper so the date row's sticky top-0 actually sticks during vertical scroll (was bound to wrong scroll ancestor); 15/30/45 minor labels added under each hour (text-[10px] font-medium gray-500), all gridline weights unified at border-gray-300 (15/45 keep dashed for hierarchy); getCalendarHours(settings, dayOfWeeks?) filters by visible weekdays so one stray 24h-configured weekday no longer stretches the grid 0-24. (2) Per-item drag — multi-service booking blocks expose a GripVertical handle at top-right (negative offset peeking outside, full-opacity solid bg); BookingBlock.onDragStart accepts mode: 'whole' | 'item'; per-item drag (handle pointer-down) only moves that virtual block. New parseVirtualId helper, computeDragPreview + handleDragEnd build items[] payload with only the dragged index modified, recompute parent.startTime/endTime = min/max(item.startTime + duration). Drag-end flicker fix: await qc.invalidateQueries(...) before clearPreview() so preview holds new position until cache refreshes. Virtual blocks now keep items array (was items: undefined) so the hover tooltip can list all sibling services even when hovering on one slice. (3) BookingHoverTooltip — extracted to shared component + useBookingTooltip<T>() hook; used by both day-view block and week-view rows. Body redesigned to mirror customer booking ticket (per-item rows with duration · price, TOTAL footer, status badge). (4) Bulk-add shifts modal+ button under each weekday header in /admin/work-schedule opens a multi-slot editor and POSTs /resources/:id/schedules per (staff × slot) so all staff get the same recurring slot for that weekday. New shared slot-validation.ts (isSlotsValid, computeSlotErrors, calcSlotMinutes) used by both bulk modal and ScheduleCellEditor. TimeField gains placeholder + error props. (5) Availability respects business hoursAvailabilityService.getAvailableSlots intersects each resource's time ranges with tenant.settings.businessHours[dayOfWeek].slots; staff working 24h on a salon open 9-17 no longer surfaces 04:45 slots. Empty business hours = []. +3 unit tests (clip-overflow, closed-day, lunch-break). (6) Multi-provider Pay buttons — exposed enabledProviders: ProviderKey[] on tenant settings DTO + PublicBookingDetailDto. New shared <ProviderPayButton> (src/components/payments/ProviderPayButton.tsx) renders one CTA per active PSP using getProviderMeta registry (Bambora / Vipps / Worldline / Stripe / Nets / Adyen brand color + text color). Used by both /invoice (per-provider Pay button) and /book (per-provider Submit button). Loading state: clicked button keeps full brand color + spinner + "Redirecting…", siblings drop to opacity-40 cursor-not-allowed. Empty state: requiresPayment + enabledProviders=[] shows amber "no payment methods" notice. Backend plumbing: EnsureCheckoutSessionDto.provider, PublicCreateBookingDto.preferredProvider → metadata → BookingCreated.payload.metadata.preferredProviderPaymentIntegrationService.onBookingCreated reads + forwards to InitiatePaymentCommand.provider; InitiateRemainingPaymentCommand.provider forwards to InitiatePaymentCommand. selectConfig(command.provider ?? first-active) keeps single-PSP tenants behaving as before. (7) Invoice "fully settled" bug fixderiveNextPayment previously returned NO_PAYMENT_DUE whenever a PENDING booking had no DEPOSIT row, assuming the BookingCreated listener was warming up. Bug: deposit-disabled tenants never get a deposit row → invoice page falsely showed "Nothing to pay right now. Your booking is fully settled." Fix: new requiresDeposit: boolean field on PublicBookingDetailDto (computed via computeDepositAmount(totalAmount, settings) > 0); helper signature now deriveNextPayment(status, total, payments, requiresDeposit). Listener-race branch only fires when requiresDeposit=true; otherwise falls through to REMAINING_PAYMENT for the full bill. +2 unit tests. (8) Inline schedule-editor validationScheduleCellEditor adopted same inline-error pattern as bulk modal (range error eager, missing-field gated on submit), setSubmitted(true) short-circuits Save when invalid. Lint 0/0 · TS clean both repos · 65 API tests + 14 web vitest pass on touched code · API total 1389 · Web total 149.
  • Track E2 — Invoice page + always-fresh PSP session (2026-04-26). New stable customer URL /b/:slug/bookings/:id/invoice (Stripe Hosted Invoice / PayPal pattern) decouples shareable links from PSP-session expiry. Backend: POST /public/.../bookings/:id/checkout-session {intent} mints a fresh Bambora session on every call (Bambora invalidates URLs ahead of expiresAt; reusing one surfaces "Sesjonen har utløpt"); reuses InitiatePayment (DEPOSIT) / InitiateRemainingPayment (REMAINING_PAYMENT, with new forceNewSession?: boolean flag bypassing the dedup-by-intent + booking-status guards) handlers. New GET /public/.../bookings/:id/payments (plural) returns full Payment history newest-first via PaymentRepositoryPort.findAllByBookingIds (batched). PublicBookingDetailDto adds totalPaid (sum capturedAmount − refundedAmount across settled rows; fixes "Paid: 2 093 kr" mis-render where remaining-INITIATED leaked through) + tenant.logoUrl + payments[].intent. Frontend: InvoiceClient reuses BookingTicket (no QR, showQR={false}) for booking summary + PaymentLedger (one row per intent with plain-language hint + status pill, stale INITIATED collapsed per intent) + Pay button. Pay button intent decided by deriveNextPayment(bookingStatus, totalAmount, payments) covering 3 edge cases: PENDING+deposit-row→continue DEPOSIT, status≥ARRIVED or deposit-settled→REMAINING_PAYMENT, owner toggle COMPLETED→PENDING falls back to DEPOSIT. Admin DepositCheckoutModal + CollectRemainingModal QR/copy-bar now encode the invoice URL (stable) instead of raw Bambora URL. PaymentReturnClient → thin router.replace('/invoice?from=payment-return'). BookingPage form keeps direct Bambora redirect (customer just reviewed). Booking detail page footer gains "View invoice" + "Back to salon" links, no inline ledger duplication. Cross-cutting: BookingDrawer (admin) edit-mode wraps useBooking(id) to fetch fresh detail before mounting form (price snapshot accuracy, fixes PAYMENT_REMAINING_AMOUNT_EXCEEDED from list-cache lag); replaces N+1 per-resource time-offs with single /resources/time-offs?from&to tenant-wide endpoint. New full-list endpoints GET /services/all + GET /resources/all?status replace silent-cap ?limit=100 callers across BookingDrawer/List/Calendar/StaffFormModal/ServiceFormModal/useSchedule/useDashboard; centralised hooks useAllServices/useAllResources carry tenantId in queryKey for defense-in-depth. New reusable components CustomerSelect (debounced server search + auto-fetch out-of-page customer) + SalonAvatar (logo or first-initial fallback square; used in BookingTicket + salon hero + booking form). Admin booking list dropped Service column, renamed Deposit → "Payment", cell shows compact "Paid: X / Total" line via batched findAllByBookingIds. Tests: +12 API · +8 web vitest (next-payment 8 cases, getPayments plural endpoint isolation, ensureCheckoutSession always-fresh, forceNewSession bypass, totalPaid aggregation). API 1384 pass · 137 web pass · lint 0/0 · build clean. Memory: 8 new feedback notes covering invoice pattern + intent derivation + paid-from-captured + force-new-session + tenant-in-querykey.
  • 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.
  • Track E1.1 — Manual payment record (cash + standalone terminal) + status-guard removal + email URL fix (2026-05-07). (1) Domain: PaymentProvider Prisma enum + ProviderKey domain enum gain MANUAL_CASH + MANUAL_TERMINAL (migration 20260507012328_add_manual_payment_providers); isManualProvider() helper; PaymentConfig.create rejects manual providers (no credentials, no per-tenant config). (2) Handler: new RecordManualPaymentCommand + Handler skips PSP entirely — Payment.initiate(...) with synthetic manual-${id} providerRef → payment.capture(...) in same domain transaction. metadata.recordedByUserId audits the staff who took the money + optional note. Same earmarked formula as PSP remaining-payment so cash/QR can co-exist on one booking. Refund flows through existing RefundPaymentHandler (system records, staff returns physical cash outside). (3) Endpoint: POST /admin/payments/manual (OWNER+STAFF+ADMIN, STAFF resource-scoped via assertStaffOwnsBooking). Body {bookingId, method: 'CASH'|'TERMINAL', amount, note?, idempotencyKey}. (4) Status-guard removal: both InitiateRemainingPaymentHandler and RecordManualPaymentHandler lost ALLOWED_BOOKING_STATUSES + InvalidBookingStateForRemainingPaymentError / InvalidBookingStateForManualPaymentError. Salons need to top up from any state — customer pays then asks for an extra service on COMPLETED, cancellation/no-show fees on CANCELLED — booking-state validation belongs in the booking context, payment context just records money. FE BookingPaymentSummary dropped COLLECT_ALLOWED_STATUSES + bookingStatus prop; CTA on whenever remaining > 0. (5) CollectRemainingModal rewrite — QR view now the default (auto-fires POST /admin/payments/remaining on mount when PSP active OR seeds from existingPayment for resume). Cash + Card terminal sit beneath QR as alternative paths labelled "Or record manually"; each opens an inline ManualConfirmPanel (replaces modal body, no stacked ConfirmDialog z-index trap). showWaiting={false} passed to PaymentCheckoutView so the QR slot no longer shows the misleading "Waiting for the customer to pay…" copy. Removed unused collect-remaining-schema.ts + test. (6) Verifying-payment banner fixBookingConfirmedClient + InvoiceClient returnOutcome anchor on booking.totalPaid >= totalAmount first, then latest payment row status. Old logic checked "any INITIATED row anywhere" → leftover INITIATED from abandoned attempts kept the banner stuck on "Verifying payment…" even after the customer had paid in full. (7) Email URL fixEmailBookingPayloadBuilder + BrandingResolver were reading PUBLIC_BASE_URL (never defined) → fallback https://booking.no → every email link pointed at the wrong domain. Fixed both to read PUBLIC_WEB_URL (the env name actually used by 5 other files in the codebase) with http://localhost:3020 dev default. (8) Provider metadata + i18nMANUAL_CASH ("Cash") + MANUAL_TERMINAL ("Card terminal") in lib/payment/provider-metadata.ts; new collectRemaining.{chooseMethod, manualAlternativesLabel, methodCash{Title,Description}, methodTerminal{Title,Description}, methodQrUnavailable, confirmCash{Title,Message}, confirmTerminal{Title,Message}, confirmRecord, manualCashRecorded, manualTerminalRecorded} (en + nb-NO). Tests: API record-manual-payment.handler.spec 11 cases + payment.controller.spec +3 (CASH dispatch / TERMINAL+note / STAFF cross-resource 403); initiate-remaining-payment.handler.spec collapsed 3 status tests into 1 "allows every booking status" loop covering PENDING through NO_SHOW; record-manual-payment.handler.spec mirrors that. API payment suite 627 → 643 pass · web lint + build clean · gitnexus_impact LOW–MEDIUM (no HIGH/CRITICAL).
  • POS integration — physical card-reader terminal integrated with PSP webhook (Bambora POS / Verifone with online auth) — distinct from Track E1.1's MANUAL_TERMINAL which is a manual record of an unintegrated terminal.
  • 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.
  • Refund-as-row refactor (2026-05-11) — moved refund flow from in-place Payment.refundedAmount mutation to a separate PaymentIntent.REFUND child row with parentPaymentId self-FK. Matches Stripe / Adyen / Square / PayPal / Vipps. Per-event audit (date, reason, amount, PSP refund tx id) lives on the child; cumulative total derived via sum(refunds.capturedAmount). Big-bang single deploy: schema migration backfilled 5 legacy refunded rows into REFUND children (provider tx id NULL, metadata.backfilled = true), dropped the refunded_amount column. Domain swap: instance Payment.refund() removed → static Payment.createRefund() factory + Payment.applyRefundProjection() (walks parent status + emits legacy PaymentRefunded / PaymentPartiallyRefunded events for backward compat with booking-projection + notification listeners). New PaymentRefundCreated event on the child. RefundPaymentHandler rewritten: cumulative-cap check → adapter call (skipped for manual providers) → create child → project parent → save both. Webhook processor mirrored. All readers (booking previewCancellation, integration onBookingCancelled, initiateRemainingPayment, recordManualPayment, public booking ticket totalPaid, superadmin revenue trend/tenants) switched from cap - ref formula to "REFUND children subtract their capturedAmount". DTO layer keeps a derived refundedAmount aggregate on the wire so PaymentList / BookingPaymentSummary / next-payment / BookingTicket compile unchanged; new refunds: RefundLineDto[] + parentPaymentId exposed for the drawer's "Refund history" timeline + the FE useRefundPayment callback. Tests: domain spec full rewrite (16 new cases), handler spec rewrite, every fixture seeding refunded: N flipped to a sibling REFUND child. Results: 1729/1731 API unit + 16/16 payment-related e2e green; 171/171 web vitest green; lint+build clean both repos. Plan + caveats live in docs/architecture/refund-as-row-refactor.md.
  • Refund-as-row follow-up fixes (2026-05-11) — UX + integrity issues surfaced by live testing on app-dev.novagoo.com on top of the morning's refund-as-row refactor. (1) Critical UNIQUE collision in RefundPaymentHandler — Bambora Classic /credit reuses the parent transaction id, the handler copied that onto the new REFUND child, Postgres (provider, provider_transaction_id) UNIQUE blocked the save AFTER Bambora had already credited the customer → local rollback, state diverged with PSP. Fix: default child providerRef to a new ProviderRef.synthetic(providerKey) (NULL tx id) and only adopt the refund tx id when genuinely distinct (Stripe / Adyen). Webhook path mirrored. (2) Stripe-style charge semanticsPayment.applyRefundProjection no longer mutates the parent's status; the charge row stays CAPTURED ("Paid") forever. Booking-side projection stays correct because OnPaymentStateProjectionListener was already event-driven on PaymentRefunded / PaymentPartiallyRefunded event names, not the row's status field. (3) List shows REFUND rows as first-class entrieslistByTenant now defaults to including REFUND children (Stripe / Square dashboard style). (4) Intent-aware status badgePaymentStatusBadge overrides REFUND + CAPTURED → "Refunded" (refund completed successfully), every other combination renders normally so pending / failed refunds surface their real status. (5) BookingDrawer bookingId edit-only path — clicking a payment's "Booking" link no longer opens the create flow while the fetch is in flight; new BookingDrawerEditByIdWrapper holds a skeleton, never falls through to create mode, and shows a "Booking unavailable" panel when the fetch 404s. Results: 875/877 API unit + 171/171 web vitest green; lint + build clean both repos. Plan + caveats in docs/architecture/refund-as-row-refactor.md.
  • 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 🚧 Payment lifecycle live, in-app inbox shipped, branded emails shipped, SMS booking confirmation pending

  • BullMQ queue processor wired (NotificationProcessor, fan-out SMS + email per NotificationType)
  • EmailProvider port + LogEmailProvider default impl (P1-4) — production SMTP defer (p1-4-smtp-vendor)
  • Payment lifecycle SMS — refund / partial refund / void (P1-10) Norwegian templates
  • Payment retry email + SMS với deep-link /account/bookings?retry=<id> (P1-4) — OnPaymentFailedRetryNotificationListener
  • In-app admin inbox (P1-12, 2026-04-25)AdminNotification table + per-recipient REST API (/admin/notifications list / unread-count / mark-read / read-all) + bell dropdown + full page. Listeners fan booking events to OWNER + assigned STAFF, payment events to OWNER only. ADMIN role deferred until login-as-tenant lands. 40 unit + 7 e2e tests, polling 30s (SSE deferred).
  • Branded transactional emails (2026-04-28) — 6 templates × 2 locales (BOOKING_CREATED / CONFIRMED / CANCELLED / PAYMENT_RECEIVED / REFUNDED / NEW_OWNER), tenant logo + primary-color branding, dedicated branded-emails BullMQ queue, OnBookingEmailListener + OnPaymentEmailListener wire booking + payment events, tenant-wide on/off toggle in /admin/settings → Notifications (tenant.settings.emailNotifications), +16 tests (12 snapshots + 4 logic)
  • Reminder 24h before appointment (Phase 2)
  • Admin email preview + test-send page (Phase 2)
  • SMS booking confirmation khi BookingConfirmed (Norwegian template chuẩn)
  • SMS status change notification (CANCELLED / NO_SHOW)
  • Owner/Staff push notifications (new booking, walk-in) — pending mobile scaffold
  • NoShow + void email channel (P2-7)
  • In-app: SSE/WebSocket realtime push (replace 30s polling)

Epic 8: Admin Portal 🚧 Phase 1 + 1.1 done (2026-04-28)

  • Superadmin Phase 1 (2026-04-28) — 3 read-only endpoints (/superadmin/overview, /tenants, /recent-activity) under @Roles('ADMIN'); FE pages at /admin/superadmin/{,tenants,activity}; sidebar gated to ADMIN; RoleLandingGuard bounces ADMIN off tenant-scoped routes; +11 unit tests
  • Superadmin Phase 1.1 charts (2026-04-28) — /superadmin/trend + /superadmin/distributions + previousPeriod on overview; hero card sparklines + Δ%; combo trend chart; top-10 tenants ranking; industry donut; booking-status donut; onboarding funnel; +6 unit tests
  • Superadmin Phase 1.2 basic tenant edit (2026-04-28) — PATCH /tenants/:id/status ADMIN-only endpoint; edit modal (name + description) + suspend/activate confirm in tenants table; +5 unit tests
  • Superadmin Phase 1.3 whitelabel platform settings (2026-05-08) — singleton PlatformSetting row (brand name, per-locale tagline, optional logo/favicon storage keys). New superadmin/platform-settings ADMIN endpoints (GET/PUT + upload/delete logo + upload/delete favicon) + public public/platform-settings (no auth) for every page to fetch the live brand. New upload purposes PLATFORM_LOGO/PLATFORM_FAVICON, UploadedFile.tenantId made nullable, ADMIN uploads scoped under platform/<uuid>.<ext>. AppLogo + customer footer + root generateMetadata (title template + description + favicon) now read the platform settings; 14 hardcoded ' | BookingSystem' titles stripped so the root template appends the live brand. Super-admin sidebar entry + /admin/superadmin/settings form (RHF + Zod) ship logo/favicon uploaders with preview + remove confirm. +18 API unit tests + 5 web unit tests + 4 Playwright e2e (with new ADMIN fixture). Lays the groundwork for per-partner whitelabel later.
  • SVG + dark logo variant + SSR hydration (2026-05-08) — extends Phase 1.3: image/svg+xml whitelisted on PLATFORM_LOGO + PLATFORM_FAVICON (super-admin only, sanitised via sanitize-html strict profile blocking <script>/<foreignObject>/on*/external href; imgproxy raw=1 passthrough preserves vector). New schema columns logo_dark_storage_key + logo_dark_mime_type + POST/DELETE /superadmin/platform-settings/logo-dark endpoints. AppLogo reads useTheme() to pick logoDarkUrl on dark surfaces (falls back to light variant when missing). SSR-prefetch via HydrationBoundary in root layout.tsx eliminates F5 fallback flash — AppLogo + tagline render with real values on first paint. AppLogo SIZES bumped (h-10/h-14/h-16) + outer span align-middle leading-none to strip the inline descender that pushed <a> wrappers ~7px taller than the image. PlatformSettingsForm ships side-by-side Light/Dark uploaders, drops coloured preview backdrop (uploaded logos carry their own bg). Customer header h-14 → h-18. +22 API tests (12 SVG sanitizer + 4 validator + 1 imgproxy raw + 3 service dark + 2 controller dark) · web vitest 171/171 · lint + tsc clean.
  • allowDoubleBooking default ON + reset-superadmin CLI + dropdown trim (2026-05-08) — operational polish bundle. BEAUTY_SETTINGS + BARBERSHOP_SETTINGS industry defaults plus FE DEFAULT_BOOKING_POLICY flip allowDoubleBooking: false → true so new salons land on the onboarding "Booking policy" step with the toggle ON (overlap is common in beauty — parallel services, room-shared treatments; owners can still turn it off in settings). New yarn reset:superadmin CLI (interactive: prompts email + masked password, min 6 chars, re-prompts on invalid input instead of exiting) bumps tokenVersion to invalidate cached JWTs and upserts by role=ADMIN. UserDropdown hides "Edit profile" for ADMIN since super-admin lacks a tenant-scoped profile route. 355 tenant + booking tests green; lint + tsc clean both repos.
  • Editable footer pages — Privacy / Terms / About / Contact / Help / Pricing (2026-05-09) — new ContentPage Prisma model (singleton-per-slug, EN+NB title/body, updatedBy audit). ContentPagesService.onModuleInit() upserts six rows with hand-written Norway/EU defaults at boot (Privacy citing GDPR Art. 6 + Personopplysningsloven, Terms citing Forbrukerkjøpsloven + Angrerettloven § 22(m)) — idempotent upsert leaves admin edits untouched on restart, same pattern as PlatformSetting. New superadmin/content-pages (ADMIN list + GET + PUT, slug enum guard) and public public/content-pages/:slug?locale=en|nb (no auth, locale-keyed payload). HTML body sanitised server-side by sanitize-html with strict allow-list (h2/h3, p, strong/em, ul/ol/li, a with mailto/tel/http(s), blockquote) — target=_blank auto gets rel=noopener noreferrer. New super-admin route /admin/superadmin/pages (list with Published / Empty (Coming soon) badges + last-updated relative time) and /[slug] (RichTextEditor in EN/NB tabs, single save updates both locales). 6 customer pages now SSR-fetch via getContentPageServer and render with dangerouslySetInnerHTML; empty body falls back to existing InfoComingSoon placeholder. +20 API tests (3 service onModuleInit + 1 listAdmin + 2 getAdmin + 2 getPublic + 3 update incl. XSS strip + target=_blank rel-injection + 5 admin controller + 4 public controller). Sidebar entry + en/nb translations + types regenerated.
  • Featured tenants — curated marketplace homepage list (2026-05-14) — new super-admin surface to pick which salons surface on / ("Popular salons"). Schema: FeaturedTenant { tenantId @unique, position } with FK CASCADE. API: GET/POST/DELETE /superadmin/featured-tenants (bulk add + bulk remove), GET /available?search=&page=&limit= (paginated tenant pool excluding already-featured), PATCH /reorder (two-phase transaction to sidestep position UNIQUE collisions, validates contiguous 0..N-1). PublicBookingController.listTenants splits no-filter (curated) from filtered (full population) so customers can still search every salon. UI: /admin/superadmin/featured-tenants with @dnd-kit/sortable vertical drag-drop table (auto-save on drop, optimistic local order cleared in onSettled), multi-select + bulk remove toolbar, AddFeaturedTenantsModal (debounced search, 10/page, cap-aware selection). Hard-cap 50 tenants. 28 new API tests + updated public-booking.controller.spec (mocks listPublicFeatured for the no-filter branch). API 1910/1912, web 170/170. Sidebar: Star icon entry between Tenants and Activity (ADMIN only).
  • Site scripts editor (Integrations) + sidebar admin flatten + content typography (2026-05-09) — completes the superadmin Integrations surface for plugging in third-party JS (GA4, Meta Pixel, chatbots). New SiteScript Prisma model (uuid + name + placement enum HEAD | BODY_START | BODY_END + code + enabled + sortOrder + audit) with two migrations (add_site_scripts then add_body_start_placement). Admin CRUD /superadmin/site-scripts + public list /public/site-scripts (enabled-only, audit stripped). Snippets are NOT sanitised — purpose is loading vendor JS — compensated by ADMIN-only writes, default enabled=false, audit updatedBy, and a <!-- site-script: <name> --> DOM marker. Site scripts only fire on public routes via the existing src/proxy.ts (Next 16 renamed middlewareproxy); a small nextWithPathname() helper attaches x-pathname to every NextResponse.next(). The root layout reads it, skips /admin/*, and injects HEAD scripts via <Script strategy="beforeInteractive"> (App-Router escape hatch — bare React <script> JSX only hoists async src= per React 19). BODY_START / BODY_END use raw dangerouslySetInnerHTML anchored at body top / body bottom. Sidebar reorganised for ADMIN: section "Super admin", 6 superadmin routes promoted to level-1, empty "Other" group hidden, active-state matches prefix for parents with dynamic children (/admin/superadmin/pages/[slug] keeps "Pages" highlighted). Footer pages editor (shipped same day) gained a "Reset to default" endpoint + button, locale-tab fix (key remount), inline validation, single close button. Customer footer pages render via shared .content-prose typography in globals.css (Tailwind v4 ships without @tailwindcss/typography, so the prior prose was a no-op). +13 site-scripts API tests + 2 content-pages reset tests on top of the morning's 20.
  • Tenant create UI (create new tenant from the admin portal — endpoint exists, only UI missing)
  • Per-tenant drill-down (revenue chart by month, booking trend, user list)
  • System health (DB pool, queue depth, error rate, payment provider status)
  • Super-admin impersonation + audit log (2026-05-25) — ADMIN có thể mở session OWNER ảo trên bất kỳ tenant nào từ /admin/superadmin/tenants (nút LogIn icon mỗi row). JWT mới carry sub = adminUserId, override role=OWNER + tenantId=target + thêm claim imp = { sessionId, adminUserId } → mọi guard / RBAC downstream hoạt động bình thường mà không cần đổi gì. Khi end, server re-issue admin tokens không có imp claim, no re-login. Schema: ImpersonationSession (admin, tenant, reason, ip, ua, started/ended, imp_refresh_token_hash) + ImpersonationAuditLog (method, path, statusCode, sha256(body)). Audit interceptor global insert log row cho mọi POST/PUT/PATCH/DELETE khi JWT có imp (GET skip để tiết kiệm volume; body sha256-hash để compliance/GDPR). UI: StartImpersonationModal (warning amber banner + textarea reason 500 chars), ImpersonationBanner (sticky top trên (dashboard)/layout, button Exit), /admin/superadmin/impersonations page list sessions với active filter + drilldown drawer xem audit log entries. Auth API: POST /superadmin/impersonate/start + POST /impersonate/end + GET /sessions[?activeOnly=&tenantId=] + GET /sessions/:id/logs. /auth/me mở rộng trả impersonation block khi JWT có claim. +14 unit tests (5 startImpersonation + 3 endImpersonation + 3 ImpersonationService + 5 audit interceptor). API 2011/2013 unit + web build/lint clean. Runbook: docs/operations/super-admin-impersonation.md.

Epic 9: Customer Portal ✅

  • Salon profile page (/b/[slug])
  • Service catalog (category tabs, industry-standard 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.
  • Time-slot grid picker (2026-04-26) — TimeSlotGrid swaps the dropdown list for a fixed-width chip grid (w-16 h-9, 4–6 cols, max 5 rows then scroll). Trigger keeps SearchSelect look + clear; popup uses brand-500 highlight on selected.
  • Closed-day notice (2026-04-26) — when the auto-picked or user-picked date is businessHours.isOpen=false, show a warning Alert below the date strip and hide the service form + summary sidebar so customers re-pick instead of fighting a form that can't submit.
  • Booking-page header parity with salon detail (2026-04-26) — avatar (md+border, 64px override), industry-type subtitle, live SalonClock for tenant timezone.
  • 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)
  • Phone-or-email required (2026-05-07)PublicBookingController.createBooking rejects BOOKING_CONTACT_REQUIRED when both customerPhone and customerEmail empty (after auth account-fallback). FE Zod .refine() on BookingPage surfaces the error under the phone field + helper text below the row; i18n nb + en parity. Salon always has a channel for confirmation/reminder + dedupe key for repeat customers; no-show risk reduced. +2 controller specs · 54/54 public-booking green · 1617 API total green.
  • Service + staff avatars in booking form (2026-05-08)/b/:slug/book ServiceItem header gains a 48×48 service thumbnail (imageKey via ProxyImage, fallback brand-tinted initial tile). Staff dropdown shows a 28×28 avatar per option (avatarKey via ProxyImage, fallback AvatarText initials with auto-color); SearchSelect trigger also displays the selected staff's avatar. "Any available" gets a grey Users-icon circle to visually separate from real staff. SearchSelect extended with optional icon on SearchSelectOption + new emptyIcon prop — fully backward-compatible (existing callers render unchanged). Lint + tsc clean.
  • "For business" CTA → /admin/signin (2026-05-07) — customer-facing header (auth + guest paths) and footer "For business" links repointed from /admin/signup to /admin/signin so returning salon owners land on login directly. Signup CTA still reachable via the marketing footer / pricing page.
  • Salon brand chrome + sticky tenant header (2026-05-12) — landing /b/[slug]: platform header bỏ sticky, StickyTenantHeader on-scroll bar (avatar nhỏ + tên + clock) trượt vào top khi cuộn qua identity (IntersectionObserver trên sentinel sau cover + meta block). Book page /b/[slug]/book: platform header ẩn hoàn toàn (CustomerHeader return null), StickyBookingHeader render ngoài max-w wrapper với avatar lg (80px) + tên text-2xl, scroll cùng content (no bg per UX feedback). ServiceList + ServiceItem cards thêm description line (line-clamp-2, ẩn nếu null) cho cả landing + book flow.
  • Salon landing UX overhaul (2026-05-13) — header platform thu nhỏ h-14 + position: relative trên /b/[slug] (StickyTenantHeader take over khi scroll), AppLogo size="sm" thay md để không full-bleed container; TenantTopBar overlay top-right z-50 (login/avatar, fixed inset-x-0 inner max-w-[1440px] flush với mép cover container). Login button đổi từ redirect /account/login sang CustomerLoginModal in-page (Google OAuth, đóng modal sau success, giữ trang). Cover overlay 3 dòng: logo lg (80×80) + tên + SalonClock + chip row OpenStatusTrigger ("Opening hours: HH:MM–HH:MM", màu đỏ/xanh báo trạng thái khi showDot={false}, hydration-safe via useOpenStatus hook, re-tick 60s) + LocationContactTrigger. 2 modal mới qua SalonOverlay primitive (createPortal(document.body) để thoát text-shadow của cover): OpeningHoursModal (status pill + bảng 7 ngày, header icon + title + close button cùng baseline) và LocationContactModal (map hero + FAB Get directions với Google Maps URL coords/encoded fallback + address card + Call/Email buttons, hover chỉ đổi bg per UX feedback). TeamGridCard sidebar 3×3 avatar grid (max 9, items-start, no hover bg) + "View more (+N)" → TeamModal (grid 2-3 cols, avatar 96 + name + role); TeamSection vertical-cards footer bỏ. Admin Settings: tab Location rename → Location & contact, section "Public contact" thêm contactEmail/contactPhone (@ValidateIf cho phép empty string clear, sanitize ra null khi expose qua GET /public/tenants/:slug). Footer logo wrap <Link href="/">. termsNoticet.rich() với <Link href="/terms"> + <Link href="/privacy">.
  • Tenant cover split desktop ↔ mobile (2026-05-13) — replaces the focal-point picker model with two independently uploaded covers. TenantBranding JSONB swaps coverFocalX / coverFocalY for optional coverMobileKey; public renderers prefer coverMobileKey on phones + on the 5:3 homepage thumbnail (closer to 5:2 than 5:1), fall back to coverKey when only one image is shipped. Admin BrandingSection shows two ImageUploader cards (desktop 5:1 + mobile 5:2) with amber warning when only desktop is set. FocalPointPicker UI removed (underlying ProxyImage / useImageProxyUrl focal props kept as generic primitives). Migration 20260513082622_split_tenant_cover_desktop_mobile strips both focal keys + seeds coverMobileKey: null via defaults || existing JSONB pattern (idempotent). branding.resolver.ts, PublicBookingController.listTenants + public-tenant DTO updated. Also: unified section title sizing (text-lg font-semibold across Services / About / Location / Opening hours / Team) + removed Clock/Users icons from sidebar headers; StaticMap now takes required height prop with same-height skeleton from dynamic({ loading }) so modal map no longer jumps from 140px → 600px.
  • Claim guest booking after login (2026-05-13) — guest có thể gắn booking vào account sau khi login từ confirmation page. POST /public/tenants/:slug/bookings/:id/claim (CustomerAuth) set Booking.customerId + upsert TenantCustomer. GET /public/.../bookings/:id thêm hasCustomer: boolean (server không expose raw customerId — chỉ boolean — để anonymous viewer không enumerate ownership được). BookingConfirmedClient render "Save this booking to your account" CTA dưới "View invoice" khi !authLoading && hasCustomer === false; logged-in click fires claim trực tiếp, guest mở CustomerLoginModal rồi useRef flag auto-fire claim sau login (tránh React 19 set-state-in-effect). Bảo mật: (a) customer JWT, (b) tenant scope từ slug, (c) atomic UPDATE … WHERE customer_id IS NULL race-safe với simultaneous claims, (d) 60-minute window từ createdAt để giới hạn blast radius khi URL leak. KHÔNG enforce identity match (email/phone) — guest hay đặt hộ người khác. Error codes: BOOKING_NOT_FOUND (404), BOOKING_ALREADY_CLAIMED (409), CLAIM_WINDOW_EXPIRED (403). 7 controller specs (happy in-window, happy without identity match, 404, 409 already, 403 expired, 409 race-loss, slug 404). i18n en + nb. API 1862/1864 green.
  • Hide platform header on booking detail / invoice / payment subroutes (2026-05-13)CustomerHeader returns null cho mọi /b/<slug>/bookings/<id> route + sub-routes (/invoice, /payment/return, /payment/cancelled) — same focused-screen rationale như booking flow. Regex /^\/b\/[^/]+\/bookings\/[^/]+(\/.*)?$/; query strings (?from=payment-return) tự nhiên cover vì usePathname exclude. Booking detail "Back to salon" đổi từ filled primary button sang quiet text link match invoice footer style.
  • Modal fade transition + scrollbar gutter + mask normalize (2026-05-13)SalonOverlay 4-phase state machine (closed → entering → open → leaving → closed) qua useReducer (tránh React 19 set-state-in-effect): entry double requestAnimationFrame giữa BEGIN_ENTER + COMMIT_ENTER để paint state opacity-0 scale-95 translate-y-2 trước khi visible; exit 1 rAF rồi 150ms unmount timer; backdrop hiện instant (mượt), chỉ dialog fade+scale (duration-150 = TRANSITION_MS). Replaced scrollbar-gutter: stable bằng html { overflow-y: scroll } để gutter cố định không shift khi body.overflow: hidden. Mask opacity normalize sang bg-gray-900/25 trên 22 file (modals/drawers/dialogs/sidebar backdrop), bỏ backdrop-blur-[2px] (mask alone đủ). Lightbox bg-black/95 + uploader loading bg-white/70 giữ nguyên. BookingTicket py-5pt-5 để strip BALANCE DUE flush với neighbour.
  • 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

✅ DONE (2026-06-01) — Custom Domain (Feature 1) · 🚀 DEPLOYED PROD 2026-06-02

Tenant gắn domain riêng (book.mysalon.com hoặc apex mysalon.com) → resolve về /b/<slug> với clean URLs. Phases P1–P8 + apex UI done. Deploy prod 2026-06-02: Caddy cutover live, salon.novagoo.com test ACTIVE OK; fix header tenant-mode trên custom domain (web 26becd5). P8 Google login reworked 2026-06-03 → server-side Authorization Code redirect (bỏ broker popup) — cần set GOOGLE_CLIENT_SECRET/GOOGLE_OAUTH_REDIRECT_URI/PLATFORM_ORIGIN + redirect URI Google Console + migration rồi redeploy. Chi tiết: architecture/custom-domain.md §9.

  • P1 — Data model: TenantDomain model + status enum + additive migration (no backfill)
  • P2 — Domain module: owner CRUD (/tenant-domains), public resolve, internal tls-allow (Caddy on-demand TLS ask)
  • P3 — DNS verify: DomainVerificationService — TXT ownership + CNAME/A routing check, apex-aware (apex dùng A record, không CNAME)
  • P4 — Host-aware URLs: shared public-url.helper resolve payment return + email links từ tenant's ACTIVE domain; /account + /admin giữ platform domain
  • P5 — Web proxy: proxy.ts resolve custom host → rewrite /b/<slug>, set x-custom-domain; SalonLinkContext + useSalonLink → clean URLs across 8 client components
  • P6 — Admin UI: Settings → Domain tab (add/verify/remove + apex-aware DNS instructions); allowedDevOrigins env-driven (DEV_ORIGINS); Docker/GHA NEXT_PUBLIC_PLATFORM_DOMAINS
  • P7 — TLS edge (Caddy): Caddy thay nginx + certbot — auto-HTTPS + on-demand TLS, caddy/Caddyfile + Caddyfile.dev; docker-compose.prod + deploy.sh cutover; docs custom-domain.md / embed-widget.md / caddy-dev-setup.md
  • Apex UI: apex-aware DNS instructions trong Domain tab (A record cho apex vs CNAME cho subdomain)
  • P8 — Google login trên custom domain (server-side redirect)DONE 2026-06-03 — GIS chặn JS-origin chưa đăng ký nên login Google fail trên custom domain; broker popup (2026-06-02) lách được nhưng 2-click + popup blocker. Thay bằng OAuth 2.0 Authorization Code flow server-side (validate redirect_uri): auth/customer google/start+google/callback+google/complete, model CustomerAuthTicket (single-use handoff), web GoogleSignInButton + /account/auth/callback + completeGoogleLogin. 1 click, không popup, 1 redirect URI Google Console. Broker xoá hẳn. 10 e2e. Chi tiết: architecture/custom-domain.md §9.
  • Header/Footer custom-domain brandingDONE 2026-06-04 — trên custom domain header render logo + tên salon (SalonAvatar) thay AppLogo GLAMVOO (prop salon resolve qua x-tenant-slug/resolveTenantSluggetTenant); footer riêng CustomDomainFooter (copyright © {brandName} + "Powered by Novago JSC" + locale/theme), bỏ cột nav platform. Fallback platform brand khi fetch lỗi. gitnexus impact LOW, lint+build pass.

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

✅ DONE (2026-05-12) — Stamp voucher refactor + cross-salon hybrid UX

  • Stamp voucher backend (migration, domain, listeners, atomic reserve, global filter fallback)
  • Customer voucher apply flow (preview endpoint, booking-create integration, lifecycle transitions)
  • Cross-salon loyalty dashboard (/account?tab=loyalty 4 sections + stats card + reserved-as-separate-section)
  • Invoice-style ticket layout (Subtotal → Discount → Total → Paid → Balance due, voucher badge chip)
  • Apply voucher input gated by tenant.hasActiveStampCard
  • UI hides FREE_SERVICE + DISCOUNT_AMOUNT in card form (DEFER:loyalty-reward-types)
  • Seed yarn seed:loyalty-demo <email> for full demo data
  • Deferred: stamps + points cancel revert (docs/flows/stamp-voucher-flow.md §18)

Web Admin Features

Feature Trạng thái
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
Settings — Payment provider (Bambora Classic, health-check, rotate) ✅ Done
Payments — list + detail drawer + refund/void/capture (Track D2) ✅ Done
Collect remaining QR (Track E1) ✅ Done
Loyalty Programs (cards, conditional form validation) ✅ Done
Settings URL-based tabs (?tab=location) ✅ Done
Bookings view mode persist (localStorage) ✅ Done
BookingList sortable headers + persist (localStorage) ✅ Done
Tenant onboarding wizard (7-step full-page; Services + Resources skippable since 2026-05-07) ✅ Done
Admin header "View salon page" link (opens /b/{slug} in new tab) ✅ Done

Static Pages (Customer-facing)

Trang Route Trạng thái
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 Trạng thái
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
Multi-instance ready ✅ Done (2026-05-07) — ThrottlerModule swapped to Redis-backed storage (@nest-lab/throttler-storage-redis), enableShutdownHooks() for graceful SIGTERM handling, trust proxy = 'loopback' so per-IP rate limits read the real client IP from Nginx's X-Forwarded-For. API can now scale horizontally behind a load balancer without bypass risk on 5/60s public-booking throttle. Redis is now a hard dependency for rate-limiting (in addition to BullMQ jobs).

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.

Epic 11: Upload & Storage 🚧 Phase 1 + 2 + 3 + 4 DONE (Phase 4.6 shipped 2026-05-01 — Phase 4 now fully closed), Phase 5-6 pending

Provider-agnostic S3-compatible upload với imgproxy serve-time resize. See docs/architecture/upload-architecture.md.

Migration drop: minio package + MINIO_* env vars — replaced với @aws-sdk/client-s3 (Phase 1) và xoá ở Phase 6. MinIO Inc. đã chuyển AIStor model 2025, community edition không còn hấp dẫn cho self-host production.

Hiện trạng baseline (đã có trên main):

  • booking-api/src/core/upload/UploadService + UploadController dùng minio package, single-purpose multipart, public-read bucket, MIME allowlist (jpeg/png/webp/svg), max 5 MB, tenant key prefix
  • docker-compose.yml — MinIO container (port 9010/9011)
  • FE — BrandingSection.tsx, DropZone.tsx, FileInputExample.tsx (chưa wire vào UploadService thật)
  • Schema — Service.imageUrl (1 field, kiểu URL string — sẽ rename imageKey ở Phase 4)

Security gaps phát hiện (sẽ fix Phase 1):

  1. CRITICAL — DELETE không verify tenantId trong URL → cross-tenant delete khả thi
  2. SVG trong allowlist → XSS risk khi browser render
  3. Chỉ check file.mimetype client-provided → magic-bytes spoof khả thi
  4. URL hardcode http://${endpoint}:${port} → break sau Nginx/HTTPS
  5. Không có DB tracking → orphan files tích luỹ
  6. Không strip EXIF → leak GPS coordinates
  7. SDK minio package → không portable sang R2/Hetzner/B2/Spaces
  8. Chưa có presigned URL → không support file private (onboarding doc, invoice)

Phase 1 — Harden core ✅ DONE (2026-04-28, branch feat/upload-storage-phase-1, commit 278fb8f)

Mục tiêu: swap SDK chuẩn S3, fix security gaps CRITICAL/HIGH, presigned helper sẵn sàng cho Phase 5. Backward compatible với existing endpoint shape (FE chưa cần đổi).

Code changes:

  • Add deps: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner, file-type@16 (CJS-compatible với Jest), sharp, dev aws-sdk-client-mock. Removed minio package.
  • domain/ports/storage.port.tsStoragePort interface + STORAGE_PORT symbol; types cho put/presigned-put/presigned-get/delete/head
  • domain/errors.tsUploadDomainError abstract + 10 subclasses (UPLOAD_INVALID_TYPE, UPLOAD_TYPE_MISMATCH, UPLOAD_TOO_LARGE, UPLOAD_TOO_SMALL, UPLOAD_DIMENSIONS_TOO_LARGE, UPLOAD_NOT_FOUND, UPLOAD_TENANT_MISMATCH, UPLOAD_PURPOSE_FORBIDDEN, UPLOAD_PROVIDER_ERROR, UPLOAD_INTEGRITY_FAILED). Mỗi class carry stable code + httpStatus.
  • domain/file.validator.tsvalidateUpload() magic-bytes via file-type + size + dim, processImage() rotate + EXIF strip via sharp
  • domain/upload-purpose.config.ts — registry 7 purpose × per-purpose maxBytes/MIME/visibility/imagePipeline (TENANT_LOGO 2MB, TENANT_COVER 5MB, SERVICE_IMAGE 3MB, RESOURCE_AVATAR 2MB, PORTFOLIO_PHOTO 5MB, ONBOARDING_DOC 10MB private, PAYMENT_ATTACHMENT 10MB private)
  • infrastructure/storage/s3-storage.adapter.ts — implements StoragePort via @aws-sdk/client-s3 (PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand) + getSignedUrl cho presigned PUT/GET. NotFound → UploadNotFoundError, 5xx → UploadProviderError.
  • interface/filters/upload-domain-error.filter.tsUploadDomainError → JSON { success: false, error: { code, message } } với httpStatus (4xx warn / 5xx error log).
  • Refactor upload.service.ts:
    • Inject STORAGE_PORT + UPLOAD_SERVICE_CONFIG qua DI
    • upload(input) chạy validate → image pipeline (nếu purpose pipeline) → putObject → return { key, url, sizeBytes, mimeType, width?, height? }
    • delete(key, callerTenantId) parse key prefix → throw UploadTenantMismatchError nếu mismatch (CRITICAL fix)
    • resolveKeyFromUrl(urlOrKey) strip public base + bucket prefix (BC cho FE truyền URL hoặc bare key)
  • Refactor upload.controller.ts:
    • POST /upload accept multipart file + optional purpose body (default TENANT_LOGO cho FE BC). Return { url, key, sizeBytes, mimeType, width?, height? }.
    • DELETE /upload accept body { url? | key? }, resolve → key → service.delete với tenant verify
    • @UseFilters(UploadDomainErrorFilter) ở class
  • Refactor upload.module.ts — providers: S3StorageAdapter, STORAGE_CONFIG factory (env validation), STORAGE_PORT useClass, UPLOAD_SERVICE_CONFIG factory (STORAGE_PUBLIC_BASE ?? STORAGE_ENDPOINT)
  • Update .env.exampleSTORAGE_* block (ENDPOINT, REGION, ACCESS_KEY, SECRET_KEY, BUCKET, FORCE_PATH_STYLE, PUBLIC_BASE) provider-agnostic. Old MINIO_* block đã được xoá kèm email commit (anh chủ động cleanup).
  • [~] docker-compose.yml — không cần update vì MinIO container vẫn giữ cho legacy dev local; sẽ xoá Phase 6 deprecation cleanup khi production cloud bucket setup xong.

Tests (TDD — 35 tests mới, +27 over baseline):

  • Unit file.validator.spec.ts — 12 cases: PNG/JPEG/WebP/PDF accept × 3 purposes, SVG rejected (XSS), polyglot defense (PDF-as-PNG), MIME mismatch (PNG-as-JPEG), oversize, undersize (<100B), declaredSize vs buffer mismatch, 56 megapixel rejected, processImage strips EXIF
  • Unit s3-storage.adapter.spec.ts — 8 cases với aws-sdk-client-mock: putObject ok / deleteObject ok / headObject NotFound → null / headObject metadata / NoSuchKey → UploadNotFoundError / 5xx → UploadProviderError / presignedPut TTL + headers / presignedGet uses GetObjectCommand
  • Unit upload.service.spec.ts (rewrite) — 9 cases: PNG happy path / image pipeline EXIF strip / MIME mismatch / tenantId prefix / cross-tenant DELETE blocked / valid DELETE / malformed key DELETE / resolveKeyFromUrl strips prefix / resolveKeyFromUrl passthrough
  • Unit upload.controller.spec.ts (update) — 6 cases: default purpose / explicit purpose / unknown purpose fallback / DELETE accepts url / DELETE accepts bare key
  • [DEFER upload-phase-1-e2e] e2e test/upload.e2e-spec.ts — DEFER sang Phase 4 (cần real cloud bucket hoặc LocalStack setup). Logic security đã cover ở unit level + integration tests sẽ wire khi FE update Phase 4.

Acceptance criteria:

  • gitnexus_impact({target: "UploadService", direction: "upstream"}) LOW risk, 0 process affected (verified pre-task: 9 nodes)
  • yarn lint = 0 errors (3 pre-existing warnings trong email/recipient.resolver.ts không phải scope Phase 1)
  • yarn build (booking-api nest build) pass clean
  • Unit suite green: 1469 pass / 1471 total (+27 over baseline 1443)
  • Cross-tenant DELETE attack test pass (CRITICAL gap fixed) — UploadService.delete rejects với UPLOAD_TENANT_MISMATCH 403
  • Magic-bytes spoof test pass (HIGH gap fixed) — validateUpload rejects PNG-as-JPEG với UPLOAD_TYPE_MISMATCH
  • SVG XSS vector blocked (HIGH gap fixed) — SVG removed from allowlist, rejected với UPLOAD_INVALID_TYPE
  • BrandingSection upload backward-compat — POST /upload still returns { url } (+ new metadata fields)
  • [DEFER upload-phase-1-openapi-sync] OpenAPI spec regen + booking-web types sync — defer cho Phase 4 cùng FE wire (response shape sẽ stable hơn sau khi DB tracking + presigned endpoints expose)
  • gitnexus_detect_changes() LOW risk, 0 process affected, scope chỉ trong upload module
  • Changelog entry — see docs/progress/changelog.md 2026-04-28

Phase 2 — imgproxy integration ✅ DONE (2026-04-28)

  • imgproxy container vào docker-compose.yml — port 9020, S3 source ở MinIO, IMGPROXY_KEY/SALT, AVIF + WebP detection, ALLOWED_SOURCES whitelist s3://booking-assets/, STRIP_METADATA + AUTO_ROTATE
  • ImageProxyService (booking-api/src/core/upload/services/image-proxy.service.ts) — HMAC-SHA256(salt || path) signing, hex key/salt validation, dimension/quality/dpr clamp (16-2000 px / 60-100 / dpr ≤ 3), key-shape validation rejects PRIVATE doc keys
  • GET /image-proxy/sign?key=...&width=...&height=...&resize=...&gravity=...&quality=...&dpr=...@Public() + 600 req/min throttle, returns { url }. Public-no-auth chính đáng vì storage keys UUIDv7 + images vốn world-readable + public booking flow no JWT
  • FE component <ProxyImage> (booking-web/src/components/ui/images/ProxyImage.tsx) — accept full URL hoặc bare key, normalize qua extractStorageKey, devicePixelRatio cap 3, fallback prop khi loading hoặc error
  • FE hook useImageProxyUrl()useQuery 1-hour staleTime, deterministic queryKey
  • FE helper extractStorageKey() (booking-web/src/lib/upload.ts) — strip <host>/<bucket>/ prefix, fallback bare key, configurable via NEXT_PUBLIC_STORAGE_BUCKET
  • Replace <img> / next/image calls — BrandingSection logo + cover, SalonAvatar (logo across booking ticket / public salon page / tenant picker), HomeContent discovery card cover, public salon b/[slug]/page cover, TenantPicker logo. accept attributes cũng dropped image/svg+xml để khớp Phase 1 magic-bytes
  • Acceptance: format auto-detect WebP/AVIF qua Accept header (imgproxy env enable); signed URL deterministic; signature recompute test pass; gravity-only-when-fill behaviour tested
  • Tests — backend +13 (image-proxy.service.spec.ts 11 cases + image-proxy.controller.spec.ts 2 cases), web +11 (upload.test.ts 7 cases on extractStorageKey + useImageProxyUrl.test.tsx 2 cases)
  • Env — IMGPROXY_PUBLIC_URL/KEY/SALT + NEXT_PUBLIC_STORAGE_BUCKET added to .env.example cho cả 2 repo
  • gitnexus_impact LOW risk · gitnexus_detect_changes 0 affected processes · nest build clean · next build 32 pages clean · lint 0/0 cả 2 repo
  • Changelog entry — see docs/progress/changelog.md 2026-04-28
  • DEFER: production Nginx + TLS reverse proxy in front of imgproxy (Phase 6 deploy hardening); rename *Url*Key schema fields (Phase 4 — Service.imageUrl → imageKey, Tenant.logoUrl → logoKey, etc.); Resource avatar UI surface (no admin form yet — wire khi resource form nâng cấp)

Phase 3 — DB tracking + orphan cleanup ✅ DONE (2026-04-28)

  • Prisma migration add_uploaded_files (20260428082450_add_uploaded_files) — model UploadedFile + enums UploadPurpose (7 values) + UploadVisibility (PUBLIC / PRIVATE). Three indices for admin browse, cleanup query, and visibility filtering. Tenant.onDelete: Cascade so deleted tenant takes file rows with it.
  • UploadedFileRepository (infrastructure/persistence/uploaded-file.repository.ts) — tenant-scoped findById / findByKey (returns null for cross-tenant attempts), create, setReference, clearReference, findOrphans(beforeAt, limit) (cross-tenant — worker only), deleteByKeys. Every method accepts optional Prisma.TransactionClient so feature modules can link/unlink atomically with their domain UPDATE.
  • UploadService.upload() inserts UploadedFile row với referencedAt = null after storage putObject succeeds; result shape gains id.
  • UploadService.linkReference(fileId, target, callerTenantId, tx?) — verifies tenant ownership, throws UploadNotFoundError on cross-tenant attempts.
  • UploadService.unlinkReference(fileId, callerTenantId, tx?) — symmetric flip back to orphan candidate.
  • UploadService.delete() also bulk-deletes the DB row by key (best-effort — legacy Phase 1→3 keys without a row still succeed).
  • BullMQ cron OrphanCleanupWorkerOrphanCleanupQueue registers repeatable sweep every 1h, OrphanCleanupProcessor queries findOrphans(NOW − 24h, 100), deletes from S3 then bulk-deletes DB rows. Storage-delete failures keep the DB row for next-sweep retry. Returns {scanned, storageDeleted, storageFailed, rowsDeleted} summary.
  • Acceptance: orphan > 24h dọn sạch (sweep verified via processor unit tests); tenant cascade DB-side ✓
  • Tests +26 (1515 / 1517 pass): repository 11, service +5, processor 5, queue scheduler 5
  • gitnexus_impact LOW · gitnexus_detect_changes LOW · lint 0 errors · nest build clean
  • Changelog entry — see docs/progress/changelog.md 2026-04-28
  • DEFER: tenant cascade S3 sweep — current cascade only drops DB rows, S3 objects fall to the next orphan sweep on their own (24h grace acceptable for Phase 3). Per-tenant storage quota tracking (architecture §15.6 — needs Tenant.storageUsedBytes + decrement-on-cleanup). Admin UI for orphan inspection (defer to Phase 4 alongside feature wiring).

Phase 4 — Wire 6 use cases (split into 4.1-4.6)

Phase 4.1 — linkReference wiring for existing image fields ✅ DONE (2026-04-28)

  • UploadService.syncReference(prev, next, target, tenantId, tx?) — single helper handling 4 transitions (no-op same-key, claim-on-new, release-on-null, swap). Best-effort silent skip for legacy uploads + cross-tenant keys.
  • Tenant.branding.logoUrl / coverUrl wired in TenantService.update — reads pre-merge value, syncs both inside same transaction. Conditional wrap: only wraps in $transaction when at least one branding URL changed.
  • Resource.imageUrl wired in ResourceService.create + update (all 3 branches: shortcut/no-login, add-login, change-login). Conditional wrap on update.
  • Tests +6 (syncReference 6 cases) · suite 1521/1523 · lint 0 errors · nest build clean
  • Existing 4 broken specs fixed (time-off.spec, resource.service.spec, tenant.service.spec add UploadService stub; payment.module.spec extends env stub for transitive UploadModule boot)
  • Changelog entry — see docs/progress/changelog.md 2026-04-28

Phase 4.2 — <ImageUploader> reusable + Service.imageUrl wiring ✅ DONE (2026-04-28)

  • Schema — Service.imageUrl column added (migration 20260428160418_add_service_image_url); the earlier audit's assumption that this column existed was a misread (the imageUrl: true in service.service.ts:157 was inside getResourcesForService reading the Resource field).
  • DTO — CreateServiceDto.imageUrl?: string + UpdateServiceDto.imageUrl?: string | null (with ValidateIf so explicit-null clears).
  • ServiceService.create / update wire syncReference — create runs post-commit (best-effort); update only wraps in $transaction when dto.imageUrl !== undefined.
  • FE reusable <ImageUploader> (booking-web/src/components/ui/images/ImageUploader.tsx) — encapsulates multipart upload + <ProxyImage> preview + remove cycle. Props: value, onChange, purpose, previewWidth/height/resize/gravity, previewClassName, showRemove.
  • api.upload extended to accept optional extra: Record<string, string> form-data bag (needed to pass purpose).
  • BrandingSection refactored to use two <ImageUploader> instances (TENANT_LOGO 64×64 fit, TENANT_COVER 1280×320 fill/sm) — removed 90 lines of bespoke upload plumbing.
  • i18n — common.imageUploader.* for en + nb.
  • Test suite repair — service.service.spec.ts got UploadService stub.
  • DEFER inside this phase resolved 2026-04-29ServiceFormModal now wires <ImageUploader purpose="SERVICE_IMAGE"> into the Details tab (DTO + schema + payload diff-aware so non-image edits stay single UPDATE); StaffFormModal adds <ImageUploader purpose="RESOURCE_AVATAR"> between description and metadata/color row. Service.imageUrl added to FE types/booking.ts (was missing). i18n services.image + resources.avatar for en + nb. See changelog 2026-04-29.

Multi-image + crop preview (via react-image-crop) deferred to Phase 4.4 (Portfolio gallery — that's where multi-image actually lands).

Phase 4.3 — Rename *Url*Key across schema, API, FE ✅ DONE (2026-05-01)

  • Prisma migration 20260501100105_rename_image_url_to_storage_keyservices.image_urlimage_key, resources.image_urlavatar_key, JSON tenants.branding.{logoUrl,coverUrl}{logoKey,coverKey}. Backfill strips ^https?://[^/]+/[^/]+/ URL prefix.
  • API DTOs renamed (Service imageKey, Resource avatarKey, Tenant branding logoKey/coverKey); @IsUrl dropped in favour of @IsString. OpenAPI regen + FE types regen.
  • BrandingResolver injects ImageProxyService to pre-resolve logoKey → 48×48 imgproxy URL for email templates (Gmail can't lazy-resolve <img src>).
  • ImageUploader.onChange(res.data.key) — returns key not url. Drop url from POST /upload response shape entirely.
  • extractStorageKey strips URL-shape branch — rejects :// inputs. Callers must pass bare keys post-Phase 4.3.
  • UploadService.resolveKeyFromUrl + buildPublicUrl deleted; UploadServiceConfig.publicBaseUrl deleted; STORAGE_PUBLIC_BASE env no longer wired.
  • All FE forms + display sites updated (BrandingSection, ServiceFormModal, StaffFormModal, SalonAvatar, TenantPicker, SignInForm, BookingTicket, HomeContent, public salon page, BookingPage).
  • Tests updated — API 1518/1520 (net -3 = 2 deleted resolveKeyFromUrl tests + 1 deleted DELETE-accepts-url test); web 173/173. Lint + build clean both repos.
  • End-to-end smoke verified via curl — upload + create + swap + clear all flow with key only; DB stores bare keys.
  • Schema PortfolioItem (id, tenantId, resourceId, fileKey, caption, sortOrder, createdAt, updatedAt) — migration 20260501110609_add_portfolio_items with cascade-on-tenant + cascade-on-resource. beforeAfterPair deferred — single-photo + caption covers MVP; salons typically upload pre-composed before/after collages.
  • Backend module core/portfolio/PortfolioService (list / create / update-caption / reorder bulk / delete) + PortfolioController (GET /resources/:id/portfolio, POST /resources/:id/portfolio, PATCH /resources/:id/portfolio/reorder, PATCH /portfolio/:id, DELETE /portfolio/:id). All mutations transactional; create + delete call UploadService.syncReference so orphan-cleanup respects gallery photos.
  • Per-resource cap = 30 photos (PORTFOLIO_MAX_ITEMS_PER_RESOURCE); create rejects with PORTFOLIO_MAX_ITEMS_REACHED once full. Cross-tenant fileKey rejected with PORTFOLIO_INVALID_FILE_KEY before reaching linkReference.
  • Tests — 13 new unit (portfolio.service.spec.ts) covering tenant isolation, cap, ownership rejections, reorder ownership check, monotonic sortOrder, claim/release on create+delete. Total API: 1531 / 1533 (was 1518).
  • Frontend — usePortfolio hooks (query + 4 mutations with React Query invalidation), <PortfolioGalleryEditor> component using @dnd-kit/sortable for drag-reorder, inline caption editing (Enter to save, Escape to cancel), confirm-on-delete via <ConfirmDialog>. Wired into StaffFormModal as a 3rd tab "Portfolio" — only visible in edit mode (needs persisted resource id).
  • Public surface — GET /public/tenants/:slug/resources/:resourceId/portfolio (only isActive + isBookableOnline resources), new <TeamSection> on /b/[slug] salon page rendering each staff card with up to 10 thumbnails + "+N" overflow tile, click opens <PortfolioLightbox> (full-screen with prev/next, Esc/arrow keys).
  • i18n — portfolio.* namespace + resources.portfolioTab + publicBooking.team in nb + en.

Phase 4.5 — Onboarding documents ✅ DONE (2026-05-01)

  • Schema TenantOnboardingDoc (id, tenantId, fileKey, docType BUSINESS_LICENSE|GOVERNMENT_ID|INSURANCE|OTHER, mimeType, sizeBytes, originalName?, uploadedBy, verifiedAt?, verifiedBy?, note?, createdAt, updatedAt) + cascade-on-tenant + 2 indices ((tenantId, docType), (verifiedAt)). Migration 20260501112807_add_tenant_onboarding_docs. Multiple rows per docType allowed (no unique constraint on docType — owners can upload ID front + back, multi-page insurance docs as separate files).
  • 3-step presigned PUT flow in UploadService: requestPresignedUpload (validates MIME + size against UPLOAD_PURPOSE_CONFIG, creates UploadedFile row with referencedAt = null, returns {fileId, key, uploadUrl, requiredHeaders} with 5-min TTL); confirmPresignedUpload (headObject to verify file landed; UPLOAD_INTEGRITY_FAILED 422 when sizes mismatch — caller can retry the PUT step; UPLOAD_NOT_FOUND when row missing or storage empty); getPresignedDownload (1h TTL, optional contentDisposition for force-attachment).
  • Backend module core/onboarding-doc/: OnboardingDocService (list / requestUpload / confirmUpload / getDownloadUrl / delete / verify) + OnboardingDocController mounted at /onboarding-docs. Endpoints: GET /onboarding-docs (OWNER+ADMIN), POST /onboarding-docs/presigned-put (OWNER+ADMIN), POST /onboarding-docs/confirm (OWNER+ADMIN — wraps in transaction: insert doc row + linkReference), GET /onboarding-docs/:id/download (OWNER+ADMIN — force-attachment disposition with sanitised filename), DELETE /onboarding-docs/:id (OWNER+ADMIN — releases storage claim), POST /onboarding-docs/:id/verify (ADMIN only — OWNER cannot self-verify; idempotent: re-verifying refreshes note but preserves original verifiedAt).
  • Tests — +22 unit (upload.service.spec.ts +10 for presigned helpers; onboarding-doc.service.spec.ts +12 for service logic). Total API: 1553 / 1555 (was 1531).
  • FrontenduseOnboardingDocs hooks (useQuery for list + useUploadOnboardingDoc mutation that orchestrates the 3-step flow with raw fetch for the S3 PUT + envelope-wrapped api.post for confirm + useDeleteOnboardingDoc). New <DocumentsSection> mounted as a 6th tab "Documents" in the Identity group of admin settings (/admin/settings?tab=documents). Per-docType card shows verified-vs-pending badge, upload button, file list with size + date + verified-on annotation, download (opens 1h presigned URL in new tab) + delete (ConfirmDialog).
  • i18nsettings.documents.* namespace (title, description, types[4], hints[4], badges, toasts) + common.download + settings.menu.documents (en + nb).
  • Verify endpoint shipped, UI deferredPOST /:id/verify accepts ADMIN-only request body. Superadmin UI to call this lives in a future sweep alongside the existing tenant detail page; the endpoint is wired so it can be smoke-tested via curl today.

Phase 4.6 — Payment attachments ✅ DONE (2026-05-01)

  • Schema PaymentAttachment (id, paymentId, tenantId, fileKey, mimeType, sizeBytes, originalName?, note?, uploadedBy, createdAt, updatedAt) + cascade-on-payment + cascade-on-tenant + indices (paymentId, createdAt) and (tenantId). Migration 20260501114326_add_payment_attachments.
  • Backend module core/payment-attachment/ reuses the 3 presigned helpers from 4.5. Endpoints under /payments/:paymentId/attachments (list / presigned-put / confirm — STAFF+OWNER+ADMIN) and /payment-attachments/:id (PATCH note OWNER+ADMIN, GET download STAFF+OWNER+ADMIN, DELETE OWNER+ADMIN per architecture §10.5). Per-payment cap = 10 attachments (PAYMENT_ATTACHMENT_MAX_PER_PAYMENT); requestUpload rejects with PAYMENT_ATTACHMENT_MAX_REACHED once full. Cross-tenant payment access blocked via assertPayment before every operation.
  • Tests — +13 unit (payment-attachment.service.spec.ts): list, cross-tenant payment rejection, MIME/size rejection, cap rejection, happy delegate, confirmUpload links claim, update note, getDownloadUrl with attachment disposition, delete + claim release, cross-tenant on every mutation. Total API: 1566 / 1568 (was 1553).
  • Frontend — usePaymentAttachments hooks (3-step upload orchestrator + delete + standalone download helper). New <PaymentAttachmentsSection> mounted inside PaymentDetailDrawer between the timeline and failure block: shows count badge, upload button (STAFF+OWNER+ADMIN), file list with size + date + note. Delete button hidden for STAFF (matches API role guard).
  • i18n — payments.attachments.* namespace (title, uploadButton, empty, toasts, deleteConfirm, errors) for en + nb.

Phase 4 acceptance overall ✅ MET (across 4.2-4.6): mỗi use case có CRUD + tenant isolation tests; FE không còn <input type="text" placeholder="Image URL">. Public surfaces (Tenant logo/cover, Service image, Resource avatar, Portfolio gallery) qua <ImageUploader> + imgproxy; private surfaces (Onboarding docs, Payment attachments) qua presigned PUT/GET với 3-step flow.

Phase 4.7 — Public service thumbnails + lightbox ✅ DONE (2026-05-01)

  • API — GET /public/tenants/:slug/services whitelists fields explicitly (id, name, description, duration, price, currency, isActive, sortOrder, imageKey). Internal columns (tenantId, metadata, accounting refs) no longer leak to the public surface. +2 controller spec cases (whitelist shape + leak guard).
  • OpenAPI + types regen — PublicService interface gains imageKey: string | null.
  • <ImageLightbox> reusable single-image component (components/ui/images/ImageLightbox.tsx). Wrapper/Inner pattern keyed by imageKey; ESC + X + backdrop click close; image-click stopPropagation.
  • ServiceList.tsx — industry-standard 1:1 thumbnail (h-16 w-16 mobile / h-20 w-20 desktop, rounded-lg) via <ProxyImage resize="fill" gravity="sm">. Layout falls back to text-only when imageKey === null (no placeholder gradient).
  • i18n — publicBooking.viewImage = "View image of {name}" / "Se bilde av {name}" (en + nb).
  • Verification: API 1567/1569 pass, lint 0/3 pre-existing warnings, nest build clean. Web 173/173 vitest, lint 0/0, next build 32 pages clean.

Phase 4.8 — Cover focal-point picker + public salon page polish ✅ DONE (2026-05-02)

  • Imgproxy paramsImageProxyService accepts focalX/focalY (emit g:fp:X.XX:Y.YY when both set, override g:sm, ignored on resize=fit) + blur (1-50 px, emit bl:N). +8 unit tests. SignImageDto validates ranges. @Throttle({600/min})@SkipThrottle() (HMAC compute is offline; 1 page = 30+ image mounts).
  • TenantBranding schemacoverFocalX, coverFocalY (default 0.5/0.5) in JSON column. BrandingResolver.mergeBranding reads with type-safe fallback. (Superseded 2026-05-13: focal-point keys removed, replaced by optional coverMobileKey for separate desktop/mobile covers — see Customer Portal section "Tenant cover split". Phase 4.8 imgproxy focalX/Y + blur params kept as generic primitives.)
  • Public APIGET /public/tenants + GET /public/tenants/:slug surface focal coords. FE PublicTenantBranding + PublicTenantListItem extended.
  • <FocalPointPicker> admin component (components/ui/images/FocalPointPicker.tsx):
    • Click/drag-to-set marker (target reticle: white ring + red dot + animate-ping halo + drop-shadow)
    • Container aspectRatio syncs with <img>.naturalWidth/Height via new ProxyImage.onLoad prop → click coords map 1:1 to source (no letterbox mismatch)
    • 2 LIVE preview cards (Desktop 5:1 + Mobile 5:2) mirror the production rendering pipeline: same imgproxy dims + objectPosition for CSS-side crop
  • BrandingSection — picker rendered between cover uploader + form footer; uploader onChange resets focal to (0.5, 0.5).
  • i18nsettings.{focalLabel, focalDescription, focalHint, focalDesktop, focalMobile, logoHint, coverHint, logoUploaderHint, coverUploaderHint} (en + nb).
  • /b/[slug] cover refactor:
    • Aspect aspect-[5/2] sm:aspect-[5/1] (mobile tall enough to read, desktop short banner)
    • imgproxy request 1440×288 (matches desktop 5:1 exactly — no double crop)
    • Focal forwarded via both imgproxy focalX/Y props AND CSS style.objectPosition (handles horizontal crop on mobile container 5:2)
    • Desktop overlay: salon avatar + name + clock at bottom-left with gradient + drop-shadow
    • Mobile: clean cover, separate avatar/name/clock block below
    • Industry-type label removed
  • ServiceList hover preview — new <ImageHoverPreview> (240×240 popup, 250ms delay, auto-flip on viewport overflow, portal-rendered, pointer-events:none).
  • <ImageLightbox> polish:
    • Inline-style backdrop rgba(0,0,0,0.8) (no Tailwind JIT dependency)
    • body.style.overflow = 'hidden' while open
    • <img style={{width:'auto',height:'auto'}}> + max-w/h-full (no upscale on small sources)
    • X button: white solid + dark icon + strokeWidth=2.5 + shadow
  • OpeningHoursCard — open/closed solid pill (emerald/red) with animate-ping halo on Open now; status detail inherits pill colour.
  • CustomerHeader — scroll < 10px transparent (cover bleeds full-width); scrolled = solid white + shadow.
  • Page layout — About + Location collapsed into main column; sidebar Opening hours sticky via <div className="sticky top-20"> inside <aside> (sticky on direct grid children fights align-items: stretch).
  • docker-compose.yml — opt-in IMGPROXY_IGNORE_SSL_VERIFICATION env (default false) for dev with flaky upstream certs.
  • Verification: API 1574/1576 (was 1567), web 173/173, lint 0/0 both, nest build + next build 32 pages clean.

Phase 4.8.1 — Cover crop hardening + invoice paid-only filter ✅ DONE (2026-05-02)

  • Imgproxy aspect-cap fixImageProxyService.signUrl now scales BOTH width+height by MAX_DIMENSION / max(w,h) when capping; previously width-only clamp turned 1440×288 (5:1) at DPR=2 into 2000×576 (~3.47:1) — imgproxy over-cropped L/R while CSS ate the top. +1 regression test (1440×288 DPR=2 → rs:fill:2000:400:0).
  • Admin/frontend cover parityBrandingSection ImageUploader synced with public render: previewWidth/Height = 1440/288, gravity="ce", containerClassName="aspect-[5/1] w-full max-w-2xl" (was 1280/320, sm, h-40). Owner now sees the EXACT crop customer sees.
  • Focal-point picker v2 — added 3rd Listing card PreviewCard (5:3 — matches HomeContent salon list card). LayoutGrid icon + focalListing i18n (en + nb). Grid switched to sm:grid-cols-3. All 3 frontend cover contexts now have a live preview.
  • Public rendererspage.tsx + HomeContent.tsx: gravity="sm"gravity="ce" (forced centre when no focal); Tailwind object-center (silent no-emit on this v4 build) replaced with explicit inline style.objectPosition; focalX/Y defaulted to 0.5 so legacy tenants without DB focal no longer fall into g:sm while admin previews showed g:fp:0.5:0.5 (the original mismatch the owner saw on mobile).
  • Invoice paid-only ledgerInvoiceClient filters payments to AUTHORIZED / CAPTURED / PARTIALLY_REFUNDED / REFUNDED before passing to <PaymentLedger> on /b/{slug}/bookings/{id}/invoice. Master payments query untouched so polling + deriveNextPayment + returnOutcome still see all rows.
  • Verification: API 1577/1579 pass (+1 imgproxy regression), web lint 0/0. gitnexus_impact LOW on signUrl + SalonPage.

Phase 5 — Mobile (pending)

  • expo-image-picker integration trong booking-mobile
  • useImageUpload hook (camera + gallery)
  • <ProxyImage> RN port (dùng Image native + signed URL)
  • Owner avatar + portfolio upload từ mobile
  • Acceptance: iOS + Android tested với staging cloud bucket

Phase 6 — Production deploy + cleanup (pending)

  • Nginx reverse proxy /imgproxy/ → imgproxy container; TLS termination
  • CDN cache headers (Cache-Control: public, max-age=31536000, immutable cho signed URLs)
  • Backup cron mc mirror (hoặc rclone) → cold storage bucket weekly
  • Provider failover playbook (R2 → Hetzner switch via env)
  • Deprecation cleanup: xoá minio package, MINIO_* env, MinIO container khỏi docker-compose
  • Update docs/operations/docker-deploy.md với section Upload + ImageProxy
  • Acceptance: production checklist done, restore-from-backup test pass

Epic 12: SaaS Subscription 🚧 S1–S4 + Phase A/B + Phase D + S8 reconcile shipped; P4 sandbox-verified

Platform billing — tenant trả phí định kỳ cho mình (không nhầm với Booking Payment / Bambora). Provider-agnostic theo BillingProviderPort, MVP = Lemon Squeezy, migration path sang Stripe Billing đã sketch.

🔄 2026-06-08 — chuyển provider sang Polar.sh (LS khó verify). Plan: docs/architecture/polar-integration-plan.md. MVP scope rút gọn còn 1 plan "Premium" · monthly · flat/salon. Shipped P1 (enum POLAR + migration) + P2 (Polar adapter skeleton dùng @polar-sh/sdk, đăng ký cạnh LS). LemonSqueezy UI (tab Billing super-admin) tạm ẩn (giữ code). P3–P6 pending (config wiring, sandbox webhook verify, FE un-hide + provider POLAR, tests).

✅ 2026-06-11 — Public booking gated by subscription (uncommitted). Salon lapsed (EXPIRED/trial-ended) → trang /b/[slug] + stepper hiện banner trung tính "không nhận đặt lịch online" + disable Book/Continue; POST bookings chặn 409 TENANT_NOT_ACCEPTING_BOOKINGS; unlist khỏi featured + search (direct link vẫn vào). Predicate isTenantBookingBlocked dùng chung với BillingGuard (refactor). Message trung tính (không lộ billing) theo industry (Square/Fresha). +8 tests. Xem changelog 2026-06-11.

✅ 2026-06-11 — Re-subscribe after full expiry (uncommitted). EXPIRED tenant (terminal) quay lại đóng tiền: handler tạo row Subscription mới (reactivation = append-only, never mutate EXPIRED) thay vì InvalidStateTransition; UI EXPIRED → PlanPicker + notice thay vì current-sub card khóa. KHÔNG trial lần 2 — đã dùng trial rồi thì reactivate charge ngay (remainingTrialDays=0 + noTrial UI). +4 tests (271 subscription). Xem changelog 2026-06-11.

✅ 2026-06-11 — P4 live-verified + trial-deferral/cancel-trial + auth/UX fixes (uncommitted). Real Polar sandbox checkout → webhook subscription.created → Subscription đúng. Subscribe giữa trial = charge lúc trial end (trial-deferral); cancel-trial giữ ACTIVE để expiry-sweep lo; honest errors (404/409 thay vì 500) + global-filter gap fix. Auth: sameSite lax + bfcache no-store (sửa treo/bounce khi Back-from-Polar — cần re-login + rm .next). Web: "Subscribe with Polar.sh" button, optimistic-cancel, dashboard SubscriptionStatusCard, banner dedup. Xem changelog 2026-06-11.

✅ 2026-06-11 — S8 reconciliation sweep shipped (uncommitted). Cron subscription-reconcile (6h) poll Polar cho sub đã link, non-terminal → drift check → re-apply qua SyncFromWebhookHandler (lưới an toàn khi webhook miss → tránh over-grant). reconcileSubscription optional trên port. 267 api tests. Xem changelog 2026-06-11 + architecture §13a.

✅ 2026-06-10 — Phase A/B (owner checkout + portal + cancel) shipped (uncommitted). POST /subscription/checkout (thin, webhook tạo Subscription), GET /subscription status view, GET /subscription/portal, POST /subscription/cancel. Web nav "Subscription" → /admin/subscription: plan picker (Start 14-day trial → Polar) khi chưa subscribe, current-sub card (Manage billing/Cancel) khi đã subscribe. 224 api unit tests. Xem changelog 2026-06-10.

✅ 2026-06-10 — Phase D (trial-on-signup + enforcement) shipped (uncommitted). Gating 100% hệ thống mình; Polar = payment rail. Plan: docs/architecture/subscription-enforcement-plan.md. D1 trial-on-signup (outbox tenant.registeredSubscriptionTrialService, 14d), D2 BillingGuard (global, block WRITE khi trial-lapsed/EXPIRED → 402), D3 Tenant.billingExempt super-admin toggle, D4 isTrialing + web SubscriptionBanner, D5 cron sweep (hourly), D6 grandfather, D7 tests (2291 unit pass). Xem changelog 2026-06-10.

BẮT BUỘC đọc trước khi code: docs/architecture/subscription-architecture.md (DDD bounded context, schema, provider matrix, migration strategy) + docs/flows/subscription-flow.md (12 scenarios E2E).

Plan catalog (MVP):

planKey Display Pricing Seat Trial
solo_monthly Solo 199 NOK/tháng flat 1 cap 14 days
pro_monthly_per_seat Pro Monthly 149 NOK/seat/tháng unlimited 14 days
pro_yearly_per_seat Pro Yearly 1490 NOK/seat/năm (~17% off) unlimited 14 days
enterprise_custom Enterprise Manual contract unlimited N/A

Decision highlights:

  • Subscription Context tách hoàn toàn Payment Context (D1) — khác provider, khác audit
  • BillingProviderPort abstract → đổi LS → Stripe = viết 1 adapter mới, domain 0 thay đổi (D3)
  • Subscription.provider immutable — provider migration qua parallel forever + cohort migration (D4)
  • Trial = derived (status=ACTIVE + trialEndsAt > now), không state riêng (D6)
  • Graceful degrade: PAST_DUE ≤7d soft banner, >7d hard read-only, EXPIRED full block (D8)
  • Customer portal = provider-hosted, không tự build (D10)

Phase S1 — Schema + plan catalog ✅ shipped 2026-05-18

  • Migration 20260518040226_add_subscription_context: model Subscription, SubscriptionEvent, SubscriptionWebhookInbox + enum SubscriptionStatus / BillingProvider
  • Tenant additions in same migration: subscriptionStatus (default ACTIVE grandfathers existing tenants), trialEndsAt, currentPlanKey, seatLimit
  • Plan catalog plan-catalog.ts (planKey ↔ provider variant IDs — env-based originally, superseded by S2.5 DB-backed mapping)
  • SubscriptionModule skeleton + provider stubs (lemonsqueezy/stripe/paddle README) wired into AppModule
  • 17 unit tests cho catalog (variant-mapping env tests dropped in S2.5)
  • Backfill Subscription rows for tenants hiện hữu (deferred → ship cùng S5 guard; nếu deploy S5 trước seed, guard sẽ pass nhờ subscription_status DEFAULT 'ACTIVE')

Phase S2 — Provider port + Lemon Squeezy adapter ✅ shipped 2026-05-20

  • domain/ports/billing-provider.port.ts — interface với 5 methods + BILLING_PROVIDER_REGISTRY DI token + BillingProviderRegistry lookup contract
  • domain/events/billing-subscription-event.ts — 7-variant discriminated union (created/renewed/updated/payment_failed/payment_recovered/canceled/expired) + DomainEventBase with tenantId for routing
  • domain/errors.ts — abstract SubscriptionDomainError + 4 cases (BILLING_PROVIDER_NOT_REGISTERED, WEBHOOK_SIGNATURE_INVALID, WEBHOOK_PAYLOAD_INVALID, CHECKOUT_FAILED)
  • providers/lemonsqueezy/:
    • lemonsqueezy.config.ts — env reader + LemonSqueezyConfigMissingError (apiKey/storeId/webhookSecret required)
    • lemonsqueezy.client.ts — native-fetch HTTP client với bounded timeout + 3-attempt exponential retry on 5xx/429/network
    • lemonsqueezy.signature.ts — HMAC SHA-256 verify với timingSafeEqual + length-mismatch guard
    • lemonsqueezy.event-mapper.ts — payload → DomainSubscriptionEvent; planKey from custom_data primary + variant_id reverse-lookup fallback; paused/unpaused/refunded ignored (return null)
    • lemonsqueezy.adapter.ts — implements port; POST /checkouts với custom_data.{tenantId,planKey} embedded; DELETE for cancelAtPeriodEnd; PATCH quantity với invoice_immediately:false; GET subscription for portal URL
    • lemonsqueezy.module.ts — factory provider pattern; missing env → graceful skip (dev mode); registered env present → wire into registry + assertProviderMappingComplete at boot
  • infrastructure/providers/billing-provider.registry.tsInMemoryBillingProviderRegistry impl (register/get/has/list, throws BILLING_PROVIDER_NOT_REGISTERED on miss)
  • plan-mapping.config.ts — added resolvePlanKeyByVariantId reverse helper for webhook routing fallback
  • SubscriptionModule wires BILLING_PROVIDER_REGISTRY + imports LemonSqueezyModule
  • .env.example documents 6 LS env vars (API_KEY, STORE_ID, WEBHOOK_SECRET, optional API_BASE_URL + STORE_DOMAIN, 3 VARIANT_* mappings)
  • Unit tests: signature (8 cases incl. replay + wrong-length), event-mapper (15 cases incl. 8 fixtures + ignored events + custom_data fallback), adapter (16 cases incl. all 5 port methods + happy + error paths), registry (5 cases). 60/60 pass.
  • Sanitized webhook fixtures test/fixtures/lemonsqueezy/{subscription_created,subscription_updated,subscription_payment_success,subscription_payment_failed,subscription_payment_recovered,subscription_cancelled,subscription_expired,subscription_paused}.json — all data anonymised, test_mode:true

S2.5 refactor 2026-05-26: env-based config (LEMONSQUEEZY_API_KEY / STORE_ID / WEBHOOK_SECRET / VARIANT_*) removed in favour of DB-backed PlatformBillingConfig (super-admin owned). LemonSqueezyModule deleted (folded into SubscriptionModule). plan-mapping.config.ts deleted. See S2.5 below.

Phase S2.5 — DB-backed PlatformBillingConfig + super-admin UI ✅ shipped 2026-05-26

Replaces env vars (S2) with super-admin-owned DB config so deploys don't break on missing env + super-admin can rotate credentials via UI. See docs/architecture/subscription-architecture.md §16 + docs/progress/changelog.md.

Schema (migration 20260526072713_add_platform_billing_config):

  • PlatformBillingConfig — per (provider, isTest) UNIQUE, AES-256-GCM encrypted credentials (reuse PAYMENT_ENCRYPTION_KEY), public storeId/apiBaseUrl/storeDomain/displayName, lastHealthCheckAt/Status, keyVersion. Singleton isActive enforced at service layer.
  • PlatformBillingPlanMapping — 1:N with config, (configId, planKey) UNIQUE. Replaces env LEMONSQUEEZY_VARIANT_*.

Domain + service:

  • PlatformBillingConfig plain entity (no AggregateRoot — platform-level state, no tenantId → can't emit DomainEvent with tenantId: string non-null). Methods: create / update / rotateCredentials / activate / deactivate / recordHealthCheck / decryptCredentials.
  • PlatformBillingConfigId VO. 7 new domain errors (NOT_FOUND, DUPLICATE, ALREADY_ACTIVE/INACTIVE, EMPTY_CREDENTIALS, BILLING_PROVIDER_NOT_AVAILABLE, VARIANT_MAPPING_MISSING).
  • PlatformBillingConfigServiceactivate(id) enforces singleton (deactivates other ACTIVE rows in same call), getActiveWithCredentials() helper for adapter, resolveActiveVariantId(planKey) fast path.
  • PlatformBillingConfigRepositoryPort + Prisma impl with mapper.

LemonSqueezy adapter refactor:

  • Constructor injects PlatformBillingConfigService (was static config + client). resolveConfig() lazy resolve per call from DB.
  • clientFactory visible-for-testing override (keeps @Injectable() 1-arg).
  • parseWebhook reverse-lookup variantId → planKey from DB plan mappings.
  • healthCheck() pings GET /v1/users/me — swallows errors and returns {ok, detail}.
  • BILLING_PROVIDER_NOT_AVAILABLE thrown when no active config → SubscriptionDomainErrorFilter maps to HTTP 503.
  • LemonSqueezyModule deleted; lemonsqueezy.config.ts env reader deleted; plan-mapping.config.ts deleted.

Super-admin REST API (/superadmin/billing-configs, ADMIN-only):

  • 10 endpoints: list, get, create (duplicate guard), update, rotate-credentials, activate (singleton-enforced), deactivate, health-check, delete, plan-mappings PUT/GET (bulk replace).
  • DTOs with class-validator + @Type(() => Nested) for credentials, full @ApiProperty annotations → OpenAPI types regen cleanly for frontend.
  • SubscriptionDomainErrorFilter (APP_FILTER) maps 12 codes → HTTP status.

SubscriptionModule re-enabled in app.module.ts (was commented out from S2). Module flattens cipher / repo / service / registry / adapter to avoid child→parent DI cycle.

Side fixes:

  • generate:openapi script: ts-node → nest build && node dist/scripts/generate-openapi.js (Node 24 ERR_REQUIRE_CYCLE_MODULE workaround).
  • SuperadminActivityKind DTO: @ApiProperty({ enum: [...], enumName: ... }) so openapi-typescript emits string union (was Record<string, never> blocking web typecheck).
  • Button component accepts type prop (default 'button') — Cancel buttons inside forms no longer accidentally submit.

Frontend: Platform Settings tab refactor (/admin/superadmin/settings):

  • PlatformSettingsContent.tsx wrapper mirrors tenant SettingsContent pattern — desktop sidebar with 3 tabs + mobile bottom nav + ?tab= URL param.
  • Branding tab — extracted from old PlatformSettingsForm.tsx (deleted). Wrapper/Inner pattern preserved.
  • Subscription tab — "Coming soon" placeholder for future Plans CMS epic.
  • Billing tab — full CRUD UI:
    • List PlatformBillingConfig cards with badges (Active/Inactive · Test/Production · last health-check OK/Failed) + metadata grid
    • Empty state CTA + warning banner when configs exist but none active
    • Modals: Create / Edit / Rotate Credentials / Plan Mappings (3 self-serve plans)
    • Inline actions: Activate↔Deactivate, Health-check, Edit, Rotate, Plan Mappings, Delete (ConfirmDialog danger variant)
    • SecretField wrapper with show/hide eye toggle on apiKey + webhookSecret
  • Hook useSuperadminBillingConfig (9 methods: 2 queries + 7 mutations, invalidates list + detail on writes).
  • 52 i18n keys per locale (en + nb) under superadmin.settings.{menu, tabSubtitle, subscription, billing}.

Tests:

  • Domain spec 13 cases (encrypt round-trip, defaults, empty creds throw, update patches, rotate replaces, activate/deactivate idempotency, recordHealthCheck overwrite, snapshot+rehydrate).
  • Service spec 12 cases (create + duplicate guard + test/prod coexist, findById not found, singleton activation flipping, rotate, plan mappings replace + clear, resolveActiveVariantId, getActiveWithCredentials).
  • Controller spec 8 cases (list/get DTO map, create delegation, rotate body pass-through, healthCheck OK/FAILED/not-registered, replaceMappings delegate).
  • Adapter spec +3 graceful-degrade cases (createCheckoutSession + parseWebhook throw BILLING_PROVIDER_NOT_AVAILABLE; healthCheck swallows error returns {ok:false}).
  • Subscription jest: 96/96 pass. Full booking-api: 2049/2051 (2 skipped, 0 failed). Web vitest 170/170 unchanged.

Deferred:

  • Customer subscription checkout UI (will consume the 503 → graceful banner) — wait until S3 lands
  • PlatformBillingAuditLog table (mirror ImpersonationAuditLog) — defer until compliance asks
  • Public availability endpoint GET /public/billing/status — defer until concrete consumer exists

Phase S3 — Webhook controller + idempotency ✅ shipped 2026-05-27

  • POST /webhooks/subscription/:provider controller (interface/webhooks/subscription-webhook.controller.ts) — @Public(), @HttpCode(200), @Throttle({ ttl: 60_000, limit: 60 }) per-IP, case-insensitive provider param, 1MB body cap.
  • WebhookInbox persistence — unique (provider, providerEventId), Prisma insertIfAbsent swallows P2002 → inserted=false for retry deliveries.
  • Idempotent guard: dedup signal via UNIQUE constraint (controller returns deduped=true on collision). processedAt left null for S4 worker to flip after dispatch.
  • Signature verify trước khi insert Inbox (invalid → WebhookSignatureInvalidError → 401 via SubscriptionDomainErrorFilter; row NOT written so junk traffic doesn't grow the table).
  • Domain-ignored events (LS subscription_paused/unpaused/payment_refunded) — row still recorded with eventType + null tenantId, controller returns ignored=true, no downstream dispatch.
  • Port refactor: BillingProviderPort.parseWebhook returns ParsedWebhook { providerEventId, eventName, payload, event } instead of DomainSubscriptionEvent | null. Adapter throws on signature/payload errors; event=null signals ignored-by-domain.
  • Unit tests +23: repo 8 cases, ingest service 6 cases, controller 7 cases, adapter +1 (meta.event_name guard), registry mock updated. Full Jest: 2072 pass / 2 skipped (was 2049 pre-S3).
  • E2E test: double-fire same event → 1 SubscriptionEvent row (deferred — needs live LS sandbox + S4 aggregate; tracked under S9)

Phase S4 — Domain aggregate + SyncFromWebhook handler 🚧 core shipped 2026-05-27, ancillary commands deferred

  • domain/subscription.ts — aggregate với 7 apply* methods + 2 factories (startTrial, createdFromCheckout) + rehydrate. Tenant-match guard + status transition guard on every apply.
  • domain/subscription-status-transitions.ts — pure transition matrix. EXPIRED terminal, self-loops everywhere (idempotent replay).
  • domain/value-objects/subscription-id.ts — UUID v4 VO.
  • domain/errors.ts — 4 new errors: SUBSCRIPTION_NOT_FOUND (404), SUBSCRIPTION_INVALID_STATE_TRANSITION (409), SUBSCRIPTION_TENANT_MISMATCH (409), SUBSCRIPTION_ALREADY_EXISTS (409). Filter mapping wired.
  • application/ports/subscription-repository.port.ts + infrastructure/persistence/prisma-subscription.repository.ts — 4 methods (save upsert, findById, findByTenantId latest, findByProviderSubId).
  • application/ports/tenant-billing-read-model.port.ts + Prisma impl — sync 4 denorm fields on Tenant row (subscriptionStatus, trialEndsAt, currentPlanKey, seatLimit).
  • application/commands/sync-from-webhook.handler.ts — main S4 dispatcher. Resolution order (by providerSubId → by tenantId → materialise on subscription.created); writes Subscription + Tenant read model. Throws on tenant mismatch / providerSubId collision; non-created event with no aggregate → log warn + return empty (S8 sweep will replay).
  • S3 → S4 wiring: SubscriptionWebhookIngestService now dispatches synchronously to handler, marks inbox processed on success, marks failed + rethrows on handler crash.
  • Command handlers: StartTrial, CreateCheckout, Cancel, SyncSeats, Reactivate — deferred to S5/S6 (need TenantOnboarded listener + admin UI surfaces).
  • Query handlers: GetCurrentPlan, GetPortalUrl — deferred to S6 admin UI.
  • application/policies/dunning-policy.ts — deferred to S5 BillingGuard.
  • Domain events → cross-context listener (Tenant read model sync currently via direct port call; switch to event-listener pattern when more subscribers appear).
  • Unit tests: 38 new (transition matrix 5, aggregate 14, repository 6, handler 8, ingest service refactored 4 + 2 new). Full Jest: 2110 pass / 2 skipped (was 2072 post-S3).

Phase S5 — Billing guard + seat limit

  • BillingGuard NestJS — áp vào mọi mutation route admin (after OwnerGuard)
  • Whitelist: /me/subscription, /subscription/checkout, /subscription/portal, /webhooks/*, /auth/logout
  • SeatLimitPolicy.assertCanAddSeat(tenantId) — call từ StaffInvitationService.create + ResourceService.createWithUser
  • OnResourceCreatedListenerSyncSeatsCommand push provider
  • Public booking guard: POST /public/tenants/:slug/bookings → 503 nếu tenant EXPIRED
  • Read-only mode cho EXPIRED tenant: dashboard load OK, mutations 403
  • E2E test: seed tenants per state, assert guard behavior

Phase S6 — Admin UI

  • /admin/billing page — current plan card, seat usage, next invoice, [Manage billing] button
  • /admin/billing/upgrade — plan picker (Solo / Pro Monthly / Pro Yearly), seat quantity input, total preview
  • Trial banner (admin layout global) — countdown + [Upgrade] CTA
  • Past-due banner (amber) — [Update card] → portal
  • Expired wall — auto-redirect mọi route → /admin/billing với resubscribe CTA
  • Seat-limit modal — SeatLimitReachedModal triggered từ Invite Staff khi blocked
  • Customer-facing salon unavailable page — /b/<slug> 503 → "Salon temporarily unavailable" (no billing mention)
  • Super-admin: /admin/superadmin/tenants/:id/billing tab — list Subscription history, override (manual extend trial, gift comp)

Phase S7 — Email templates

  • Welcome trial (en + nb)
  • Trial ending — day 11 + 13 + 14 (en + nb)
  • Subscription activated (post-checkout) (en + nb)
  • Payment failed — attempt 1/2/3 (en + nb)
  • Payment recovered (en + nb)
  • Subscription canceled (cancel-at-period-end) (en + nb)
  • Subscription expired (en + nb)
  • Reactivation welcome back (en + nb)
  • Wire qua BrandedEmailProcessor

Phase S8 — Cron jobs + observability

  • subscription-trial-reminder.cron.ts — daily 09:00 UTC
  • subscription-dunning-escalate.cron.ts — hourly, flip soft → hard
  • subscription-period-end-sweep.cron.ts — hourly, fallback nếu webhook miss
  • subscription-seat-drift-check.cron.ts — daily 04:00 UTC, alert nếu lệch
  • subscription-data-retention-sweep.cron.ts — daily 03:00 UTC, hard-delete EXPIRED >90d
  • Metrics: webhook received/verified counters, state transition counters, MRR by plan gauge
  • Alerts: webhook verify rate <99% / 5min, past-due stuck >21d, seat drift >5 tenants

Phase S9 — E2E + load test

  • Playwright: trial banner countdown, upgrade checkout (LS test mode), seat-limit modal, expired wall
  • Webhook idempotency load test: 1000 concurrent same eventId → 1 row
  • Reconciliation script: SUM(Subscription.amount) by month vs LS dashboard

Phase V2 (post-MVP) — Stripe Billing adapter

  • Implement StripeAdapter — same BillingProviderPort interface
  • Map plan catalog: pro_monthly_per_seat → Stripe price_xxx
  • Test Stripe webhook → normalized event đúng
  • Feature flag BILLING_DEFAULT_PROVIDER — tenant mới onboard sang Stripe
  • Migration UI: banner cho LS tenants, "Move to Stripe for better rates"
  • Email campaign dunning-style 30/14/7-day reminder
  • Reconciliation report: LS vs Stripe MRR breakdown

Phase V3 (later) — Usage-based add-ons

  • SMS overage billing (mỗi message > quota)
  • Email overage
  • Loyalty enabled feature flag pricing
  • API access pricing (khi expose public API)

Epic 13: Plans CMS 🚧 PC1 + PC2 + PC3 + PC4 + PC5 shipped, PC6 + PC7 pending

Move plan catalog từ TS constants → DB + public pricing page. Tách phần data (plans + features) khỏi code để pricing tweaks / marketing experiments / feature toggles không cần redeploy. BẮT BUỘC đọc trước khi code: docs/plans/plans-cms-plan.md (full schema + phase breakdown + decision log).

Phase PC1 — Schema + seeder ✅ shipped 2026-05-27

  • Migration 20260527034239_add_plans_cms: 3 model — Plan (planKey UNIQUE + bilingual + flat XOR per-seat + provider variant placeholders), PlanFeature (master catalog + category + isRoadmap + bilingual), PlanFeatureOnPlan junction (boolean = row presence; numeric = stringified value).
  • Seeder core/plans/plans-seeder.service.ts — boot-time upsert với update:{} (admin edits sticky). 4 plans (Solo / Pro Monthly / Pro Yearly / Enterprise) + 27 features grouped 8 categories. Audit shipped vs roadmap theo memory 2026-05-20: 19 shipped + 8 roadmap.

Phase PC2 — PlansService (read + CRUD) ✅ shipped 2026-05-27 (read) + 2026-05-28 (CRUD)

  • PlansService.listPublic() — returns {plans, featureCatalog} shape. Plans filtered isPublic=true, ordered by sortOrder. Feature catalog grouped by category for the comparison matrix.
  • Admin CRUD methods (listAdmin with subscriber-count groupBy, getAdmin, listAdminFeatures, create, update, replaceFeatures atomic via $transaction, remove with subscriber guard).

Phase PC3 — Super-admin UI ✅ shipped 2026-05-28

  • /admin/superadmin/plans list page (table with name, planKey, price formatted Intl, visibility badge, subscriber count, sortOrder, actions). Sort by sortOrder asc.
  • Plan edit modal — Basics tab (planKey immutable on edit, bilingual name + tagline, billing cycle, currency, trial days, pricing radio flat/per_seat/custom, visibility toggles, sortOrder, collapsible provider variant IDs) + Features tab (grouped by category, checkboxes, roadmap badge).
  • CRUD API endpoints — 7 routes under /superadmin/plans (list, get, features, create, update, replaceFeatures PUT, delete with 409 subscriber guard).
  • DTOs với class-validator (Matches regex cho planKey, ArrayUnique cho feature membership, full ApiProperty cho OpenAPI typegen).
  • Sidebar entry superadminPlans + nb/en i18n (32 keys/locale).
  • 8 new unit tests (CRUD validation paths, subscriber guard, feature key validation).
  • Drag-drop sortOrder reordering — deferred (current: edit sortOrder field manually).
  • i18n NOT NULL DB constraints (currently service-level required) — deferred.

Phase PC4 — Public API ✅ shipped 2026-05-27

  • GET /public/plans controller @Public() no-auth, Cache-Control: public, max-age=300, stale-while-revalidate=60.
  • Full Swagger DTOs (PublicPlansResponseDto + nested) → openapi-typescript clean union types cho FE.
  • Revalidate-on-write từ PC3 admin save — defer cùng PC3.

Phase PC5 — Public /pricing page ✅ shipped 2026-05-27

  • Rewrite booking-web/src/app/(customer)/(info)/pricing/page.tsx — bỏ CMS content text, build server component mới với SSR direct fetch.
  • Plan card grid (1/2/4 cols responsive) + hero header + comparison matrix grouped by category.
  • Roadmap dimming: ✓ shipped (emerald), — roadmap (amber Minus + "Snart" badge), — not included (gray dash).
  • Bilingual i18n: 32 keys per locale gồm category labels, CTA strings, trial plural, badge text.
  • Disabled CTA cho đến khi S6 admin upgrade flow ship — title tooltip giải thích.
  • Graceful degrade: API down → InfoComingSoon fallback (không crash customer surface).

Phase PC6 — LS adapter refactor (pending)

  • LemonSqueezyAdapter.createCheckoutSession read Plan.lsVariantId từ DB thay vì env-based mapping.
  • Delete plan-mapping.config.ts + LEMONSQUEEZY_VARIANT_* env entries.
  • Webhook event-mapper fall back DB lookup khi meta.custom_data.planKey absent.

Phase PC7 — Tests (pending)

  • Service spec 3 cases + seeder spec 3 cases shipped với PC1+PC2.
  • Playwright /pricing snapshot test (visual regression + i18n switch).
  • Integration test seeder vs fresh DB.

Quyết định kiến trúc

  • Tenant = Location — 1 tenant = 1 salon. Multi-location via Organization layer planned.
  • Resource abstraction — Core dùng "Resource", beauty layer map sang "Staff".
  • Schedule — Recurring weekly (ResourceSchedule) + date overrides (ResourceScheduleOverride) + time-off (ResourceTimeOff).
  • Timezone — UTC trong DB, salon tz trong TenantSettings, Intl.DateTimeFormat cho conversion. Display snapshot trên booking để bảo vệ historic data.
  • 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 trên User + Customer. JWT chứa v. Guards compare v vs DB. Increment khi đổi/reset password → invalidates all sessions. Handle DB reset (user not found → 401).
  • Auth separation — Admin guard reject customer tokens (type claim check). Separate refresh endpoints + 401 redirect paths (admin → /admin/signin, customer → /account/login). Public API paths (/public/*) bypass auth.
  • Booking guest vs auth — Guest: contact info snapshot on booking only, no customer record created. Auth: customerId từ JWT + contact snapshot. No auto-merge.
  • TenantCustomer — Bridge table (customer × tenant) cho per-salon visit stats. Auto-created khi booking.
  • Loyalty — Visit-based (stamp cards với cycles) + points-based (ledger). Auto trên COMPLETED. L1-L3 done, L4-L6 pending.
  • Payment — DDD + Hexagonal + CQRS, dual outbox + webhook inbox. Bambora Classic adapter live, Worldline Direct kept cho enterprise migration.
  • Map — pigeon-maps (free), geocoding via Nominatim (OpenStreetMap).
  • Editor — Tiptap rich text, sanitize-html ở API.

Roadmap

Ngắn hạn

  • Role-based access control audit — Phase 1-5 shipped 2026-04-23
  • Payment tracking — Bambora Classic live, admin payment list + refund/void/capture
  • Loyalty L4-L6 — lifecycle listeners + customer redemption UI
  • SMS booking confirmation — Norwegian templates (template đã có cho payment lifecycle, cần wire booking)
  • Production SMTP vendor — defer p1-4-smtp-vendor (hiện LogEmailProvider default)

Trung hạn (1-2 tháng)

  • Mobile app — React Native Expo, owner + staff screens (role audit unblocked)
  • 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 (mobile)
  • Offline sync — WatermelonDB cho mobile
  • Staff attendance — check-in/out, timesheet
  • Reconciliation cron (P2-13) — daily provider sync

Dài hạn (3-6 tháng)

  • Stripe / Vipps / Nets adapters — drop-in via PaymentProviderPort
  • 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 (terminal)
  • Multi-industry expansion — fitness, clinic, spa
  • RLS — PostgreSQL Row Level Security (Phase 8+)
  • Product sales — retail inventory, attach to booking invoice
  • Style gallery — photo portfolio cho public page

Superadmin — System Management

  • Superadmin dashboard Phase 1 (2026-04-28) — overview cards (total/active tenants, users, 30-day bookings, 30-day revenue), tenants table with 30-day per-tenant metrics, recent activity feed (booking/payment/tenant created)
  • Tenant management UI — search, filter, suspend/activate tenants (endpoints POST/PATCH /tenants already exist, UI pending)
  • Login as tenant (impersonate) — debug/support với audit log
  • Per-tenant drill-down — revenue chart by month, booking trend, user list, payment provider config status
  • Static page editor — manage content cho About, Privacy, Terms, Help, Pricing (rich text, per-language)
  • System settings — default configs cho 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 hoặc globally
  • Email templates — manage notification templates (booking confirm, reminder, ...)
  • Analytics & reports — platform-wide metrics (total bookings, active tenants, revenue)