Overview
The sections below (including architecture) are written from the engagement — they are not tied to a public repository.
/ Scope
- Multi-tenant data model (shared schema, row-level isolation)
- Booking & scheduling engine with 3 staff selection modes (auto-assign, single, per-service)
- Timeline computation engine for sequential multi-staff bookings
- Staff management with 3-layer employment model (profile → employment → service)
- Multi-salon staff onboarding (new user, existing customer, existing staff)
- Subscription billing & payments via Stripe Platform Model with idempotent webhooks
- Loyalty & rewards system with wallet-based accrual and redemption
- Customer wallet with loyalty points, debt tracking, and reconciliation
- Geolocation & maps (Google Maps API + PostGIS proximity search)
- Search & discovery with PostGIS proximity search and ranking
- Scheduled jobs & notifications (reminders, no-show detection, review requests)
- Reviews & ratings system with customer feedback and loyalty integration
- Unified calendar (merges bookings, availability, time-off, and events)
- Time-off management with conflict resolution and compensation offers
- Platform fee system with snapshot at booking creation
- PWA + Capacitor mobile (iOS/Android)
- 7-tier RBAC across platform and tenant hierarchies
- Full audit trail with booking_history event ledger
- AWS deployment & CI/CD
Highlights
01
Solo full-stack: Next.js 16 + NestJS + AWS — shipped to production
02
Booking writes protected by pessimistic locking + a unique-index backstop; found and fixed a real fee-calculation bug via a live production audit
03
True multi-tenant isolation enforced at the framework layer
04
Stripe Platform Model with idempotent webhook processing
05
Employment-based staff model supporting multi-salon work with independent roles per salon
06
Live product serving real salons — donebyme.dk
/ Tracks
- Client
Media
The Problem
Approach
Key Decisions
- 01
Shared database with row-level tenant_id
I chose shared DB over DB-per-tenant. Cheaper, simpler to operate, and a Drizzle query wrapper auto-injects the tenant filter on every query. Forget to set the tenant context and it throws at the framework layer. No silent leaks. - 02
Service-Repository separation
Business rules like overlap detection, cancellation windows, and loyalty accrual live in services. Persistence lives in repositories. I can test the booking engine without Postgres and swap storage without touching domain logic. - 03
Pessimistic locking plus a unique index, not three stacked layers
Two customers hitting the same 3:00 PM slot at the same time was the hardest problem to solve. A SELECT FOR UPDATE inside the write transaction locks the relevant rows before insert, and a partial unique index on (tenant, staff, date, start_time) is the hard backstop if that check is ever skipped. I originally described this as a three-layer system including a Redis lock — on review, the Redis lock actually protects a separate concern (the temporary slot-hold cache during checkout), not the booking insert itself. Worth being precise about which mechanism guards what. - 04
Fixing a real fee-mismatch bug by removing the second source of truth
An audit I ran on the platform fee system found two independent settings deciding the platform's cut of a booking — one config table read by the pricing preview, one environment variable read by the actual Stripe charge — and they disagreed by 5 percentage points. Worse, the calculated fee was never persisted to the booking row, so it was silently discarded. Fixed it by making the booking row the single source of truth: the fee is computed once at booking creation, stored there, and every downstream read (payment, refund, reporting) reads from the booking, not from a live config or env var. - 05
Timeline computation for per-service staff assignment
When a customer assigns different staff to different services, the system has to find start times where everyone's free sequentially. I built a cursor-based engine that iterates 15-minute intervals, chains services with buffer times, and validates constraints in one pass with zero I/O. - 06
Staff modeled like LinkedIn, not flat staff records
Staff needed to work at multiple salons with different roles and pay at each. I modeled it like LinkedIn: one global profile per person, then per-salon employment records, then per-service competencies. A stylist who works at two salons has two employment records, each with its own schedule, services, pay rate, and visibility. The same person can be a manager at Salon A and regular staff at Salon B. Onboarding detects whether the invitee is new, an existing customer, or already staff elsewhere and adapts accordingly. - 07
Platform fee snapshotting at booking creation
Platform fees are calculated and stored on the booking record when it's created. They're never recalculated or modified after that. This prevents disputes about what the fee was at the time of booking vs what it is now. The fee rate comes from a two-tier resolution: per-tenant override if set, otherwise global platform default. - 08
Availability modeled as three overlapping tiers
Staff availability isn't just 'works 9-5'. It's recurring weekly schedules, overridden by specific-date changes, further modified by exceptions like holidays or emergencies. Each tier can set different hours, breaks, max bookings, and service restrictions. The repository queries check overrides first, fall back to recurring, and exclude exception dates. Breaks are treated as busy intervals and removed from available slots. - 09
Single Stripe account instead of Stripe Connect
I chose a single platform Stripe account over Stripe Connect. All customers and payments live under one platform Stripe account. Every payment carries tenant_id and booking_id in metadata for settlement. This is simpler to implement and avoids Stripe Connect's complexity with onboarding, verification, and liability. The trade-off is that automated tenant payouts are a future feature. - 10
NestJS cron with advisory lock gating
Background jobs run via @nestjs/schedule Cron decorators. Each job acquires a PostgreSQL advisory lock on startup. If another instance holds it, the job skips. No job queue needed, and multi-instance safety is handled by the database. - 11
Idempotent webhook handlers with event ledger
Stripe webhooks arrive out of order and retry on failure. Incremental state updates broke after retries. I made every handler idempotent: subscription state is derived from the latest known event, and an event_id ledger prevents double-processing.
Architecture
/ System proof
These are not tool badges. They describe the boundaries, consistency controls, async paths, and failure-mode decisions behind the build.
- 01Shared schema, row-level tenant isolation (tenant_id on every table)
- 02Service-Repository pattern — domain logic never touches SQL
- 033 staff selection modes: auto-assign, single staff, per-service (timeline computation)
- 04Booking-write concurrency: SELECT FOR UPDATE inside the transaction + a partial unique index backstop; a separate Redis lock protects the slot-hold cache
- 05NestJS cron jobs with PostgreSQL advisory lock distributed gating
- 06Employment-based 3-layer staff model (global profile → per-salon employment → per-service competencies)
- 073-tier availability resolution (recurring schedule → date override → exception/break)
- 08Time-off conflict resolution with progressive deadlines, customer alternatives, and auto-resolution cron
- 097-tier RBAC across two hierarchies (super_admin → customer, owner → staff)
- 10Stripe Platform Model with metadata-based tenant settlement and idempotent webhook processing
- 11Redis cache with tenant-scoped key namespacing
- 12Unified calendar merging bookings, availability, time-off, and calendar events
- 13PostGIS proximity search with Haversine fallback
- 14Platform fee snapshotting at booking creation with two-tier rate resolution
- 15Walk-in booking atomic transaction (user + profile + booking in one DB transaction)
- 16PWA + Capacitor for cross-platform mobile delivery
- 17Full audit trail via booking_history with JSONB snapshots
- 18Staff auto-assign scoring: specialization match, years of experience (capped), seniority tier, same-day workload penalty
Challenges & How I Solved Them
Concurrent booking race conditions
/ Problem
/ Solution
Multi-staff sequential booking (timeline computation)
/ Problem
/ Solution
The employment model: same person, different roles at different salons
/ Problem
/ Solution
Resolving availability across schedules, overrides, exceptions, and breaks
/ Problem
/ Solution
Time-off approval affects existing bookings
/ Problem
/ Solution
No-show detection across timezones
/ Problem
/ Solution
Walk-in atomic transaction
/ Problem
/ Solution
Outcomes
- Live at donebyme.dk serving real salons.
- Booking engine handles auto-assign, single staff, and per-service selection with sequential timeline computation.
- Found and fixed a real billing bug where the platform fee shown to customers and the fee actually charged came from two disconnected sources — now a single source of truth, snapshotted on the booking.
- No double-booking incidents reported since the transactional lock plus unique-index guard shipped — the unique index has never actually had to catch anything, but it's there.
- Tenant isolation is enforced by the framework. No developer has to remember WHERE tenant_id.
- Stripe Platform Model with replay-safe webhook processing and metadata-based tenant settlement.
- Staff can work at multiple salons with independent roles, pay, and visibility at each.
- Availability resolves across recurring schedules, date overrides, exceptions, and breaks in real-time.
- Time-off approvals trigger customer notification, alternatives, progressive deadlines, and auto-resolution.
- Scheduled jobs for notifications, reminders, no-show detection, and loyalty run via cron with advisory lock gating.
- Staff see a unified calendar that merges bookings, availability, time-off, and personal events in one view.
- PWA and Capacitor deliver native-like iOS and Android apps.
- 7-tier RBAC split across platform roles (super_admin, admin, support, customer) and tenant roles (owner, manager, staff).
What I Learned
- 01
I spent way too long early on trying to make optimistic locking work for booking slots. Advisory locks and DB constraints are simpler and you can trust them. Move on faster next time.
- 02
The event_id ledger for Stripe webhooks saved me more than once. Webhook handlers that mutate state incrementally are fragile. Derive state from the latest event and make every handler safe to replay.
- 03
Service-repository separation felt like overhead at first. But when I needed to add an admin panel and webhook handlers, both reused the same services without changes. That's when it paid off.
- 04
The employment model (global profile + per-salon records) was more work upfront but worth it. Duplicating staff profiles per salon would have been faster but wrong. The onboarding detection service that handles three user scenarios was the trickiest part.
- 05
Not every background job needs a dedicated queue. Cron with advisory lock gating covers most cases. BullMQ or RabbitMQ would have been overkill for what amounts to 'run this every 5 minutes and don't fire twice'.
- 06
I described the concurrency setup as 'three layers of defense' for a while before actually re-checking what each mechanism protects. The Redis lock guards the slot-hold cache, not the booking insert — the real anti-double-booking guarantee is the transactional lock plus the unique index, two mechanisms, not three. Precision here matters more than the bigger-sounding number.
- 07
I chose a single Stripe account over Stripe Connect. It's simpler and gets the job done. But if the platform grows to hundreds of tenants needing automated payouts, I'll have to migrate. The metadata-based settlement model makes that possible without a full rewrite.
Tech Stack
Next Steps
- Per-tenant analytics on a read replica with materialized view refresh
- Multi-location support (one tenant operating across multiple physical salons)
- Self-serve onboarding for new salon owners