Meunu
A multi-tenant restaurant SaaS I built and still run alone β 364 stores signed up, 1,085 diners served, 2,531 orders processed.
Meunu is a digital menu, order board, table system, cash register and inventory for Brazilian restaurants β merchant dashboard on one side, a public per-subdomain storefront on the other. 364 stores have signed up, 74 of them in the last 30 days, and 33 are running orders through it; 1,085 distinct diners have ordered, across 2,531 orders since April 2024 β roughly a fifth of them in the last month. Solo across three years and 971 commits: the product decisions, the schema, the money handling and the on-call are all mine. That constraint shaped the architecture more than any preference did.
ROLE
Founder
Software Engineer
Full Stack
TOOLS
Next.js
React
TypeScript
Prisma
PostgreSQL
SWR
TailwindCSS
Playwright
DURATION
2023 - Present
INTEGRATIONS
Stripe
Mercado Pago
WhatsApp Cloud API
Sentry
Vercel
π
meunu.com.br
βΆ Five ways to place an order, one transaction boundary
Storefront, table QR, manual POS, payment webhook and WhatsApp all converge on a single function. What belongs in the transaction is what must be true together; the printer, the notifications and the non-blocking stock path run after the commit, each isolated β because a failure there must never destroy an order that already exists.
βΆ Whether to refuse a sale depends on the channel
Out of stock is not one rule. On the storefront the customer is still choosing, so the decrement runs inside the transaction as UPDATE β¦ WHERE stockQuantity >= qty β a compare-and-swap that makes negative stock unreachable without any lock. On a webhook the money is already captured, so the same code clamps to zero instead of failing, because refusing there would leave someone charged with no order.
Refusing a sale is a business decision, not a technical one
enforce Β· true
storefront Β· table qr
the customer is still choosing β refusing is the honest answer
The WHERE clause is a compare-and-swap: no lock, and negative stock is unreachable even under concurrent orders. The loop keeps going to report every shortage at once instead of one per attempt.
enforce Β· false
manual pos Β· payment webhook
the money is already captured, or the owner is looking at the shelf
Order matters: decrementing first would let the clamp re-match the row it just reduced and zero it. Refusing here would leave a customer charged with no order β the worse failure.
Availability is derived, never a stored status: status === true && (stockQuantity == null || stockQuantity > 0) β so sold-out is reversible on restock and never gets confused with an item the owner deliberately disabled.
βΆ Realtime without a websocket
Supabase Realtime was removed on purpose. What replaced it is a polling protocol: one tab wins a Web Locks lease and becomes the only poller, the server short-circuits on MAX(updatedAt) before running any snapshot query, and the delta fans out to the other tabs over BroadcastChannel. Moving the interval from 3s to 5s cut about 40% of the platform's request volume for two seconds of latency.
Realtime without a websocket
1 Β· one tab wins the lock
tab A
idle
tab B
idle
tab C
idle
navigator.locks.request('meunu:orders-polling-leader') β the handler returns a promise that never resolves, so the lock is held until the tab unloads. No locks API, no lock: everyone polls, exactly the old behaviour.
2 Β· GET /orders/changes?since=cursor
every 5s, leader only Β· the cursor lives in module scope so both hooks share one SWR cache entry
3 Β· server short-circuits
MAX(updatedAt) β€ since β { changed: false }, no snapshot query
empty result echoes `since` back instead of now β advancing the cursor past data that does not exist loops forever on changed:true, orders:[]
orderBy desc, not asc β with asc + take:limit, a store sitting on many old pending orders would never see today's
4 Β· BroadcastChannel fan-out
followers write the delta straight into the SWR cache with revalidate:false β one network request feeds every open tab. The leader tags each message with its own TAB_ID so it ignores its own echo.
βΆ A subscription webhook that assumes it will be retried
The effect and its idempotency marker are written in the same transaction, because applying one without recording the other is what turns a retry into a double grant. Out-of-order delivery is handled by a watermark in the WHERE clause rather than by trusting timestamps, and permanent failures are marked processed and answered 200 so Stripe stops retrying something that can never succeed.
Subscription webhook β six guards
signature + replay window
constructEvent with a 300s tolerance
an old captured body cannot be replayed later
effect and marker share one transaction
$transaction([...ops, processedWebhookEvent.create()])
applying the effect then failing to record it would let a retry duplicate it
P2002 on the marker is benign
another worker already applied it β log a warning, return null
a concurrent duplicate is not an error worth failing the request over
watermark for out-of-order events
updateMany WHERE stripeSubscriptionUpdatedAt < eventDate
Stripe does not guarantee ordering β the WHERE clause is the atomic barrier
post-transaction race detection
count === 0 without the pre-check firing β tagged post-tx-race
separates a real lost race from an expected skip, instead of silently swallowing both
permanent vs transient errors
Stripe 4xx β mark processed, return 200
retrying a permanently invalid event forever buys nothing; everything else returns 500
βΆ Diagnosing before fixing
The cash register under-counted, and the write-up traced it to one cause: paying a table then finishing it nulls Payment.tableId, which is the only thing scoping that payment to a tenant β so the normal flow was precisely the one that broke. The same root cause explained two other symptoms, and the fix is a shiftId that makes a payment born inside a shift instead of being scraped into it by a time window.
βΆ A scalability plan that argued with itself
The performance write-up ran a review against its own first draft and published the corrections: the endpoint assumed to be expensive was round-trip bound rather than scan bound, and the obvious composite index was dropped once the predicate turned out to be a negation a b-tree cannot use. Cache targets were walked back after noticing hit rate falls as a store gets busier β tag invalidation means caching helps the idle store most.