4 min readDony Marsahid
Building ERP Finance Modules Accountants Can Trust
Ledgers, journals, and audit trails: the engineering rules that keep financial numbers correct when a business system grows from one building to many.
- ERP
- Finance
- MongoDB
- Node.js
01 / 02 · Building ERP Finance Modules Accountants Can Trust
Most business systems eventually become finance systems. A property platform bills tenants; a loyalty platform turns points into liabilities; a warehouse app pays couriers and craftsmen. The moment money moves, the accounting team becomes your most demanding user — and they are right to be.
This article collects the rules I follow when building finance modules in Node.js and MongoDB. None of them are exotic. Together they are the difference between a dashboard that shows numbers and a system accountants will actually sign off on.
Start from the journal, not the report
Managers ask for reports: a Profit & Loss, a Trial Balance, an aging list. It is tempting to build each report as its own query over invoices, payments, and expenses. Do not.
Every financial fact should first become a journal entry: a dated record with at least two lines whose debits equal its credits. Reports are then just views over posted journal lines grouped by account and period.
interface JournalLine {
accountCode: string; // e.g. "1-1100" Cash, "4-1000" Revenue
debit: number; // in the smallest currency unit
credit: number;
}
interface JournalEntry {
number: string; // JV-2026-10-000123, sequential per period
date: Date;
source: { type: "invoice" | "payment" | "expense"; id: string };
lines: JournalLine[];
status: "draft" | "posted" | "void";
}
When the General Ledger, Trial Balance, and P&L all read from the same posted lines, they cannot disagree with each other. On one property platform, moving reports onto the journal cut the monthly recap from about a week to one or two hours, because nobody had to reconcile reports against each other anymore.
Store money as integers
Floating-point numbers cannot represent most decimal amounts exactly. Store amounts as integers in the smallest unit (cents, or whole rupiah when there are no decimals), and format only at the edge. Validate that every entry balances before saving:
const totalDebit = lines.reduce((s, l) => s + l.debit, 0);
const totalCredit = lines.reduce((s, l) => s + l.credit, 0);
if (totalDebit !== totalCredit) throw new Error("Unbalanced journal entry");
Posted means immutable
Accountants do not delete history; they correct it. Once an entry is posted, never update or delete it. To fix a mistake, post a reversing entry and then the correct one. Your API should make the wrong thing impossible: no PATCH on posted entries, and a dedicated /reverse endpoint that creates the mirror entry and links both.
The same applies to documents that create entries. A paid invoice should be voided and reissued, not edited in place. Soft-delete with a trash view is fine for drafts and master data, never for posted transactions.
Make every write idempotent
Payment gateways retry webhooks. Users double-click. Mobile networks resend requests. If a payment notification arrives twice, your system must record one payment, not two.
- Accept an idempotency key on every endpoint that creates money movements, and store it with a unique index.
- For gateway webhooks, use the gateway's own transaction ID as the key.
- Return the original result when a duplicate key arrives instead of failing loudly.
await payments.createIndex({ idempotencyKey: 1 }, { unique: true });
Use transactions where documents must agree
Marking an invoice as paid, recording the payment, and posting the journal entry must succeed or fail together. MongoDB supports multi-document transactions on replica sets, so use them for these few critical paths:
const session = await mongoose.startSession();
await session.withTransaction(async () => {
await Payment.create([payment], { session });
await Invoice.updateOne({ _id: invoiceId }, { status: "paid" }, { session });
await JournalEntry.create([entry], { session });
});
Keep transactions short and keep them out of the request path for slow work like PDF generation or emails; do that after the commit.
Leave an audit trail people can read
Every change to financial data needs who, when, what changed, and why. Store the actor, timestamp, before and after values, and a required reason on reversals. Then show it in the UI. An audit log that only developers can query does not help the finance team at month end.
Verification states help too. In a warehouse finance system I built, every cost moved through explicit steps — recorded, checked, paid, checked after payment, done — and each step stamped the user and the date. Disputes became a matter of reading the history instead of searching chat threads.
Close periods
Once a month is reported, it should be locked. Add a period table with an open / closed status and reject any posting dated inside a closed period. Late corrections go into the current period with a clear reference. This one rule protects every report that has already been sent to management.
Reconcile automatically, review manually
Build reconciliation views that compare what should match: gateway settlements against payments, bank statements against cash accounts, invoice totals against receivable balances. Let the system highlight differences, and let people decide what to do about them.
A checklist before you ship
- Every money movement creates a balanced journal entry.
- Amounts are integers; formatting happens only in the UI.
- Posted entries are immutable; corrections use reversals.
- Money-creating endpoints are idempotent.
- Critical multi-document updates run inside transactions.
- Every change is auditable — and the audit trail is visible.
- Closed periods reject postings.
Finance modules reward boring, careful engineering. Get these foundations right and the reports become the easy part.
Dony Marsahid
Senior Fullstack Engineer (Tech Lead) · ERP & AI-Integrated Business Systems
→
More articles
4 min read
AI-Assisted Development With Engineering Discipline
Specs, project memory, verification gates, and handover notes: how to let an AI agent write most of the code without letting it own the decisions.
- AI
- Claude Code
- MCP
- Workflow
4 min read
Shipping React Native Without Expo: A Release Checklist for iOS and Android
Signing, versioning, store review, and the small native details that decide whether a bare React Native release goes smoothly or stalls for a week.
- React Native
- iOS
- Android
- Release
Start a project
Tell me what the system needs to do. I will reply with questions, an approach, and a realistic timeline.

