Skip to content

Accounting core

The accounting module is the heart of SAE Books. Every other module (bank feeds, GST/BAS, fixed assets, payroll) ultimately produces or consumes journals against the general ledger defined here.

Chart of accounts

A hierarchical list of accounts keyed by code (e.g. 1-1000, 1-1000-01). Eight account types (the AccountType enum):

  • ASSET
  • LIABILITY
  • EQUITY
  • INCOME
  • OTHER_INCOME
  • EXPENSE
  • COST_OF_SALES
  • OTHER_EXPENSE

Each account carries a tenant_id, a company_id, an is_header flag (non-posting header rows), an archived_at timestamp (soft-delete), an optional parent_id (to form the tree), and a tax_code_default that informs the default GST code when a line is posted against the account.

Bank-side accounts additionally carry ABA/Direct Entry fields (bsb, bank_account_number, bank_account_title, apca_user_id, bank_abbreviation) and an account_kind enum (BANK_CHECKING, BANK_SAVINGS, CREDIT_CARD, BANK_LOAN, CASH, OTHER) so credit cards, loans, and cash all appear in the bank-account list and on the dashboard. Credit-limit accounts carry credit_limit and credit_limit_kind (soft warns when exceeded; hard is enforced). Trust bank accounts are marked via is_trust_account (NSW Property Act commingling guard).

Codes are advisory — they help humans group accounts and drive default report ordering, but the system identifies accounts by UUID. Renaming or recoding an account never breaks history.

Journals & lines

The atomic unit of bookkeeping is a journal with two or more journal lines. Each line is a debit or a credit against one account, with an amount in the journal currency. The journal is balanced (SUM(debits) = SUM(credits)) at post time — an unbalanced journal is rejected.

flowchart LR Doc[Source doc
invoice / bill / payment] --> Journal Journal --> Lines Lines --> Posting[(journal_lines)]

Posting writes the lines to the journal_lines table (with status POSTED) and the parent JournalEntry is stamped posted_at. Reports query journal_lines joined to journal_entries filtered by status; only POSTED and REVERSED entries appear in financial reports (draft entries are excluded).

Source documents and provenance

Every journal entry carries an origin (a JournalOrigin enum stored as a plain string) that declares what created it, plus source_type and source_id pointing back to the originating record. This provenance keystone makes a hand-authored manual entry a visible exception (MANUAL) rather than indistinguishable from an engine-posted one.

Declared origins (the full JournalOrigin enum): MANUAL, UNKNOWN, INVOICE, CREDIT_NOTE, BILL, PAYMENT, EXPENSE, PAYRUN, DEPRECIATION, FX_REVAL, DEFERRED_REVENUE, BANK_REC, YEAR_END_CLOSE, TRUST_DISTRIBUTION, CASHBOOK_BACKFILL, FIXED_ASSET, REVERSAL, INTERCOMPANY, TRANSFER, SUPPLIER_CREDIT_NOTE, RECEIPT, RECLASSIFICATION, BAD_DEBT_WRITEOFF, and BAD_DEBT_RECOVERY. UNKNOWN is the DB column default for pre-provenance rows (no backfill); MANUAL is the posting chokepoint default when no machine origin is declared.

Journal entries also support optional flagged_for_review and review_note fields that let a reviewer mark a transaction for follow-up without changing its posting state.

Reversal vs. hard-delete

  • Reversal posts an opposite journal that nets to zero. The original journal stays in history, and both rows are visible on audit reports. This is the correct path for correcting posted activity in a period that has been reported on.
  • Hard-delete removes the journal and its ledger rows. Admin-only, audited, used for bona-fide errors that should not appear in any report (test data accidentally posted to live, duplicate import runs, etc.). Hard-deleting a journal that crosses a closed period requires reopening that period first.

Periods

Period locking is implemented via the period_locks table (PeriodLock model). A lock has a locked_through date; the engine rejects any post dated on or before that date unless the lock is first removed. The lock row itself carries locked_by (the actor's user ID) and an optional reason field. Unlock is performed by deleting the PeriodLock row; no separate audit-log entry is written for the lock or unlock — the period_locks table is the record.

Currency

AUD is the default reporting currency. Foreign-currency transactions are stored in their original currency and converted to AUD at the exchange rate on the document date. Unrealised FX gains/losses on open AR/AP are computed on revaluation runs (origin FX_REVAL) and posted as their own journals — see saebooks.services.fx.reval.

Reporting

The standard reports under /api/v1/reports/:

Report Endpoint
Trial balance GET /reports/trial_balance
Profit & Loss GET /reports/profit_loss
Balance sheet GET /reports/balance_sheet
Aged receivables GET /reports/aged_receivables
Aged payables GET /reports/aged_payables
Cash flow statement GET /reports/cashflow
BAS summary GET /reports/bas_summary
Depreciation schedule GET /reports/depreciation_schedule
FX revaluation GET /reports/fx_revaluation
Budget vs actual GET /reports/budget_vs_actual
P&L by segment GET /reports/pl_by_segment
Revenue by customer GET /reports/revenue_by_customer
YTD turnover GET /reports/ytd_turnover

Note: endpoint names use underscores, not hyphens. Reports are computed on demand; there is no nightly aggregation step to break.