◆ booting phatdev.net
<PhatLam/>
000%
← All case studies
Internal software · ERP · PostgreSQL

Tan Doanh ERP: inventory, sales, warranty and accounting in one app, now in a trial run

Stock, orders, cash collected on delivery, warranty and accounting numbers sit in many places, each counted its own way. I built an internal app for Tan Doanh on one foundation rule: a written entry is never edited, only reversed.

Published 8 Nov 2026 · 5 min read

Tan Doanh ERP: inventory, sales, warranty and accounting in one app, now in a trial run
Client
Tan Doanh and IGA (internal use)
Industry
Retail, inventory, sales and accounting
Year
2026
Role
Planning, architecture and operations (code written with Claude Code)
Duration
From 9 Sep 2026, in internal trial
Stack
  • Next.js
  • TypeScript
  • PostgreSQL
  • Docker
  • Playwright
126
business screens, counted in the source on 8 Nov 2026
120+
database migration files, numbered up to 140, counted 8 Nov 2026
14
automated browser test suites, per the project documentation
6 × 4
departments × levels in the structured permissions

Background

A retail group like Tan Doanh keeps its numbers in many places. Stock in one, orders in another, the cash that carriers collect on delivery in a third, warranty in a fourth, the accounting books in a fifth. Each place counts in its own way. To learn the gross margin, or how much a carrier still owes, someone has to sit down and reconcile by hand.

Worse, one wrongly edited old document is enough to make a figure drift without anyone knowing where from. I started building the Tan Doanh ERP on 9 Sep 2026: a single piece of software for inventory, sales, purchasing, warranty, finance and accounting, serving the Tan Doanh and IGA retail business.

This story is plain about where things stand. The software is for internal use, it is in a trial run, and it is not open to the public. So I can only tell you about its size and how it was built. I have no business results to show, and I will not invent any.

Diagram of the main blocks: staff, shared sign-in, application, database, data sources and backups
The main blocks of the ERP. Dashed lines are data synced one way into the ERP.

The problem

Three constraints come together.

First, this is accounting. One wrong number is wrong money, and when it is wrong you must be able to trace why. Second, many people in many departments use it, and one department's permissions must not leak into another's. Third, the real data lives in systems that are already running, so the ERP has to read from them without damaging them.

And there is a quiet fourth: the accounting part must not be confident too early. It has to run as a draft next to the accounting software already in use, until the two agree.

How it was done

1. A written entry is never edited, only reversed

Everything else rests on this rule. The stock, cash, receivables and cash-on-delivery (COD) ledgers each have a guard in the database itself: it rejects edit and delete commands on those ledgers. A wrong entry is fixed with a reversing entry linked to the wrong one, then the right one is entered. The whole chain stays in place, so every number can be traced back.

Illustration: a wrong stock-in, a reversal, a correct stock-in
An illustration of how a wrong ledger row is corrected, not real data.

I chose PostgreSQL for this because the database needs things it already has: exclusion constraints, triggers, fuzzy search without accents, and time types with time zones.

2. An order follows one closed path

A quote becomes an order, the order reserves stock, stock goes out, the parcel is handed to a carrier, delivery succeeds, COD is reconciled, returns are handled. All of it runs on one engine. A few rules I settled early because they decide the numbers: revenue is recognised on successful delivery, cost is averaged per item and per warehouse, a return is refunded at the discounted price, and COD debt equals collected minus paid in minus approved fees.

Diagram of an order's life in seven steps
The life of one order. Revenue is recognised at the delivered step.

3. Permissions by department, not one flat list

If every role goes into one shared list of permissions, one department's rights easily leak into another's. The ERP uses structured permissions: 6 departments, 4 levels, and each permission is tied to a scope (my own, assigned to me, the whole department, everything). The permission check always receives context, and with missing context it refuses. Delegation needs a reason, an approver and an expiry, and cannot be wider than the delegator's own rights. Finance actions separate the person who prepares from the person who approves. These guards sit in the service layer and the database, and do not depend on the interface.

4. Say what is not certain

Business assumptions that are not yet settled all run on a pre-chosen option and are listed in the software's Decision Register. Change the option and the reports change with it, under control, with no code edit. Any field that is guessed or is sample data carries a Demo data label on screen, so it is not mistaken for a real number.

5. Backups must prove themselves

Backups run nightly, encrypted, with 14 copies kept and one more copy off the machine. Every Sunday the system rebuilds the database from a backup by itself, counts the tables, tests the guard that blocks ledger edits, and raises an alarm if anything fails. That drill once caught a function that did not name its schema explicitly, and once raised a false alarm by counting too early. Both are fixed.

Results

This is the size of the system, not a business result. The numbers come from counting directly in the source code on 8 Nov 2026.

Chart of screens by area, 126 in total
126 screens, grouped by area. Counted from screen files on 8 Nov 2026.

The application has 126 business screens, spread over inventory (21), finance (17), accounting (17), sales (16) and several smaller areas. The database is built from 120+ migration files, numbered up to 140. Each new feature comes with its own migration file. According to the project's documentation there are 14 automated browser test suites, run against a copy built from the latest backup and hard-blocked if they point at real data.

From 9 Sep to 6 Oct the project went step by step: on day one the core ledger engine and 18 business test cases passing (9 Sep), more than 5,000 products loaded into the catalogue (11 Sep), purchasing (12 Sep), quotes (18 Sep), department permissions applied for real (29 Sep), the accounting ledger running as a draft (6 Oct).

Life of an order
A vertical version of the order-life diagram, drawn as an illustration because every real screen shows customer names and amounts.

There is one thing I have not managed, and it is the most important one. I do not have business results yet. The accounting part is writing a draft next to the accounting software now in use, and under the current plan it becomes the official ledger on 1 Jan 2027. Until then, any claim about how much time or effort the ERP saves would be a guess.

Lessons

Choose the foundation rule before you write screens. The sentence "write once, reverse never edit" sounds rigid, but it makes everything else easier: checking, tracing, correcting.

Put the guard where it is hardest to get around. A button hidden in the interface can still be called directly. A trigger inside the database cannot.

Let the software say "not sure". The Decision Register and the demo labels tell users which parts are still on trial, instead of trusting every number.

And do not rush to tell results you do not have. A bookkeeping system has to run in parallel long enough to be trusted, and it deserves a write-up once it has passed that stage.

Need something similar?

If your numbers are scattered across places that each count in their own way, or you need internal software where one wrong number is wrong money, send me a few lines about your current process. I will redraw the data flow first, then tell you plainly what to do first.

ERPPostgreSQLNext.jsAccountingInventory

Learn more