Files
Distribution_GL/Docs/Accounting_System_Design_LKAS.md
T

107 KiB
Raw Blame History

Accounting System — Full Design Specification

LKAS-Compliant | Recursive Chart of Accounts | No Authentication / Role Layer


1. Purpose & Scope

This document specifies a complete double-entry accounting system designed for a company operating under Sri Lanka Accounting Standards (LKAS), which are substantially converged with IFRS.

Key design decisions:

  • No authentication or role-based access control — the system is designed as a single-tenant, open-access accounting engine. Any security layer can be bolted on later without affecting the accounting core.
  • Recursive account hierarchy — the Chart of Accounts (COA) uses a self-referencing parent/child table (parent_account_id), not fixed "Level 1/Level 2/Level 3" columns. This allows unlimited depth, easy restructuring, and clean recursive rollups using recursive CTEs.
  • Accounts Receivable and Accounts Payable have no dedicated subledger tables. AR/AP is not a separate module with its own invoice/receipt/payment tables that must be kept in sync with the General Ledger — instead, all AR/AP transactions post directly into the core ledger (journal_entry / journal_entry_line), tagged with a party reference. Every AR/AP report (aging, statements, control account reconciliation) is a live query against the core ledger, so there is only ever one source of truth and nothing can fall out of sync.

2. LKAS Standards Addressed

Standard Area How the system complies
LKAS 1 Presentation of Financial Statements Structured Statement of Financial Position, Statement of Comprehensive Income, Statement of Changes in Equity, notes
LKAS 2 Inventories Inventory module supports FIFO / Weighted Average costing, lower of cost vs NRV
LKAS 7 Statement of Cash Flows Cash flow engine (direct & indirect method) derived from GL transactions
LKAS 8 Accounting Policies, Estimates & Errors Journal entry types include "Prior Period Adjustment" and "Change in Estimate"; corrections never overwrite posted entries
LKAS 10 Events After Reporting Period Adjusting vs non-adjusting journal flag on post-period-end entries
LKAS 12 Income Taxes Deferred tax asset/liability accounts, tax computation module
LKAS 16 Property, Plant & Equipment Fixed Asset Register with cost model, depreciation, revaluation, disposal
LKAS 17 / SLFRS 16 Leases Right-of-use asset & lease liability accounts
LKAS 18 / SLFRS 15 Revenue Revenue recognition rules per AR transaction line (point-in-time / over-time)
LKAS 21 Foreign Exchange Multi-currency transactions, realized/unrealized FX gain-loss accounts
LKAS 23 Borrowing Costs Capitalization flag on qualifying asset construction
LKAS 36 Impairment of Assets Impairment testing flags & journal type
LKAS 37 Provisions, Contingent Liabilities Provision accounts + disclosure register
LKAS 38 Intangible Assets Intangible asset register (separate from PPE)

3. High-Level Architecture

┌─────────────────────────────────────────────────────────────┐
│                     Presentation Layer                       │
│        (Web UI / API clients — no auth in this scope)        │
├─────────────────────────────────────────────────────────────┤
│                     Application / Service Layer               │
│  Journal Engine │ Period Close │ Reporting │ Tax │ FX Engine  │
├─────────────────────────────────────────────────────────────┤
│                        Core Ledger Engine                    │
│   Chart of Accounts (recursive) │ Journal Entries │ GL Postings│
│   AR / AP transactions post directly here — no subledger DB   │
├─────────────────────────────────────────────────────────────┤
│                          Database (RDBMS)                     │
│         PostgreSQL recommended (native recursive CTE)         │
└─────────────────────────────────────────────────────────────┘

4. Core Modules

  1. Chart of Accounts (COA) — recursive, unlimited depth
  2. General Ledger (GL) — the single source of truth; everything posts here
  3. Journal Entries — manual + system-generated (double entry, always balanced)
  4. Accounts Receivable (AR) — customer master data only; invoices/receipts post directly into the core ledger (party-tagged); reports are live queries, not a separate subledger
  5. Accounts Payable (AP) — supplier master data only; bills/payments post directly into the core ledger (party-tagged); reports are live queries, not a separate subledger
  6. Fixed Assets — register, depreciation schedules, disposals, revaluation
  7. Inventory — items, stock movements, costing (FIFO/WAVG), COGS
  8. Bank & Cash — bank accounts, cash books, bank reconciliation
  9. Tax Engine — configurable VAT/SVAT, WHT, NBT rule engine (Section 12.1)
  10. Multi-Currency / FX — exchange rate table, realized/unrealized gain-loss (Section 12.3)
  11. Budgeting — budget vs actual by account & cost center
  12. Fiscal Periods & Year-End Close — period locking, closing entries, retained earnings roll-forward
  13. Financial Reporting — Trial Balance, SOFP, SOCI, SOCE, Cash Flow, AR/AP Aging, Notes
  14. Cost Centers / Dimensions — optional analytical tagging (department, project) — also recursive
  15. Multi-Branch — recursive branch hierarchy with branch-tagged transactions
  16. Manual Entry Portal & Adjustment/Correction Workflow — the single entry point for anything not auto-posted by a module, plus the standard method for correcting posted entries

5. Database Schema

Convention: id = UUID or BIGSERIAL primary key. All monetary columns use NUMERIC(18,2) (or NUMERIC(18,4) for FX-sensitive rate fields). No users, roles, or permissions tables are included per requirements.

5.1 Chart of Accounts — RECURSIVE (core requirement)

CREATE TABLE account_type (
    id              SMALLINT PRIMARY KEY,
    name            VARCHAR(50) NOT NULL,   -- Asset, Liability, Equity, Income, Expense
    normal_balance  CHAR(2) NOT NULL CHECK (normal_balance IN ('DR','CR'))
);

CREATE TABLE accounts (
    account_id          BIGSERIAL PRIMARY KEY,
    account_code        VARCHAR(20) UNIQUE NOT NULL,
    account_name        VARCHAR(150) NOT NULL,
    parent_account_id   BIGINT REFERENCES accounts(account_id) ON DELETE RESTRICT,
    account_type_id     SMALLINT NOT NULL REFERENCES account_type(id),
    is_control_account  BOOLEAN DEFAULT FALSE,   -- TRUE for AR control / AP control accounts
    is_postable          BOOLEAN DEFAULT TRUE,   -- FALSE for header/summary nodes
    currency_code        CHAR(3) DEFAULT 'LKR',
    is_active             BOOLEAN DEFAULT TRUE,
    description           TEXT,
    created_at            TIMESTAMPTZ DEFAULT now(),
    updated_at            TIMESTAMPTZ DEFAULT now()
);

-- Prevents an account from being its own ancestor (cycle guard, enforced at app layer
-- via recursive check before insert/update, since plain FK cannot block cycles).
CREATE INDEX idx_accounts_parent ON accounts(parent_account_id);

Why recursive instead of level columns: A fixed level1_code / level2_code / level3_code schema hard-limits depth and forces schema changes when the COA needs a 4th or 5th tier. The self-referencing parent_account_id allows:

  • Arbitrary depth without schema change
  • Easy re-parenting (move a subtree by updating one row)
  • Natural recursive rollups (a parent's balance = sum of all descendant leaf balances)

5.2 Fiscal Calendar

CREATE TABLE fiscal_year (
    fiscal_year_id   SERIAL PRIMARY KEY,
    year_name        VARCHAR(20) NOT NULL,      -- '2025/2026'
    start_date       DATE NOT NULL,
    end_date         DATE NOT NULL,
    is_closed        BOOLEAN DEFAULT FALSE
);

CREATE TABLE accounting_period (
    period_id        SERIAL PRIMARY KEY,
    fiscal_year_id   INT NOT NULL REFERENCES fiscal_year(fiscal_year_id),
    period_number    SMALLINT NOT NULL,          -- 1..12
    start_date       DATE NOT NULL,
    end_date         DATE NOT NULL,
    status           VARCHAR(10) DEFAULT 'OPEN'  -- OPEN | CLOSED | LOCKED
);

5.3 Journal Entries (core ledger — also carries all AR/AP data)

CREATE TABLE journal_entry (
    journal_id        BIGSERIAL PRIMARY KEY,
    journal_no        VARCHAR(30) UNIQUE NOT NULL,
    period_id         INT NOT NULL REFERENCES accounting_period(period_id),
    entry_date        DATE NOT NULL,
    journal_type      VARCHAR(30) NOT NULL,   -- MANUAL, AR_INVOICE, AR_RECEIPT, AP_BILL, AP_PAYMENT,
                                               -- DEPRECIATION, FX_REVAL, ADJUSTMENT, CLOSING, REVERSAL
    source_module     VARCHAR(30),            -- AR, AP, GL, ASSET, INVENTORY, BANK
    reference         VARCHAR(100),
    narration         TEXT,
    is_posted         BOOLEAN DEFAULT FALSE,
    is_reversed       BOOLEAN DEFAULT FALSE,
    reversal_of_id    BIGINT REFERENCES journal_entry(journal_id),
    created_at        TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE journal_entry_line (
    line_id           BIGSERIAL PRIMARY KEY,
    journal_id        BIGINT NOT NULL REFERENCES journal_entry(journal_id) ON DELETE CASCADE,
    account_id        BIGINT NOT NULL REFERENCES accounts(account_id),
    debit_amount      NUMERIC(18,2) DEFAULT 0 CHECK (debit_amount >= 0),
    credit_amount     NUMERIC(18,2) DEFAULT 0 CHECK (credit_amount >= 0),
    currency_code     CHAR(3) DEFAULT 'LKR',
    exchange_rate     NUMERIC(18,6) DEFAULT 1,
    base_debit        NUMERIC(18,2) DEFAULT 0,   -- functional-currency equivalent
    base_credit       NUMERIC(18,2) DEFAULT 0,
    cost_center_id    BIGINT REFERENCES cost_center(cost_center_id),
    branch_id         BIGINT REFERENCES branch(branch_id),   -- optional line-level branch override, Section 13
    -- AR / AP fields — this is what replaces the old separate subledger tables:
    party_type        VARCHAR(10),                -- CUSTOMER, SUPPLIER, NULL for non-AR/AP lines
    party_id          BIGINT,                      -- customer.customer_id or supplier.supplier_id, per party_type
    document_no       VARCHAR(30),                 -- invoice / bill / receipt / payment reference number
    due_date          DATE,                        -- required for AR/AP aging when the line hits a control account
    memo              TEXT,
    CHECK ( (debit_amount = 0 AND credit_amount > 0)
         OR (credit_amount = 0 AND debit_amount > 0) ),
    CHECK ( (party_type IS NULL AND party_id IS NULL) OR (party_type IS NOT NULL AND party_id IS NOT NULL) )
);

Balance rule (app + trigger level): SUM(debit_amount) = SUM(credit_amount) per journal_id before is_posted can be set TRUE. AR/AP rule: any line posted to an account where is_control_account = TRUE must carry party_type, party_id, document_no, and due_date — enforced at the application layer (a plain FK cannot reference two different tables conditionally, so this is validated in code before posting, not via a database FK).

5.4 Cost Centers — also recursive

CREATE TABLE cost_center (
    cost_center_id      BIGSERIAL PRIMARY KEY,
    code                 VARCHAR(20) UNIQUE NOT NULL,
    name                 VARCHAR(150) NOT NULL,
    parent_cost_center_id BIGINT REFERENCES cost_center(cost_center_id),
    is_active             BOOLEAN DEFAULT TRUE
);

5.5 Party Master — Customers & Suppliers (AR/AP data lives in the core ledger)

No sales_invoice, purchase_invoice, customer_receipt, supplier_payment, or allocation tables exist in this design. These master tables only hold the party identity and control account mapping — every actual transaction (invoice, bill, receipt, payment) is a normal journal_entry posted through the Manual/Core Entry Portal (Section 14), tagged with party_type/party_id/document_no/due_date on the line that hits the control account.

CREATE TABLE customer (
    customer_id            BIGSERIAL PRIMARY KEY,
    customer_code           VARCHAR(20) UNIQUE NOT NULL,
    name                     VARCHAR(150) NOT NULL,
    ar_control_account_id      BIGINT NOT NULL REFERENCES accounts(account_id),  -- must have is_control_account = TRUE
    credit_limit                  NUMERIC(18,2),
    payment_terms_days              SMALLINT DEFAULT 30,
    currency_code                     CHAR(3) DEFAULT 'LKR',
    home_branch_id                       BIGINT REFERENCES branch(branch_id),
    is_active                               BOOLEAN DEFAULT TRUE
);

CREATE TABLE supplier (
    supplier_id            BIGSERIAL PRIMARY KEY,
    supplier_code           VARCHAR(20) UNIQUE NOT NULL,
    name                     VARCHAR(150) NOT NULL,
    ap_control_account_id      BIGINT NOT NULL REFERENCES accounts(account_id),  -- must have is_control_account = TRUE
    payment_terms_days           SMALLINT DEFAULT 30,
    currency_code                   CHAR(3) DEFAULT 'LKR',
    home_branch_id                     BIGINT REFERENCES branch(branch_id),
    is_active                             BOOLEAN DEFAULT TRUE
);

How an AR invoice is recorded (example — no invoice table involved):

-- One journal_entry, two (or more) journal_entry_line rows:
-- Line 1: Debit the customer's AR control account, tagged with the party + document + due date
-- Line 2: Credit the relevant Revenue account (and a 3rd line for tax output, if applicable)

INSERT INTO journal_entry (journal_no, period_id, entry_date, journal_type, source_module, reference, narration)
VALUES ('JV-2026-000456', :period_id, :invoice_date, 'AR_INVOICE', 'AR', 'INV-1001', 'Sale to ABC Traders');

INSERT INTO journal_entry_line (journal_id, account_id, debit_amount, credit_amount, party_type, party_id, document_no, due_date, memo)
VALUES
 (:journal_id, :customer_ar_control_account_id, 118000.00, 0,      'CUSTOMER', :customer_id, 'INV-1001', :due_date, 'Sale - Invoice INV-1001'),
 (:journal_id, :revenue_account_id,             0,         100000.00, NULL, NULL, 'INV-1001', NULL, 'Revenue - Invoice INV-1001'),
 (:journal_id, :output_vat_account_id,           0,         18000.00,  NULL, NULL, 'INV-1001', NULL, 'Output VAT - Invoice INV-1001');

A customer receipt against that invoice is simply another journal entry (journal_type = 'AR_RECEIPT') that credits the same AR control account with the same party_id/document_no, so the running balance for that document naturally nets to zero when fully paid. AP bills and supplier payments work identically, mirrored onto the AP control account.

5.6 Fixed Assets (LKAS 16)

CREATE TABLE fixed_asset (
    asset_id            BIGSERIAL PRIMARY KEY,
    asset_code          VARCHAR(30) UNIQUE NOT NULL,
    asset_name          VARCHAR(150) NOT NULL,
    asset_account_id    BIGINT NOT NULL REFERENCES accounts(account_id),
    accum_depr_account_id BIGINT NOT NULL REFERENCES accounts(account_id),
    depr_expense_account_id BIGINT NOT NULL REFERENCES accounts(account_id),
    branch_id             BIGINT REFERENCES branch(branch_id),
    acquisition_date     DATE NOT NULL,
    acquisition_cost     NUMERIC(18,2) NOT NULL,
    residual_value       NUMERIC(18,2) DEFAULT 0,
    useful_life_months   INT NOT NULL,
    depreciation_method  VARCHAR(20) DEFAULT 'STRAIGHT_LINE', -- STRAIGHT_LINE, REDUCING_BALANCE
    status                VARCHAR(15) DEFAULT 'ACTIVE'         -- ACTIVE, DISPOSED, FULLY_DEPRECIATED
);

CREATE TABLE depreciation_schedule (
    schedule_id      BIGSERIAL PRIMARY KEY,
    asset_id         BIGINT NOT NULL REFERENCES fixed_asset(asset_id),
    period_id        INT NOT NULL REFERENCES accounting_period(period_id),
    depreciation_amount NUMERIC(18,2) NOT NULL,
    accumulated_depreciation NUMERIC(18,2) NOT NULL,
    net_book_value    NUMERIC(18,2) NOT NULL,
    journal_id        BIGINT REFERENCES journal_entry(journal_id)
);

CREATE TABLE asset_disposal (
    disposal_id       BIGSERIAL PRIMARY KEY,
    asset_id          BIGINT NOT NULL REFERENCES fixed_asset(asset_id),
    disposal_date     DATE NOT NULL,
    proceeds          NUMERIC(18,2),
    net_book_value_at_disposal NUMERIC(18,2),
    gain_loss         NUMERIC(18,2),
    journal_id        BIGINT REFERENCES journal_entry(journal_id)
);

5.7 Inventory (LKAS 2)

CREATE TABLE inventory_item (
    item_id           BIGSERIAL PRIMARY KEY,
    item_code         VARCHAR(30) UNIQUE NOT NULL,
    item_name         VARCHAR(150) NOT NULL,
    inventory_account_id BIGINT NOT NULL REFERENCES accounts(account_id),
    cogs_account_id      BIGINT NOT NULL REFERENCES accounts(account_id),
    costing_method        VARCHAR(15) DEFAULT 'WAVG', -- FIFO, WAVG
    reorder_level          NUMERIC(18,3),
    branch_id                BIGINT REFERENCES branch(branch_id),  -- NULL = shared/central item
    is_active                  BOOLEAN DEFAULT TRUE
);

CREATE TABLE stock_movement (
    movement_id       BIGSERIAL PRIMARY KEY,
    item_id           BIGINT NOT NULL REFERENCES inventory_item(item_id),
    movement_date     DATE NOT NULL,
    movement_type     VARCHAR(15) NOT NULL,  -- PURCHASE, SALE, ADJUSTMENT, TRANSFER
    quantity          NUMERIC(18,3) NOT NULL,   -- +in / -out
    unit_cost         NUMERIC(18,2) NOT NULL,
    journal_id        BIGINT REFERENCES journal_entry(journal_id)
);

5.8 Bank & Cash (retrofit 2026-07-22 — CashAccount split into its own table, implemented and verified live)

The gap: bank_account already had nullable bank_name/account_number, but nothing ever said what a row with both NULL meant — there was no way to distinguish "an actual bank account, details just not filled in yet" from "this represents physical cash on hand, bank details don't apply." A user-facing Cash/Bank management screen surfaced this: creating a "Cash" account and a "Bank" account need a way to tell them apart, and a cash account needs its own category (Petty Cash, Till Cash, etc.).

Superseded design, never shipped: a bank_account.account_type (CASH/BANK) discriminator was drafted first, following the same pattern used elsewhere in this system (ReportType, CorrectionType, TransactionType). Reviewing it surfaced that a cash account isn't a kind of bank account, it's a peer concept — cramming both into one table with conditionally-meaningless bank_name/account_number fields depending on the discriminator was judged confusing for future developers rather than clarifying. Built instead: two separate tables, bank_account reverted to its original pre-discriminator shape, plus a new cash_account and a small growable cash_account_type reference table:

-- Unchanged from the original design (Section 5.8's pre-2026-07-22 shape)
CREATE TABLE bank_account (
    bank_account_id   BIGSERIAL PRIMARY KEY,
    account_name      VARCHAR(150) NOT NULL,
    bank_name         VARCHAR(150),
    account_number    VARCHAR(50),
    gl_account_id     BIGINT NOT NULL REFERENCES accounts(account_id),
    currency_code     CHAR(3) DEFAULT 'LKR'
);

CREATE TABLE bank_statement_line (
    stmt_line_id      BIGSERIAL PRIMARY KEY,
    bank_account_id   BIGINT NOT NULL REFERENCES bank_account(bank_account_id),
    txn_date          DATE NOT NULL,
    description       VARCHAR(255),
    amount            NUMERIC(18,2) NOT NULL,
    is_reconciled     BOOLEAN DEFAULT FALSE,
    matched_journal_id BIGINT REFERENCES journal_entry(journal_id)
);

-- New, 2026-07-22
CREATE TABLE cash_account_type (
    cash_account_type_id   SERIAL PRIMARY KEY,
    name                     VARCHAR(100) UNIQUE NOT NULL
    -- Seeded: Petty Cash, Till Cash, Safe Cash, Cash in Transit. Grows at runtime -
    -- POST /cash-accounts with a cashAccountTypeName not matching an existing row
    -- creates one on the fly, so a frontend "Other, please specify" field becomes
    -- a permanent selectable option from then on.
);

CREATE TABLE cash_account (
    cash_account_id       BIGSERIAL PRIMARY KEY,
    account_name          VARCHAR(150) NOT NULL,
    cash_account_type_id  INT NOT NULL REFERENCES cash_account_type(cash_account_type_id),
    account_number        VARCHAR(50) UNIQUE NOT NULL,
    -- Required overall, unlike bank_account.account_number - but optional on input:
    -- if the caller omits it, a sequential CASH-000001-style reference is auto-generated
    -- (probe-and-advance uniqueness check, same pattern as Section 8.1's code generators).
    gl_account_id         BIGINT NOT NULL REFERENCES accounts(account_id),
    currency_code         CHAR(3) DEFAULT 'LKR'
);

Table/endpoint naming, decided explicitly: bank_account/bank-accounts keep their name — they predate this split and stay exactly as originally built (Phases 23/24/25/34 all already live-tested against it). The new concept gets its own new table/route (cash_account/cash-accounts) rather than retrofitting the old one. GET /bank-accounts is the one place both concepts meet: it became a unified, filterable list (?accountType=Cash|Bank|Both, default Both) returning a combined, flattened response — accountType is a response-only discriminator here, telling the caller which table a row came from, not a stored column on either table.

Statement import/reconcile (Phases 24/25) are bank_account-only, unchanged. These two phases were never extended to cash_account — a bank_statement_line conceptually assumes a real bank feed, and a physical cash account has no equivalent statement to import or reconcile against. If cash-side reconciliation is ever wanted, it would need its own mechanism, not a retrofit of bank_statement_line.

Retrofit 2026-07-31 — gl_account_id is no longer caller-supplied on either table; the backing GL account is always auto-created. Previously POST /bank-accounts/POST /cash-accounts required the caller to already know and select an existing postable GL account (glAccountCode, CLAUDE.md Rule 8.2). The user explicitly asked for that to go away — a bank account is definitionally a bank account, a cash account of a given type is definitionally under that type, so there's no real choice to make. gl_account_id on both tables stays BIGINT NOT NULL REFERENCES accounts(account_id) exactly as above; it's just always populated by the system now, never read from the request body.

New auto-created GL hierarchy, named per explicit user direction ("Bank"/"Cash", not "Bank Accounts"/"Cash Accounts"):

Bank                              (root Asset account, non-postable, auto-created on first bank account)
  └─ <bank account's own name>    (leaf, postable - one per BankAccount, never reused)

Cash                              (root Asset account, non-postable, auto-created on first cash account)
  └─ <cash account type name>     (node, non-postable - one per cash_account_type, created once, then reused)
      └─ <cash account's own name> (leaf, postable - one per CashAccount, never reused)
  • Bank/Cash roots are found-or-created by exact account_name match at the root level (parent_account_id IS NULL) — a plain lookup, not a system_config key, since these are fixed system-owned singletons the same way cash_account_type's seeded rows are fixed, not because a company might name something else "Bank" by coincidence (that scenario isn't guarded against, same trust level this system already extends to other fixed-name lookups like Currency.CurrencyCode).
  • cash_account_type gained a new nullable gl_account_id BIGINT REFERENCES accounts(account_id) (migration AddGlAccountIdToCashAccountType) — the link from a cash account type (e.g. Petty Cash) to its own GL node under Cash. Nullable because the four seeded types (Petty Cash, Till Cash, Safe Cash, Cash in Transit) predate this column and start NULL; the column is backfilled lazily the first time each type is actually used to create a cash account, not by a data migration.
  • Every level uses the exact same code-generation scheme as a normal POST /accounts call (CLAUDE.md Rule 8.1 — root accounts get the next multiple of 100 in the Asset block, children append -01/-02/...), factored into a shared Services/AccountCodeGenerator.cs static helper (same shared-static-helper shape as RecursiveTreeQueries/DepthGuard, CLAUDE.md Rule 11) so AccountService.CreateAccountAsync and BankService's auto-creation logic can't drift out of sync.
  • No DepthGuard/COA_MAX_DEPTH check is run for this auto-created structure — it's fixed at 2 (Bank) or 3 (Cash → type → leaf) levels, well under the default max of 6, and the guard exists to bound unpredictable user-driven nesting, not this fixed system shape.
  • Every auto-created Account (root, type node, or leaf) gets its own Ledger/Create audit row, in addition to the existing Administrative/Create audit row for the BankAccount/CashAccount itself — consistent with CLAUDE.md Rule 10 treating any COA-tree change as Ledger, regardless of what triggered it.
  • Verified live: first bank account ever created auto-generated 1500 Bank → 1500-01 <account name>; first and second Petty Cash cash accounts auto-generated 1600 Cash → 1600-01 Petty Cash (created once) → 1600-01-01/1600-01-02 (one leaf each) — confirmed cash_account_type's Petty Cash row's gl_account_id was NULL before the first call and populated (unchanged on the second call) afterward.

5.9 Tax Engine

The full configurable tax rule engine (tax_type, tax_code, tax_exemption, rate history, calculation logic) is defined once in Section 12.1 — Tax Configuration, and referenced from here rather than duplicated. AR invoice lines and AP bill lines simply carry a tax_code_id reference at posting time (via the Manual/Core Entry Portal, Section 14) and the engine posts the resulting tax amount to the correct output/input tax account automatically.

5.10 Multi-Currency

The full currency configuration (currency, exchange_rate, base-currency enforcement, rounding rules, FX revaluation) is defined once in Section 12.3 — Currency Configuration, and referenced from here rather than duplicated.

5.11 Budgeting

CREATE TABLE budget (
    budget_id         BIGSERIAL PRIMARY KEY,
    fiscal_year_id     INT NOT NULL REFERENCES fiscal_year(fiscal_year_id),
    name                VARCHAR(100) NOT NULL
);

CREATE TABLE budget_line (
    budget_line_id    BIGSERIAL PRIMARY KEY,
    budget_id         BIGINT NOT NULL REFERENCES budget(budget_id) ON DELETE CASCADE,
    account_id         BIGINT NOT NULL REFERENCES accounts(account_id),
    period_id            INT NOT NULL REFERENCES accounting_period(period_id),
    cost_center_id         BIGINT REFERENCES cost_center(cost_center_id),
    budgeted_amount          NUMERIC(18,2) NOT NULL
);

6. Recursive Query Patterns (the heart of the "recursive not level" design)

6.1 Get full descendant tree of any account (any depth)

WITH RECURSIVE account_tree AS (
    SELECT account_id, account_code, account_name, parent_account_id, 1 AS depth
    FROM accounts
    WHERE account_id = :given_account_id

    UNION ALL

    SELECT a.account_id, a.account_code, a.account_name, a.parent_account_id, t.depth + 1
    FROM accounts a
    JOIN account_tree t ON a.parent_account_id = t.account_id
)
SELECT * FROM account_tree ORDER BY depth, account_code;

6.2 Roll up a parent account's balance from all descendant leaf accounts

WITH RECURSIVE descendants AS (
    SELECT account_id FROM accounts WHERE account_id = :parent_id
    UNION ALL
    SELECT a.account_id FROM accounts a
    JOIN descendants d ON a.parent_account_id = d.account_id
)
SELECT COALESCE(SUM(jel.base_debit - jel.base_credit), 0) AS net_balance
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
WHERE je.is_posted = TRUE
  AND jel.account_id IN (SELECT account_id FROM descendants);

6.3 Full ancestor path / breadcrumb for any account (for report labeling)

WITH RECURSIVE ancestors AS (
    SELECT account_id, account_name, parent_account_id, account_name::TEXT AS path
    FROM accounts WHERE account_id = :leaf_id

    UNION ALL

    SELECT a.account_id, a.account_name, a.parent_account_id,
           a.account_name || ' > ' || anc.path
    FROM accounts a
    JOIN ancestors anc ON a.account_id = anc.parent_account_id
)
SELECT path FROM ancestors WHERE parent_account_id IS NULL;

6.4 Cycle prevention (must run before allowing a re-parent operation)

-- Reject if :new_parent_id is a descendant of :account_id (would create a loop)
WITH RECURSIVE descendants AS (
    SELECT account_id FROM accounts WHERE account_id = :account_id
    UNION ALL
    SELECT a.account_id FROM accounts a
    JOIN descendants d ON a.parent_account_id = d.account_id
)
SELECT EXISTS (SELECT 1 FROM descendants WHERE account_id = :new_parent_id) AS would_cycle;

7. Posting Engine Rules

  1. Every transaction — AR invoice/receipt entry, AP bill/payment entry, stock_movement, depreciation_schedule, asset_disposal, or a plain manual entry — generates one balanced journal_entry with ≥2 journal_entry_line rows. There is no separate subledger posting step for AR/AP: the journal entry is the AR/AP transaction.
  2. A journal cannot be marked is_posted = TRUE unless SUM(debit_amount) = SUM(credit_amount).
  3. Posting is only allowed to is_postable = TRUE accounts (header/summary nodes reject direct posting — balances only ever come from recursive rollup of children).
  4. Any line posted to an is_control_account = TRUE account (AR/AP control accounts) must carry party_type, party_id, document_no, and due_date — validated by the application layer before posting.
  5. Posting is only allowed into an accounting_period with status = 'OPEN'.
  6. Reversal creates a new journal with debit/credit flipped and links via reversal_of_id; the original is never deleted (immutable audit trail).

8. Period Close / Year-End Process

  1. Verify all periods within the fiscal year are CLOSED except the final one.
  2. Run trial balance check (total debits = total credits across all posted journals).
  3. Post closing entries: zero out Income and Expense accounts into Retained Earnings (Income Summary approach or direct close — either supported).
  4. Lock the fiscal year (fiscal_year.is_closed = TRUE); no further postings allowed into its periods.
  5. Carry forward Asset/Liability/Equity balances as opening balances of the new fiscal year (via a journal_type = 'OPENING_BALANCE' entry) — this includes any still-open AR/AP control account balances, which carry forward with their original party_id/document_no/due_date intact so aging continues correctly into the new year.

9. Core Financial Reports & Their Logic

Report LKAS Ref Source Logic
Trial Balance — SUM(debit)/SUM(credit) per account, posted journals only, for a date range
Statement of Financial Position (Balance Sheet) LKAS 1 Recursive rollup of Asset / Liability / Equity account trees at a point in time
Statement of Comprehensive Income LKAS 1 Recursive rollup of Income / Expense account trees over a period
Statement of Changes in Equity LKAS 1 Opening equity + profit for period − dividends/drawings ± other equity movements
Statement of Cash Flows LKAS 7 Derived from GL movements in cash/bank accounts, classified into Operating/Investing/Financing via journal_type mapping
Aged Receivables / Payables — journal_entry_line grouped by party_id + document_no + due_date against the AR/AP control account — no separate invoice table (Section 11.6)
Customer / Supplier Statement of Account — journal_entry_line filtered by party_id, running balance (Section 11.6)
Fixed Asset Register / Depreciation Schedule LKAS 16 fixed_asset + depreciation_schedule
Inventory Valuation Report LKAS 2 stock_movement running balance per costing method
Income Tax Computation — Profit Before Tax (from P&L) reconciled through Adjusted Business Profit, Assessable Income, and Taxable Income to a final Balance Tax Payable/(Refund) — the standard Sri Lankan corporate tax computation shape. Full design: Section 11.7. Supersedes the 2026-07-21 per-tax_code VAT/WHT/NBT summary (kept for reference, Section 11.7†)

10. Implementation Roadmap

Phase 1 — Core Ledger: accounts (recursive), fiscal calendar, journal entry engine, trial balance. Phase 2 — AR/AP on the Core Ledger: customer/supplier master data, control-account posting rules, party-tagged journal lines, AR/AP aging and statement reports — Bank/Cash and reconciliation. Phase 3 — Assets & Inventory: Fixed Assets + depreciation, Inventory + costing. Phase 4 — Tax & FX: Tax engine, multi-currency revaluation. Phase 5 — Reporting Suite: SOFP, SOCI, SOCE, Cash Flow, AR/AP aging, tax reports. Phase 6 — Budgeting & Period Close: Budget vs actual, year-end close automation. Phase 7 — Multi-Branch: recursive branch hierarchy, branch-tagged postings, branch-wise and consolidated reporting. Phase 8 — Manual Entry Portal & Corrections: draft/review/post workflow, recurring templates, reversal and adjustment tooling. Phase 9 (optional, out of current scope): Authentication & role-based access can be layered on top later without touching the ledger schema.


11. Report Generation Engine — Recursive Depth Logic

All core reports below are generated directly off the recursive accounts tree — no report has its own hardcoded hierarchy. Each query walks the tree with a WITH RECURSIVE CTE, so a report stays correct no matter how deep the COA gets restructured (2 levels or 12 levels — same query, no code change). AR and AP reports (Section 11.6) work the same way, but pivot on party_id instead of the account tree, since AR/AP no longer has its own subledger tables to query.

Shared helper concept used by every report — account_tree_with_depth: expands the whole COA once, tagging each node with its true recursive depth and a materialized path (array of ancestor ids), which every report then filters/groups by.

WITH RECURSIVE account_tree_with_depth AS (
    SELECT
        account_id, account_code, account_name, parent_account_id,
        account_type_id, is_postable,
        1                       AS depth,
        ARRAY[account_id]       AS path_ids,
        account_code::TEXT      AS path_codes
    FROM accounts
    WHERE parent_account_id IS NULL          -- root nodes (Assets, Liabilities, Equity, Income, Expense)

    UNION ALL

    SELECT
        a.account_id, a.account_code, a.account_name, a.parent_account_id,
        a.account_type_id, a.is_postable,
        t.depth + 1,
        t.path_ids || a.account_id,
        t.path_codes || ' > ' || a.account_code
    FROM accounts a
    JOIN account_tree_with_depth t ON a.parent_account_id = t.account_id
)
SELECT * FROM account_tree_with_depth;

This CTE is referenced (conceptually — inline it, or wrap it in a view v_account_tree) by every report query below.


11.1 General Ledger (per-account transaction listing, any depth — or every account at once)

CREATE OR REPLACE VIEW v_account_tree AS
WITH RECURSIVE t AS (
    SELECT account_id, account_code, account_name, parent_account_id,
           account_type_id, is_postable, 1 AS depth, ARRAY[account_id] AS path_ids
    FROM accounts WHERE parent_account_id IS NULL
    UNION ALL
    SELECT a.account_id, a.account_code, a.account_name, a.parent_account_id,
           a.account_type_id, a.is_postable, t.depth + 1, t.path_ids || a.account_id
    FROM accounts a JOIN t ON a.parent_account_id = t.account_id
)
SELECT * FROM t;

-- General Ledger for :account_id (and every descendant), for a date range
-- Used when :account_id IS NOT NULL -- this is the "Account Ledger" case, one account's
-- own subtree, one running balance.
WITH RECURSIVE descendants AS (
    SELECT account_id FROM accounts WHERE account_id = :account_id
    UNION ALL
    SELECT a.account_id FROM accounts a JOIN descendants d ON a.parent_account_id = d.account_id
)
SELECT
    je.entry_date,
    je.journal_no,
    a.account_code,
    a.account_name,
    je.narration,
    jel.debit_amount,
    jel.credit_amount,
    SUM(jel.debit_amount - jel.credit_amount)
        OVER (PARTITION BY jel.account_id ORDER BY je.entry_date, je.journal_id
              ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_balance
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
JOIN accounts a        ON a.account_id = jel.account_id
WHERE je.is_posted = TRUE
  AND jel.account_id IN (SELECT account_id FROM descendants)
  AND je.entry_date BETWEEN :from_date AND :to_date
ORDER BY a.account_code, je.entry_date, je.journal_id;

-- General Ledger for every postable account at once, for a date range
-- Used when :account_id IS NULL -- this is the true General Ledger: every account's own
-- transactions together, each with its own running balance (PARTITION BY account_id resets
-- the running total whenever the account changes, since rows are also ordered by account_code
-- first). Non-postable header/summary accounts are excluded structurally, not filtered after
-- the fact -- they can never carry a posted line, so the plain INNER JOINs already guarantee
-- an is_postable = FALSE account produces zero rows even without the explicit filter below;
-- the filter is kept anyway so the intent ("postable accounts only") is explicit in the SQL,
-- not just an accidental consequence of the join shape (retrofitted 2026-07-20 -- this mode
-- did not exist originally, added because a single-account listing was being asked to serve
-- as "the General Ledger", which is a different report).
SELECT
    je.entry_date,
    je.journal_no,
    a.account_code,
    a.account_name,
    je.narration,
    jel.debit_amount,
    jel.credit_amount,
    SUM(jel.debit_amount - jel.credit_amount)
        OVER (PARTITION BY jel.account_id ORDER BY je.entry_date, je.journal_id
              ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_balance
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
JOIN accounts a        ON a.account_id = jel.account_id
WHERE je.is_posted = TRUE
  AND a.is_postable = TRUE
  AND je.entry_date BETWEEN :from_date AND :to_date
ORDER BY a.account_code, je.entry_date, je.journal_id;

11.2 Trial Balance (recursive, all leaf + rollup rows)

WITH RECURSIVE tree AS (
    SELECT account_id, account_code, account_name, parent_account_id,
           account_type_id, is_postable, 1 AS depth
    FROM accounts WHERE parent_account_id IS NULL
    UNION ALL
    SELECT a.account_id, a.account_code, a.account_name, a.parent_account_id,
           a.account_type_id, a.is_postable, t.depth + 1
    FROM accounts a JOIN tree t ON a.parent_account_id = t.account_id
),
leaf_balances AS (
    SELECT jel.account_id,
           SUM(jel.base_debit)  AS total_debit,
           SUM(jel.base_credit) AS total_credit,
           SUM(jel.base_debit - jel.base_credit) AS net
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    WHERE je.is_posted = TRUE
      AND je.entry_date <= :as_of_date
    GROUP BY jel.account_id
)
SELECT
    t.depth,
    REPEAT('    ', t.depth - 1) || t.account_code AS indented_code,
    t.account_name,
    COALESCE(lb.total_debit, 0)  AS debit,
    COALESCE(lb.total_credit, 0) AS credit
FROM tree t
LEFT JOIN leaf_balances lb ON lb.account_id = t.account_id
WHERE t.is_postable = TRUE           -- only leaf/postable accounts carry transactions
  AND (lb.total_debit > 0 OR lb.total_credit > 0)
ORDER BY t.account_code;

-- Global check: total debit column MUST equal total credit column

11.3 Statement of Financial Position — Balance Sheet (recursive rollup, LKAS 1)

WITH RECURSIVE tree AS (
    SELECT account_id, account_code, account_name, parent_account_id,
           account_type_id, 1 AS depth, ARRAY[account_id] AS path_ids
    FROM accounts
    WHERE parent_account_id IS NULL AND account_type_id IN (1,2,3)  -- Asset, Liability, Equity
    UNION ALL
    SELECT a.account_id, a.account_code, a.account_name, a.parent_account_id,
           a.account_type_id, t.depth + 1, t.path_ids || a.account_id
    FROM accounts a JOIN tree t ON a.parent_account_id = t.account_id
),
balances AS (
    SELECT jel.account_id, SUM(jel.base_debit - jel.base_credit) AS net_movement
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    WHERE je.is_posted = TRUE AND je.entry_date <= :as_of_date
    GROUP BY jel.account_id
)
SELECT
    t.depth,
    REPEAT('    ', t.depth - 1) || t.account_name AS line_item,
    at.name AS account_type,
    (SELECT COALESCE(SUM(b.net_movement), 0)
       FROM tree d
       JOIN balances b ON b.account_id = d.account_id
      WHERE d.path_ids @> ARRAY[t.account_id]        -- d is t or a descendant of t
    ) AS balance
FROM tree t
JOIN account_type at ON at.id = t.account_type_id
ORDER BY t.account_type_id, t.account_code;

-- Presentation split (grouped in application layer or a second pass):
--   ASSETS                = SUM(balance WHERE account_type = 'Asset')       -- includes AR control account rollup
--   LIABILITIES            = SUM(balance WHERE account_type = 'Liability')  -- includes AP control account rollup
--   EQUITY                 = SUM(balance WHERE account_type = 'Equity') + current-year profit
--   Check: ASSETS = LIABILITIES + EQUITY

Implemented 2026-07-20, with one deliberate deviation from the line above. "current-year profit" as written here reads naturally as "this fiscal year's P&L" — but there is no closing-entry mechanism anywhere in this system that ever moves a prior fiscal year's Income/Expense activity into an Equity account (no RetainedEarnings account, no year-end closing journal, nothing). If "current-year profit" were scoped to only the fiscal year containing :as_of_date, the ASSETS = LIABILITIES + EQUITY check above would hold for the first fiscal year a ledger is used, then silently break again the moment a second fiscal year has any posted activity, since that first year's profit would be sitting in Income/Expense accounts this report excludes, with nowhere else for it to go. So the actual implementation (ReportService.BuildBalanceSheetAsync) computes this figure as SUM(Income - Expense) since inception — every posted line from the very first transaction through :as_of_date, not just the current fiscal year — reusing the 11.4 P&L query with period_start = the earliest possible date and period_end = :as_of_date. It is surfaced as one synthetic row, { depth: 1, line_item: "Current Year Earnings", account_type: "Equity", balance: <that sum> }, appended to the result set (always present, even at zero) rather than computed only in a "second pass" as the comment above suggests. This keeps the design doc's own terminology ("Current Year Earnings") while actually satisfying the equation it was meant to guarantee.

Retrofit 2026-07-31 — classified presentation (current/non-current split), replacing the "COA-hierarchy-only" approach above. The note above ("classification is achieved entirely through how the company structures its own Chart of Accounts... this template renders that hierarchy as-is; it does not independently classify anything") was a deliberate design choice at the time, but a user-supplied reference template (a classified LKAS 1 Statement of Financial Position — Non-Current Assets and Current Assets each subtotaled, likewise Non-Current/Current Liabilities) needs classification independent of however the company's own COA tree happens to be shaped. Rather than requiring every company to structure its COA with literal "Current Assets"/"Non-Current Assets" header nodes (fragile — nothing stops a differently-shaped COA from breaking the report), a new tagged column does the job explicitly, the same mechanism cash_flow_category (Section 11.5) and income_statement_section (Section 11.4) already use:

ALTER TABLE accounts ADD COLUMN balance_sheet_classification VARCHAR(25);
-- NON_CURRENT_ASSET, CURRENT_ASSET (Asset accounts) |
-- NON_CURRENT_LIABILITY, CURRENT_LIABILITY (Liability accounts) | NULL
-- Equity accounts need no tag - account_type_id alone already puts them in one Equity section.

Tagged once on whichever top-level Asset/Liability header account represents each group in the company's own COA (e.g. tag a "Fixed Assets" root NON_CURRENT_ASSET, a "Cash and Bank" root CURRENT_ASSET), then inherited down to every descendant via the identical tree-CTE COALESCE(a.balance_sheet_classification, t.balance_sheet_classification) pattern Sections 11.4/11.5 already use. The recursive rollup-by-subtree query above is replaced entirely by a leaf-rows-only query (same shape as Section 11.4's P&L leaf-row query, not the old depth/path_ids rollup):

WITH RECURSIVE tree AS (
    SELECT account_id, account_code, account_name, parent_account_id,
           account_type_id, is_postable, balance_sheet_classification
    FROM accounts
    WHERE parent_account_id IS NULL AND account_type_id IN (1,2,3)  -- Asset, Liability, Equity
    UNION ALL
    SELECT a.account_id, a.account_code, a.account_name, a.parent_account_id,
           a.account_type_id, a.is_postable,
           COALESCE(a.balance_sheet_classification, t.balance_sheet_classification)
    FROM accounts a JOIN tree t ON a.parent_account_id = t.account_id
),
balances AS (
    SELECT jel.account_id,
           ROUND(SUM(CASE WHEN a2.account_type_id = 1 THEN jel.base_debit - jel.base_credit
                          ELSE jel.base_credit - jel.base_debit END), 10) AS net_movement
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    JOIN accounts a2 ON a2.account_id = jel.account_id
    WHERE je.is_posted = TRUE AND je.entry_date <= :as_of_date
    GROUP BY jel.account_id
)
SELECT t.account_code, t.account_name, t.account_type_id, t.balance_sheet_classification,
       b.net_movement AS balance
FROM tree t
JOIN balances b ON b.account_id = t.account_id
WHERE t.is_postable = TRUE AND b.net_movement <> 0
ORDER BY t.account_code;

-- Application layer groups leaf rows into sections purely by account_type_id + balance_sheet_classification:
--   Non-Current Assets      = rows WHERE account_type_id = 1 AND classification = 'NON_CURRENT_ASSET'
--   Current Assets          = rows WHERE account_type_id = 1 AND classification = 'CURRENT_ASSET'
--   Unclassified Assets     = rows WHERE account_type_id = 1 AND classification NOT IN the two above
--   Equity                  = rows WHERE account_type_id = 3 (plus the Current Year Earnings line, as before)
--   Non-Current Liabilities = rows WHERE account_type_id = 2 AND classification = 'NON_CURRENT_LIABILITY'
--   Current Liabilities     = rows WHERE account_type_id = 2 AND classification = 'CURRENT_LIABILITY'
--   Unclassified Liabilities = rows WHERE account_type_id = 2 AND classification NOT IN the two above
--   Total Assets             = Non-Current + Current + Unclassified Assets
--   Total Equity and Liabilities = Equity + Non-Current + Current + Unclassified Liabilities
-- A row with balance_sheet_classification = NULL (untagged) is a data-setup gap, not silently
-- dropped - surfaced in its own "unclassified" bucket per side, same rule as Section 11.4's
-- Unclassified income-statement bucket.

Verified live against the seeded dataset: totalAssets = 5,363,714 = totalEquityAndLiabilities, after tagging a handful of representative header/leaf accounts. Implemented as AccountRepository.GetBalanceSheetLeafRowsAsync / ReportService.BuildBalanceSheetAsync, replacing the old GetBalanceSheetAsync recursive-rollup query. Full response shape: 04_API_Reference_And_Scenarios.md's BalanceSheet reportType row.

11.4 Statement of Comprehensive Income — Profit & Loss (recursive rollup, period-based)

Retrofit 2026-07-22 — the "designated COGS subtree" comment below was, like Section 11.5's original Cash Flow sketch, never actually translated into a real mechanism. A real Sri Lankan Income Statement template (user-supplied reference) needs more structure than a flat Income/Expense split: Sales and Other Income are two distinct Income groups (only Sales feeds into Gross Profit; Other Income is added afterward), and Expenses split into Cost of Sales (subtracted from Sales for Gross Profit) plus four further groups — Distribution, Administration, Other, and Financial expenses — each subtracted from Gross Profit + Other Income to reach Net Profit. None of that is derivable from account_type_id alone (which only ever says Income or Expense).

New mechanism: income_statement_section, same recursive-tag pattern as cash_flow_category (Section 11.5).

ALTER TABLE accounts ADD COLUMN income_statement_section VARCHAR(25);
-- SALES, OTHER_INCOME (Income accounts) | COST_OF_SALES, DISTRIBUTION_EXPENSE,
-- ADMINISTRATION_EXPENSE, OTHER_EXPENSE, FINANCIAL_EXPENSE (Expense accounts) | NULL

Tagged once on whichever top-level Income/Expense header account represents each group in the company's own COA (e.g. tag the "Sales" root SALES, the "Cost of Sales" root COST_OF_SALES), then inherited down to every descendant via the identical tree-CTE pattern cash_flow_category already uses — untagged descendants pick up their nearest tagged ancestor's value, exactly like Section 11.5.

WITH RECURSIVE tree AS (
    SELECT account_id, account_code, account_name, parent_account_id,
           account_type_id, income_statement_section, 1 AS depth, ARRAY[account_id] AS path_ids
    FROM accounts
    WHERE parent_account_id IS NULL AND account_type_id IN (4,5)  -- Income, Expense
    UNION ALL
    SELECT a.account_id, a.account_code, a.account_name, a.parent_account_id,
           a.account_type_id, COALESCE(a.income_statement_section, t.income_statement_section),
           t.depth + 1, t.path_ids || a.account_id
    FROM accounts a JOIN tree t ON a.parent_account_id = t.account_id
),
balances AS (
    SELECT jel.account_id, SUM(jel.base_credit - jel.base_debit) AS net_movement
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    WHERE je.is_posted = TRUE
      AND je.entry_date BETWEEN :period_start AND :period_end
    GROUP BY jel.account_id
)
SELECT
    t.depth,
    REPEAT('    ', t.depth - 1) || t.account_name AS line_item,
    at.name AS account_type,
    t.income_statement_section,
    (SELECT COALESCE(ROUND(SUM(b.net_movement), 10), 0)
       FROM tree d
       JOIN balances b ON b.account_id = d.account_id
      WHERE d.path_ids @> ARRAY[t.account_id]
    ) AS amount
FROM tree t
JOIN account_type at ON at.id = t.account_type_id
WHERE t.is_postable = TRUE            -- leaf rows only; section totals are computed in the app layer, not queried per header
ORDER BY t.income_statement_section, t.account_code;

-- Application layer then computes, purely by summing leaf rows per income_statement_section:
--   Sales (net of Sales Returns, since both tag SALES)     = SUM(rows WHERE income_statement_section = 'SALES')
--   Cost of Sales                                          = SUM(rows WHERE income_statement_section = 'COST_OF_SALES')
--   Gross Profit                                           = Sales - Cost of Sales
--   Other Income                                           = SUM(rows WHERE income_statement_section = 'OTHER_INCOME')
--   Distribution / Administration / Other / Financial      = SUM(rows WHERE income_statement_section = '<that value>') each
--   Total Expenses (the four groups above, combined)       = their sum
--   Net Profit for the Period                              = Gross Profit + Other Income - Total Expenses  (LKAS 1 bottom line)
-- A row with income_statement_section = NULL (untagged) is a data-setup gap, not silently dropped —
-- surface it in its own "Unclassified" bucket rather than excluding it, so an incomplete COA tagging
-- pass is visible in the report rather than quietly understating expenses.

This is the same COA-hierarchy-driven philosophy already used for the Balance Sheet's current/non-current split and Cash Flow's cash_flow_category — the schema doesn't hardcode Sri Lankan chart-of-accounts conventions; the company's own COA setup determines which accounts are "Cost of Sales" vs "Distribution Expense" by tagging the right header node once, and every descendant inherits it. A company that never tags anything gets every Income/Expense row bucketed as "Unclassified" rather than the report silently producing the old flat Income/Expense split.

11.5 Statement of Cash Flows (LKAS 7 — indirect method, fully specified)

Retrofit 2026-07-21 — this section replaces the original sketch. The original version below computed a category_net_change per cash_flow_category but never actually became a full statement — the live implementation (04_API_Reference_And_Scenarios.md) used netProfitForPeriod directly as a stand-in for Net Cash from Operating Activities, with no non-cash add-backs, no working-capital changes, and no opening/closing cash reconciliation. Two problems, fixed here: (1) the "Build the statement" comment block was never translated into real queries, and (2) WHERE t.account_type_id NOT IN (SELECT id FROM account_type WHERE name ILIKE '%cash%') was always a no-op — account_type only ever contains Asset/Liability/Equity/Income/Expense, never a row literally named "Cash," so this clause silently excluded nothing.

New mechanism: identifying "cash and cash equivalents" accounts. There is no is_cash_equivalent flag on accounts — consistent with how the Balance Sheet's current/non-current split is resolved (Section 11.3's note), this is resolved through the COA hierarchy the company already has, not a new per-account column. One new system_config key identifies the root:

Relationship to bank_account/cash_account (Section 5.8, retrofit 2026-07-22): these are two different, complementary mechanisms, not duplicates of each other. CASH_ACCOUNT_ROOT_ID is a reporting classification — which GL accounts count as cash for this statement. bank_account/cash_account are operational metadata — the records a user creates to track "this is our Commercial Bank current account" or "this is petty cash," independent of any report. In practice, every bank_account.gl_account_id/cash_account.gl_account_id should be a descendant of CASH_ACCOUNT_ROOT_ID for the Cash Flow report to correctly include it — an expectation, not a database-enforced constraint (same "documented, not enforced" posture as tax_code's output/input account uniqueness, Section 11.7).

INSERT INTO system_config (config_key, config_value, description, is_editable, is_locked) VALUES
    ('CASH_ACCOUNT_ROOT_ID', '<account_id of the company''s "Cash and Cash Equivalents" header account>',
     'Root account whose recursive descendants are treated as cash/bank for the Cash Flow Statement''s opening/closing balance and for excluding cash accounts from working-capital changes',
     TRUE, FALSE);

Same one-time-editable semantics as COA_MAX_DEPTH and the other seeded keys (Section 12.2) — set once during initial COA setup, then locked. Every account under this root (via the same recursive-descendants pattern as Section 6.2) is "cash-like" for the purposes below.

-- Cash-like accounts (recursive descendants of the configured root)
WITH RECURSIVE cash_accounts AS (
    SELECT account_id FROM accounts
    WHERE account_id = (SELECT config_value::BIGINT FROM system_config WHERE config_key = 'CASH_ACCOUNT_ROOT_ID')
    UNION ALL
    SELECT a.account_id FROM accounts a JOIN cash_accounts c ON a.parent_account_id = c.account_id
)
SELECT account_id FROM cash_accounts;

1. Operating Activities

-- Profit for the period: reuse 11.4's Profit & Loss query unchanged, for [period_start, period_end]

-- Non-cash add-back: depreciation posted in the period (only tracked non-cash item currently — Section 5.6)
SELECT COALESCE(SUM(ds.depreciation_amount), 0) AS depreciation_addback
FROM depreciation_schedule ds
JOIN journal_entry je ON je.journal_id = ds.journal_id
WHERE je.is_posted = TRUE
  AND je.entry_date BETWEEN :period_start AND :period_end;

-- Working capital changes: accounts tagged cash_flow_category = 'OPERATING' that are NOT cash-like
-- (the cash_flow_category tree-inheritance CTE is unchanged from the original design below).
-- Change = closing balance at period_end minus opening balance at (period_start - 1 day),
-- sign-adjusted the same way Section 11.3's Balance Sheet fix adjusts for normal balance:
-- an INCREASE in an operating asset (e.g. Trade Receivables) is a cash OUTFLOW (negative);
-- an INCREASE in an operating liability (e.g. Trade Payables) is a cash INFLOW (positive).
WITH RECURSIVE tree AS (
    SELECT account_id, parent_account_id, account_type_id, cash_flow_category, ARRAY[account_id] AS path_ids
    FROM accounts WHERE parent_account_id IS NULL
    UNION ALL
    SELECT a.account_id, a.parent_account_id, a.account_type_id,
           COALESCE(a.cash_flow_category, t.cash_flow_category), t.path_ids || a.account_id
    FROM accounts a JOIN tree t ON a.parent_account_id = t.account_id
),
opening AS (
    SELECT jel.account_id,
           SUM(CASE WHEN a.account_type_id = 1 THEN jel.base_debit - jel.base_credit
                     ELSE jel.base_credit - jel.base_debit END) AS balance
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    JOIN accounts a ON a.account_id = jel.account_id
    WHERE je.is_posted = TRUE AND je.entry_date < :period_start
    GROUP BY jel.account_id
),
closing AS (
    SELECT jel.account_id,
           SUM(CASE WHEN a.account_type_id = 1 THEN jel.base_debit - jel.base_credit
                     ELSE jel.base_credit - jel.base_debit END) AS balance
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    JOIN accounts a ON a.account_id = jel.account_id
    WHERE je.is_posted = TRUE AND je.entry_date <= :period_end
    GROUP BY jel.account_id
)
SELECT
    a.account_code, a.account_name,
    COALESCE(cl.balance, 0) - COALESCE(op.balance, 0) AS raw_change,
    CASE WHEN COALESCE(cl.balance, 0) - COALESCE(op.balance, 0) >= 0 THEN 'Increase' ELSE 'Decrease' END AS direction,
    -- flip sign for asset-type operating accounts (a receivable increase consumes cash)
    CASE WHEN a.account_type_id = 1 THEN -(COALESCE(cl.balance, 0) - COALESCE(op.balance, 0))
         ELSE COALESCE(cl.balance, 0) - COALESCE(op.balance, 0) END AS cash_flow_impact
FROM tree t
JOIN accounts a ON a.account_id = t.account_id
LEFT JOIN opening op ON op.account_id = a.account_id
LEFT JOIN closing cl ON cl.account_id = a.account_id
WHERE t.cash_flow_category = 'OPERATING'
  AND a.account_id NOT IN (SELECT account_id FROM cash_accounts)   -- the CTE two blocks above
  AND a.is_postable = TRUE;

-- Net Cash from Operating Activities = profit_for_period + depreciation_addback + SUM(cash_flow_impact)

Retrofit 2026-07-22 — direction added. A user-supplied reference template labels each working-capital line "Increase in Trade Receivables" / "Decrease in Trade Receivables" rather than just the account name and a signed number. That wording is driven by raw_change's own sign (the balance itself went up or down) — not cash_flow_impact's sign (whether that increase helped or hurt cash) — so it's a genuinely separate value, not derivable from cash_flow_impact alone: a Trade Receivables increase (direction = 'Increase') and a Trade Payables increase (direction = 'Increase') have opposite cash_flow_impact signs (outflow vs inflow) despite both being labeled "Increase."

2. Investing Activities and 3. Financing Activities — itemized, not a single aggregate (needed for the line-by-line template in 05_LKAS_Report_PDF_Templates.md):

-- Same tree CTE as above, applied per category
SELECT a.account_code, a.account_name,
       SUM(jel.base_credit - jel.base_debit) AS amount   -- movement during the period only, not a balance
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
JOIN accounts a ON a.account_id = jel.account_id
JOIN tree t ON t.account_id = a.account_id                -- tree CTE from above
WHERE je.is_posted = TRUE
  AND je.entry_date BETWEEN :period_start AND :period_end
  AND t.cash_flow_category = 'INVESTING'                    -- or 'FINANCING' for that section
  AND a.account_id NOT IN (SELECT account_id FROM cash_accounts)
  AND a.is_postable = TRUE
GROUP BY a.account_code, a.account_name;

4. Opening / closing cash balance — computed for internal validation only, not part of the displayed statement (retrofit 2026-07-22):

-- Opening cash balance: sum of cash-like accounts' balance immediately before period_start
SELECT COALESCE(SUM(jel.base_debit - jel.base_credit), 0) AS opening_cash_balance
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
WHERE je.is_posted = TRUE AND je.entry_date < :period_start
  AND jel.account_id IN (SELECT account_id FROM cash_accounts);

-- Closing cash balance: same query, entry_date <= period_end
-- Validation, not just computation: closing_cash_balance MUST equal
--   opening_cash_balance + net_increase_decrease_in_cash
-- computed independently — if they don't match, that's a bug in one of the category
-- computations above, the same kind of cross-check Section 11.3's Balance Sheet fix relies on
-- (Assets = Liabilities + Equity as the correctness signal, not just "it ran without error").

A user-supplied reference template for this statement doesn't show an opening/closing cash balance section at all — it ends at the combined Operating + Investing + Financing total (labeled e.g. "Cash Flow for FY Ended [date]"). Rather than drop the reconciliation check entirely (it's a genuine correctness safeguard, not decoration), both values stay in the outputFormat=Json response and the underlying validation still runs — the PDF/CSV templates simply don't print an opening/closing balance line, matching the reference template's visible structure. 05_LKAS_Report_PDF_Templates.md Section 5 reflects this split between "computed and returned" vs. "displayed."

Response shape (supersedes the flat 6-field object 04_API_Reference_And_Scenarios.md originally documented, and gains direction on working-capital lines as of this retrofit):

{
  "periodStart": "2026-07-01", "periodEnd": "2026-07-31",
  "operatingActivities": {
    "profitForPeriod": 252700.00,
    "nonCashAdjustments": [ { "description": "Depreciation", "amount": 2300.00 } ],
    "workingCapitalChanges": [
      { "accountCode": "1102", "accountName": "Trade Receivables", "direction": "Decrease", "changeAmount": 15000.00 },
      { "accountCode": "2101", "accountName": "Accounts Payable", "direction": "Increase", "changeAmount": 15000.00 },
      { "accountCode": "2301", "accountName": "Taxes Payable", "direction": "Increase", "changeAmount": 2000.00 },
      { "accountCode": "1301", "accountName": "Inventory", "direction": "Increase", "changeAmount": -30000.00 }
    ],
    "netCashFromOperatingActivities": 2012000.00
  },
  "investingActivities": {
    "lines": [ { "accountCode": "1201", "accountName": "Equipment", "amount": -500000.00 } ],
    "netCashFromInvestingActivities": -500000.00
  },
  "financingActivities": {
    "lines": [ { "accountCode": "2401", "accountName": "Notes Payable", "amount": 10000.00 } ],
    "netCashFromFinancingActivities": 10000.00
  },
  "netIncreaseDecreaseInCash": 1522000.00,
  "openingCashBalance": 0.00,
  "closingCashBalance": 1522000.00
}

changeAmount is always cash_flow_impact (already correctly signed for the statement — positive means it added cash, negative means it consumed cash), paired with direction (the plain-English "Increase"/"Decrease" describing the account balance's own movement, independent of that sign, per the note above). The PDF/CSV renderer buckets every entry from both nonCashAdjustments and workingCapitalChanges by amount/changeAmount sign into two groups at render time — positive → "Additions to Cash," negative → "Subtractions From Cash" — rather than the response itself being pre-split into two arrays; see 05_LKAS_Report_PDF_Templates.md Section 5 for the exact grouping logic.

11.6 Accounts Receivable & Accounts Payable Reports — generated entirely from the core ledger

Because AR/AP has no dedicated subledger tables, every AR/AP report is a direct query against journal_entry_line, filtered to lines that hit a control account (is_control_account = TRUE) and grouped by party_id/document_no. This is the core-module reporting the design centers on: one ledger, no duplicate data store.

AR Aging (by customer, bucketed by due date):

WITH ar_lines AS (
    SELECT
        jel.party_id AS customer_id,
        jel.document_no,
        jel.due_date,
        SUM(jel.base_debit - jel.base_credit) AS open_balance
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    JOIN customer c ON c.customer_id = jel.party_id
    WHERE je.is_posted = TRUE
      AND jel.party_type = 'CUSTOMER'
      AND jel.account_id = c.ar_control_account_id
      AND je.entry_date <= :as_of_date
    GROUP BY jel.party_id, jel.document_no, jel.due_date
    HAVING SUM(jel.base_debit - jel.base_credit) <> 0     -- still open
)
SELECT
    c.customer_code, c.name,
    ar.document_no, ar.due_date, ar.open_balance,
    CASE
        WHEN :as_of_date - ar.due_date <= 0  THEN 'Current'
        WHEN :as_of_date - ar.due_date <= 30 THEN '1-30 Days'
        WHEN :as_of_date - ar.due_date <= 60 THEN '31-60 Days'
        WHEN :as_of_date - ar.due_date <= 90 THEN '61-90 Days'
        ELSE '90+ Days'
    END AS aging_bucket
FROM ar_lines ar
JOIN customer c ON c.customer_id = ar.customer_id
ORDER BY c.customer_code, ar.due_date;

AP Aging (mirror of AR, credit-normal):

WITH ap_lines AS (
    SELECT
        jel.party_id AS supplier_id,
        jel.document_no,
        jel.due_date,
        SUM(jel.base_credit - jel.base_debit) AS open_balance
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    JOIN supplier s ON s.supplier_id = jel.party_id
    WHERE je.is_posted = TRUE
      AND jel.party_type = 'SUPPLIER'
      AND jel.account_id = s.ap_control_account_id
      AND je.entry_date <= :as_of_date
    GROUP BY jel.party_id, jel.document_no, jel.due_date
    HAVING SUM(jel.base_credit - jel.base_debit) <> 0
)
SELECT
    s.supplier_code, s.name,
    ap.document_no, ap.due_date, ap.open_balance,
    CASE
        WHEN :as_of_date - ap.due_date <= 0  THEN 'Current'
        WHEN :as_of_date - ap.due_date <= 30 THEN '1-30 Days'
        WHEN :as_of_date - ap.due_date <= 60 THEN '31-60 Days'
        WHEN :as_of_date - ap.due_date <= 90 THEN '61-90 Days'
        ELSE '90+ Days'
    END AS aging_bucket
FROM ap_lines ap
JOIN supplier s ON s.supplier_id = ap.supplier_id
ORDER BY s.supplier_code, ap.due_date;

Customer / Supplier Statement of Account (running balance, same pattern as the GL statement in 11.1 but filtered by party instead of account):

SELECT
    je.entry_date, je.journal_no, jel.document_no, je.narration,
    jel.debit_amount, jel.credit_amount,
    SUM(jel.debit_amount - jel.credit_amount)
        OVER (PARTITION BY jel.party_id ORDER BY je.entry_date, je.journal_id
              ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_balance
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
WHERE je.is_posted = TRUE
  AND jel.party_type = :party_type          -- 'CUSTOMER' or 'SUPPLIER'
  AND jel.party_id   = :party_id
  AND je.entry_date BETWEEN :from_date AND :to_date
ORDER BY je.entry_date, je.journal_id;

Control account tie-out (standard reconciliation check — total aging must equal the GL control account balance):

-- AR side: sum of open AR aging lines for a customer's control account
-- must equal that control account's recursive rollup balance (Section 6.2) for the same customer's postings.
SELECT
    c.ar_control_account_id,
    (SELECT COALESCE(SUM(jel.base_debit - jel.base_credit), 0)
       FROM journal_entry_line jel JOIN journal_entry je ON je.journal_id = jel.journal_id
      WHERE je.is_posted = TRUE AND jel.account_id = c.ar_control_account_id
        AND jel.party_id = c.customer_id AND jel.party_type = 'CUSTOMER'
        AND je.entry_date <= :as_of_date)                              AS gl_balance,
    (SELECT COALESCE(SUM(open_balance), 0) FROM ar_lines WHERE customer_id = c.customer_id) AS aging_total
FROM customer c
WHERE c.customer_id = :customer_id;
-- gl_balance and aging_total must always match — if they don't, a line was posted to the
-- control account without proper party/document tagging, which the app-layer rule in
-- Section 7.4 is designed to prevent.

11.7 Income Tax Computation Report (redesigned 2026-07-22 — supersedes the 2026-07-21 VAT/WHT summary design below †)

A user-supplied reference template made clear the report actually needed here is a full Corporate Income Tax Computation — Profit Before Tax reconciled through to a final Balance Tax Payable/(Refund) figure, the standard shape every Sri Lankan corporate tax return computation follows — not the VAT/WHT/NBT-per-tax_code summary originally built under this same reportType name the day before. That summary is a real, valid report in its own right (still fully described below, marked †, for reference), but it is not what "Tax Report" turned out to mean here. This section replaces it.

Scope, still stated plainly: this is closer to LKAS 12 territory than the superseded design was, but it's still not deferred tax — no deferred tax asset/liability recognition or movement is tracked anywhere in this schema. This report computes current tax only, which is what the reference template itself shows (no deferred tax line appears in it either).

Five new pieces of infrastructure needed, none of which existed before this section:

1. accounts.is_tax_deductible — new column, drives "Non-Deductible Expenses":

ALTER TABLE accounts ADD COLUMN is_tax_deductible BOOLEAN NOT NULL DEFAULT TRUE;
-- Meaningful only on Expense accounts. An accounting expense that Sri Lankan tax law
-- doesn't allow as a deduction (e.g. certain entertainment costs, donations to
-- non-approved charities, fines/penalties) gets this flipped to FALSE on the relevant
-- account — same "tag the account, not the transaction" philosophy as every other
-- classification in this system (cash_flow_category, income_statement_section).

2–4. Three new system_config keys (same seeding/one-time-lock pattern as every other key in Section 12.2):

INSERT INTO system_config (config_key, config_value, description, is_editable, is_locked) VALUES
    ('CORPORATE_INCOME_TAX_RATE', '30', 'Default corporate income tax rate, percent — overridable per report request', TRUE, FALSE),
    ('STATUTORY_RELIEF_AMOUNT', '0', 'Default Qualifying Payments/Reliefs figure — overridable per report request', TRUE, FALSE),
    ('APIT_CREDIT_ACCOUNT_ID', '<account_id>', 'GL account where APIT (Advance Personal Income Tax) credits are recorded — like CASH_ACCOUNT_ROOT_ID, cannot be HasData-seeded, set later via PUT /system-config once the account exists', TRUE, FALSE),
    ('WHT_CREDIT_ACCOUNT_ID', '<account_id>', 'GL account where WHT credit receivable is recorded — same seeding constraint as above', TRUE, FALSE),
    ('QUARTERLY_TAX_PAYMENT_ACCOUNT_ID', '<account_id>', 'GL account where quarterly self-assessment tax installment payments are recorded — same seeding constraint as above', TRUE, FALSE);

5. Four figures that are genuinely not GL-derived — request-time manual entry, not stored data. "Allowable Deductions," "Other Taxable Income," "Qualifying Payments/Reliefs," and "Surcharge/Education Levy" are the kind of figure a tax preparer determines at filing time from current tax law, not something a chart of accounts can systematically classify — real tax software treats these the same way, as entry fields at computation time, not derived data. qualifyingPaymentsReliefs gets a system_config-provided default (STATUTORY_RELIEF_AMOUNT) since it's often a fixed statutory figure; the other three default to 0 and are only ever request-supplied.

The computation, end to end:

-- 1. Profit Before Tax — reuse Section 11.4's Profit & Loss net figure unchanged, for [period_start, period_end]

-- 2. Non-Deductible Expenses
SELECT COALESCE(ROUND(SUM(jel.base_credit - jel.base_debit), 10), 0) AS non_deductible_expenses
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
JOIN accounts a ON a.account_id = jel.account_id
WHERE je.is_posted = TRUE
  AND je.entry_date BETWEEN :period_start AND :period_end
  AND a.account_type_id = 5              -- Expense
  AND a.is_tax_deductible = FALSE;
-- (Expense is credit-negative/debit-positive in nature — base_credit - base_debit yields a
--  negative number for a normal debit-balance expense; the application layer takes the
--  absolute value here, consistent with how every other expense figure in this report is
--  presented as a positive "Add:" line.)

-- 3. Adjusted Business Profit = Profit Before Tax + Non-Deductible Expenses - Allowable Deductions (request param, default 0)

-- 4. Assessable Income = Adjusted Business Profit + Other Taxable Income (request param, default 0)

-- 5. Taxable Income = Assessable Income - Qualifying Payments/Reliefs (request param, defaults to STATUTORY_RELIEF_AMOUNT)

-- 6. Corporate Income Tax = Taxable Income × (taxRateOverride request param, defaults to CORPORATE_INCOME_TAX_RATE) / 100

-- 7. Gross Tax Liability = Corporate Income Tax + Surcharge/Education Levy (request param, default 0)

-- 8/9/10. Credits — each a simple sum of the configured account's activity in the period:
SELECT COALESCE(ROUND(SUM(jel.base_credit - jel.base_debit), 10), 0) AS credit_amount
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
WHERE je.is_posted = TRUE
  AND je.entry_date BETWEEN :period_start AND :period_end
  AND jel.account_id = (SELECT config_value::BIGINT FROM system_config WHERE config_key = 'APIT_CREDIT_ACCOUNT_ID');
-- repeat for WHT_CREDIT_ACCOUNT_ID and QUARTERLY_TAX_PAYMENT_ACCOUNT_ID

-- 11. Balance Tax Payable / (Refund) = Gross Tax Liability - APIT Credit - WHT Credit - Quarterly Tax Payments

Response shape:

{
  "periodStart": "2026-01-01", "periodEnd": "2026-12-31",
  "profitBeforeTax": 12500000.00,
  "nonDeductibleExpenses": 350000.00,
  "allowableDeductions": 150000.00,
  "adjustedBusinessProfit": 12700000.00,
  "otherTaxableIncome": 300000.00,
  "assessableIncome": 13000000.00,
  "qualifyingPaymentsReliefs": 150000.00,
  "taxableIncome": 12850000.00,
  "taxRatePercent": 30.00,
  "corporateIncomeTax": 3855000.00,
  "surchargeAmount": 0.00,
  "grossTaxLiability": 3855000.00,
  "apitCredit": 200000.00,
  "whtCredit": 150000.00,
  "quarterlyTaxPayments": 1000000.00,
  "balanceTaxPayable": 2505000.00
}

A negative balanceTaxPayable is a refund position (credits/payments exceeded the gross liability) — valid, not an error, same non-clamping convention as every other signed figure in this system's reports.


11.7† Superseded design (2026-07-21) — VAT/WHT/NBT Summary, kept for reference only

Not removed from the schema or the codebase — tax_code.output_tax_account_id/input_tax_account_id and the query below remain valid and useful for actually preparing a periodic VAT/WHT/NBT return, which the redesigned report above does not cover at all (it's a once-a-year corporate income tax computation, not a periodic indirect-tax return). If a VAT-return-style report is still wanted going forward, it needs its own reportType (e.g. IndirectTaxSummary) rather than continuing to share TaxSummary's name with the new Income Tax Computation — flagged as an open decision, not resolved here, since the current retrofit (Document 2, Phase 17) simply replaces TaxSummary's content wholesale rather than adding a second report type on top of an already-large change.

Scope, as originally stated: LKAS 12 (Income Taxes) governs recognition, measurement, and disclosure of current and deferred tax in the financial statements themselves — a reconciliation between tax expense and accounting profit at the applicable rate, a breakdown of current vs. deferred tax components, and so on. This system has no deferred tax tracking anywhere in the schema, so a genuine LKAS 12 note isn't something this report can produce. What it produced instead was the practical, everyday need this system's Tax Configuration module (Section 12.1) already exists to serve: a periodic summary of tax collected vs. tax paid, per tax_code, of the kind needed to actually fill in a VAT/WHT/NBT return to the Inland Revenue Department.

A real constraint worth stating upfront: journal_entry_line has no tax_code_id column (Section 5.3) — there's no way to attribute one specific posted line back to the exact tax_code that produced it. This report works around that by relying on each tax_code having its own distinct output_tax_account_id/input_tax_account_id (Section 12.1) — it sums activity in those specific GL accounts, not activity tagged with the tax code directly. This is accurate as long as no two tax_code rows share the same output or input account; if a company's setup ever does share one, this report can no longer distinguish between them.

SELECT
    tc.tax_code_id, tc.code, tt.name AS tax_type_name, tc.rate_percent,
    COALESCE(output_movement.amt, 0) AS output_tax_amount,
    COALESCE(input_movement.amt, 0)  AS input_tax_amount,
    COALESCE(output_movement.amt, 0) - COALESCE(input_movement.amt, 0) AS net_tax_payable
FROM tax_code tc
JOIN tax_type tt ON tt.tax_type_id = tc.tax_type_id
LEFT JOIN (
    SELECT jel.account_id, SUM(jel.base_credit - jel.base_debit) AS amt   -- credit-normal: liability, tax collected
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    WHERE je.is_posted = TRUE AND je.entry_date BETWEEN :period_start AND :period_end
    GROUP BY jel.account_id
) output_movement ON output_movement.account_id = tc.output_tax_account_id
LEFT JOIN (
    SELECT jel.account_id, SUM(jel.base_debit - jel.base_credit) AS amt   -- debit-normal: asset, tax reclaimable
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    WHERE je.is_posted = TRUE AND je.entry_date BETWEEN :period_start AND :period_end
    GROUP BY jel.account_id
) input_movement ON input_movement.account_id = tc.input_tax_account_id
WHERE tc.is_active = TRUE
ORDER BY tc.code;

These are setup/configuration tables — admin-editable master data that drives system behavior. No auth is attached to editing them (per the no-role-based-access requirement); they are simply configuration records like any other master table.

12.1 Tax Configuration (fully configurable, not hardcoded)

Rather than hardcoding VAT/WHT/NBT logic, tax is a configurable rule engine: each tax_code defines its own rate, accounts, calculation basis, and applicability — new tax types (or rate changes over time) are added by inserting/editing rows, not by changing code.

CREATE TABLE tax_type (
    tax_type_id     SERIAL PRIMARY KEY,
    code            VARCHAR(20) UNIQUE NOT NULL,   -- VAT, WHT, NBT, SSCL, INCOME_TAX
    name            VARCHAR(100) NOT NULL,
    description     TEXT
);

CREATE TABLE tax_code (
    tax_code_id         BIGSERIAL PRIMARY KEY,
    tax_type_id         INT NOT NULL REFERENCES tax_type(tax_type_id),
    code                VARCHAR(20) UNIQUE NOT NULL,   -- VAT18, VAT_ZERO, WHT5, WHT10, SVAT
    name                VARCHAR(100) NOT NULL,
    rate_percent         NUMERIC(6,3) NOT NULL,
    calculation_basis     VARCHAR(15) DEFAULT 'EXCLUSIVE',  -- EXCLUSIVE, INCLUSIVE
    applies_to             VARCHAR(15) NOT NULL,             -- SALES, PURCHASE, BOTH
    output_tax_account_id   BIGINT REFERENCES accounts(account_id),  -- liability (tax collected)
    input_tax_account_id     BIGINT REFERENCES accounts(account_id), -- asset (tax reclaimable)
    effective_from             DATE NOT NULL,
    effective_to                 DATE,                        -- NULL = still active; allows rate-change history
    is_active                     BOOLEAN DEFAULT TRUE
);

-- Rate history is preserved: e.g. VAT moved 15% -> 18% on a given date;
-- old row gets effective_to set, new row inserted — nothing overwritten.

CREATE TABLE tax_exemption (
    exemption_id        BIGSERIAL PRIMARY KEY,
    customer_id          BIGINT REFERENCES customer(customer_id),
    supplier_id            BIGINT REFERENCES supplier(supplier_id),
    tax_code_id             BIGINT NOT NULL REFERENCES tax_code(tax_code_id),
    exemption_reference       VARCHAR(100),   -- e.g. exemption certificate number
    valid_from                  DATE NOT NULL,
    valid_to                      DATE
);

Configurable calculation logic (applied when a party-tagged AR/AP line is posted):

SELECT
    CASE
        WHEN tc.calculation_basis = 'EXCLUSIVE'
            THEN :line_total * (tc.rate_percent / 100)
        WHEN tc.calculation_basis = 'INCLUSIVE'
            THEN :line_total - (:line_total / (1 + tc.rate_percent / 100))
    END AS tax_amount
FROM tax_code tc
WHERE tc.tax_code_id = :tax_code_id
  AND tc.effective_from <= :txn_date
  AND (tc.effective_to IS NULL OR tc.effective_to >= :txn_date);

Tax posting always splits into the correct account automatically:

  • Sales → credit output_tax_account_id (tax payable/liability)
  • Purchases → debit input_tax_account_id (tax receivable/asset, if reclaimable e.g. VAT input)
  • WHT on payments → credit a WHT payable account and reduce net payment to supplier

12.2 Configurable Recursive Depth (Chart of Accounts / Cost Centers)

The COA is recursive with no hard depth limit in the schema — but a configurable soft limit is enforced at the application layer so the hierarchy doesn't get uncontrollably deep. This is a setting, not a structural constraint, so it can be changed anytime without a schema migration.

CREATE TABLE system_config (
    config_key       VARCHAR(50) PRIMARY KEY,
    config_value      VARCHAR(255) NOT NULL,
    description         TEXT,
    updated_at            TIMESTAMPTZ DEFAULT now()
);

INSERT INTO system_config (config_key, config_value, description) VALUES
    ('COA_MAX_DEPTH',        '6',  'Maximum allowed levels in the recursive Chart of Accounts tree'),
    ('COST_CENTER_MAX_DEPTH','4',  'Maximum allowed levels in the recursive Cost Center tree'),
    ('BRANCH_MAX_DEPTH',     '4',  'Maximum allowed levels in the recursive Branch tree'),
    ('COA_ENFORCE_MAX_DEPTH','TRUE','If FALSE, depth is unlimited (config value ignored)'),
    ('CASH_ACCOUNT_ROOT_ID', '<account_id>', 'Root account whose recursive descendants are treated as cash/bank for the Cash Flow Statement (Section 11.5, retrofit 2026-07-21) — one-time-editable, same as the other keys here'),
    ('CORPORATE_INCOME_TAX_RATE', '30', 'Default corporate income tax rate, percent, for the Income Tax Computation report (Section 11.7, retrofit 2026-07-22) — overridable per report request'),
    ('STATUTORY_RELIEF_AMOUNT', '0', 'Default Qualifying Payments/Reliefs figure for the Income Tax Computation report (Section 11.7) — overridable per report request'),
    ('APIT_CREDIT_ACCOUNT_ID', '<account_id>', 'GL account for APIT credits, read by the Income Tax Computation report (Section 11.7) — same seed-time constraint as CASH_ACCOUNT_ROOT_ID'),
    ('WHT_CREDIT_ACCOUNT_ID', '<account_id>', 'GL account for WHT credit receivable, read by the Income Tax Computation report (Section 11.7) — same seed-time constraint'),
    ('QUARTERLY_TAX_PAYMENT_ACCOUNT_ID', '<account_id>', 'GL account for quarterly self-assessment tax payments, read by the Income Tax Computation report (Section 11.7) — same seed-time constraint');

Enforcement query — run before INSERT/UPDATE of an account's parent_account_id:

WITH RECURSIVE ancestor_depth AS (
    SELECT account_id, parent_account_id, 1 AS depth
    FROM accounts WHERE account_id = :new_parent_id

    UNION ALL

    SELECT a.account_id, a.parent_account_id, ad.depth + 1
    FROM accounts a
    JOIN ancestor_depth ad ON a.account_id = ad.parent_account_id
)
SELECT MAX(depth) + 1 AS resulting_depth   -- depth the NEW child would sit at
FROM ancestor_depth;

-- Application layer compares resulting_depth against system_config.COA_MAX_DEPTH
-- (only if COA_ENFORCE_MAX_DEPTH = 'TRUE') and rejects the insert/move if exceeded.

This keeps the design recursive and unlimited by nature, while giving the business a configurable governance knob (e.g. "no more than 6 levels deep") that can be raised, lowered, or switched off entirely without touching the schema. The same pattern applies to the Cost Center tree (5.4) and the Branch tree (13.1).


12.3 Currency Configuration

CREATE TABLE currency (
    currency_code       CHAR(3) PRIMARY KEY,     -- ISO 4217: LKR, USD, EUR, GBP, INR ...
    currency_name        VARCHAR(50) NOT NULL,
    symbol                 VARCHAR(5),
    decimal_places           SMALLINT DEFAULT 2,
    is_base_currency           BOOLEAN DEFAULT FALSE,   -- exactly one row = TRUE (company's functional/reporting currency)
    is_active                    BOOLEAN DEFAULT TRUE
);

-- Enforce single base currency at app layer (or a partial unique index):
CREATE UNIQUE INDEX one_base_currency ON currency (is_base_currency) WHERE is_base_currency = TRUE;

CREATE TABLE exchange_rate (
    rate_id             BIGSERIAL PRIMARY KEY,
    currency_code        CHAR(3) NOT NULL REFERENCES currency(currency_code),
    rate_date              DATE NOT NULL,
    rate_to_base              NUMERIC(18,6) NOT NULL,   -- 1 unit of currency_code = X units of base currency
    rate_source                 VARCHAR(30) DEFAULT 'MANUAL',  -- MANUAL, CENTRAL_BANK, API_FEED
    UNIQUE(currency_code, rate_date)
);

CREATE TABLE currency_rounding_rule (
    currency_code        CHAR(3) PRIMARY KEY REFERENCES currency(currency_code),
    rounding_method         VARCHAR(15) DEFAULT 'NEAREST',  -- NEAREST, UP, DOWN
    rounding_account_id       BIGINT REFERENCES accounts(account_id)  -- posts rounding differences
);

FX revaluation logic (period-end, LKAS 21):

SELECT
    a.account_id,
    a.currency_code,
    SUM(jel.base_debit - jel.base_credit)                                   AS booked_base_balance,
    SUM((jel.debit_amount - jel.credit_amount) * er.rate_to_base)           AS revalued_base_balance,
    SUM((jel.debit_amount - jel.credit_amount) * er.rate_to_base)
        - SUM(jel.base_debit - jel.base_credit)                             AS fx_gain_loss
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
JOIN accounts a        ON a.account_id = jel.account_id
JOIN exchange_rate er   ON er.currency_code = a.currency_code AND er.rate_date = :period_end_date
WHERE a.currency_code != (SELECT currency_code FROM currency WHERE is_base_currency = TRUE)
  AND je.is_posted = TRUE
GROUP BY a.account_id, a.currency_code;
-- Positive fx_gain_loss -> credit "Unrealized FX Gain"; negative -> debit "Unrealized FX Loss"

12.4 Lock Period Configuration

ALTER TABLE accounting_period
    ADD COLUMN lock_ap        BOOLEAN DEFAULT FALSE,   -- blocks new AP postings
    ADD COLUMN lock_ar        BOOLEAN DEFAULT FALSE,   -- blocks new AR postings
    ADD COLUMN lock_inventory BOOLEAN DEFAULT FALSE,   -- blocks stock movements
    ADD COLUMN lock_gl        BOOLEAN DEFAULT FALSE,   -- blocks manual journal entries
    ADD COLUMN locked_at      TIMESTAMPTZ,
    ADD COLUMN lock_reason    VARCHAR(255);

CREATE TABLE period_lock_log (
    log_id            BIGSERIAL PRIMARY KEY,
    period_id         INT NOT NULL REFERENCES accounting_period(period_id),
    action            VARCHAR(15) NOT NULL,   -- LOCK, UNLOCK, PARTIAL_LOCK
    module_locked     VARCHAR(15),            -- AP, AR, GL, INVENTORY, ALL
    reason            VARCHAR(255),
    action_at         TIMESTAMPTZ DEFAULT now()
);

Status model (config-driven, not just a flag):

status Meaning
OPEN All modules can post
SOFT_CLOSE Reporting cut-off suggested; posting still allowed but flagged with a warning
LOCKED No posting to any module allowed — must reopen (logged) to post
CLOSED Permanently closed as part of year-end close (Section 8); reopening requires explicit UNLOCK action + log entry

Enforcement — checked by the posting engine (Section 7) before any journal is posted:

SELECT
    ap.status,
    ap.lock_gl, ap.lock_ap, ap.lock_ar, ap.lock_inventory
FROM accounting_period ap
WHERE :entry_date BETWEEN ap.start_date AND ap.end_date;

-- Application logic:
--   IF status IN ('LOCKED','CLOSED') -> reject all postings
--   IF status = 'OPEN' AND lock_<module> = TRUE for the source module -> reject that module's postings only
--   IF status = 'SOFT_CLOSE' -> allow posting but surface a UI/API warning

Reopening a locked/closed period always writes a period_lock_log row (reason required) so the audit trail shows exactly when and why a closed period was reopened — this is the one piece of traceability retained even without a full user/auth system (it can store a free-text actor name rather than a linked user record).


13. Multi-Branch Architecture

The company operates multiple branches. Design goal: one shared Chart of Accounts, but every transaction is tagged to a branch, so you can pull a report for a single branch, branch-by-branch side-by-side, or fully consolidated — all from the same recursive account tree, just filtered/grouped differently.

13.1 Branch Master (recursive — supports Branch → Region → Head Office hierarchies)

CREATE TABLE branch (
    branch_id          BIGSERIAL PRIMARY KEY,
    branch_code         VARCHAR(20) UNIQUE NOT NULL,
    branch_name          VARCHAR(150) NOT NULL,
    parent_branch_id       BIGINT REFERENCES branch(branch_id),  -- recursive: e.g. Branch -> Region -> HQ
    is_head_office            BOOLEAN DEFAULT FALSE,
    address                     TEXT,
    default_currency_code         CHAR(3) DEFAULT 'LKR',
    is_active                       BOOLEAN DEFAULT TRUE
);

CREATE INDEX idx_branch_parent ON branch(parent_branch_id);

Same recursive pattern as the COA — branches roll up through parent_branch_id, so a "Western Province" region node automatically aggregates every branch beneath it, at any depth, using the identical recursive-CTE technique from Section 6.

13.2 Tagging Transactions by Branch

ALTER TABLE journal_entry ADD COLUMN branch_id BIGINT NOT NULL REFERENCES branch(branch_id);
-- journal_entry_line.branch_id was already added in Section 5.3 as an optional line-level
-- override for shared/allocated transactions (e.g. a head-office utility bill split across branches)

The Chart of Accounts itself is not duplicated per branch — one COA is shared company-wide. Branch separation happens through the branch_id tag on transactions, not by cloning accounts. Likewise, the AR/AP party model (Section 5.5) is shared company-wide; a customer's home_branch_id identifies its primary branch, but transactions still carry their own branch_id per entry.

13.3 Inter-Branch Transactions (Due To / Due From)

For goods/cash/services transferred between branches, use paired control accounts so each branch's trial balance still balances independently.

CREATE TABLE inter_branch_account_map (
    map_id             BIGSERIAL PRIMARY KEY,
    from_branch_id      BIGINT NOT NULL REFERENCES branch(branch_id),
    to_branch_id           BIGINT NOT NULL REFERENCES branch(branch_id),
    due_from_account_id      BIGINT NOT NULL REFERENCES accounts(account_id),  -- asset, in "from" branch's books
    due_to_account_id          BIGINT NOT NULL REFERENCES accounts(account_id),  -- liability, in "to" branch's books
    UNIQUE(from_branch_id, to_branch_id)
);

An inter-branch transfer posts two branch-tagged journal entries (one per branch), debiting due_from in the sending branch and crediting due_to in the receiving branch — both self-balanced, and both eliminated automatically at consolidation (Section 13.5).

13.4 Branch-Wise Reports (each branch reported separately)

Every report from Section 11 gains a branch_id filter — including the AR/AP aging and statement queries in 11.6, which simply add AND je.branch_id = :branch_id.

Branch Trial Balance:

WITH RECURSIVE tree AS (
    SELECT account_id, account_code, account_name, parent_account_id, is_postable, 1 AS depth
    FROM accounts WHERE parent_account_id IS NULL
    UNION ALL
    SELECT a.account_id, a.account_code, a.account_name, a.parent_account_id, a.is_postable, t.depth + 1
    FROM accounts a JOIN tree t ON a.parent_account_id = t.account_id
),
branch_balances AS (
    SELECT jel.account_id,
           COALESCE(jel.branch_id, je.branch_id) AS branch_id,
           SUM(jel.base_debit)  AS total_debit,
           SUM(jel.base_credit) AS total_credit
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    WHERE je.is_posted = TRUE AND je.entry_date <= :as_of_date
    GROUP BY jel.account_id, COALESCE(jel.branch_id, je.branch_id)
)
SELECT b.branch_code, b.branch_name,
       t.account_code, t.account_name,
       bb.total_debit, bb.total_credit
FROM branch_balances bb
JOIN tree t     ON t.account_id = bb.account_id
JOIN branch b   ON b.branch_id  = bb.branch_id
WHERE bb.branch_id = :branch_id            -- single branch report
ORDER BY t.account_code;

Branch-by-Branch comparison (all branches side by side, one query):

SELECT
    t.account_code, t.account_name,
    b.branch_name,
    SUM(bb.total_debit - bb.total_credit) AS net_balance
FROM branch_balances bb
JOIN tree t   ON t.account_id = bb.account_id
JOIN branch b ON b.branch_id  = bb.branch_id
GROUP BY t.account_code, t.account_name, b.branch_name
ORDER BY t.account_code, b.branch_name;
-- Pivot in the application/report layer: rows = accounts, columns = one per branch

The same branch_id filter/group-by pattern applies identically to the Balance Sheet (11.3), P&L (11.4), Cash Flow (11.5), General Ledger (11.1), and AR/AP Aging & Statements (11.6) — just add AND je.branch_id = :branch_id (or GROUP BY branch_id for the multi-branch comparison view).

13.5 Consolidated (All-Branch) Reports

Consolidation = the same recursive account rollup as Section 6/11, simply not filtered by branch — summing across every branch — plus elimination of inter-branch balances so internal transfers don't inflate the consolidated totals.

WITH RECURSIVE tree AS (
    SELECT account_id, account_code, account_name, parent_account_id, ARRAY[account_id] AS path_ids, 1 AS depth
    FROM accounts WHERE parent_account_id IS NULL
    UNION ALL
    SELECT a.account_id, a.account_code, a.account_name, a.parent_account_id,
           t.path_ids || a.account_id, t.depth + 1
    FROM accounts a JOIN tree t ON a.parent_account_id = t.account_id
),
company_wide_balances AS (
    SELECT jel.account_id, SUM(jel.base_debit - jel.base_credit) AS net_movement
    FROM journal_entry_line jel
    JOIN journal_entry je ON je.journal_id = jel.journal_id
    WHERE je.is_posted = TRUE AND je.entry_date <= :as_of_date
      -- exclude inter-branch due-to/due-from accounts entirely = elimination
      AND jel.account_id NOT IN (
          SELECT due_from_account_id FROM inter_branch_account_map
          UNION
          SELECT due_to_account_id FROM inter_branch_account_map
      )
    GROUP BY jel.account_id
)
SELECT
    t.depth,
    REPEAT('    ', t.depth - 1) || t.account_name AS line_item,
    (SELECT COALESCE(SUM(cb.net_movement), 0)
       FROM tree d JOIN company_wide_balances cb ON cb.account_id = d.account_id
      WHERE d.path_ids @> ARRAY[t.account_id]) AS consolidated_balance
FROM tree t
ORDER BY t.account_code;

Branch roll-up via the branch tree itself (e.g. consolidate only branches under a given Region, using the same recursive-descendants pattern as Section 6.2, applied to branch instead of accounts):

WITH RECURSIVE branch_descendants AS (
    SELECT branch_id FROM branch WHERE branch_id = :region_branch_id
    UNION ALL
    SELECT b.branch_id FROM branch b JOIN branch_descendants d ON b.parent_branch_id = d.branch_id
)
SELECT je.branch_id, SUM(jel.base_debit - jel.base_credit) AS net
FROM journal_entry_line jel
JOIN journal_entry je ON je.journal_id = jel.journal_id
WHERE je.is_posted = TRUE
  AND je.branch_id IN (SELECT branch_id FROM branch_descendants)
GROUP BY je.branch_id;

13.6 Report Matrix Summary

Report Single Branch Branch-by-Branch (side by side) Consolidated (All Branches)
General Ledger WHERE branch_id = :id GROUP BY branch_id, account_id omit branch filter entirely
Trial Balance filtered pivoted by branch summed across branches
Balance Sheet filtered one column per branch summed + inter-branch eliminated
Profit & Loss filtered one column per branch summed + inter-branch eliminated
Cash Flow filtered one column per branch summed, inter-branch transfers netted to zero
AR / AP Aging & Statements filtered one column/section per branch summed across branches, control accounts tie out per Section 11.6

Because everything still routes through the same recursive account-tree engine (Section 6/11) and the same recursive branch-tree engine (13.1/13.5), adding a new branch or a new region never requires a schema or report-code change — it's just a new row in branch.


14. Manual Ledger Entry Portal & Adjustment / Correction Workflow

Standard accounting systems always need a manual General Ledger entry path for the cases the automated subledgers (Fixed Assets, Inventory) don't cover — and in this design, it is also the entry point for all AR and AP transactions, since there are no dedicated AR/AP subledger tables. Invoices, bills, receipts, and payments are simply journal entries posted through this portal, tagged with the party fields from Section 5.3.

14.1 When Manual/Core Entry Is Used

Situation How it's entered
Stock in/out Inventory subledger (auto-generates the journal entry)
Asset purchase/depreciation/disposal Fixed Asset subledger (auto-generates the journal entry)
AR invoice, AR receipt, AP bill, AP payment Manual/Core Entry Portal — a lightweight invoice/bill capture form that writes directly into journal_entry/journal_entry_line, pre-filling the control account and validating party_type/party_id/document_no/due_date (Section 5.5)
Accrued expense not yet billed, prepayment release, provision, payroll journal, opening balances, inter-company/inter-branch allocation, reclassification, write-off, tax provision Manual/Core Entry Portal — plain journal entry, no party tagging required unless it touches a control account

The rule is simple: if a subledger module (Inventory, Fixed Assets) can produce the entry automatically, use it. Everything else — including all AR/AP activity — goes through the same core entry portal, just with different pre-filled templates (an "AR Invoice" template vs. a blank "Manual Journal" template use the identical underlying table structure).

14.2 Manual Entry Data Model (Draft → Post workflow)

ALTER TABLE journal_entry
    ADD COLUMN entry_status   VARCHAR(15) DEFAULT 'POSTED',  -- DRAFT, PENDING_REVIEW, POSTED, REVERSED, ADJUSTED, VOID
    ADD COLUMN prepared_by    VARCHAR(100),   -- free-text name, no auth/user table required
    ADD COLUMN prepared_at    TIMESTAMPTZ DEFAULT now(),
    ADD COLUMN is_recurring_template BOOLEAN DEFAULT FALSE,
    ADD COLUMN recurrence_pattern VARCHAR(20);  -- MONTHLY, QUARTERLY, ANNUAL, NULL = one-off

Portal flow:

1. New Entry           → status = DRAFT
   - Pick a template: "Manual Journal", "AR Invoice", "AR Receipt", "AP Bill", "AP Payment", or blank
   - Templated forms pre-fill the control account and require party_type/party_id/document_no/due_date;
     blank manual journals leave those fields empty unless a control account is chosen
   - Add lines: account (must be is_postable = TRUE), debit or credit, branch, cost center, memo
   - Live validation: SUM(debit) must equal SUM(credit) before "Save Draft" or "Post" is enabled
2. Save Draft           → stored, editable, NOT yet in the ledger (excluded from all reports)
3. Review (optional)    → status = PENDING_REVIEW (a second look before posting; no auth gate, just a workflow step)
4. Post                  → status = POSTED; entry becomes visible in GL/Trial Balance/AR-AP reports/etc.;
                            rejected if the target accounting_period is LOCKED/CLOSED (Section 12.4);
                            rejected if lines don't balance, hit a non-postable account, or a control-account
                            line is missing its required party/document/due-date tagging (Section 7.4)
-- Validation run before allowing POSTED status:
SELECT journal_id,
       SUM(debit_amount)  AS total_debit,
       SUM(credit_amount) AS total_credit,
       SUM(debit_amount) - SUM(credit_amount) AS difference
FROM journal_entry_line
WHERE journal_id = :journal_id
GROUP BY journal_id
HAVING SUM(debit_amount) <> SUM(credit_amount);   -- must return zero rows to allow posting

14.3 Recurring / Standing Manual Entries

CREATE TABLE recurring_journal_template (
    template_id        BIGSERIAL PRIMARY KEY,
    template_name        VARCHAR(150) NOT NULL,
    recurrence_pattern     VARCHAR(20) NOT NULL,  -- MONTHLY, QUARTERLY, ANNUAL
    next_run_date             DATE NOT NULL,
    end_date                    DATE,
    is_active                     BOOLEAN DEFAULT TRUE
);

CREATE TABLE recurring_journal_template_line (
    line_id             BIGSERIAL PRIMARY KEY,
    template_id          BIGINT NOT NULL REFERENCES recurring_journal_template(template_id) ON DELETE CASCADE,
    account_id             BIGINT NOT NULL REFERENCES accounts(account_id),
    debit_amount             NUMERIC(18,2) DEFAULT 0,
    credit_amount              NUMERIC(18,2) DEFAULT 0,
    memo                          VARCHAR(255)
);
-- A scheduled job generates a new DRAFT journal_entry from the template on next_run_date,
-- routed into the portal for review before posting — never auto-posted blind.

14.4 Adjusting a Wrong Entry — Correction Workflow

Core rule (audit integrity): a POSTED journal entry is never edited or deleted — this applies equally to a plain manual journal and to an AR/AP entry, since they are the same underlying record. Every correction is itself a new, fully traceable journal entry linked back to the original, in line with LKAS 8 (errors are corrected transparently, not silently overwritten).

CREATE TABLE journal_correction (
    correction_id        BIGSERIAL PRIMARY KEY,
    original_journal_id    BIGINT NOT NULL REFERENCES journal_entry(journal_id),
    correction_journal_id    BIGINT NOT NULL REFERENCES journal_entry(journal_id),
    correction_type            VARCHAR(20) NOT NULL,  -- FULL_REVERSAL, PARTIAL_ADJUSTMENT, RECLASSIFICATION
    reason                        TEXT NOT NULL,
    corrected_by                    VARCHAR(100),   -- free-text, no auth/user table
    corrected_at                       TIMESTAMPTZ DEFAULT now()
);

A) Full Reversal + Re-entry — used when the entire entry was wrong (wrong accounts, wrong amounts, wrong customer/supplier tag entirely).

-- Step 1: system auto-generates the exact mirror entry (party/document/due-date tags copied as-is)
INSERT INTO journal_entry (journal_no, period_id, entry_date, journal_type,
                            reference, narration, reversal_of_id, entry_status)
SELECT
    :new_journal_no, period_id, :reversal_date, 'REVERSAL',
    reference, 'Reversal of ' || journal_no || ': ' || :reason,
    journal_id, 'POSTED'
FROM journal_entry WHERE journal_id = :original_journal_id;

INSERT INTO journal_entry_line (journal_id, account_id, debit_amount, credit_amount,
                                 branch_id, cost_center_id, party_type, party_id, document_no, due_date, memo)
SELECT :new_journal_id, account_id, credit_amount, debit_amount,
       branch_id, cost_center_id, party_type, party_id, document_no, due_date, memo
FROM journal_entry_line WHERE journal_id = :original_journal_id;

-- Step 2: mark the original as reversed, log the correction, then post the correct entry fresh
UPDATE journal_entry SET entry_status = 'REVERSED' WHERE journal_id = :original_journal_id;

INSERT INTO journal_correction (original_journal_id, correction_journal_id, correction_type, reason, corrected_by)
VALUES (:original_journal_id, :new_journal_id, 'FULL_REVERSAL', :reason, :corrected_by);

-- Step 3: accountant then creates a brand-new correct entry via the portal (Section 14.2)

B) Adjusting Entry (delta only) — used when only the amount, account, or tag was partly wrong; post a smaller correcting entry for just the difference instead of reversing everything.

-- Example: an AR invoice was tagged to the wrong customer, or a manual journal booked an
-- expense LKR 10,000 too high on the wrong cost center — adjusting entry moves only the
-- difference, created through the same portal, tagged journal_type = 'ADJUSTMENT'.

INSERT INTO journal_correction (original_journal_id, correction_journal_id, correction_type, reason, corrected_by)
VALUES (:original_journal_id, :adjustment_journal_id, 'PARTIAL_ADJUSTMENT', :reason, :corrected_by);

Which method to use:

Scenario Method
Wrong account entirely, wrong customer/supplier, wrong period, duplicate entry Full Reversal + Re-entry
Wrong amount only, wrong cost center/branch tag only Adjusting Entry (delta)
Entry posted into a period that's since been LOCKED/CLOSED Reversal must be dated in the current OPEN period; original stays untouched in its closed period (never edited retroactively)

14.5 Entry Status Lifecycle

DRAFT ──► PENDING_REVIEW ──► POSTED ──┬──► REVERSED (via Full Reversal)
                                        └──► ADJUSTED (via Adjusting Entry, original stays POSTED)
DRAFT ──► VOID   (a draft can simply be discarded before posting — no ledger impact, no trail needed)

Once POSTED, an entry only ever moves to REVERSED or gains a linked ADJUSTED correction — it is never deleted and its original debit_amount/credit_amount values are never changed in place. Every correction is queryable end-to-end via journal_correction, giving a full "what changed and why" history for any entry in the ledger — AR/AP entries included, since they're stored exactly the same way.


15. Notes on "No Authentication" Design

Because no user/role model exists in this scope, all created_by/approved_by audit columns are intentionally free-text (prepared_by, corrected_by) rather than foreign keys into a user table. If an authentication and role system becomes necessary later, these free-text columns can be swapped for a proper user_id foreign key without touching the ledger, AR/AP, tax, currency, branch, or reporting logic described in this document — the accounting core stays untouched.