Journal entries
A journal entry (JE) in Fexl Lite is one row in journal_entries plus two-or-more lines in journal_entry_lines whose debits sum to credits exactly. Every flow that moves money — POS, refunds, restocks, supplier payments, expenses, cancellations, comps — writes one. They’re the foundation that the Trial Balance, General Ledger, Balance Sheet, and P&L all read from. This page is the shape, the invariants, and the guard rails.
Anatomy of a journal entry
A JE has two parts:
- Header (
journal_entries) — one row per entry. Carriesdate(business date),description,source_type(e.g.invoice,refund,expense,restock,supplier_payment,inventory_adjustment),source_id+source_uuid(link back to the originating record),user_id(who triggered it). - Lines (
journal_entry_lines) — two or more rows. Each carriesaccount_id,account_code,debit,credit, and an optional per-linedescription. Exactly one of debit/credit is non-zero on any given line.
The entry is the atomic unit on the GL. Every report that reads from accounting reads journal_entries joined to journal_entry_lines, never one without the other.
The balanced-entry invariant
Σ debits == Σ credits on every entry. Always.
Fexl Lite enforces this at the single chokepoint every handler goes through (postJournalEntryWithDate in accounting.go). The function sums lines before insert and rejects the entry with an error if |debit − credit| > 0.005. The 0.005 tolerance covers float-precision artefacts from VAT and discount percentage maths; any real imbalance is at least one unit of the smallest currency, far above the floor.
unbalanced journal entry rejected: debit=605.00 credit=705.00 diff=-100.00 ...Pre-fix, this was a silent acceptance — and one production tenant ended up with JE 4806 carrying a 100 IQD imbalance on a shipping-revenue reversal line. Once one unbalanced JE is in the ledger, every downstream report drifts. The chokepoint catches the bug at write-time, before it can poison the books.
Per-line descriptions
Each line carries an optional description field, populated when a single JE has two lines that share an account-and-amount but mean different things. The classic case is a sale-with-bonus: two CR 1200 Inventory lines on the same invoice, one for sold-product relief and one for bonus relief. Without per-line labels, the GL would render them as identical-looking duplicates and you couldn’t tell which was which.
#55 v1.6.100 added the per-line description column and wired it into the GL view: when set, the GL shows the line’s own label; when null, it falls back to the parent entry description.
Account resolution
Every line carries both account_id and account_code. The handler-side helper resolves code → ID at write time by reading the tenant’s chart of accounts:
codeToID := map[string]int64{}for _, a := range chart { codeToID[code(a)] = id(a)}// ... per line:line.AccountID = codeToID[line.AccountCode]Reports that aggregate by account_id (Trial Balance, GL grouped) need both columns to be in sync. If the chart of accounts wasn’t seeded when the entry posted (race on first-tenant boot), the line lands with account_id = NULL. Startup runs ResolveOrphanJELineAccountIDs which walks every NULL line, looks up the code in the now-seeded chart, and stamps the right ID. #10 v1.6.100 wired this rescue path so old data heals automatically.
The resolution is idempotent — lines that already have a valid account_id are untouched.
Source linkage
The source_type + source_id + source_uuid triple ties every JE back to the record that triggered it. The General Ledger uses this to render a link to the originating invoice / refund / restock / expense. Source types you’ll see:
invoice— POS sales, manual invoices, backdated invoicesrefund— return-wizard completionscancel— invoice cancellationsexpense— direct or prepaid expense postsrestock— ad-hoc restocks via the dialoginventory_adjustment— PO receive, PO void, manual stock adjustmentssupplier_payment— bothpaidandowedsupplier-side entriescustomer_payment— debt settlements, credit usage, credit withdrawalscash_drawer_transaction— manual cash-in / cash-out, transfers between sub-accountsmanual— admin-entered adjustments without a source record
Filtering the GL by source type is the fastest way to audit a single flow (“show me every refund this quarter”).
Backdated business date
The date column on journal_entries is the business date — the day the transaction happened. The default is now (entry-creation time). The invoice handler accepts a backdatedAt parameter and forwards it to postJournalEntryWithDate, so a late-entered sale lands in the correct period for Trial Balance, GL, and P&L period filters — even though the row was inserted today.
What writes a JE — the canonical patterns
Every flow you’ll see has the same skeleton: open a transaction, do the business work (update inventory, customer, etc.), call postJournalEntry with the right line set, commit. Below are the line patterns you’ll encounter most.
Invoice (cash sale)
- DR
1010-xxxcash sub-account (gross paid) - CR
4010revenue (line subtotal) - CR
2020VAT (if applicable) - CR
4040shipping (if charged) - DR
5010COGS — Products (per-line FIFO cost) - CR
1200Inventory (per-line FIFO cost) - (Plus
5020/1200for any bonus units)
Invoice (partial / pay-later)
- Same shape, except the cash leg is reduced and the unpaid portion lands as DR
1100AR.
Refund (cash, no defect)
- DR
4020Sales Returns (gross) - DR
1200Inventory (FIFO cost — restock leg) - CR
1010-xxxcash sub-account (gross refunded) - CR
5010COGS (FIFO cost — reverse-COGS leg)
Refund (store credit)
- DR
4020Sales Returns - CR
2100Customer Credits
PO receive (with supplier, partial paid)
- DR
1200Inventory (total landed cost) - CR
2010AP (unpaid portion) - DR
2010AP (paid portion) - CR
1010-xxxcash sub-account (paid portion)
Expense (cash)
- DR
6xxx(or7100) — the expense account - CR
1010-xxxcash sub-account
Comp invoice — no JE
Comp invoices (payment_type='comp') deliberately skip JE creation. No revenue, no cash, no inventory move — by design. When auditing invoice-vs-JE gaps, filter out comps first; the gap will always equal total comp amount, and that’s correct, not a bug.
Reading and finding entries
The General Ledger (reports/general-ledger) is the daily-driver view: every line, filterable by date range, account code, and source type. Click any entry to expand it and see all its lines side by side. Entry numbers (JE-2026-04829-style) are the canonical reference for a manual reconciliation conversation.
The Trial Balance (reports/trial-balance) is the audit summary: every account with a non-zero balance over the period, debits and credits in their own columns, sub-totaled by account type. The TB rows are the cheapest safety net for catching unbalanced JEs in a fresh codebase change — that’s why the e2e scenario suite asserts on TB rows for every scenario.
Editing or reversing an entry
You can’t edit a posted JE. There’s no PATCH endpoint, by deliberate design — once a number is in the ledger, the only honest way to change it is to reverse with a fresh JE that flips debit and credit, then post a corrected one.
The handlers that need this — invoice cancellation, PO void, refund — have purpose-built flows that wrap the reversal pattern in a single user action. If you find yourself wanting to “edit JE 1234”, the right question is “which flow should write the corrective entry?” — not “how do I open the row in the database.”
Related
- Chart of accounts — the codes every line uses
- General Ledger — every line, filterable
- Trial Balance — every account with a non-zero balance
- Balance Sheet — derived from JE lines, sum-of-sub-accounts on cash
- P&L — revenue minus COGS minus expenses, all from JE lines
- Cancel an invoice — the canonical reversal flow