The problem
A small fried-food stall sells at the counter, on GrabFood and on ShopeeFood. Each channel takes a different commission, the apps add VAT and ad fees, and ingredient prices change with every delivery. The owner knew how much money came in, but not how much of it was profit, or which dish was losing money.
Yumi Kitchen is a small ERP built for that owner: menu, recipes, stock, orders on every channel, cash shifts and expenses, turned into an accurate profit-and-loss report. I built it alone in six weeks, and the owner moved over from Excel with their past figures and ran the stall on it until they stopped selling.
Scope
- A menu with a recipe for every dish, down to the gram.
- Ingredients, suppliers and purchases; stock updates itself with every order.
- Orders from the counter and both apps, plus a customer ordering page with Telegram alerts.
- Cash shifts, expenses, and Excel import and export for everything.
About 30K lines of TypeScript, 48 API endpoints and 20 PostgreSQL tables.
Architecture
One Next.js 16 app: pages render on the server and the business logic lives in route handlers under /api. No ORM: raw SQL over pg, with a small migration runner I wrote. It runs on Heroku.
Next.js 16 (App Router)
├── /menu, /checkout, /order customer ordering
├── /admin/* owner's back office
└── /api/* route handlers ──► raw SQL (pg) ──► PostgreSQL
costing · stock · P&L rules
Key technical decisions
Recipes drive stock, and cost is frozen at the moment of sale
Each order deducts its ingredients by recipe and records what they cost at that moment. So last week's cost of goods stays right after a supplier raises prices, and cancelling an order puts back exactly what it took.
Integer math for rates and margins
Rates are stored as integer basis points (5500 = 55.00%), and all rounding happens in one helper, so no floating-point error ends up in the profit figure.
Real platform rules, not a simplified model
The P&L follows how each app actually pays out: its own commission, 8% VAT on GrabFood's commission, and ad fees. If an app charged a different amount, the owner can enter the real figure on the order.
Built for an owner who lives in spreadsheets
Closing a shift compares the cash the system expects with the cash counted and records the difference. Excel templates let the owner bring existing data in without retyping it.
Tests that check the money, not the status code
Playwright runs a whole store lifecycle (buy stock, sell on every channel, cancel orders, close a shift) and checks stock, cost, revenue and margin after each step. A test passes only when the numbers are right.
What the real shop taught me
- Each app charges differently. Only GrabFood adds VAT on its fees, so only Grab orders get it.
- Discounts come in two shapes, a fixed amount or a percentage, and orders accept both.
- Dates follow the shop's day. While dates were in UTC, recurring expenses were saved one day early. Everything now runs on Vietnam time, and a migration fixed the old rows without touching the ones the owner had edited.
- The owner thinks in dishes, not tables, so dishes can be duplicated and recipe lines reordered by drag and drop.
What I would change next
Move the admin token from localStorage to an httpOnly cookie, refuse to start when the session secret is missing, and move the in-memory rate limiter to PostgreSQL before running more than one instance.