Skip to content

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.

Updated 4 May 2026·For v1.6.100·5 min read

Anatomy of a journal entry

A JE has two parts:

  • Header (journal_entries) — one row per entry. Carries date (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 carries account_id, account_code, debit, credit, and an optional per-line description. 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.

reports · general ledger · one JE expanded showing lines

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 invoices
  • refund — return-wizard completions
  • cancel — invoice cancellations
  • expense — direct or prepaid expense posts
  • restock — ad-hoc restocks via the dialog
  • inventory_adjustment — PO receive, PO void, manual stock adjustments
  • supplier_payment — both paid and owed supplier-side entries
  • customer_payment — debt settlements, credit usage, credit withdrawals
  • cash_drawer_transaction — manual cash-in / cash-out, transfers between sub-accounts
  • manual — 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-xxx cash sub-account (gross paid)
  • CR 4010 revenue (line subtotal)
  • CR 2020 VAT (if applicable)
  • CR 4040 shipping (if charged)
  • DR 5010 COGS — Products (per-line FIFO cost)
  • CR 1200 Inventory (per-line FIFO cost)
  • (Plus 5020 / 1200 for any bonus units)

Invoice (partial / pay-later)

  • Same shape, except the cash leg is reduced and the unpaid portion lands as DR 1100 AR.

Refund (cash, no defect)

  • DR 4020 Sales Returns (gross)
  • DR 1200 Inventory (FIFO cost — restock leg)
  • CR 1010-xxx cash sub-account (gross refunded)
  • CR 5010 COGS (FIFO cost — reverse-COGS leg)

Refund (store credit)

  • DR 4020 Sales Returns
  • CR 2100 Customer Credits

PO receive (with supplier, partial paid)

  • DR 1200 Inventory (total landed cost)
  • CR 2010 AP (unpaid portion)
  • DR 2010 AP (paid portion)
  • CR 1010-xxx cash sub-account (paid portion)

Expense (cash)

  • DR 6xxx (or 7100) — the expense account
  • CR 1010-xxx cash 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.”