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: xemgaps-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 / Darkon a settings strip below the Back/Skip/Next nav row, separated by a hairline divider, right-aligned. Reuses customer-footerFooterLocaleSwitcher/FooterThemeSwitcherso 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. SchemaTenantSettings.temporaryClose { enabled, until, message, startedAt }+ backfill migration20260515021622. 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 guardPOST /public/tenants/:slug/bookingsreject 409TENANT_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-localmin=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 onStaffFormModal(separate from theisActivepause toggle). Always soft-deletes: stampsdeletedAt, clearsuserId(frees@uniqueso the User can be re-linked later), flipsisActive=false. Child rows (ResourceSkill/Schedule/ScheduleOverride/TimeOff/PortfolioItem) stay queryable for audit. Seedocs/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+ migration20260514033919), Phase 2 (admin APIPOST/list/resend/revoke+ publicverify/accept+ 29/29 tests + rate limit + tenant isolation + orphan-User re-invite recovery), Phase 3 (InviteStaffModal+StaffListsplit CTA Invite vs Add Resource + Pending badge + password-freeStaffFormModal+ 3 regression tests), Phase 4 (/admin/accept-invitepage + identity-swap warning banner +httpOnlycookie set quasetAuthCookiesshared helper), Phase 5 (i18nacceptInvite+inviteStaff+pendingInvitenamespaces en/nb + 9INVITATION_*error codes). Side fixes:HttpExceptionFiltermap 429 →RATE_LIMIT_EXCEEDED,ResourceService.findById/findAllByTenant*eager-loaduser.staffInvitations[0]để render Pending state không cần extra fetch. Tests api 1882/1884, web 170/170. Remaining roadmap: Phase 6 (xoá hẳnResource.passwordfield + 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 whileBookingItemsnapshots keep rendering paid history. New "Delete permanently" button onServiceFormModal(the previous tenant UI was missing — only theisActivetoggle existed). Seedocs/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), rowitems-start. - Description textarea + giới hạn 200 từ (2026-06-08) — field Description trên
ServiceFormModalđổi từ<input>sang textarea (TextAreaFieldmới, react-hook-formuseController) với bộ đếm từ trực tiếpn/200 words(đỏ khi vượt) + Zod.refine(countWords <= 200, 'maxWords'). HelpercountWords()ởlib/utils.ts. i18nvalidation.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 bybookingUiVersion(V1 single-page giữ nguyên). Shipped 2026-05-29. - Booking draft / shareable cart (
?sessionId=, 2026-05-29) —BookingDraftserver-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 precedencesessionId > 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" quagetStepOrder), mọi item mặc định "Any available" (resourceId=null). Chỉ hợp lệ cùngbookingMode: allow_unassigned(guardvalidateSettingsCombination+ zod superRefine +TENANT_SETTINGS_STAFF_SELECTION_REQUIRES_UNASSIGNED). Defaulttrue. Backfill migration20260602041500(strict parser REQUIRED_KEY). Settings toggle khoá ON + auto-bật khi đổi sangassigned_only. Enforce cả API + UI. Xembooking-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ùngExpandableText, đo overflow thật quaResizeObserver). Thumbnail + check căn trên đầu (items-start+mt-1) cho card nhiều dòng. i18npublicBooking.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ùngImageHoverPreview, thêm propclassName). Cùng pattern với salon landing.
- V2 Stepper (industry-standard 4-step) —
- Available time slots (schedule + overrides + time-off + conflicts)
- Owner/Staff tạo booking thủ công (admin)
- Walk-in handling
- Unassigned booking (allow_unassigned mode)
- Status management (PENDING → CONFIRMED → ARRIVED → IN_PROGRESS → COMPLETED, admin free transition)
- OWNER/ADMIN force override — cancel-window + deposit-required guards bypassed with audit reason (status-matrix P0-1 + P0-2)
- Walk-in
paymentMode: IN_PERSON— Payment listener skips PSP init for walk-ins (status-matrix P0-3) - Customer self-cancel —
POST /public/tenants/:slug/bookings/:id/cancel+ CTA on customeraccountportal with ConfirmDialog (status-matrix P1-1) - Out-of-window cancel dialog —
OutOfWindowDialogreplaces red toast with policy explanation + booking reference card + salon page link (status-matrix P1-2) - Deposit-status projection —
OnPaymentStateProjectionListenerkeepsBooking.depositStatusin 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
OnPaymentNotificationListenerwith 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 aCancelPreviewDialogthat shows refund / void / forfeit / no-action amount before the cancel mutation fires (status-matrix P1-8) - Phone-booking
paymentMode=IN_PERSONderivation —source=PHONEin admin create flow tellsOnBookingCreatedto skip PSP init (status-matrix P1-3) - Bambora 7-day auth-hold safety net — settings hard cap (
depositEnabled⇒maxBookingDaysInAdvance ≤ 7) + auto-cancel CONFIRMED onPaymentExpiredwithAUTHORIZATION_EXPIREDaudit reason (status-matrix P1-11) - Payment retry on failure —
PaymentFailureKind(TRANSIENT vs PERMANENT) onPaymentFailedPayload; PERMANENT counted toward 3-attempt cap before auto-cancelPAYMENT_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 newOnPaymentFailedRetryNotificationListener+ pluggableEmailProviderport (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 + JSONBserviceSnapshot/resourceSnapshotfor 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. Seedocs/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 upsertingTenantCustomer.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 viacustomerName/customerPhone/customerEmailsnapshots. UI: "Delete" button inCustomerFormModaledit mode + ConfirmDialog with bold-name copy. Global GDPR right-to-be-forgotten is a separate concern (customer self-service / platform admin) — deferred. Seedocs/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_inboxON CONFLICT DO NOTHING → BullMQpayment-webhookjob → ProcessWebhookInboxService applies transition - BullMQ root config + BullBoard at AppModule; modules register queues without duplicating config
-
rawBody: truein main.ts for webhook RawBodyRequest
Phase 6 Track A — DONE (branch feat/payment-foundation, +4 commits, 848 tests):
- Shared primitives moved to neutral
src/shared/(events, clock, ids) so Booking consumes without depending on Payment - Prisma migration
add_outbox_last_attempt_at—last_attempt_at+ composite index for backoff-aware polling -
OutboxRepositoryPortextended —findById,listStuck(beforeAt, limit),markFailed(id, error, attemptedAt),deletePublishedOlderThan(cutoff),appendreturns generated UUID v7 IDs for enqueue-after-commit -
OutboxModule— BullMQoutbox-publisherqueue with hybrid delivery: hot-pathOutboxQueue.enqueuePublish(id)after dual-write commits, repeatable janitor (30s) scans stuck rows and re-enqueues, repeatable cleanup (1h) enforces 30-day retention, dead-letter at 10 attempts -
OutboxPublisherService— idempotent re-hydration (eventId = outbox.id), publishes toEventBus, marks published/failed -
OutboxPublisherProcessor— BullMQ WorkerHost dispatches by job name (publish/janitor/cleanup) - Prometheus metrics —
payment_outbox_published_total{event_type,tenant_id},_retries_total,_dead_letter_totalcounters +payment_outbox_unpublishedgauge; registry injectable for test isolation - BullBoard registers
outbox-publisherqueue at/api/queues - Resilience —
queue.on('error')+@OnWorkerEvent('error')on both queues & processors (Outbox + Payment Webhook) prevent Node process crashes on Redis flap;OnModuleDestroycloses queue for clean SIGTERM shutdown
Phase 6 Track B — DONE (branch feat/payment-foundation, +10 commits, 900 unit tests + 5 e2e):
- Booking event catalog at
core/booking/domain/events/booking-events.ts(source of truth). Payment context imports from here; the old duplicate inpayment/application/integration/removed - Payload builders
buildBookingCreatedPayload / Confirmed / Cancelled / NoShow / Completed— pure functions with 16 tests covering deposit rounding (percentage + fixed), clamping to total, customer snapshot, ISO serialization - Prisma migration
add_booking_idempotency_flags—processed_for_tenant_customer+processed_for_loyaltyBOOLEAN DEFAULT false (safe ADD COLUMN for existing rows) -
BookingServicerefactor —create() / updateStatus() / walkIn()wrapped inprisma.$transaction: booking row +domainEventOutbox.createManycommit atomically; enqueue publish job after commit (janitor re-enqueues on Redis failure);Clockport injected for testableoccurredAttimestamps -
updateStatus()emits exactly one event per terminal transition (Confirmed/Cancelled/NoShow/Completed); IN_PROGRESS/PENDING/ARRIVED intentionally no-emit;cancelledByresolved fromperformer.role(CUSTOMER vs SALON);cancellationWindowHoursfrom tenant settings - Inline
tenantCustomerService.onBookingCompleted+loyaltyService.autoStamp/autoEarnPointscalls removed from BookingService; LoyaltyService injection dropped from BookingModule -
OnBookingCompletedTenantCustomerListener(tenant-customer module) — CAS claim-first + rollback-on-failure idempotency viaprocessedForTenantCustomer; Prisma upsert + guarded updateMany forlastVisitto avoid race with Loyalty listener's upsert -
OnBookingCompletedLoyaltyListener(loyalty module) — resolves tenantCustomerId via upsert, calls LoyaltyService.autoStamp + autoEarnPoints, same CAS pattern viaprocessedForLoyalty - URL convention for BookingCreated payload:
returnUrl = {PUBLIC_WEB_URL}/b/{slug}/bookings/{id},cancelUrl = {PUBLIC_WEB_URL}/b/{slug},webhookUrl = {API_BASE_URL}/api/webhooks/payments(env vars via ConfigService; Payment adapters append provider+tenantId) - Lint cleanup — 11 errors + 63 warnings on branch reduced to 0/0 (e2e test typing, Prisma proper types, typed Request for auth decorators/guards, ms.StringValue for JWT expiresIn)
- E2E test
test/booking-outbox.e2e-spec.ts(5 cases) — BookingCreated outbox row shape + URLs, publishOne marks published, Completed triggers TC+Loyalty DB effects, idempotent re-publish, guest booking leaves flags false - E2E suite back to green (2026-04-18) — fix 12 preexisting failures (customer-auth 8 + public-booking 4). Customer-auth helper includes
v: tokenVersionfor guard check; refresh test assertsSet-Cookieheaders (HttpOnly, no body tokens). Public-booking tests realigned to the post-1ec327cguest flow (snapshot on booking, no Customer auto-create, no phone dedupe for guests). 900 unit + 57 e2e all green. - Authorization expiry cron (2026-04-18) — repeatable BullMQ job
payment-expiry:sweepevery 15 min scansPaymentrows wherestatus ∈ {INITIATED, AUTHORIZED} AND expiresAt ≤ now(). For AUTHORIZED + txnId +supportsVoid: best-effortprovider.voidwith reasonAUTHORIZATION_EXPIREDand per-payment idempotency key; failure is logged + counted but does NOT block the domain transition. Always callspayment.markExpired(now)→ outbox event → listeners. Per-payment errors isolated (one failure doesn't abort batch); idempotent becausefindExpirablefilters non-terminal. Metrics:payment_expiry_swept_total{provider_key,from_status},payment_expiry_void_failed_total{provider_key},payment_expiry_skipped_total{reason}. Registered on BullBoard. NewfindExpirable(asOf, limit)port method is cross-tenant (system-level exception — documented). 26 new tests (9 service + 6 queue + 3 processor + 5 metrics + 2 repo + 1 module wiring), 926/926 green.
Phase 7 Track D1 — Admin UI: Provider Config (IN PROGRESS 2026-04-18):
- Backend
PaymentConfigDtoconverted interface → class with@ApiProperty+@ApiOkResponseon every controller endpoint → OpenAPI response schema no longer empty,api.generated.tsnow has full type - Frontend test infra: Vitest + @testing-library/react + jsdom (scripts
test,test:watch); Playwright 1.59 + chromium (scriptstest:e2e,test:e2e:integration,test:e2e:ui); QueryClient test wrapper helper - Provider split — Bambora Classic vs Worldline Direct (2026-04-18). Discovered that the initial "bambora/" adapter was actually coded against Worldline Direct API (preprod.worldline-solutions.com), while Norwegian SMB merchants sign up for Bambora Europe Checkout (Classic) — the two products are distinct, and Worldline doesn't onboard directly in NO (that's why they acquired Bambora in 2020). Split cleanly: renamed existing code to
providers/worldline-direct/+ ProviderKey.WORLDLINE (kept for future enterprise migration, disabled in UI), newproviders/bambora/adapter implements Bambora Europe Checkout (Classic) with Basic-auth(access:secret), MD5 callback signatures, 4-URL endpoint map (transaction/merchant/checkout-api/login). Prisma enum + domain enum gain WORLDLINE (additive migration). - Bambora Classic adapter (~1640 LOC + 72 tests): credentials parse (merchantNumber T/P + accessToken + secretToken + md5Key, derives isTest from prefix), endpoints, MD5 callback hash (compute + verify, constant-time, case-insensitive), HTTP client (copied from worldline-direct — lift to shared in Phase 5+), error mapper (meta.result=false treated as failure, insufficient-funds codes mapped), transaction-shape mapper, full 7-method PaymentProviderPort: createSession (POST /checkout) / capture / void (/delete) / refund (/credit) / fetchStatus / verifyWebhook / healthCheck (GET /merchant/functionpermissionsandfeatures)
- Frontend Zod schema rewritten: 4 fields (merchantNumber optional + 3 required secrets: accessToken, secretToken, md5Key).
deriveBamboraCredentialsderives isTest from T/P prefix (defaults true when absent). No Test-mode toggle anywhere in the UI — single source of truth is the merchant number. - API client hooks in
usePaymentConfigs.ts— list + create + update + rotate + activate + deactivate + health-check; all invalidate the sharedpayment-configsquery key; mutations pipe throughuseFormMutation(toast + error-code translation) - Components in
components/settings/payment/:ProviderCard(3 status states + disabled "Coming soon" for Vipps + Worldline),HealthCheckBadge(OK / FAILED / not-yet with hover timestamp),BamboraConfigForm(FormField + 3 × PasswordField, live MerchantModeBadge flips Test/Produksjon as owner types,Wrapper/Innerdrawer pattern viashowMerchantNumber/showDisplayName),ConnectedProviderActions(Verify/Activate/Deactivate/Rotate),PaymentSettings(grid page) -
FormField+ newPasswordFieldnow emit properhtmlFor/idlabels for a11y +getByLabelTexttest ergonomics; PasswordField has show/hide toggle with localized aria-labels - Settings sidebar tab "Betaling / Payment" wired into
SettingsContentwithCreditCardicon, URL?tab=payment; i18n keys undersettings.payment.*(nb.json + en.json) covering providers, status, health check, actions, Bambora form fields (merchantNumber/accessToken/secretToken/md5Key + test/produksjon labels), messages - Provider selector: Bambora (enabled); Vipps MobilePay + Worldline (enterprise) shown as "Kommer snart" / "Coming soon" with disabled CTA. Stripe/Nets/Adyen kept in provider enum but not surfaced in UI
- "Delete config" intentionally omitted (phase D1 scope) — Deactivate covers pausing, matches backend which has no DELETE endpoint
- E2E:
e2e/payment-settings.spec.ts(3 smoke cases — cards render, Vipps disabled, drawer opens);e2e/payment-settings-integration.spec.ts(@integration — real Bambora sandbox health check, auto-skip whenE2E_BAMBORA_{ACCESS_TOKEN,SECRET_TOKEN,MD5_KEY}envs missing; optionalE2E_BAMBORA_MERCHANT_NUMBER). Playwright auth fixture logs in as seed OWNER (owner1@gmail.com/123456) - Provider-card + drawer UX polish (2026-04-18) —
ProviderCard: health badge moved right of title and relabeled "Connect failed!" (not "Failed") when the provider rejects creds; Test/Production mode badge now gated behindlastHealthCheckStatus === 'OK'so the owner never sees an unverified mode claim; Verify/Manage/Connect/Coming-soon buttons shrunk to a compact!px-2.5 !py-1.5 !text-xsstyle withh-3.5 w-3.5icons.PaymentSettings.handleBamboraCreaterefactored toasync/mutateAsync— create no longer closes the drawer on success; it awaits health-check first and only closes when OK. On FAILED the drawer swaps to manage mode (configId preserved) so the retry goes throughrotateinstead of 409-ing a secondPOST /payment-configs.handleManageSubmitlikewise waits on health-check before closing when a rotate happened. Activate now auto-fireshealthCheckMutationon success so stale-OK claims after a deactivate/reactivate round-trip can't fool the UI. Drawer chrome (ProviderConfigDrawer,EditConfigDrawer,BamboraConfigForm) restructured toflex max-h-[90vh] flex-col→ body scrolls (flex-1 min-h-0 overflow-y-auto), footer sticks (shrink-0 border-t); Cancel disabled while submitting. Tests updated (ProviderCard +2 cases for mode-badge gating = 17; HealthCheckBadge text change; 58 Vitest green overall). - Tests green: API 999 Jest (was 927, +72 Bambora adapter suite — 12 credentials + 9 signature + 7 errors + 10 http-client + 12 mapper + 15 adapter + 7 retry) · Web 58 Vitest · Playwright 4 tests listed (3 smoke + 1 @integration)
- Bambora Classic webhook → Payment.authorize end-to-end LIVE (2026-04-20) — three sequential fixes unblocked deposit flow after customer completes Bambora test checkout: (1)
PaymentWebhookControllerhad only@Postbut Bambora Classic dispatches callback asGETper docs — added@Get(':provider/:tenantId')reading raw query string fromreq.url(preserves Bambora MD5 signing order), sharedprocess()with POST path; (2) MD5 verify failed on arrival — dev-only forensic log ([Bambora verify mismatch] md5KeyLen=9 md5KeyPrefix=UOc8... charCodes=... provided=... expected=... concat=...) showed the admin-form paste had dropped the trailing char, owner re-entered via Rotate credentials; retainedIncoming GET webhook+Webhook verified/Webhook verify FAILEDstructured logs for future triage; (3)ProcessWebhookInboxServicewas Worldline-shaped (payload.payment.id,payment.authorizedeventType) and treated Bambora's flat{txnid, orderid}as "unhandled" — fixed by: Bamboraadapter.createSession.providerSessionId = toBamboraOrderNumber(paymentId)(merchant order reference = what Bambora echoes asorderidon callback, enablesfindByProviderSessionId(orderid)without extra API call; Bambora session token stays inredirectUrl, not needed for capture/void/refund/status which all use txnid), Bamboraadapter.verifyWebhook.eventType = 'payment.authorized'default (docs: callback only fires on successful auth), processor extractsproviderTransactionId = payload.payment?.id ?? payload.txnidand falls back tofindByProviderSessionId(orderid)whenfindByProviderRef(txnid)misses,transitionAggregatefor authorize safely picks txnid from either shape. Verified live: callback arrives → MD5 passes → Payment found by orderid →Payment.authorize(txnid)→PaymentAuthorizedevent →OnPaymentAuthorizedListenerflips booking PENDING → CONFIRMED. Return page polling sees AUTHORIZED → "Deposit secured" card. Backward-compat note: Payments created before this commit stored Bambora session token asproviderSessionId(not orderid), their pending callback retries won't match viafindByProviderSessionId— only new bookings are fixed. 505/507 payment tests green (2 skipped, pre-existing). - Spec conformance + public provider branding (2026-04-19) — adapter alignment with Worldline Online Checkout v1 docs (cached in Obsidian
Bambora/vault): endpoint/checkout→/sessions,url.decline→url.cancel, removedurl.immediateredirecttoaccept: false(field isintegerseconds, boolean caused40400/50000 Serialization error: 'false' cannot be parsed as Int32),languagerelocated from top-level →paymentwindow.language,order.ordernumber→order.id. PublicGET /public/tenants/:sluggainssettings.paymentProvider(BAMBORA | null) from the first activePaymentConfig;PublicBookingModuleimportsPaymentModule. BookingPage CTA branded: "Pay with Bambora · 500 kr" + "Secured by Bambora" footer (ShieldCheck icon) viaprovider-metadata.ts— future Vipps/Stripe is a one-line metadata enable. Hydration fix:initialDate = todayInZone(tenant.settings.timezone)computed on the server component and threaded down as prop toBookingPage+DateStrip(previously each callednew Date()client-side, racing SSR around midnight). Public cache:fetchPublicflipped fromnext.revalidate: 60→cache: 'no-store'— tenant settings / services / availability are live edit targets, 60s ISR window produced SSR/hydrate payload drift. Polling safety:pollForCheckoutUrlnow exits onstatus ∈ {FAILED, EXPIRED}throwingPaymentCheckoutFailedErrorinstead of spamming the poll endpoint until 15s timeout; BookingPage surfacesbook.errorPaymentFailed(nb + en). Redirect race:redirectToCheckoutreplacedthrow new Error('Redirect did not happen')withnew Promise<never>(() => {})— avoids red error flash during navigation. 14/14 adapter + 25/25 public-booking controller specs green. External blocker surfaced: Bambora test merchant returned40401 currency not supportedfor NOK — owner must enable NOK in the Bambora backoffice or switch tenant currency to DKK/EUR for testing.
Phase 7+ — Planned:
- Track C — Public deposit flow (IN PROGRESS). Authorize-on-book, capture-on-complete.
- C1 — Public booking ↔ Payment plumbing (2026-04-18).
InitiatePaymentHandlerpersistscheckoutUrlinPayment.metadata(previously transient).resolveInitialStatus(settings, { depositRequired })forces PENDING when deposit > 0 regardless ofautoConfirm— staff never see a confirmed-but-unpaid booking.computeDepositAmount()extracted intobooking-settings.helperso BookingService (status) andbuildBookingCreatedPayload(event) share one implementation. NewGET /public/tenants/:slug/bookings/:bookingId/paymentreturns{ status, checkoutUrl, amount, ... }or null; FE polls until the asynconBookingCreated → InitiatePaymentCommandlistener lands.POST /public/tenants/:slug/bookingsresponse gainsrequiresPayment+paymentPollUrl. 1019 unit (+19) + 59 e2e (+2) green. - C2 — IN PROGRESS. Webhook
POST /api/webhooks/payments/bambora(MD5 verify) →markAuthorized→ listeners transition booking.- C2 backend (2026-04-18) —
OnPaymentAuthorizedListener(PENDING → CONFIRMED onPaymentAuthorized) +OnPaymentSettledNegativeListener(PENDING → CANCELLED onPaymentFailed/PaymentExpired).bookingIdenriched on the three payment payloads.BookingService.updateStatusgains arole: 'SYSTEM'bypass for the cancellation-window rule so event-driven cancels always fire. Idempotent (skip non-PENDING), race-safe (swallowINVALID_STATUS_TRANSITION), tenant-guarded. 19 new unit tests. Webhook endpoint +ProcessWebhookInboxService→Payment.authorizepipeline was already wired from Phase 5, so only the Booking-side subscribers were missing. - C2 FE (2026-04-18) —
buildBookingUrlsnow points returnUrl at/b/{slug}/bookings/{id}/payment/return(poll-until-status landing) and cancelUrl at/b/{slug}/bookings/{id}/payment/cancelled(user-cancelled landing). Sharedlib/payment/public-payment-api.tsaddsfetchBookingPayment,classifyOutcome(status)(pending/success/failed/cancelled),pollForCheckoutUrl(slug, bookingId, { intervalMs, timeoutMs })(500ms default, 15s cap) andredirectToCheckout(slug, bookingId)wrapper. Two new App Router pages:/b/[slug]/bookings/[id]/payment/return(client-side polling with 2s interval + 30s timeout, tone cards for each outcome) and/b/[slug]/bookings/[id]/payment/cancelled(static "you cancelled" with retry).BookingPage.onSubmitbranches onrequiresPayment: when true it awaitsredirectToCheckout(browser leaves page); onPaymentCheckoutTimeoutErrorit surfaces a friendlyerrorPaymentTimeoutmessage so the customer can retry. i18n keys added underpaymentReturn.*(nb + en parity) + newbook.errorPaymentTimeout. Build clean, vitest 64/64, lint 0/0.
- C2 backend (2026-04-18) —
- C3 (pre-existing from Phase 6, verified 2026-04-18) —
PaymentIntegrationServicealready subscribed to the booking lifecycle:onBookingCompleted→CapturePaymentCommand(MANUAL + AUTHORIZED);onBookingCancelled→decideCancellationRefundpolicy →VoidPaymentCommand/CapturePaymentCommand (forfeit)/RefundPaymentCommand/ no-op based on cancel window +cancelledBy;onBookingNoShow→CapturePaymentCommand(no-show fee default). 10 unit tests inpayment-integration.service.spec.ts. Only the listeners that TRANSITION the booking on payment events (C2.1 + C2.2) were missing. - C4 UI — DONE (2026-04-20)
- C4.3 Customer booking-form deposit preview — public
GET /public/tenants/:slugsurfacesdepositEnabled/depositType/depositValue; newlib/payment/deposit-calc.tsmirrors the backend math (7 vitest). BookingPage shows amber notice + swaps CTA to "Continue to payment · X". i18nbook.{continueToPayment,depositNotice,redirecting}nb + en. - C4.1 Admin booking-drawer payment summary — new
BookingPaymentSummary(Total / Deposit+status badge / Paid / Remaining, failure banner when latest FAILED) powered byuseBookingPayments(bookingId)→GET /admin/payments/by-booking/:bookingId. Sums captured-minus-refunded across retries. FEPaymenttype intypes/payment.tspending OpenAPI regen. i18nbookingPayment.*nb + en. - C4.2 Admin booking-list deposit badge (2026-04-20) —
PaymentRepositoryPortgainsfindLatestStatusByBookingIds(bookingIds, tenantId): Promise<Map<string, PaymentStatus>>(batched, tenant-scoped, returns the most-recent bycreatedAtso a retry-after-FAILED naturally wins).BookingController.findAllimportsPAYMENT_REPOSITORY(viaBookingModule → PaymentModule) and decorates each list item withpaymentStatus: PaymentStatus \| null— one extra query per page, no N+1. SharedPaymentStatusBadgeextracted fromBookingPaymentSummaryintocomponents/payment/and reused on the list table; absent-status renders em-dash. New booking list column "Depositum/Deposit" viabookings.depositi18n key. +5 tests (1 controller merge + 4 Prisma repo: empty array, tenant+IN scope, latest-wins dedupe, null-bookingId skip). Results: 1044 API unit + 57 e2e · 71 web vitest · lint 0/0 · both typechecks clean. - C4.4 Collect-remaining when deposit OFF (2026-04-26) —
BookingPaymentSummarypreviously returned null wheneverpayments.length === 0, so the Collect-remaining CTA never rendered on bookings created withdepositEnabled=false. Now the early-return guard isisLoading \|\| (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.bookingProviderfalls back toactiveConfig.providerwhen no payment row exists yet so the modal routes correctly.
- C4.3 Customer booking-form deposit preview — public
- C1 — Public booking ↔ Payment plumbing (2026-04-18).
- Track L — Loyalty discount applied to booking amount (IN PROGRESS). Gap: customer can redeem stamp/point rewards (FREE_SERVICE / DISCOUNT_AMOUNT / DISCOUNT_PERCENT) but redemption does NOT reduce
booking.totalor Payment.amount — customer still pays full. Phased rollout:- L1 — Data model + backfill (2026-04-21). Prisma migration
add_loyalty_discount_fields: Booking gainsdiscountAmount(Int?) +appliedRedemptionId(String? UNIQUE FK → LoyaltyRedemption ON DELETE SET NULL);LoyaltyRedemptiongains new enumLoyaltyRedemptionStatus(RESERVED | CONSUMED | CANCELLED) +redeemedAt+cancelledAtnullable timestamps + index onstatus. Legacy admin-created redemptions backfilledstatus=CONSUMED(DB default) +redeemed_at = created_at. Unique constraint onapplied_redemption_idso one redemption can only back one booking.LoyaltyService.redeemStampCardnow explicitly setsstatus=CONSUMED, redeemedAt=new Date()on create so the admin-manual path keeps coherent state;redeemPointsdoesn't create LoyaltyRedemption rows (points-burn is viaLoyaltyPointTransactionledger) so no change needed there. No behaviour change, no API contract change, zero new tests — pure data model preparation unblocking L2–L6. - L2 — Discount compute helper (2026-04-21). Pure
computeLoyaltyDiscount(input): LoyaltyDiscountResultatcore/loyalty/compute-loyalty-discount.ts— no Prisma access, caller hydrates input. Returns{discountAmount, freeServiceItemId?, eligibleSubtotal}. Rules: FREE_SERVICE auto-picks the sole eligible item; on multi-eligible requiresselectedServiceItemId(throwsLOYALTY_SERVICE_PICK_REQUIRED) — no auto-pick most-expensive.applicableServiceIdsnarrows the candidate set; a pick outside throwsLOYALTY_PICKED_ITEM_NOT_ELIGIBLE. DISCOUNT_AMOUNT subtracts fixed øre, clamped tomin(eligibleSubtotal, rawTotal). DISCOUNT_PERCENTround(eligibleSubtotal × value / 100)then clamp — accepts >100 (clamps to total, supports premium-tier "120% off" edge cases). WhenapplicableServiceIdsnon-empty the reward discounts only the matching-items subtotal (Stripe/Booksy default). Errors:LOYALTY_NO_ITEMS,LOYALTY_NO_APPLICABLE_ITEMS,LOYALTY_INVALID_REWARD_VALUE,LOYALTY_PICKED_ITEM_NOT_FOUND,LOYALTY_PICKED_ITEM_NOT_ELIGIBLE,LOYALTY_SERVICE_PICK_REQUIRED. +19 unit tests (TDD: spec-first, 19/19 green on GREEN phase). 1089 API unit total. - L3 — DDD layering + reserve on booking create (2026-04-21). Loyalty reorganized into
domain/(pure) +application/(use cases + ports) +infrastructure/(Prisma adapter), mirroring the Payment context.computeLoyaltyDiscountmoved toloyalty/domain/; newredemption-policy.ts(pure guards:assertStampRedeemable,assertPointsRedeemable,pointsToDiscountAmount). Newapplication/loyalty-redemption.service.tsexposespreflight(cmd)(read-only validation + discount compute) +reserveInTx(tx, bookingId, cmd, preflight)(RESERVED row for VISIT_BASED, points ledger REDEEM for POINTS_BASED); persistence hidden behindLOYALTY_REDEMPTION_REPOSITORYport →PrismaLoyaltyRedemptionRepositoryininfrastructure/. BookingService.create pipeline: (1) preflight before tx, (2) computepayableTotal = rawTotal − discountAmount, (3) inside$transaction: booking row withdiscountAmount→reserveInTx→booking.update({ appliedRedemptionId })when VISIT_BASED → outbox row.BookingCreatedPayloadextended with optionaloriginalAmount+discountAmount(backward-compat);totalAmount = payableTotal; deposit % computed on discounted total. DTO addsBookingRedemptionInputDto { cardId, selectedServiceItemIndex?, pointsToRedeem? }—selectedServiceItemIndexis 0-based intoitems[](FE can't know server-side booking-item UUIDs at submit time). Guest bookings withredemptionrejected pre-preflight (LOYALTY_GUEST_NOT_ALLOWED). Docs:docs/flows/loyalty-flow.md— layering rules, sequence diagram, error-code catalog, backward-compat notes. +39 tests (19 policy + 13 application service + 4 payload builder + 3 booking-service integration). 1127 API unit + 59 e2e green (public-booking multi-day-timeoff spec fixed:getNextWeekday(3)produced endDate < startDate when today is Tuesday → replaced withnextTuesday + 1 day; 2 e2e cases were silently passing on non-Tuesdays) · 1 pre-existing e2e flake inpublic-booking.e2e-spec › multi-day time-offdocumented as unrelated (fails on main before L3)**. Booking-outbox e2e updated:captureMode: 'AUTO'→'MANUAL'for DEPOSIT intent (stale assertion since 2026-04-20 deposit hotfix). Weekday-flake fix inpublic-booking.e2e-spec › multi-day time-off: root cause was test helpergetNextWeekday(3)returning Wed tomorrow whilegetNextWeekday(2)returned Tue next week when run on a Tuesday —nextWednow derived fromnextTuesday + 1 dayso the time-off span is always valid (availability service itself was correct). - L4 — Lifecycle listeners. COMPLETED → status=CONSUMED + redeemedAt=now. CANCELLED pre-capture → status=CANCELLED + restore stamps/points. Post-capture → forfeit (no restore), mirrors payment-cancellation policy.
- L5 — Public API + customer UI.
GET /public/tenants/:slug/customer/rewardsauth-gated list, booking payload acceptsredemption, BookingPage "Apply reward" section with preview. Guest bookings blocked from redeem (no TenantCustomer record). - L6 — Admin UX + E2E. Booking drawer + list show discount breakdown, Payment Detail breakdown original / discount / payable.
- L1 — Data model + backfill (2026-04-21). Prisma migration
- 2026-04-27 bundle — Calendar UX + multi-provider Pay + bulk-day schedule + invoice settled bug fix. (1) Calendar grid polish —
TimeColumn+ week-view header time-spacer nowsticky left-0(column stays visible on horizontal scroll);WeekOverviewlost its inneroverflow-auto h-fullwrapper so the date row'ssticky top-0actually 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 atborder-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 aGripVerticalhandle at top-right (negative offset peeking outside, full-opacity solid bg);BookingBlock.onDragStartacceptsmode: 'whole' | 'item'; per-item drag (handle pointer-down) only moves that virtual block. NewparseVirtualIdhelper,computeDragPreview+handleDragEndbuilditems[]payload with only the dragged index modified, recomputeparent.startTime/endTime = min/max(item.startTime + duration). Drag-end flicker fix:await qc.invalidateQueries(...)beforeclearPreview()so preview holds new position until cache refreshes. Virtual blocks now keepitemsarray (wasitems: 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-scheduleopens a multi-slot editor and POSTs/resources/:id/schedulesper (staff × slot) so all staff get the same recurring slot for that weekday. New sharedslot-validation.ts(isSlotsValid,computeSlotErrors,calcSlotMinutes) used by both bulk modal andScheduleCellEditor.TimeFieldgainsplaceholder+errorprops. (5) Availability respects business hours —AvailabilityService.getAvailableSlotsintersects each resource's time ranges withtenant.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 — exposedenabledProviders: ProviderKey[]on tenant settings DTO +PublicBookingDetailDto. New shared<ProviderPayButton>(src/components/payments/ProviderPayButton.tsx) renders one CTA per active PSP usinggetProviderMetaregistry (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 toopacity-40 cursor-not-allowed. Empty state:requiresPayment + enabledProviders=[]shows amber "no payment methods" notice. Backend plumbing:EnsureCheckoutSessionDto.provider,PublicCreateBookingDto.preferredProvider→ metadata →BookingCreated.payload.metadata.preferredProvider→PaymentIntegrationService.onBookingCreatedreads + forwards toInitiatePaymentCommand.provider;InitiateRemainingPaymentCommand.providerforwards to InitiatePaymentCommand.selectConfig(command.provider ?? first-active)keeps single-PSP tenants behaving as before. (7) Invoice "fully settled" bug fix —deriveNextPaymentpreviously returnedNO_PAYMENT_DUEwhenever 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: newrequiresDeposit: booleanfield on PublicBookingDetailDto (computed viacomputeDepositAmount(totalAmount, settings) > 0); helper signature nowderiveNextPayment(status, total, payments, requiresDeposit). Listener-race branch only fires whenrequiresDeposit=true; otherwise falls through to REMAINING_PAYMENT for the full bill. +2 unit tests. (8) Inline schedule-editor validation —ScheduleCellEditoradopted 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 ofexpiresAt; reusing one surfaces "Sesjonen har utløpt"); reusesInitiatePayment(DEPOSIT) /InitiateRemainingPayment(REMAINING_PAYMENT, with newforceNewSession?: booleanflag bypassing the dedup-by-intent + booking-status guards) handlers. NewGET /public/.../bookings/:id/payments(plural) returns full Payment history newest-first viaPaymentRepositoryPort.findAllByBookingIds(batched).PublicBookingDetailDtoaddstotalPaid(sumcapturedAmount − refundedAmountacross settled rows; fixes "Paid: 2 093 kr" mis-render where remaining-INITIATED leaked through) +tenant.logoUrl+payments[].intent. Frontend:InvoiceClientreusesBookingTicket(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 byderiveNextPayment(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 → thinrouter.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 wrapsuseBooking(id)to fetch fresh detail before mounting form (price snapshot accuracy, fixesPAYMENT_REMAINING_AMOUNT_EXCEEDEDfrom list-cache lag); replaces N+1 per-resource time-offs with single/resources/time-offs?from&totenant-wide endpoint. New full-list endpointsGET /services/all+GET /resources/all?statusreplace silent-cap?limit=100callers across BookingDrawer/List/Calendar/StaffFormModal/ServiceFormModal/useSchedule/useDashboard; centralised hooksuseAllServices/useAllResourcescarrytenantIdin queryKey for defense-in-depth. New reusable componentsCustomerSelect(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 batchedfindAllByBookingIds. Tests: +12 API · +8 web vitest (next-payment8 cases,getPaymentsplural endpoint isolation,ensureCheckoutSessionalways-fresh,forceNewSessionbypass,totalPaidaggregation). 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_PAYMENTadded (domain enum + additive Prisma migration). New hexagonalBookingLookupPort+PrismaBookingLookupAdapter(1 query: Booking + items + tenant.settings) so Payment context reads booking summary without reaching into Booking internals. NewInitiateRemainingPaymentCommand + Handler: validates booking status ∈ {ARRIVED, IN_PROGRESS, COMPLETED}, computesremaining = total − Σ captured + Σ refunded(skipping FAILED/VOIDED/EXPIRED Payments since no money moved), clampscommand.amount ≤ remaining(defaults to full when omitted), idempotency-by-intent reuses an unexpired INITIATED REMAINING_PAYMENT row so the owner can close/reopen the QR modal without zombie sessions, then delegates toInitiatePaymentHandlerwithcaptureMode=AUTO. Four new domain errors:PAYMENT_BOOKING_NOT_FOUND,PAYMENT_INVALID_BOOKING_STATE,PAYMENT_NO_REMAINING_AMOUNT,PAYMENT_REMAINING_AMOUNT_EXCEEDED. AdminPOST /admin/payments/remainingendpoint (Roles: OWNER, STAFF). Frontend:qrcode.reactQR (240px),CollectRemainingModal3-step state machine (input → qr → success) driven by derivedstep(React-Compiler-safe, no setState-in-effect),usePayment(id, { pollIntervalMs })with terminal-status auto-stop, booking drawer CTAKrev resterende · X krgated byremaining > 0 + allowed statuses, i18ncollectRemaining.*nb + en parity + 4 new payment error-code translations. +16 API handler tests + 2 controller tests = 18 backend · +7 schema + 2 hook = 9 frontend. 1073 API unit + 57 e2e · 112 web vitest · lint 0/0 · both builds clean. Track L loyalty integration moved to backlog. - Track E1.1 — Manual payment record (cash + standalone terminal) + status-guard removal + email URL fix (2026-05-07). (1) Domain:
PaymentProviderPrisma enum +ProviderKeydomain enum gainMANUAL_CASH+MANUAL_TERMINAL(migration20260507012328_add_manual_payment_providers);isManualProvider()helper;PaymentConfig.createrejects manual providers (no credentials, no per-tenant config). (2) Handler: newRecordManualPaymentCommand + Handlerskips PSP entirely —Payment.initiate(...)with syntheticmanual-${id}providerRef →payment.capture(...)in same domain transaction.metadata.recordedByUserIdaudits the staff who took the money + optionalnote. Same earmarked formula as PSP remaining-payment so cash/QR can co-exist on one booking. Refund flows through existingRefundPaymentHandler(system records, staff returns physical cash outside). (3) Endpoint:POST /admin/payments/manual(OWNER+STAFF+ADMIN, STAFF resource-scoped viaassertStaffOwnsBooking). Body{bookingId, method: 'CASH'|'TERMINAL', amount, note?, idempotencyKey}. (4) Status-guard removal: bothInitiateRemainingPaymentHandlerandRecordManualPaymentHandlerlostALLOWED_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. FEBookingPaymentSummarydroppedCOLLECT_ALLOWED_STATUSES+bookingStatusprop; CTA on wheneverremaining > 0. (5)CollectRemainingModalrewrite — QR view now the default (auto-firesPOST /admin/payments/remainingon mount when PSP active OR seeds fromexistingPaymentfor resume). Cash + Card terminal sit beneath QR as alternative paths labelled "Or record manually"; each opens an inlineManualConfirmPanel(replaces modal body, no stackedConfirmDialogz-index trap).showWaiting={false}passed toPaymentCheckoutViewso the QR slot no longer shows the misleading "Waiting for the customer to pay…" copy. Removed unusedcollect-remaining-schema.ts+ test. (6) Verifying-payment banner fix —BookingConfirmedClient+InvoiceClientreturnOutcomeanchor onbooking.totalPaid >= totalAmountfirst, 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 fix —EmailBookingPayloadBuilder+BrandingResolverwere readingPUBLIC_BASE_URL(never defined) → fallbackhttps://booking.no→ every email link pointed at the wrong domain. Fixed both to readPUBLIC_WEB_URL(the env name actually used by 5 other files in the codebase) withhttp://localhost:3020dev default. (8) Provider metadata + i18n —MANUAL_CASH("Cash") +MANUAL_TERMINAL("Card terminal") inlib/payment/provider-metadata.ts; newcollectRemaining.{chooseMethod, manualAlternativesLabel, methodCash{Title,Description}, methodTerminal{Title,Description}, methodQrUnavailable, confirmCash{Title,Message}, confirmTerminal{Title,Message}, confirmRecord, manualCashRecorded, manualTerminalRecorded}(en + nb-NO). Tests: APIrecord-manual-payment.handler.spec11 cases +payment.controller.spec+3 (CASH dispatch / TERMINAL+note / STAFF cross-resource 403);initiate-remaining-payment.handler.speccollapsed 3 status tests into 1 "allows every booking status" loop covering PENDING through NO_SHOW;record-manual-payment.handler.specmirrors that. API payment suite 627 → 643 pass · web lint + build clean ·gitnexus_impactLOW–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/paymentspage with filter toolbar (status, provider, bookingId search, date range) + paginated table (createdAt, provider, intent, status badge, amount, captured, refunded, booking link).PaymentDetailDrawerframer-motion slide-in with meta / amounts breakdown / provider refs / timeline / failure panel. Action bar (Capture/Refund/Void) gated by status + captureMode + user.role === 'OWNER'.RefundDialogtwo-step (form → ConfirmDialog danger) — Zod caps amount atcaptured − refunded, reason required.VoidDialogsingle-step with optional reason.CaptureDialogtwo-step — amount optional (null = full), bounded by authorized amount. HooksusePayments / usePayment / useRefundPayment / useVoidPayment / useCapturePayment+generateIdempotencyKey()viacrypto.randomUUID(). Sidebar entry with CreditCard icon. i18n nb + en (payments.*+ 12 payment error codes + validation keys). +23 vitest, +2 Playwright smoke, 0 backend changes. - Post-D2 UX polish (2026-04-20).
- Backend — bookingId prefix filter (
ListPaymentsQuery): admin UI shows an 8-char UUID prefix in the bookingId column but pasting that prefix into the filter returned empty because bothPrismaPaymentRepository.listByTenantandInMemoryPaymentRepository.listByTenantdid exact-match. Switched tostartsWith(Prismawhere.bookingId = { startsWith }; in-memoryr.bookingId?.startsWith(prefix)); full UUIDs still match via starts-with-itself. +2 query tests. - Backend — no-op
Saveguard (BookingService.update): owners who open the booking drawer and reflexively hit Save were generatingUPDATEDaudit rows even when nothing had changed, because the items array is always sent on update. NewitemsEqual(existing, resolved)helper deep-compares length + per-item (serviceId, resourceId, startTime in ms) after sort-by-sortOrder; when identical,items: 'replaced'stays out ofchangesand the service short-circuits withreturn existingbefore touchingprisma.booking.update— avoids both theupdatedAtbump and the noise audit row. Also added the missingcustomerIddiff to the scalar compare block. +1 service test. FE mirror:BookingDrawer.onSubmitedit-mode checksformState.isDirtyand callsforceClose()instead of dispatching the mutation on no-op. - Backend — snapshot tenant display settings at creation time (
422fc12):BookingService.createnow writes a frozen copy of tenant display settings (timezone, currency, locale) intobooking.metadata.displaySnapshotvia newbuildDisplaySnapshot()helper. FE renders booking time/money from this snapshot so a later tenant timezone change doesn't retroactively shift historical bookings (e.g. admin account page + web booking list render viadisplaySnapshot.timezone, not the live tenant setting). +10 tests (5 helper + 5 service integration). - Backend — customer portal timezone flatten (
3c487c1):GET /customer/me/bookingswas exposing nestedtenant.settings.timezonethat the FE had to dig for; service now flattens tobooking.tenantTimezoneat the DTO boundary so the account-page booking card can renderformatDateTimeInZone(startTime, tenantTimezone)without re-fetching the tenant. +2 service tests. - FE — shared
DatePickeroverhaul (components/form/date-picker.tsx): altInputd/m/Ydd/mm/yyyy display (wire format staysY-m-d), compact inputh-10, Ant-Design-style rangemode="range"withshowMonths: 2+ separator→, injected "Today" footer button (single-date mode only), hover-to-clear icon (Calendar → X on hover when value present), partial-range revert vialastValidRangeRefon close with 1-date, lucideCalendaricon (fixes clipped SVG bottom).globals.cssflatpickr rounded-md day cells +bg-brand-100inRange band.PaymentListfilter toolbar swapped two<input type="date">→ singleDatePicker mode="range". - FE — cross-page Payment → Booking drawer:
PaymentDetailDrawerbooking-id<Link>replaced with button firingonViewBooking(id)→PaymentsContentfetches via newuseBooking(id)hook (enabled on open) → renders nestedBookingDrawer. Closing the booking drawer leaves the payment drawer on screen so owner keeps payment context. Booking-id cell gets aCopyButton(icon-only, 1.5s check flash, stopPropagation). - FE —
BookingHistorydiff modal rewrite: was single-column "changed fields only"; now full-snapshot side-by-side table (Field / Before / After). Per-field rows classified as CHANGED (tinted red-50/60 strikethrough left, emerald-50/60 bold right), UNCHANGED (single muted cell pulled from livebookingprop), or NOT_SET (filtered out). Separate amber row foritems: 'replaced'since audit doesn't store old/new item arrays. Fields: status, startTime, endTime, resourceId, customerId, notes, isPaid, source. Prop signature changed(bookingId, resources)→(booking, resources); new i18nbookings.{diffField, diffBefore, diffAfter, historyNoDiff, historyItemsReplaced, fieldCustomer}nb + en. - FE — shared
formatDateTimeInZone(iso, tz)+createDateTimeFormatterInZone(tz)inlib/timezone.ts— canonicaldd/mm/yyyy HH:mm(en-GB+hour12: false+formatToPartsassembly). Replaces duplicatedIntl.DateTimeFormat('nb-NO', {...})inPaymentList+PaymentDetailDrawer. +6 timezone.test.ts cases (Europe/Oslo DST, UTC, midnight normalise, single-digit pad). - Results: 1047 API unit (was 1044, +3: 2 query + 1 service) + 57 e2e · 100 web vitest (was 94, +6 DatePicker/timezone) · lint 0/0 · both builds clean.
- Backend — bookingId prefix filter (
- Capture trigger move — Confirmed → Arrived (2026-04-21). Previously
onBookingConfirmedcaptured MANUAL+AUTHORIZED deposits, but PaymentAuthorized flips booking PENDING→CONFIRMED within seconds of authorize, so Void window shrank to ~zero. Moved capture toonBookingArrived(primary) + keptonBookingCompleted(fallback — state machine allows CONFIRMED→IN_PROGRESS directly). Matches industry standard (Booksy / Timely / Vagaro / Phorest). New event + payload + builder; 4 integration-service tests. - Lead-time cap
maxBookingDaysInAdvance(2026-04-21). New TenantSettings field, default 30 (range 1–365), enforced in BookingService create + update viavalidateBookingLeadTime()returningBOOKING_TOO_FAR_IN_ADVANCE. Admin does NOT get an override — hard cap for both public and admin create. Public endpoint exposes the cap; FE pre-validates and clamps admin DateField max. Settings form shows deposit warning when cap > 7 days (Bambora hold = 7 days). Industry defaults (BEAUTY_SETTINGS,BARBERSHOP_SETTINGS) realigned to currency NOK +maxBookingDaysInAdvance: 30;prisma/seed.tsupdated. 5 helper tests (inside-cap / past-cap / boundary / past-booking / legacy-missing-setting). Customer DateStrip clamp (follow-up 2026-04-21) —DateStripacceptsmaxDaysInAdvance?: number, days array length =min(28, cap + 1)so today + the final allowed day are both selectable; BookingPage threadssettings.maxBookingDaysInAdvance. Closes a gap where the strip hard-codedlength: 28and offered dates the API would reject on submit. E2E lock —booking-web/e2e/public-booking.spec.ts(2 Playwright cases): default seed (cap=30 → 28 cells) + owner PATCH cap=5 → 6 cells with today / today+5 boundary check,finally-block restores seed. Compact salon header — BookingPage gains inline logo/avatar + salon name + back-link row so deep-linked customers never lose tenant context. - Admin UI polish — unified SearchSelect + sortable BookingList + profile link fix (2026-04-21). All remaining native
<select>across admin pages (BookingList status filter, PaymentList status + provider, Settings General Currency + Timezone, Settings Booking bookingMode + depositType, Accounting VAT rate) swapped to SearchSelect so chevrons/borders/heights match. Required-by-zod fields passrequiredprop so the clear-X button is hidden and auto-asterisk renders. BookingList gains "Created" column + sortablestartTime/createdAtheaders with 3-state toggle persisted to localStorage; backend addssortBy/sortOrderwhitelist inBookingService.findAllByTenant(calendar mode still forcesstartTime asc). UserDropdown "Edit profile" href/profile → /admin/profile. - Deposit capture-mode hotfix + Payment detail AMOUNTS redesign (2026-04-20). Two intertwined bugs caused every Bambora deposit to silently capture server-side while our DB still showed AUTHORIZED (owner Void → Bambora
134: No approved Authorize available for Delete). (A)buildBookingCreatedPayloadhardcodedcaptureMode: 'AUTO'— MANUAL branch inPaymentIntegrationService+ provider adapters was unreachable from booking-created path. Fixed by deriving from intent:intent === 'DEPOSIT' ? 'MANUAL' : 'AUTO'so deposit bookings hold and capture on Confirmed/Completed; full-prepay (future path) keeps AUTO. (B) Bambora Classic's/checkout/sessionstreats presence ofinstantcaptureamount(not its value) as "capture on authorize" — adapter was sendinginstantcaptureamount: 0for MANUAL, which silently captured the full amount server-side. Fixed by omitting the field entirely unlesscaptureMode === AUTO. FE — Payment detail AMOUNTS section redesign: newOn holdrow (tone pending, hint "Reserved on the card") whenstatus === AUTHORIZED, renderingamount − capturedAmount; zero Captured/Refunded render—(muted) instead of0 krmatching Stripe/Adyen convention;Up to X kr refundable → X krconfusing duplication renamed to cleanRefundable → X kr;AmountRowgains optionalhintslot +'muted'tone. i18npayments.detail.{held, heldHint, refundable}nb + en. Tests replace "AUTO captureMode by default" with two explicit cases (FULL_PAYMENT → AUTO, DEPOSIT → MANUAL); Bambora adapter spec now assertsexpect(body).not.toHaveProperty('instantcaptureamount')for MANUAL (the originaltoBe(0)was exactly the bug). No schema/migration, no API contract change — pre-fix payments still show diverged state (Bambora already captured them; use Refund instead of Void). Follow-ups queued: (1) move deposit capture fromonBookingConfirmed→onBookingArrivedprimary +onBookingCompletedfallback ✅ shipped 2026-04-21, (2) booking auto-cancel on Payment auth-expiry ✅ already shipped with Track C2 (OnPaymentSettledNegativeListenersubscribes bothPaymentFailed+PaymentExpired); audit 2026-04-21 added a contract test toauthorization-expiry.service.spec.tsasserting the sweep emitsPaymentExpiredwithbookingId+tenantIdon the envelope so the listener invariant can't regress silently, (3) tenant settingmaxBookingDaysInAdvance(default 30, hard-capped for both public + admin create) ✅ shipped 2026-04-21. - Refund-as-row refactor (2026-05-11) — moved refund flow from in-place
Payment.refundedAmountmutation to a separatePaymentIntent.REFUNDchild row withparentPaymentIdself-FK. Matches Stripe / Adyen / Square / PayPal / Vipps. Per-event audit (date, reason, amount, PSP refund tx id) lives on the child; cumulative total derived viasum(refunds.capturedAmount). Big-bang single deploy: schema migration backfilled 5 legacy refunded rows into REFUND children (provider tx id NULL,metadata.backfilled = true), dropped therefunded_amountcolumn. Domain swap: instancePayment.refund()removed → staticPayment.createRefund()factory +Payment.applyRefundProjection()(walks parent status + emits legacyPaymentRefunded/PaymentPartiallyRefundedevents for backward compat with booking-projection + notification listeners). NewPaymentRefundCreatedevent on the child.RefundPaymentHandlerrewritten: 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 fromcap - refformula to "REFUND children subtract their capturedAmount". DTO layer keeps a derivedrefundedAmountaggregate on the wire so PaymentList / BookingPaymentSummary / next-payment / BookingTicket compile unchanged; newrefunds: RefundLineDto[]+parentPaymentIdexposed for the drawer's "Refund history" timeline + the FEuseRefundPaymentcallback. Tests: domain spec full rewrite (16 new cases), handler spec rewrite, every fixture seedingrefunded: Nflipped 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 indocs/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/creditreuses 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 childproviderRefto a newProviderRef.synthetic(providerKey)(NULL tx id) and only adopt the refund tx id when genuinely distinct (Stripe / Adyen). Webhook path mirrored. (2) Stripe-style charge semantics —Payment.applyRefundProjectionno longer mutates the parent's status; the charge row staysCAPTURED("Paid") forever. Booking-side projection stays correct becauseOnPaymentStateProjectionListenerwas already event-driven onPaymentRefunded/PaymentPartiallyRefundedevent names, not the row's status field. (3) List shows REFUND rows as first-class entries —listByTenantnow defaults to including REFUND children (Stripe / Square dashboard style). (4) Intent-aware status badge —PaymentStatusBadgeoverridesREFUND + CAPTURED → "Refunded"(refund completed successfully), every other combination renders normally so pending / failed refunds surface their real status. (5) BookingDrawerbookingIdedit-only path — clicking a payment's "Booking" link no longer opens the create flow while the fetch is in flight; newBookingDrawerEditByIdWrapperholds 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 indocs/architecture/refund-as-row-refactor.md. - Lift
http-client.ts+retry.ts→ sharedinfrastructure/http/(2026-04-20). Before: identical files duplicated inproviders/bambora/+providers/worldline-direct/(~127 LOC × 2) — any fix to retry/backoff had to be patched twice. After: one canonical copy atcore/payment/infrastructure/http/{http-client,retry}.ts, adapters + specs import from the shared path.provider-bootstrap.tsnow uses a singleFetchHttpClientimport instead of two aliased copies. Tests de-duplicated (http-client.spec.ts+retry.spec.tskept only inhttp/),worldline-direct/*.tsduplicatesgit rm'd. Lint 0/0, build clean, 1060 API unit (was 1073; -13 from removing the duplicate spec files, zero real test coverage lost). - Stripe / Vipps / Nets adapters (drop-in, zero domain change)
Epic 7: Notifications 🚧 Payment lifecycle live, in-app inbox shipped, branded emails shipped, SMS booking confirmation pending
- BullMQ queue processor wired (
NotificationProcessor, fan-out SMS + email perNotificationType) -
EmailProviderport +LogEmailProviderdefault 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) —
AdminNotificationtable + per-recipient REST API (/admin/notificationslist / 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-emailsBullMQ 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;RoleLandingGuardbounces ADMIN off tenant-scoped routes; +11 unit tests - Superadmin Phase 1.1 charts (2026-04-28) —
/superadmin/trend+/superadmin/distributions+previousPeriodon 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/statusADMIN-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
PlatformSettingrow (brand name, per-locale tagline, optional logo/favicon storage keys). Newsuperadmin/platform-settingsADMIN endpoints (GET/PUT + upload/delete logo + upload/delete favicon) + publicpublic/platform-settings(no auth) for every page to fetch the live brand. New upload purposesPLATFORM_LOGO/PLATFORM_FAVICON,UploadedFile.tenantIdmade nullable, ADMIN uploads scoped underplatform/<uuid>.<ext>.AppLogo+ customer footer + rootgenerateMetadata(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/settingsform (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+xmlwhitelisted onPLATFORM_LOGO+PLATFORM_FAVICON(super-admin only, sanitised viasanitize-htmlstrict profile blocking<script>/<foreignObject>/on*/externalhref;imgproxy raw=1passthrough preserves vector). New schema columnslogo_dark_storage_key+logo_dark_mime_type+POST/DELETE /superadmin/platform-settings/logo-darkendpoints.AppLogoreadsuseTheme()to picklogoDarkUrlon dark surfaces (falls back to light variant when missing). SSR-prefetch viaHydrationBoundaryin rootlayout.tsxeliminates F5 fallback flash — AppLogo + tagline render with real values on first paint. AppLogoSIZESbumped (h-10/h-14/h-16) + outer spanalign-middle leading-noneto 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 headerh-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. -
allowDoubleBookingdefault ON + reset-superadmin CLI + dropdown trim (2026-05-08) — operational polish bundle.BEAUTY_SETTINGS+BARBERSHOP_SETTINGSindustry defaults plus FEDEFAULT_BOOKING_POLICYflipallowDoubleBooking: false → trueso 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). Newyarn reset:superadminCLI (interactive: prompts email + masked password, min 6 chars, re-prompts on invalid input instead of exiting) bumpstokenVersionto invalidate cached JWTs and upserts byrole=ADMIN.UserDropdownhides "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
ContentPagePrisma model (singleton-per-slug, EN+NB title/body,updatedByaudit).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 asPlatformSetting. Newsuperadmin/content-pages(ADMIN list + GET + PUT, slug enum guard) and publicpublic/content-pages/:slug?locale=en|nb(no auth, locale-keyed payload). HTML body sanitised server-side bysanitize-htmlwith strict allow-list (h2/h3, p, strong/em, ul/ol/li, a withmailto/tel/http(s), blockquote) —target=_blankauto getsrel=noopener noreferrer. New super-admin route/admin/superadmin/pages(list withPublished/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 viagetContentPageServerand render withdangerouslySetInnerHTML; empty body falls back to existingInfoComingSoonplaceholder. +20 API tests (3 service onModuleInit + 1 listAdmin + 2 getAdmin + 2 getPublic + 3 update incl. XSS strip +target=_blankrel-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.listTenantssplits no-filter (curated) from filtered (full population) so customers can still search every salon. UI:/admin/superadmin/featured-tenantswith@dnd-kit/sortablevertical drag-drop table (auto-save on drop, optimistic local order cleared inonSettled), multi-select + bulk remove toolbar,AddFeaturedTenantsModal(debounced search, 10/page, cap-aware selection). Hard-cap 50 tenants. 28 new API tests + updatedpublic-booking.controller.spec(mockslistPublicFeaturedfor the no-filter branch). API 1910/1912, web 170/170. Sidebar:Staricon 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
SiteScriptPrisma model (uuid + name + placement enumHEAD | BODY_START | BODY_END+ code +enabled+sortOrder+ audit) with two migrations (add_site_scriptsthenadd_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, defaultenabled=false, auditupdatedBy, and a<!-- site-script: <name> -->DOM marker. Site scripts only fire on public routes via the existingsrc/proxy.ts(Next 16 renamedmiddleware→proxy); a smallnextWithPathname()helper attachesx-pathnameto everyNextResponse.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 hoistsasync src=per React 19). BODY_START / BODY_END use rawdangerouslySetInnerHTMLanchored 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 (keyremount), inline validation, single close button. Customer footer pages render via shared.content-prosetypography inglobals.css(Tailwind v4 ships without@tailwindcss/typography, so the priorprosewas 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 carrysub = adminUserId, overriderole=OWNER+tenantId=target+ thêm claimimp = { 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óimpclaim, 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/impersonationspage 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/memở rộng trảimpersonationblock 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.mdfor per-test inventory. - Time-slot grid picker (2026-04-26) —
TimeSlotGridswaps 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
SalonClockfor 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.createBookingrejectsBOOKING_CONTACT_REQUIREDwhen bothcustomerPhoneandcustomerEmailempty (after auth account-fallback). FE Zod.refine()onBookingPagesurfaces 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/bookServiceItem header gains a 48×48 service thumbnail (imageKeyviaProxyImage, fallback brand-tinted initial tile). Staff dropdown shows a 28×28 avatar per option (avatarKeyviaProxyImage, fallbackAvatarTextinitials with auto-color); SearchSelect trigger also displays the selected staff's avatar. "Any available" gets a greyUsers-icon circle to visually separate from real staff.SearchSelectextended with optionalicononSearchSelectOption+ newemptyIconprop — 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/signupto/admin/signinso 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,StickyTenantHeaderon-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),StickyBookingHeaderrender 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: relativetrên/b/[slug](StickyTenantHeader take over khi scroll),AppLogo size="sm"thaymdđể không full-bleed container;TenantTopBaroverlay top-right z-50 (login/avatar, fixed inset-x-0 innermax-w-[1440px]flush với mép cover container). Login button đổi từ redirect/account/loginsangCustomerLoginModalin-page (Google OAuth, đóng modal sau success, giữ trang). Cover overlay 3 dòng: logolg(80×80) + tên +SalonClock+ chip rowOpenStatusTrigger("Opening hours: HH:MM–HH:MM", màu đỏ/xanh báo trạng thái khishowDot={false}, hydration-safe viauseOpenStatushook, re-tick 60s) +LocationContactTrigger. 2 modal mới quaSalonOverlayprimitive (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).TeamGridCardsidebar 3×3 avatar grid (max 9, items-start, no hover bg) + "View more (+N)" →TeamModal(grid 2-3 cols, avatar 96 + name + role);TeamSectionvertical-cards footer bỏ. Admin Settings: tab Location rename → Location & contact, section "Public contact" thêmcontactEmail/contactPhone(@ValidateIfcho phép empty string clear, sanitize ranullkhi expose quaGET /public/tenants/:slug). Footer logo wrap<Link href="/">.termsNotice→t.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.
TenantBrandingJSONB swapscoverFocalX/coverFocalYfor optionalcoverMobileKey; public renderers prefercoverMobileKeyon phones + on the 5:3 homepage thumbnail (closer to 5:2 than 5:1), fall back tocoverKeywhen only one image is shipped. AdminBrandingSectionshows twoImageUploadercards (desktop 5:1 + mobile 5:2) with amber warning when only desktop is set.FocalPointPickerUI removed (underlyingProxyImage/useImageProxyUrlfocal props kept as generic primitives). Migration20260513082622_split_tenant_cover_desktop_mobilestrips both focal keys + seedscoverMobileKey: nullviadefaults || existingJSONB pattern (idempotent).branding.resolver.ts,PublicBookingController.listTenants+ public-tenant DTO updated. Also: unified section title sizing (text-lg font-semiboldacross Services / About / Location / Opening hours / Team) + removedClock/Usersicons from sidebar headers;StaticMapnow takes requiredheightprop with same-height skeleton fromdynamic({ 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) setBooking.customerId+ upsertTenantCustomer.GET /public/.../bookings/:idthêmhasCustomer: boolean(server không expose rawcustomerId— chỉ boolean — để anonymous viewer không enumerate ownership được).BookingConfirmedClientrender "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ởCustomerLoginModalrồiuseRefflag 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) atomicUPDATE … WHERE customer_id IS NULLrace-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) —
CustomerHeaderreturnsnullcho 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ìusePathnameexclude. 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) —
SalonOverlay4-phase state machine (closed → entering → open → leaving → closed) quauseReducer(tránh React 19 set-state-in-effect): entry doublerequestAnimationFramegiữaBEGIN_ENTER+COMMIT_ENTERđể paint stateopacity-0 scale-95 translate-y-2trướ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). Replacedscrollbar-gutter: stablebằnghtml { overflow-y: scroll }để gutter cố định không shift khibody.overflow: hidden. Mask opacity normalize sangbg-gray-900/25trên 22 file (modals/drawers/dialogs/sidebar backdrop), bỏbackdrop-blur-[2px](mask alone đủ). Lightboxbg-black/95+ uploader loadingbg-white/70giữ nguyên.BookingTicketpy-5→pt-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:
TenantDomainmodel + status enum + additive migration (no backfill) - P2 — Domain module: owner CRUD (
/tenant-domains), publicresolve, internaltls-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.helperresolve payment return + email links từ tenant's ACTIVE domain;/account+/admingiữ platform domain - P5 — Web proxy:
proxy.tsresolve custom host → rewrite/b/<slug>, setx-custom-domain;SalonLinkContext+useSalonLink→ clean URLs across 8 client components - P6 — Admin UI: Settings → Domain tab (add/verify/remove + apex-aware DNS instructions);
allowedDevOriginsenv-driven (DEV_ORIGINS); Docker/GHANEXT_PUBLIC_PLATFORM_DOMAINS - P7 — TLS edge (Caddy): Caddy thay nginx + certbot — auto-HTTPS + on-demand TLS,
caddy/Caddyfile+Caddyfile.dev;docker-compose.prod+deploy.shcutover; docscustom-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/customergoogle/start+google/callback+google/complete, modelCustomerAuthTicket(single-use handoff), webGoogleSignInButton+/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 branding ✅ DONE 2026-06-04 — trên custom domain header render logo + tên salon (
SalonAvatar) thayAppLogoGLAMVOO (propsalonresolve quax-tenant-slug/resolveTenantSlug→getTenant); footer riêngCustomDomainFooter(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=loyalty4 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 trongJwtAuthGuard(mỗi request 1 query nhẹ qua User unique index).Performer.resourceId(BookingService) — performer interface mở rộng, controller truyềnuser.resourceIdvào mọi call.- STAFF scoping rules (BookingService):
findAllByTenant—WHERE resourceId IN (staff.resourceId, NULL); reject khi query resourceId khác.findById— 403BOOKING_NOT_IN_STAFF_SCOPEnếu booking.resourceId ≠ staff và ≠ null.create— block 403 nếudto.resourceIdhoặc bất kỳitems[].resourceIdkhác staff.resourceId.walkIn—WALK_IN_RESOURCE_NOT_ALLOWED_FOR_STAFFnếudto.resourceId≠ staff.update—BOOKING_REASSIGN_NOT_ALLOWED_FOR_STAFFnếu đổi sang resource khác; cấm items[] target khác.updateStatus— reusefindByIdguard, auto 403 nếu out-of-scope.selfPick—SELF_PICK_RESOURCE_MUST_BE_SELFnếu caller pass resourceId ≠ mình.
- TenantCustomer whitelist —
update()chỉ persistnotes/tags/metadata; mọi field khác silent drop. - ResourceService time-off — STAFF create/update/delete chỉ trên
:id = staff.resourceId; edit/delete bị block nếuisApproved=true. - Payment admin scope —
byBooking,getOne,initiateRemainingvalidatebooking.resourceIdcho STAFF. - Upload — POST mở cho STAFF (
@Roles('STAFF','OWNER','ADMIN')); DELETE vẫn@Roles('OWNER','ADMIN'). @Roles()tường minh — tất cả admin controller đã khai báo rõ role list (không dựa hierarchy implicit).
Checklist còn lại
Phase 3 — Web UI (DONE 2026-04-23)
Đã ship:
AuthContext.User.resourceId(từ/auth/mebackend vừa thêm) — STAFF córesourceId, OWNER/ADMIN = null./admin/(dashboard)/layout.tsxmởallowedRoles={["ADMIN","OWNER","STAFF"]}.AuthGuard: nếu user đã login nhưng role không đủ → redirect/adminthay vì/admin/signin.OwnerOnlyGuardwrapper mới (thinAuthGuardvới ADMIN/OWNER) gắn vào: Settings, Payments, Services, Staff, Work-schedule pages — STAFF direct-URL cũng bị chặn.AppSidebar:NavItem.allowedRoles?field +filterNavByRole()helper. STAFF chỉ thấy Dashboard, Bookings, Customers, Loyalty. Parents toàn sub-items hidden cũng drop khỏi menu.BookingCalendar: one-shot STAFF default filter — tất cả resource column trừ own ẩn trên lần load đầu (localStorage flagcalendar:staffDefaultsApplied). User có thể unhide colleagues để xem overview; API vẫn scope bookings → column người khác sẽ empty.BookingDrawer: STAFF dropdown resource chỉ list self;handleAddServiceforceresourceId = staffResourceIdkhi thêm item mới, bỏ qua "last item/calendar click" defaults. DuplicateuseAuth()call dọn sạch.
Result: yarn lint 0/0 · yarn build clean.
Phase 3 — Web UI (legacy plan dưới đây giữ lại để tham chiếu)
- Đổi
AuthGuard allowedRoles={["ADMIN","OWNER","STAFF"]}ở/adminlayout. -
AppSidebarfilter menu theo role — STAFF ẩn: Settings, Staff management, Services CRUD, Tax, Payment config, Loyalty CRUD. -
BookingCalendar— STAFF mặc định filter staff column = chính mình, có thể chuyển sang unassigned queue. - Button/action guard trong booking drawer theo role (Delete chỉ OWNER, Refund chỉ OWNER...).
Phase 4 — E2E Tests (DONE 2026-04-23)
- Unit tests (booking-api) — 14 test mới:
-
BookingService.findById4 test (own / unassigned / foreign-403 / OWNER bypass). -
BookingService.findAllByTenant3 test (STAFF scope / STAFF cross-query / OWNER bypass). -
BookingServicemutations 5 test (create-resourceId-403, create-items-403, update-reassign-403, updateStatus-403, walkIn-403). -
BookingService.selfPick1 test (cross-resource 403). -
ResourceServicetime-off 5 test (cross-resource 403, self OK, approved-edit-403, approved-delete-403, OWNER bypass). -
TenantCustomerService.update()1 test (silent drop metrics field). -
PaymentController3 test (getOne STAFF 403, byBooking STAFF 403, initiateRemaining STAFF 403).
-
- E2E tests (
booking-web/e2e/staff-role.spec.ts) — 7 scenarios:- STAFF signin qua form
/admin/signin→ redirect/admin,/auth/metrảrole=STAFF+resourceId≠ null. - STAFF sidebar chỉ hiện 4 menu (Dashboard, Bookings, Customers, Loyalty); ẩn Services, Resources (Staff+Work Schedule), Payments, Settings.
- STAFF vào được
/admin/bookingstrực tiếp (không bị guard redirect). - STAFF gõ URL
/admin/staff→OwnerOnlyGuardbounce về/admin. - STAFF gõ URL
/admin/settings→ bounce về/admin. - STAFF gõ URL
/admin/services→ bounce về/admin. - OWNER → logout → STAFF login cùng browser session:
/auth/meflip role, giữ nguyên tenantId, trả resourceId mới.
- STAFF signin qua form
- Fixture
fixtures/auth.tsdùngstorageStatepattern (login 1 lần/worker/role) — tránh rate-limit khi full suite.
Phase 5 — Done criteria
-
docs/architecture/role-matrix.mdmerged. - 0 endpoint còn chặn STAFF nhầm theo matrix — API harden complete.
- Web
/adminlogin được STAFF, sidebar + booking filter đúng. - ≥ 7 E2E scenarios pass — 7/7 green trong 6s solo, full suite 24/25 green + 1 fixme trong 34s.
- ≥ 5 unit scenarios pass cho BookingService scoping — 14 pass.
- OpenAPI spec regen,
booking-webtypes sync (mobile types sẽ sync khi scaffold mobile app). - Update
docs/progress/changelog.md+ chuyển dòng này sang ✅ Done.
Unblocks: mobile scaffold (booking-mobile) — API + Web + E2E role matrix đã lock, mobile có thể clone pattern mà không lo regression.
📌 Blocker cho: mobile app (mọi phase), Epic "Staff self-pick" trong Roadmap.
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:
miniopackage +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+UploadControllerdùngminiopackage, single-purpose multipart, public-read bucket, MIME allowlist (jpeg/png/webp/svg), max 5 MB, tenant key prefixdocker-compose.yml— MinIO container (port 9010/9011)- FE —
BrandingSection.tsx,DropZone.tsx,FileInputExample.tsx(chưa wire vàoUploadServicethật) - Schema —
Service.imageUrl(1 field, kiểu URL string — sẽ renameimageKeyở Phase 4)
Security gaps phát hiện (sẽ fix Phase 1):
- CRITICAL — DELETE không verify tenantId trong URL → cross-tenant delete khả thi
- SVG trong allowlist → XSS risk khi browser render
- Chỉ check
file.mimetypeclient-provided → magic-bytes spoof khả thi - URL hardcode
http://${endpoint}:${port}→ break sau Nginx/HTTPS - Không có DB tracking → orphan files tích luỹ
- Không strip EXIF → leak GPS coordinates
- SDK
miniopackage → không portable sang R2/Hetzner/B2/Spaces - 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, devaws-sdk-client-mock. Removedminiopackage. -
domain/ports/storage.port.ts—StoragePortinterface +STORAGE_PORTsymbol; types cho put/presigned-put/presigned-get/delete/head -
domain/errors.ts—UploadDomainErrorabstract + 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 stablecode+httpStatus. -
domain/file.validator.ts—validateUpload()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— implementsStoragePortvia@aws-sdk/client-s3(PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand) +getSignedUrlcho presigned PUT/GET. NotFound →UploadNotFoundError, 5xx →UploadProviderError. -
interface/filters/upload-domain-error.filter.ts—UploadDomainError→ JSON{ success: false, error: { code, message } }với httpStatus (4xx warn / 5xx error log). - Refactor
upload.service.ts:- Inject
STORAGE_PORT+UPLOAD_SERVICE_CONFIGqua 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 → throwUploadTenantMismatchErrornếu mismatch (CRITICAL fix)resolveKeyFromUrl(urlOrKey)strip public base + bucket prefix (BC cho FE truyền URL hoặc bare key)
- Inject
- Refactor
upload.controller.ts:- POST
/uploadaccept multipartfile+ optionalpurposebody (defaultTENANT_LOGOcho FE BC). Return{ url, key, sizeBytes, mimeType, width?, height? }. - DELETE
/uploadaccept body{ url? | key? }, resolve → key → service.delete với tenant verify @UseFilters(UploadDomainErrorFilter)ở class
- POST
- Refactor
upload.module.ts— providers:S3StorageAdapter,STORAGE_CONFIGfactory (env validation),STORAGE_PORTuseClass,UPLOAD_SERVICE_CONFIGfactory (STORAGE_PUBLIC_BASE??STORAGE_ENDPOINT) - Update
.env.example—STORAGE_*block (ENDPOINT, REGION, ACCESS_KEY, SECRET_KEY, BUCKET, FORCE_PATH_STYLE, PUBLIC_BASE) provider-agnostic. OldMINIO_*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ớiaws-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] e2etest/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 trongemail/recipient.resolver.tskhông phải scope Phase 1) -
yarn build(booking-apinest build) pass clean - Unit suite green: 1469 pass / 1471 total (+27 over baseline 1443)
- Cross-tenant DELETE attack test pass (CRITICAL gap fixed) —
UploadService.deleterejects với UPLOAD_TENANT_MISMATCH 403 - Magic-bytes spoof test pass (HIGH gap fixed) —
validateUploadrejects 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.md2026-04-28
Phase 2 — imgproxy integration ✅ DONE (2026-04-28)
-
imgproxycontainer vàodocker-compose.yml— port 9020, S3 source ở MinIO, IMGPROXY_KEY/SALT, AVIF + WebP detection, ALLOWED_SOURCES whitelists3://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 quaextractStorageKey, devicePixelRatio cap 3, fallback prop khi loading hoặc error - FE hook
useImageProxyUrl()—useQuery1-hour staleTime, deterministic queryKey - FE helper
extractStorageKey()(booking-web/src/lib/upload.ts) — strip<host>/<bucket>/prefix, fallback bare key, configurable viaNEXT_PUBLIC_STORAGE_BUCKET - Replace
<img>/next/imagecalls —BrandingSectionlogo + cover,SalonAvatar(logo across booking ticket / public salon page / tenant picker),HomeContentdiscovery card cover, public salonb/[slug]/pagecover,TenantPickerlogo.acceptattributes cũng droppedimage/svg+xmlđể khớp Phase 1 magic-bytes - Acceptance: format auto-detect WebP/AVIF qua
Acceptheader (imgproxy env enable); signed URL deterministic; signature recompute test pass; gravity-only-when-fill behaviour tested - Tests — backend +13 (
image-proxy.service.spec.ts11 cases +image-proxy.controller.spec.ts2 cases), web +11 (upload.test.ts7 cases on extractStorageKey +useImageProxyUrl.test.tsx2 cases) - Env —
IMGPROXY_PUBLIC_URL/KEY/SALT+NEXT_PUBLIC_STORAGE_BUCKETadded to.env.examplecho cả 2 repo -
gitnexus_impactLOW risk ·gitnexus_detect_changes0 affected processes ·nest buildclean ·next build32 pages clean · lint 0/0 cả 2 repo - Changelog entry — see
docs/progress/changelog.md2026-04-28 - DEFER: production Nginx + TLS reverse proxy in front of imgproxy (Phase 6 deploy hardening); rename
*Url→*Keyschema 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) — modelUploadedFile+ enumsUploadPurpose(7 values) +UploadVisibility(PUBLIC / PRIVATE). Three indices for admin browse, cleanup query, and visibility filtering.Tenant.onDelete: Cascadeso deleted tenant takes file rows with it. -
UploadedFileRepository(infrastructure/persistence/uploaded-file.repository.ts) — tenant-scopedfindById/findByKey(returnsnullfor cross-tenant attempts),create,setReference,clearReference,findOrphans(beforeAt, limit)(cross-tenant — worker only),deleteByKeys. Every method accepts optionalPrisma.TransactionClientso feature modules can link/unlink atomically with their domain UPDATE. -
UploadService.upload()insertsUploadedFilerow vớireferencedAt = nullafter storage putObject succeeds; result shape gainsid. -
UploadService.linkReference(fileId, target, callerTenantId, tx?)— verifies tenant ownership, throwsUploadNotFoundErroron 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
OrphanCleanupWorker—OrphanCleanupQueueregisters repeatable sweep every 1h,OrphanCleanupProcessorqueriesfindOrphans(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_impactLOW ·gitnexus_detect_changesLOW · lint 0 errors ·nest buildclean - Changelog entry — see
docs/progress/changelog.md2026-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/coverUrlwired inTenantService.update— reads pre-merge value, syncs both inside same transaction. Conditional wrap: only wraps in$transactionwhen at least one branding URL changed. -
Resource.imageUrlwired inResourceService.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 buildclean - Existing 4 broken specs fixed (
time-off.spec,resource.service.spec,tenant.service.specaddUploadServicestub;payment.module.specextends env stub for transitiveUploadModuleboot) - Changelog entry — see
docs/progress/changelog.md2026-04-28
Phase 4.2 — <ImageUploader> reusable + Service.imageUrl wiring ✅ DONE (2026-04-28)
- Schema —
Service.imageUrlcolumn added (migration20260428160418_add_service_image_url); the earlier audit's assumption that this column existed was a misread (theimageUrl: trueinservice.service.ts:157was insidegetResourcesForServicereading the Resource field). - DTO —
CreateServiceDto.imageUrl?: string+UpdateServiceDto.imageUrl?: string | null(withValidateIfso explicit-null clears). -
ServiceService.create/updatewiresyncReference— create runs post-commit (best-effort); update only wraps in$transactionwhendto.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.uploadextended to accept optionalextra: Record<string, string>form-data bag (needed to passpurpose). -
BrandingSectionrefactored to use two<ImageUploader>instances (TENANT_LOGO64×64 fit,TENANT_COVER1280×320 fill/sm) — removed 90 lines of bespoke upload plumbing. - i18n —
common.imageUploader.*foren+nb. - Test suite repair —
service.service.spec.tsgotUploadServicestub. - DEFER inside this phase resolved 2026-04-29 —
ServiceFormModalnow wires<ImageUploader purpose="SERVICE_IMAGE">into the Details tab (DTO + schema + payload diff-aware so non-image edits stay single UPDATE);StaffFormModaladds<ImageUploader purpose="RESOURCE_AVATAR">between description and metadata/color row.Service.imageUrladded to FEtypes/booking.ts(was missing). i18nservices.image+resources.avatarfor 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_key—services.image_url→image_key,resources.image_url→avatar_key, JSONtenants.branding.{logoUrl,coverUrl}→{logoKey,coverKey}. Backfill strips^https?://[^/]+/[^/]+/URL prefix. - API DTOs renamed (Service
imageKey, ResourceavatarKey, Tenant brandinglogoKey/coverKey);@IsUrldropped in favour of@IsString. OpenAPI regen + FE types regen. -
BrandingResolverinjectsImageProxyServiceto pre-resolvelogoKey→ 48×48 imgproxy URL for email templates (Gmail can't lazy-resolve<img src>). -
ImageUploader.onChange(res.data.key)— returns key not url. Dropurlfrom POST /upload response shape entirely. -
extractStorageKeystrips URL-shape branch — rejects://inputs. Callers must pass bare keys post-Phase 4.3. -
UploadService.resolveKeyFromUrl+buildPublicUrldeleted;UploadServiceConfig.publicBaseUrldeleted;STORAGE_PUBLIC_BASEenv 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
keyonly; DB stores bare keys.
Phase 4.4 — Portfolio gallery ✅ DONE (2026-05-01)
- Schema
PortfolioItem(id, tenantId, resourceId, fileKey, caption, sortOrder, createdAt, updatedAt) — migration20260501110609_add_portfolio_itemswith cascade-on-tenant + cascade-on-resource.beforeAfterPairdeferred — 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+deletecallUploadService.syncReferenceso orphan-cleanup respects gallery photos. - Per-resource cap = 30 photos (
PORTFOLIO_MAX_ITEMS_PER_RESOURCE);createrejects withPORTFOLIO_MAX_ITEMS_REACHEDonce full. Cross-tenantfileKeyrejected withPORTFOLIO_INVALID_FILE_KEYbefore reachinglinkReference. - Tests — 13 new unit (
portfolio.service.spec.ts) covering tenant isolation, cap, ownership rejections, reorder ownership check, monotonicsortOrder, claim/release on create+delete. Total API: 1531 / 1533 (was 1518). - Frontend —
usePortfoliohooks (query + 4 mutations with React Query invalidation),<PortfolioGalleryEditor>component using@dnd-kit/sortablefor drag-reorder, inline caption editing (Enter to save, Escape to cancel), confirm-on-delete via<ConfirmDialog>. Wired intoStaffFormModalas a 3rd tab "Portfolio" — only visible in edit mode (needs persisted resource id). - Public surface —
GET /public/tenants/:slug/resources/:resourceId/portfolio(onlyisActive + isBookableOnlineresources), 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.teamin nb + en.
Phase 4.5 — Onboarding documents ✅ DONE (2026-05-01)
- Schema
TenantOnboardingDoc(id, tenantId, fileKey, docTypeBUSINESS_LICENSE|GOVERNMENT_ID|INSURANCE|OTHER, mimeType, sizeBytes, originalName?, uploadedBy, verifiedAt?, verifiedBy?, note?, createdAt, updatedAt) + cascade-on-tenant + 2 indices ((tenantId, docType),(verifiedAt)). Migration20260501112807_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 againstUPLOAD_PURPOSE_CONFIG, createsUploadedFilerow withreferencedAt = null, returns{fileId, key, uploadUrl, requiredHeaders}with 5-min TTL);confirmPresignedUpload(headObjectto verify file landed;UPLOAD_INTEGRITY_FAILED422 when sizes mismatch — caller can retry the PUT step;UPLOAD_NOT_FOUNDwhen row missing or storage empty);getPresignedDownload(1h TTL, optionalcontentDispositionfor force-attachment). - Backend module
core/onboarding-doc/:OnboardingDocService(list / requestUpload / confirmUpload / getDownloadUrl / delete / verify) +OnboardingDocControllermounted 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 originalverifiedAt). - 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). - Frontend —
useOnboardingDocshooks (useQueryfor list +useUploadOnboardingDocmutation that orchestrates the 3-step flow with rawfetchfor the S3 PUT + envelope-wrappedapi.postfor 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). - i18n —
settings.documents.*namespace (title, description, types[4], hints[4], badges, toasts) +common.download+settings.menu.documents(en + nb). - Verify endpoint shipped, UI deferred —
POST /:id/verifyaccepts 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). Migration20260501114326_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);requestUploadrejects withPAYMENT_ATTACHMENT_MAX_REACHEDonce full. Cross-tenant payment access blocked viaassertPaymentbefore 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 —
usePaymentAttachmentshooks (3-step upload orchestrator + delete + standalone download helper). New<PaymentAttachmentsSection>mounted insidePaymentDetailDrawerbetween 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/serviceswhitelists 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 —
PublicServiceinterface gainsimageKey: string | null. -
<ImageLightbox>reusable single-image component (components/ui/images/ImageLightbox.tsx). Wrapper/Inner pattern keyed byimageKey; ESC + X + backdrop click close; image-click stopPropagation. -
ServiceList.tsx— industry-standard 1:1 thumbnail (h-16 w-16mobile /h-20 w-20desktop,rounded-lg) via<ProxyImage resize="fill" gravity="sm">. Layout falls back to text-only whenimageKey === 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 buildclean. Web 173/173 vitest, lint 0/0,next build32 pages clean.
Phase 4.8 — Cover focal-point picker + public salon page polish ✅ DONE (2026-05-02)
- Imgproxy params —
ImageProxyServiceacceptsfocalX/focalY(emitg:fp:X.XX:Y.YYwhen both set, overrideg:sm, ignored onresize=fit) +blur(1-50 px, emitbl:N). +8 unit tests.SignImageDtovalidates ranges.@Throttle({600/min})→@SkipThrottle()(HMAC compute is offline; 1 page = 30+ image mounts). - TenantBranding schema —
coverFocalX,coverFocalY(default 0.5/0.5) in JSON column.BrandingResolver.mergeBrandingreads with type-safe fallback. (Superseded 2026-05-13: focal-point keys removed, replaced by optionalcoverMobileKeyfor separate desktop/mobile covers — see Customer Portal section "Tenant cover split". Phase 4.8 imgproxyfocalX/Y+blurparams kept as generic primitives.) - Public API —
GET /public/tenants+GET /public/tenants/:slugsurface focal coords. FEPublicTenantBranding+PublicTenantListItemextended. -
<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
aspectRatiosyncs with<img>.naturalWidth/Heightvia newProxyImage.onLoadprop → 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 +
objectPositionfor CSS-side crop
-
BrandingSection— picker rendered between cover uploader + form footer; uploaderonChangeresets focal to (0.5, 0.5). - i18n —
settings.{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/Yprops AND CSSstyle.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
- Aspect
-
ServiceListhover 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
- Inline-style backdrop
-
OpeningHoursCard— open/closed solid pill (emerald/red) withanimate-pinghalo 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 fightsalign-items: stretch). -
docker-compose.yml— opt-inIMGPROXY_IGNORE_SSL_VERIFICATIONenv (defaultfalse) for dev with flaky upstream certs. - Verification: API 1574/1576 (was 1567), web 173/173, lint 0/0 both,
nest build+next build32 pages clean.
Phase 4.8.1 — Cover crop hardening + invoice paid-only filter ✅ DONE (2026-05-02)
- Imgproxy aspect-cap fix —
ImageProxyService.signUrlnow scales BOTH width+height byMAX_DIMENSION / max(w,h)when capping; previously width-only clamp turned 1440×288 (5:1) at DPR=2 into2000×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 parity —
BrandingSectionImageUploader 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 cardPreviewCard (5:3 — matchesHomeContentsalon list card).LayoutGridicon +focalListingi18n (en + nb). Grid switched tosm:grid-cols-3. All 3 frontend cover contexts now have a live preview. - Public renderers —
page.tsx+HomeContent.tsx:gravity="sm"→gravity="ce"(forced centre when no focal); Tailwindobject-center(silent no-emit on this v4 build) replaced with explicit inlinestyle.objectPosition;focalX/Ydefaulted to0.5so legacy tenants without DB focal no longer fall intog:smwhile admin previews showedg:fp:0.5:0.5(the original mismatch the owner saw on mobile). - Invoice paid-only ledger —
InvoiceClientfilterspaymentstoAUTHORIZED / CAPTURED / PARTIALLY_REFUNDED / REFUNDEDbefore passing to<PaymentLedger>on/b/{slug}/bookings/{id}/invoice. Masterpaymentsquery untouched so polling +deriveNextPayment+returnOutcomestill see all rows. - Verification: API 1577/1579 pass (+1 imgproxy regression), web lint 0/0.
gitnexus_impactLOW onsignUrl+SalonPage.
Phase 5 — Mobile (pending)
-
expo-image-pickerintegration trong booking-mobile -
useImageUploadhook (camera + gallery) -
<ProxyImage>RN port (dùngImagenative + 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, immutablecho signed URLs) - Backup cron
mc mirror(hoặc rclone) → cold storage bucket weekly - Provider failover playbook (R2 → Hetzner switch via env)
- Deprecation cleanup: xoá
miniopackage,MINIO_*env, MinIO container khỏi docker-compose - Update
docs/operations/docker-deploy.mdvớ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 (enumPOLAR+ 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 bookingschặn 409TENANT_NOT_ACCEPTING_BOOKINGS; unlist khỏi featured + search (direct link vẫn vào). PredicateisTenantBookingBlockeddùng chung vớiBillingGuard(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 +noTrialUI). +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: sameSitelax+ 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, dashboardSubscriptionStatusCard, 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 quaSyncFromWebhookHandler(lưới an toàn khi webhook miss → tránh over-grant).reconcileSubscriptionoptional 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 /subscriptionstatus 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 (outboxtenant.registered→SubscriptionTrialService, 14d), D2BillingGuard(global, block WRITE khi trial-lapsed/EXPIRED → 402), D3Tenant.billingExemptsuper-admin toggle, D4isTrialing+ webSubscriptionBanner, 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
BillingProviderPortabstract → đổi LS → Stripe = viết 1 adapter mới, domain 0 thay đổi (D3)Subscription.providerimmutable — 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: modelSubscription,SubscriptionEvent,SubscriptionWebhookInbox+ enumSubscriptionStatus/BillingProvider - Tenant additions in same migration:
subscriptionStatus(defaultACTIVEgrandfathers existing tenants),trialEndsAt,currentPlanKey,seatLimit - Plan catalog
plan-catalog.ts(planKey ↔ provider variant IDs — env-based originally, superseded by S2.5 DB-backed mapping) -
SubscriptionModuleskeleton + provider stubs (lemonsqueezy/stripe/paddle README) wired into AppModule - 17 unit tests cho catalog (variant-mapping env tests dropped in S2.5)
- Backfill
Subscriptionrows 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_REGISTRYDI token +BillingProviderRegistrylookup contract -
domain/events/billing-subscription-event.ts— 7-variant discriminated union (created/renewed/updated/payment_failed/payment_recovered/canceled/expired) +DomainEventBasewithtenantIdfor routing -
domain/errors.ts— abstractSubscriptionDomainError+ 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ớitimingSafeEqual+ length-mismatch guard -
lemonsqueezy.event-mapper.ts— payload →DomainSubscriptionEvent; planKey fromcustom_dataprimary +variant_idreverse-lookup fallback; paused/unpaused/refunded ignored (return null) -
lemonsqueezy.adapter.ts— implements port; POST /checkouts vớicustom_data.{tenantId,planKey}embedded; DELETE for cancelAtPeriodEnd; PATCH quantity vớiinvoice_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 +assertProviderMappingCompleteat boot
-
-
infrastructure/providers/billing-provider.registry.ts—InMemoryBillingProviderRegistryimpl (register/get/has/list, throws BILLING_PROVIDER_NOT_REGISTERED on miss) -
plan-mapping.config.ts— addedresolvePlanKeyByVariantIdreverse helper for webhook routing fallback -
SubscriptionModulewiresBILLING_PROVIDER_REGISTRY+ importsLemonSqueezyModule -
.env.exampledocuments 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-backedPlatformBillingConfig(super-admin owned).LemonSqueezyModuledeleted (folded intoSubscriptionModule).plan-mapping.config.tsdeleted. 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 (reusePAYMENT_ENCRYPTION_KEY), public storeId/apiBaseUrl/storeDomain/displayName, lastHealthCheckAt/Status, keyVersion. SingletonisActiveenforced at service layer. -
PlatformBillingPlanMapping— 1:N with config,(configId, planKey)UNIQUE. Replaces envLEMONSQUEEZY_VARIANT_*.
Domain + service:
-
PlatformBillingConfigplain entity (no AggregateRoot — platform-level state, no tenantId → can't emit DomainEvent withtenantId: stringnon-null). Methods: create / update / rotateCredentials / activate / deactivate / recordHealthCheck / decryptCredentials. -
PlatformBillingConfigIdVO. 7 new domain errors (NOT_FOUND, DUPLICATE, ALREADY_ACTIVE/INACTIVE, EMPTY_CREDENTIALS, BILLING_PROVIDER_NOT_AVAILABLE, VARIANT_MAPPING_MISSING). -
PlatformBillingConfigService—activate(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. -
clientFactoryvisible-for-testing override (keeps@Injectable()1-arg). -
parseWebhookreverse-lookup variantId → planKey from DB plan mappings. -
healthCheck()pingsGET /v1/users/me— swallows errors and returns{ok, detail}. -
BILLING_PROVIDER_NOT_AVAILABLEthrown when no active config → SubscriptionDomainErrorFilter maps to HTTP 503. -
LemonSqueezyModuledeleted;lemonsqueezy.config.tsenv reader deleted;plan-mapping.config.tsdeleted.
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@ApiPropertyannotations → 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:openapiscript: ts-node →nest build && node dist/scripts/generate-openapi.js(Node 24ERR_REQUIRE_CYCLE_MODULEworkaround). -
SuperadminActivityKindDTO:@ApiProperty({ enum: [...], enumName: ... })soopenapi-typescriptemits string union (wasRecord<string, never>blocking web typecheck). -
Buttoncomponent acceptstypeprop (default'button') — Cancel buttons inside forms no longer accidentally submit.
Frontend: Platform Settings tab refactor (/admin/superadmin/settings):
-
PlatformSettingsContent.tsxwrapper mirrors tenantSettingsContentpattern — 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
PlatformBillingConfigcards 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)
SecretFieldwrapper with show/hide eye toggle on apiKey + webhookSecret
- List
- 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
-
PlatformBillingAuditLogtable (mirrorImpersonationAuditLog) — 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/:providercontroller (interface/webhooks/subscription-webhook.controller.ts) —@Public(),@HttpCode(200),@Throttle({ ttl: 60_000, limit: 60 })per-IP, case-insensitive provider param, 1MB body cap. -
WebhookInboxpersistence — unique(provider, providerEventId), PrismainsertIfAbsentswallows P2002 →inserted=falsefor retry deliveries. - Idempotent guard: dedup signal via UNIQUE constraint (controller returns
deduped=trueon collision).processedAtleft null for S4 worker to flip after dispatch. - Signature verify trước khi insert Inbox (invalid →
WebhookSignatureInvalidError→ 401 viaSubscriptionDomainErrorFilter; row NOT written so junk traffic doesn't grow the table). - Domain-ignored events (LS
subscription_paused/unpaused/payment_refunded) — row still recorded witheventType+ nulltenantId, controller returnsignored=true, no downstream dispatch. - Port refactor:
BillingProviderPort.parseWebhookreturnsParsedWebhook { providerEventId, eventName, payload, event }instead ofDomainSubscriptionEvent | null. Adapter throws on signature/payload errors;event=nullsignals 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 7apply*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 onTenantrow (subscriptionStatus,trialEndsAt,currentPlanKey,seatLimit). -
application/commands/sync-from-webhook.handler.ts— main S4 dispatcher. Resolution order (by providerSubId → by tenantId → materialise onsubscription.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:
SubscriptionWebhookIngestServicenow 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
-
BillingGuardNestJS — á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 -
OnResourceCreatedListener→SyncSeatsCommandpush 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/billingpage — 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/billingvới resubscribe CTA - Seat-limit modal —
SeatLimitReachedModaltriggered 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/billingtab — 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— sameBillingProviderPortinterface - Map plan catalog:
pro_monthly_per_seat→ Stripeprice_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),PlanFeatureOnPlanjunction (boolean = row presence; numeric = stringified value). - Seeder
core/plans/plans-seeder.service.ts— boot-time upsert vớiupdate:{}(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 filteredisPublic=true, ordered bysortOrder. Feature catalog grouped by category for the comparison matrix. - Admin CRUD methods (
listAdminwith subscriber-count groupBy,getAdmin,listAdminFeatures,create,update,replaceFeaturesatomic via $transaction,removewith subscriber guard).
Phase PC3 — Super-admin UI ✅ shipped 2026-05-28
-
/admin/superadmin/planslist page (table with name, planKey, price formatted Intl, visibility badge, subscriber count, sortOrder, actions). Sort bysortOrderasc. - 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/planscontroller@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 →
InfoComingSoonfallback (không crash customer surface).
Phase PC6 — LS adapter refactor (pending)
-
LemonSqueezyAdapter.createCheckoutSessionreadPlan.lsVariantIdtừ 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.planKeyabsent.
Phase PC7 — Tests (pending)
- Service spec 3 cases + seeder spec 3 cases shipped với PC1+PC2.
- Playwright
/pricingsnapshot 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.DateTimeFormatcho 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 versioning —
tokenVersiontrên User + Customer. JWT chứav. Guards comparevvs DB. Increment khi đổi/reset password → invalidates all sessions. Handle DB reset (user not found → 401). - Auth separation — Admin guard reject customer tokens (
typeclaim 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:
customerIdtừ 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ệnLogEmailProviderdefault)
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 /tenantsalready 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)