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

β–Έruns inside the order transaction
β–ΈUPDATE … WHERE stockQuantity >= qty
β–Έ0 rows β†’ shortage, collect and continue
β–Έthrows β†’ order + items roll back together

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

β–Έruns after the commit, isolated
β–Έclamp to 0 first, then decrement
β–Έnever throws, never refuses
β–Έorder survives regardless

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.

Reversal idempotency is derived from the ledger rather than a flag: cancelling reads the SALE movements for that order and restores exactly what was recorded, then writes CANCEL_REVERSAL rows. Running it twice is a no-op because the second run sees the reversal already there.

✢ 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.

Supabase Realtime was removed deliberately. At hundreds of stores the constraint is not query cost β€” it is connection and round-trip volume against a serverless pool. Dropping the interval from 3s to 5s alone cut about 40% of the platform's requests.

✢ 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

01

signature + replay window

constructEvent with a 300s tolerance

an old captured body cannot be replayed later

02

effect and marker share one transaction

$transaction([...ops, processedWebhookEvent.create()])

applying the effect then failing to record it would let a retry duplicate it

03

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

04

watermark for out-of-order events

updateMany WHERE stripeSubscriptionUpdatedAt < eventDate

Stripe does not guarantee ordering β€” the WHERE clause is the atomic barrier

05

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

06

permanent vs transient errors

Stripe 4xx β†’ mark processed, return 200

retrying a permanently invalid event forever buys nothing; everything else returns 500

Recovery from past_due is driven by invoice.payment_succeeded rather than subscription.updated, because subscription.updated fires on any change and could restore access without the debt actually being paid. On failure the paid period end is preserved, so gating comes from a separate flag instead of destroying the date the UI needs.

✢ 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.