# 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) ```sql 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 ```sql 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) ```sql 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 ```sql 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. ```sql 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):** ```sql -- 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) ```sql 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) ```sql 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: ```sql -- 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) └─ (leaf, postable - one per BankAccount, never reused) Cash (root Asset account, non-postable, auto-created on first cash account) └─ (node, non-postable - one per cash_account_type, created once, then reused) └─ (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 `; 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 ```sql 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) ```sql 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 ```sql 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) ```sql 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) ```sql -- 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. ```sql 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) ```sql 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) ```sql 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) ```sql 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: }`, 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: ```sql 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): ```sql 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).** ```sql 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. ```sql 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 = '') 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). ```sql INSERT INTO system_config (config_key, config_value, description, is_editable, is_locked) VALUES ('CASH_ACCOUNT_ROOT_ID', '', '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. ```sql -- 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** ```sql -- 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`): ```sql -- 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):** ```sql -- 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): ```json { "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):** ```sql 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):** ```sql 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):** ```sql 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):** ```sql -- 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":** ```sql 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): ```sql 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', '', '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', '', 'GL account where WHT credit receivable is recorded — same seeding constraint as above', TRUE, FALSE), ('QUARTERLY_TAX_PAYMENT_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:** ```sql -- 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:** ```json { "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. ```sql 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. ```sql 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):** ```sql 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. ```sql 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', '', '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', '', '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', '', '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', '', '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`:** ```sql 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 ```sql 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):** ```sql 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 ```sql 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:** ```sql 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_ = 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) ```sql 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 ```sql 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. ```sql 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:** ```sql 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):** ```sql 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. ```sql 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`): ```sql 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) ```sql 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) ``` ```sql -- 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 ```sql 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). ```sql 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). ```sql -- 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. ```sql -- 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.