Harmonia: a single-tenant luxury salon system, end-to-end in 14 days
Single-tenant on purpose, against every SaaS instinct. An agency would have quoted 8–12 weeks at around $40K for the same POS + booking + payroll + receipts + RTL + PWA scope. I shipped v1 solo in 14 days, then a v2 sweep two days later that added role-aware UIs, finances, audit, and commission settlement. The architectural call is what made the calendar fit.
- receipts
- shipped
- pos
- single-tenant
- arabic-rtl
- productized
The cost equation a buyer cares about is the one I lead with. An agency would have quoted 8–12 weeks at around $40K for a single-tenant luxury salon system with a real POS, real bookings, real payroll, an audit log, and Arabic-first RTL polish. I shipped the v1 end-to-end in 14 days, solo. Two days later I shipped a v2 sweep that added role-aware UIs, a finances overhaul, payroll commission settlement with audit trail, and a refund flow.
This is the receipt for that build. It's also a piece of evidence about a question the prospects who reach me are usually trying to answer: can a single senior operator close real production scope in two weeks, or am I about to get an agency-style demo? Read this and decide for yourself.
00 — The brief
Harmonia Salon is a real luxury salon in El-Arish, Sinai — a coastal city in northeast Egypt. Sea Street, across from Al-Ayyub Mosque. Four phone lines. Black-and-gold "885 HARMONIA" brand imprint. Owner is non-technical, Arabic-first, runs the business off a phone and WhatsApp. Wants one view: am I making money today? Net profit, not gross revenue. Trusts what he can see.
The salon is men-only. This is the constraint the brief mentions first because it shapes every default — demo names, package copy ("باقة العريس" / "groom's package"), photography choices, even appointment slot defaults. A wedding-grade single-tenant system that pretends to be unisex would be wrong on day one.
The competitor system the owner almost bought was XCode Systems. The owner had watched their YouTube demo, written down what he liked, and was ready to sign a lease-to-own SaaS contract. I told him I'd build him a better version, single-tenant, owned outright, in less time than their onboarding usually takes. The deal was: free build in exchange for case-study rights and word-of-mouth across the owner's El-Arish network.
What "ship" had to mean for v1:
- A POS the staff can ring up a service in ≤15 seconds and get commission credit.
- A booking page customers can hit on a phone with bad signal.
- A daily cash close that reconciles cash, Instapay, and card-on-delivery separately.
- A receipt that doesn't look like generic Stripe boilerplate — it looks like the salon's brand.
- An owner dashboard that surfaces net profit, not gross.
- All in Arabic-first RTL, with English as the secondary toggle for Omar's superadmin view.
- All on infrastructure the salon can keep running for under $25/month.
01 — Single-tenant on purpose
Before the day-by-day, the call this whole ship rests on: Harmonia is a single-tenant codebase. One repo, one Supabase project, one customer. The default SaaS reflex is the opposite — build it once, sell it ten times, amortise the engineering across the buyer pool. I went the other way deliberately, and the 14-day calendar would not have been possible without that decision.
Four reasons single-tenant was right here, in order of how much budget each saved:
- The hard parts of multi-tenant SaaS are friction here, not value. RLS gymnastics, tenant-context middleware, per-tenant rate limits, tenant-scoped feature flags, plan/quota tables, billing webhooks — all of that overhead exists to support buyers who haven't bought yet. Harmonia has bought. The owner wants his POS to work. He doesn't want to subsidise a POS-platform that scales to salons that haven't signed.
- The real complexity moved to the domain. When I'm not spending architecture-budget on tenancy, I can spend it on Arabic RTL on touch tablets, commission rules that match the cash-handling reality of an Egyptian salon, an audit log the owner can show his cousin who runs the second shift, and a receipt that prints to thermal hardware and a black-and-gold PDF email. That's where the engineering hours actually went.
- I can ship in days, not months. 14 calendar days, two waves. A multi-tenant version of the same scope is a quarter of work, minimum, before the first sale. The single-tenant call collapses the calendar more than the stack choice does.
- Single-tenant is itself a sales tool. The owner doesn't share a database with strangers. His takings, his stylists' commissions, his client list — his. Owned outright, on infrastructure under his name. In a country where data-trust is hard-earned, "yours alone" is a feature, not a constraint.
The slogan I'd put on the wall: multi-tenant SaaS is a sales-stage architecture. Single-tenant is an operations-stage architecture. Harmonia is in operations stage. So the codebase is too. If salon #2 calls — and the deal is for case-study rights and word-of-mouth, so it eventually will — I'll fork. Forking a working single-tenant codebase is a one-day task. Refactoring a multi-tenant codebase to fix tenant #1's actual problems is a quarter.
The fuller architectural argument lives on the /work/harmonia case study. The short version is the rest of this essay: every day-by-day decision below got faster because tenancy wasn't on the table.
02 — The 14-day plan, day by day
This is roughly how the 14 days went. I'm leaving in the messy parts.
Days 1–2: schema first, UI never
foundationNo code in the app yet. Postgres schema only. services, service_categories, staff_profiles, appointments, orders, order_items, expenses, daily_settlements, business_settings, audit_log. RLS policies sketched but not enforced yet. The schema decision that mattered most: every order line item carries a commission_pct snapshot at sale time, not a foreign key to a "current commission rate" table. Rates change. Already-paid line items shouldn't change with them. This single decision saved me a full day in week two when I built payroll.
Days 3–4: the POS sale wizard
core pathA four-step flow: pick services → pick worker → pick payment method → confirm. Each step is one screen, big tap targets, no nested menus. Built for thumb reach on a 6.1" Android because that's what the staff actually use. Internationalised against Arabic strings from the start — I never wrote an English string then translated; that always rots into bilingual tech debt.
Days 5–6: branded receipt + thermal printer fallback
brand-criticalThe owner cares about how the receipt looks more than how the dashboard looks. The receipt is what the customer carries out the door. Black-and-gold, Cairo + Playfair Display fonts, the salon address and four phone lines in the footer. PDF rendered server-side via React + Resend's HTML rendering — same renderer as the email path so receipts can also be emailed. Thermal-printer fallback: the receipt also has a ?print=thermal mode that strips the gold and renders 80mm-friendly monochrome.
Days 7–8: customer booking 2.0
self-servePublic /book page. Worker availability is computed against actual appointments rows, not a separate calendar table — single source of truth, no sync drift. Service duration respected (a haircut blocks 30 min, a hijama session blocks 60). RTL-correct date pickers were the part that took longest; the off-the-shelf shadcn component needed a wrapper to pass dir="rtl" through to the popover.
Days 9–10: RTL + touch polish + PWA shell
feelArabic-first UI means more than dir="rtl". It means every icon that points "forward" has to flip (chevrons, arrows, the back button). Every shadow direction that was set in pixel offsets (shadow-[2px_0_…]) had to switch to logical properties (shadow-[2px_0_…] becomes shadow-[var(--shadow-inline)]). PWA: standalone display mode, theme color matching the gold accent, install prompts on Android. Manifest icon was rendered from the salon logo at 5 sizes.
Days 11–12: 300 demo orders + telemetry seed
believableEmpty production systems are bad demos and worse trust signals. I seeded 300 realistic orders backdated across the prior 90 days — masculine Arabic names, plausible service mixes (haircut → beard trim → hair oil bath), payment-method distribution that matches what the owner described (cash dominant, Instapay growing, card-on-delivery rare). Every demo row carries a <<DEMO>> marker in a notes field so a single SQL command purges them on go-live.
Days 13–14: cash close + the owner dashboard
net profitDaily settlements that reconcile cash drawer, Instapay, and card-on-delivery actuals against system totals. Variance gets flagged, not auto-corrected. The owner dashboard: today's revenue, today's expenses, today's net, week-to-date, month-to-date. One screen, three KPIs, the rest is a button to drill in.
That was v1. Domain pointed at harmonia-pos.vercel.app. Owner Telegram chat_id seeded so big-ticket sales fired a notification. I sent him the URL, he opened it on his phone, scanned a service, hit confirm, and watched the receipt PDF render. Took him ~20 seconds. He used the word "تمام" (perfect). v1 was live.
03 — The v2 sweep, two days later
V1 was correct for the owner. V1 was not correct for the staff and the receptionist who would be using it eight hours a day. Two days after launch I sat with the owner over a video call and watched him try to give a manager-level view to his cousin who runs the second shift. There was no manager view. There was just an owner view and a staff POS view.
So I shipped a v2 sweep:
Five roles, not two
role-aware UIsdeveloper (my superadmin), owner, manager, receptionist, staff. Each role gets a different sidebar. A staff member sees only POS, Appointments, Customers, My Earnings, My Attendance, My Time-off, My Payroll. A receptionist adds Memberships and Settlements. A manager adds Refunds and Reports. The owner sees everything. The developer sees everything plus impersonation.
Single source of truth for permissions
src/lib/permissions.tsOne file. Every permission key is defined there with its minimum-role floor. Every page reads requirePermission("page.X"). Every server action reads requirePermissionForWrite("action.X"). UI components read canFor(session, "action.X"). No "if (role === 'owner')" sprinkled through the codebase. The week I saved by not littering role checks across 40 routes is the week that made the v2 sweep possible in two days.
/finances — the P&L view
net profit, monthlyKPI tiles for revenue / expenses / commissions / net profit, with month-over-month delta. Twelve-month sparkline. By-payment-method bars. Expenses by category bars. Top services by revenue last 90 days. Total cash drift across the period. The view is read-only and runs entirely off a v_monthly_pnl Postgres view I built specifically so the dashboard could be one query and stay fast.
/payroll — commission settlement with audit trail
cash-close ownershipOpen commissions per worker. A "Pay & close" dialog that snapshots a commission_payouts row (worker_id, period_start, period_end, total_egp, paid_at, paid_by). Full payout history. Per-worker drilldown showing every line item between the last period_end and now. This is the system the owner uses to settle commissions in cash on Friday afternoon — the exact ritual that previously lived in his head.
/my-earnings — the staff side of the same view
trustSame data the owner sees about a worker, but scoped to that worker. Current open balance, this month's gross + items, lifetime, last payout, full payout history. A staff member can see exactly what they're owed and exactly when they were last paid. Disputes about commission math drop to ~zero when both sides see the same numbers.
/audit — the action log
paper trailEvery consequential action writes a row: order create, order void, order refund, expense create, expense delete, package sell, staff upsert, payroll settle, settlement close. Actor, action, JSON diff. Owner-only via RLS. URL-backed filters so the owner can paste a link to "every refund Ahmed approved last month" into a WhatsApp thread.
Refund flow + tax + customizable categories
finances v2Refunds are a distinct status from voids: a void is "this never happened," a refund returns money to the customer. Tax toggle in /settings adds a configurable VAT line to receipts and the UI. Expense categories moved from a static enum to a expense_category_defs table so the owner can add categories without a migration.
Two days. One operator. Six new pages. One new permissions architecture. One Postgres view. Three migrations.
04 — What the 8-week version would have looked like
I'm allergic to the framing that a 14-day ship is just an 8-week ship with corners cut. Some of what I deferred were genuine corners I'd cut again. Some were corners I would not cut a second time.
What an 8-week agency build would have added that I genuinely did not need:
- A bespoke design system. The salon's brand is captured by a four-token palette in
docs/BRAND.mdand it composes via Tailwind v4. The cost of inventing button variants the agency would later charge to maintain was avoided. - Test coverage above ~30%. I have type-checks, end-to-end smoke tests on the four critical paths, and ~zero unit test coverage on UI components. The v2 refactor revealed exactly one regression that a unit test would have caught. One. The cost of writing the other 200 tests is not justified at this scale.
- A handoff document. There is no team. The handoff is a single project-context markdown file plus
docs/PRD.mdanddocs/SCHEMA.sql, and that's enough for the next operator (which is me, or a future me's coding agent) to land a change inside one session.
05 — The commission settlement system, in code
The piece I'm proudest of in v2 is the commission settlement audit trail. The owner pays his stylists in cash on Fridays. Before this system, the math lived in his head and a paper notebook. After this system, every payout is a row, every line item is linked to a payout once it's been settled, and disputes resolve in seconds.
The core idea: don't recompute commissions, snapshot them. The commission_pct on every order line item is captured at sale time. The payout row records "this period ends now," and any open line item before that period_end is considered paid. New line items after that period_end belong to the next period.
-- v_worker_unpaid_commission
-- For each worker, find the latest commission_payouts.period_end
-- and sum every line item with that worker as commission_recipient
-- where the order was created strictly after that period_end.
create or replace view v_worker_unpaid_commission as
select
s.id as worker_id,
s.display_name,
coalesce(sum(oi.commission_amount_egp), 0) as open_commission_egp,
count(oi.id) as open_item_count,
max(o.created_at) as latest_open_item_at,
(
select max(period_end)
from commission_payouts
where worker_id = s.id
) as last_period_end
from staff_profiles s
left join order_items oi
on oi.commission_recipient_id = s.id
left join orders o
on o.id = oi.order_id
and o.status = 'paid'
and o.created_at > coalesce(
(select max(period_end) from commission_payouts where worker_id = s.id),
'1970-01-01'::timestamptz
)
where s.role in ('staff', 'manager', 'receptionist')
group by s.id, s.display_name;The settlement action is a server action wrapped in a transaction: insert the commission_payouts row, write an audit_log row tagged payroll.settle, return the snapshot to the UI. The whole thing is ~40 lines including the validation. The owner clicks "Pay Hassan 1,847 EGP for the period ending today," and three seconds later both /payroll and Hassan's /my-earnings show the same updated balance.
06 — Lessons for buyers thinking about $40K agency vs solo operator
If you've been quoted 8–12 weeks at $30–50K for a system that fits in this complexity envelope, here's what I'd check.
The $40K number isn't anecdote. Public 2026 rate cards from productized AI-engineering shops put MVP-with-AI builds at $2,500 (Cognio Basic) → $6,000 (Cognio Production). Brand-intelligence builds at $4,999 + $299/mo subscription (JFKAISLAY). Custom multi-agent builds starting $9,999. Senior fractional retainers at $3K–$7,500/mo (Alacritous, John C. Derrick). Project-scoped AI engineering at $10–30K (Damian Galarza). A single-tenant POS + booking + payroll + audit + RTL + PWA is firmly in the upper half of that band — a working agency quote of $30–50K and 8–12 weeks is what the market actually pays. So the comparison is real, not strawman.
-
Ask the agency to show you their last three single-tenant builds at that price. Not multi-tenant SaaS demos. Single-tenant systems are a different shape and most agencies underestimate the asymmetric ownership your client wants. The agency's incentive is to build something that resembles their last project. Yours is to get a system that resembles your business.
-
Ask about the schema before you ask about the UI. A v1 with the wrong schema is a v2 rewrite. The two decisions that mattered most on Harmonia — snapshotting
commission_pctper line item, and usingappointmentsas the single source of truth for worker availability — were both schema decisions made in the first 48 hours. UI choices can be reversed in an afternoon. Schema reshapes what's possible. -
Ask what happens in week 6. The owner sent me a feature request in week 6 ("can you add membership tiers?") and it shipped that weekend. Agencies tend to deliver-and-disappear, then re-engage on a change order. Solo operators iterate weekly. That difference compounds.
-
The 14 days is the floor, not the ceiling. The codebase has had ~18 migrations and four feature waves since v1, each a 1–3 day push. v1 is when the system became useful, not finished. Useful is what your client cares about in week 1.
The cost equation isn't really "$40K vs $X." It's "8 weeks of agency overhead vs 14 days of a senior operator who cares about your business and will still be here in week 6." If you're evaluating that tradeoff right now, the audit sprint is the cheapest way to see if I'm the right operator before either of us commits.
What's next on Harmonia
The current production scope is ~40 routes, 18 migrations, five roles, a HR/payroll layer with QR clock-in, three membership tiers, customer segments, discount offers with 12 rule kinds, home service visits with a state machine, and a daily-digest cron. None of that was in v1. All of it was a 1–3 day push at a time. The membership-economy ship is the next writeup — measured in MRR, not in days.
// sources cited
// next move
Want a written architecture brief on your AI stack?
1 week, $1,500, fixed scope. Working prototype of one change in your stack — yours to keep regardless.
// related essays
- 82% → 96%field-level accuracy · 3 months
Bridge Sourcing: how I moved scrape accuracy from 82% to 96%
The 8 specific changes that moved a production B2B sourcing extraction pipeline from 82% to 96% field-level accuracy over three months — and the 2 changes that made it worse. The economic moat under the pipeline is Egypt's 0% EU import-tariff lane; the engineering moat is the calibration set. Calibration sets, schema forcing, deterministic validation, drift detection.
~10 min · 2026-04-30 - 2arbitrages stacked · time zone + senior pricing
Egypt-to-EU senior AI engineering — the 2026 thesis, not the 2024 cheap-outsourcing pitch
Two arbitrages stacked on one geography — time zone and senior-tier pricing. The 2024 framing of "outsource cheap engineering to Egypt" destroys both. The 2026 framing is single-engineer coverage of EU mornings AND US East afternoons at 60-70% of London rates, IF you filter correctly.
~8 min · 2026-04-30