Context
Central Plafon sells interior materials — PVC ceilings, wall panels, PVC marble, vinyl flooring, light-steel frames — and installs them. It serves two kinds of customer: retail buyers, and contractors who buy in bulk on credit.
A business like this usually lives in three disconnected places: an online catalog, a warehouse spreadsheet, and the till in the shop. I wanted one source of truth for all of it.
What I built
One monorepo (Turborepo + Bun) with one API and three apps:
- core-api — NestJS, Drizzle ORM, PostgreSQL. Products, variants, per-branch stock, pricing, purchasing, payroll.
- shop — a bilingual Next.js storefront with a material-quantity calculator.
- console — an internal dashboard (React + TanStack Router) for stock, purchase orders, invoices, delivery notes, and payslips.
- kasir — an Electron point-of-sale that keeps working when the internet drops and prints straight to a thermal printer.
Decisions that mattered
Money is always an integer. Prices are stored in the currency's smallest unit, never as floats, so an invoice total is never off by a rupiah.
/** Money is stored as an integer minor unit (whole rupiah for IDR). */ export interface Money { amount: number; currency: CurrencyCode; }
The till never turns a customer away. Offline sales go into a local queue and sync when the connection returns. If a sync would push stock below zero, the order isn't dropped — it's flagged for a person to review.
Search that understands intent. Every product has an embedding (pgvector), so a search for "moisture-proof bathroom ceiling" still finds the right product even when the words differ.