Statements
@cynco/statements is trial balance and financial statement renderers derived
from ledger entries — derive and render from one package, built on the published
@cynco/ledger-core engine. Statements render as semantic tables inside a
<statements-container> shadow root, one table per currency (currencies are
never mixed into one column), keyed by canonical colon-delimited account paths.
Everything the numbers claim — the debit/credit tie, the accounting equation —
is computed and shown; nothing is asserted, guessed, or plugged.
Debits tie to credits on the computed proof line — Suspense:Pending-Query renders flagged as unclassified (never guessed into a type), and the contra-overridden accumulated depreciation sits in credit without an abnormal flag.
Installation
Install with the package manager of your choice. React is an optional peer
dependency required only by the /react entry; the engine
(@cynco/ledger-core) is a regular dependency, published
on npm like the rest of the suite.
pnpm add @cynco/statements| Entry point | What it exports |
|---|---|
@cynco/statements | The derivations (deriveTrialBalance, deriveIncomeStatement, deriveBalanceSheet), createAccountTaxonomy, the vanilla TrialBalance / IncomeStatement / BalanceSheet views, the pure HTML renderers, utilities (createOpeningBalanceEntry, checkBalanceAssertions, getCurrencyExponent), and the types |
@cynco/statements/react | <TrialBalance />, <IncomeStatement />, and <BalanceSheet /> |
Vanilla API
Derive, then render: derivations are pure report-time queries (one linear pass over plain entries — no store, no index rebuilds), and each view class only manages DOM lifecycle around a shared string renderer, so client output and any future SSR preload can never drift apart.
import {
createAccountTaxonomy,
deriveTrialBalance,
TrialBalance,
} from '@cynco/statements';
const taxonomy = createAccountTaxonomy();
const data = deriveTrialBalance(entries, {
taxonomy,
asOf: '2026-12-31',
});
const view = new TrialBalance({ showClassification: true });
view.render({
data,
parentNode: document.getElementById('mount')!,
});
// Derivations return a fresh immutable object per call, so re-render is a
// reference comparison — pass new data to rebuild, same data to no-op …
view.render({ data: deriveTrialBalance(nextEntries, { taxonomy }) });
// … and tear down.
view.cleanUp();The view options are named TrialBalanceViewOptions /
IncomeStatementViewOptions / BalanceSheetViewOptions because the package
also re-exports the engine’s TrialBalanceOptions (and friends) — the options
bags of the derivations — and the two surfaces must not collide.
React API
The React components wrap the vanilla views one-to-one and take the same derived
data. colorScheme pins how the built-in light-dark() colors resolve — the
shadow stylesheet follows the user’s OS preference by default, so sites with
their own light/dark toggle should pin light / dark or scope a
color-scheme on the host element.
import {
createAccountTaxonomy,
deriveBalanceSheet,
deriveIncomeStatement,
deriveTrialBalance,
type LedgerEntry,
} from '@cynco/statements';
import {
BalanceSheet,
IncomeStatement,
TrialBalance,
} from '@cynco/statements/react';
const taxonomy = createAccountTaxonomy();
export function YearEnd({ entries }: { entries: LedgerEntry[] }) {
return (
<>
<TrialBalance
data={deriveTrialBalance(entries, { taxonomy, asOf: '2026-12-31' })}
options={{ showClassification: true }}
colorScheme="system" // 'light' | 'dark' | 'system'
/>
<IncomeStatement
data={deriveIncomeStatement(entries, {
taxonomy,
periods: [
{ label: 'FY2025', dateFrom: '2025-01-01', dateTo: '2025-12-31' },
{ label: 'FY2026', dateFrom: '2026-01-01', dateTo: '2026-12-31' },
],
})}
/>
<BalanceSheet
data={deriveBalanceSheet(entries, {
taxonomy,
dates: [
{
label: '31 Dec 2026',
asOf: '2026-12-31',
fiscalYearStart: '2026-01-01',
},
],
})}
/>
</>
);
}Trial balance
deriveTrialBalance reports every account with direct postings at its signed
closing balance, positive in the debit column and negative in the credit column,
with the totals proof line computed per section. asOf bounds the balances
(inclusive); accountPaths prints zero-activity chart accounts so the statement
shows the full chart, not just accounts that saw postings; and adjustments —
any EntryFilter, conventionally { tag: 'adjustment' } — switches on the
six-column working trial balance.
// The plain trial balance: signed closing balance per (account, currency),
// split into the conventional debit/credit columns.
const closing = deriveTrialBalance(entries, {
taxonomy,
asOf: '2026-12-31', // inclusive; omitted = all-time
// Zero-activity chart accounts to print at zero in every section:
accountPaths: ['Equity:Retained-Earnings', 'Assets:Current:Petty-Cash'],
});
// The working trial balance: tag adjusting entries, pass an EntryFilter,
// and every row splits into unadjusted / adjustments / adjusted pairs —
// the six-column year-end working paper.
const working = deriveTrialBalance(entries, {
taxonomy,
asOf: '2026-12-31',
adjustments: { tag: 'adjustment' },
});Rows sort in statement convention — assets, liabilities, equity, income,
expenses, then the unclassified residue last so the flagged rows are always
visible at the bottom. An account holding the opposite of its normal balance (an
asset in credit, income in debit) flags abnormal — the classic review flag on
a printed trial balance.
Income statement
deriveIncomeStatement reports period activity — the net movement of each
income and expense account inside each period, one column per period. Cumulative
balances never appear here; that is the balance sheet’s job. Amounts are
presentation-signed by section: income lines flip so revenue reads positive,
and because the flip follows the section rather than the account, contra income
(sales returns) naturally reads negative inside its section.
Each column is the account activity inside its period — never a cumulative balance. The unclassified suspense residue is listed but excluded from every total.
const profitAndLoss = deriveIncomeStatement(entries, {
taxonomy,
// One column per period, inclusive on both ends, in display order:
periods: [
{ label: 'FY2025', dateFrom: '2025-01-01', dateTo: '2025-12-31' },
{ label: 'FY2026', dateFrom: '2026-01-01', dateTo: '2026-12-31' },
],
});
// Per currency section: income / expenses lines with one amount per
// period, totals, netIncome, and the unclassified residue.
const [myr] = profitAndLoss.sections;
myr.netIncome; // [1_790_000, 1_340_000] — minor units per columnBalance sheet
deriveBalanceSheet reports the cumulative position of asset, liability, and
equity accounts through each as-of date. The retained-earnings problem is solved
virtually: income and expense accounts never physically close in this suite,
so each column carries computed earnings lines — the cumulative P&L result
folded into equity at derivation time, computed and clearly marked, never
booked. A column’s fiscalYearStart splits that result into retained earnings
(before the fiscal year) and current-year earnings (inside it); without one,
everything reports as retained earnings.
Retained and current-year earnings are computed at derivation time — no closing entries exist in the ledger. The RM 500.00 equation difference is the suspense account’s unclassified balance, reported honestly instead of plugged.
const position = deriveBalanceSheet(entries, {
taxonomy,
// One column per as-of date; fiscalYearStart splits the computed
// earnings lines for that column:
dates: [
{ label: '31 Dec 2025', asOf: '2025-12-31', fiscalYearStart: '2025-01-01' },
{ label: '31 Dec 2026', asOf: '2026-12-31', fiscalYearStart: '2026-01-01' },
],
});
const [myr] = position.sections;
myr.retainedEarnings; // computed P&L result before each column's fiscal year
myr.currentEarnings; // computed result inside it — never booked entries
myr.balancedByDate; // the accounting equation, checked per columnAccount taxonomy
createAccountTaxonomy classifies account paths into the five fundamental types
and derives the presentation facts (normal balance, statement role) the
statements are built on. The default convention maps the five plural English
roots — Assets, Liabilities, Equity, Income, Expenses — plus Revenue
as a widely-used synonym root for income. Every opinion has an escape hatch:
per-path overrides inherit through subtrees with the nearest ancestor winning
per field, so an override may set only contra and let type fall through.
import { createAccountTaxonomy } from '@cynco/statements';
const taxonomy = createAccountTaxonomy({
overrides: {
// Contra asset: still an asset, but credit-normal — accumulated
// depreciation stops flagging as abnormal.
'Assets:Fixed:Accumulated-Depreciation': { contra: true },
// Subtrees outside the root convention can be typed wholesale;
// descendants inherit, nearest ancestor wins per field.
'Clearing:Card-Settlements': { type: 'asset' },
},
});
taxonomy.classify('Assets:Fixed:Accumulated-Depreciation');
// { type: 'asset', contra: true, normalBalance: 'credit',
// statement: 'balance-sheet' }
taxonomy.classify('Suspense:Pending-Query');
// null — unclassified. Statements flag it; they never guess.// rootTypes replaces (not merges with) the default map, so a localized
// chart states its own complete convention:
const bahasaMalaysia = createAccountTaxonomy({
rootTypes: {
Aset: 'asset',
Liabiliti: 'liability',
Ekuiti: 'equity',
Hasil: 'income',
Belanja: 'expense',
},
});A path with no root mapping and no override classifies to null — unclassified.
That is a flag, never a guess: the trial balance sorts it last and labels it,
the income statement and balance sheet list it outside every total, and no
statement ever invents a type to make output look complete. Classification is
memoized per instance (O(path depth) once, then O(1)), so classify is safe in
per-row render loops.
Honesty rules
The derivations inherit the suite-wide rule that the data layer never invents meaning:
- Per-currency sections. Every statement renders one table per currency touched — mixing currencies in one column would be a lie, and cross-currency translation is an explicit, separate feature, never a silent default.
- Computed, never asserted. The trial balance’s debit/credit tie and the balance sheet’s accounting equation are checked and reported per section — an out-of-balance ledger renders honestly with the exact difference, no plug booked to make it tie.
- Void is excluded from meaning. Entries flagged
voidcontribute to no balance, in any derivation. - Unclassified is surfaced. Accounts the taxonomy cannot place are listed and flagged, excluded from every total, and never guessed into a section.
- Integer minor units end to end. Amounts format via digit-string slicing —
no float ever touches a monetary value, and a negative zero can never leak
into output. Aggregates that cross 253 flag
hasOverflowon the section instead of silently losing precision.
Also in the box
Three engine utilities re-exported for the workflows around statements:
checkBalanceAssertions— declarative “this account held exactly this balance on this date” checks, the mechanism imports and migrations use to prove themselves. A failed assertion reports the discrepancy and changes nothing.createOpeningBalanceEntry— opening balances are not a special mechanism, just an ordinary balanced day-one entry; this builds it with one equity offset per currency so it balances by construction.getCurrencyExponent— the ISO 4217 minor-unit registry (with caller overrides), for formatting integer minor units in currencies that don’t carry two decimals.
import {
checkBalanceAssertions,
createOpeningBalanceEntry,
getCurrencyExponent,
} from '@cynco/statements';
// Opening balances are ordinary balanced entries: one posting per line
// plus one equity offset per currency, balanced by construction.
const opening = createOpeningBalanceEntry({
id: 'opening-2025',
date: '2025-01-01',
lines: [
{
account: 'Assets:Current:Cash-Maybank',
amount: 5_000_000,
currency: 'MYR',
},
{
account: 'Liabilities:Current:SST-Payable',
amount: -80_000,
currency: 'MYR',
},
],
// equityAccount defaults to 'Equity:Opening-Balances'
});
// Imports and migrations prove themselves with declared balance facts;
// a failed assertion reports the exact difference and changes nothing.
const results = checkBalanceAssertions(store, [
{
account: 'Assets:Current:Cash-Maybank',
date: '2025-01-01',
amount: 5_000_000,
currency: 'MYR',
},
]);
results[0]; // { ok: true, actual: 5_000_000, difference: 0, … }
getCurrencyExponent('MYR'); // 2 — sen
getCurrencyExponent('JPY'); // 0 — yen has no minor unitAPI reference
The full export surface of @cynco/statements, view options and the re-exported
engine derivation options alike. Types are transcribed from the source.
View options
The three view classes share one shape: an optional amountFormat on the render
subset plus a colorScheme pin. TrialBalanceViewOptions extends
TrialBalanceRenderOptions, IncomeStatementViewOptions extends
IncomeStatementRenderOptions, and BalanceSheetViewOptions extends
BalanceSheetRenderOptions.
TrialBalance (TrialBalanceViewOptions):
| Option | Type | Default | Description |
|---|---|---|---|
showClassification | boolean | false | Adds a classification column between the account path and the amounts. |
amountFormat | AmountFormat | AMOUNT_FORMAT_COMMA_DOT | Separator/grouping descriptor for every amount; debit/credit semantics are unaffected. |
colorScheme | 'light' | 'dark' | 'system' | 'system' | Pins how light-dark() colors resolve on the host element. |
IncomeStatement (IncomeStatementViewOptions) and BalanceSheet
(BalanceSheetViewOptions):
| Option | Type | Default | Description |
|---|---|---|---|
amountFormat | AmountFormat | AMOUNT_FORMAT_COMMA_DOT | Separator/grouping descriptor for every amount. |
colorScheme | 'light' | 'dark' | 'system' | 'system' | Pins how light-dark() colors resolve on the host element. |
Derivation options
deriveTrialBalance(entries, options) — TrialBalanceOptions:
| Option | Type | Default | Description |
|---|---|---|---|
asOf | string | all-time | Inclusive ISO date the balances report through. |
taxonomy | AccountTaxonomy | — | Classifies and orders rows; without one every row is unclassified and sorts by path. |
adjustments | EntryFilter | — | Working-trial-balance mode: matching entries count as adjustments, rows split in three. |
accountPaths | readonly string[] | — | Zero-activity accounts printed at zero in every currency section. |
deriveIncomeStatement(entries, options) — IncomeStatementOptions:
| Option | Type | Default | Description |
|---|---|---|---|
periods | readonly StatementPeriod[] | required | Reporting periods, one column each, in display order. At least one. |
taxonomy | AccountTaxonomy | — | Classifies accounts into the sections; without one both sections stay empty. |
deriveBalanceSheet(entries, options) — BalanceSheetOptions:
| Option | Type | Default | Description |
|---|---|---|---|
dates | readonly StatementDate[] | required | Reporting dates, one column each, in display order. At least one. |
taxonomy | AccountTaxonomy | — | Classifies accounts into the sections; without one all three stay empty. |
createAccountTaxonomy(options?) — AccountTaxonomyOptions:
| Option | Type | Default | Description |
|---|---|---|---|
rootTypes | Readonly<Record<string, AccountType>> | DEFAULT_ROOT_ACCOUNT_TYPES | Maps a path's first segment to a type; replaces (never merges with) the default. |
overrides | Readonly<Record<string, AccountTaxonomyOverride>> | — | Per-path overrides, inherited by descendants; nearest ancestor wins per field. |
createOpeningBalanceEntry(options) — OpeningBalanceOptions:
| Option | Type | Default | Description |
|---|---|---|---|
id | string | required | Stable unique entry id. |
date | string | required | ISO date the opening balances take effect. |
lines | readonly OpeningBalanceLine[] | required | Carried-forward balances, one line per (account, currency). |
equityAccount | string | DEFAULT_OPENING_BALANCE_ACCOUNT ('Equity:Opening-Balances') | Equity account the offsetting postings book against. |
flag | EntryFlag | 'cleared' | Opening balances are agreed facts. |
narration | string | 'Opening balances' | Entry narration. |
tags / links | readonly string[] | — | Entry tags (no # prefix) and cross-reference link ids. |
Utilities
Every remaining runtime export, one line each. Entries marked internal are string-builder plumbing shared by the client and any future SSR path.
| Export | Purpose |
|---|---|
checkBalanceAssertions(store, assertions) | Checks declared BalanceAssertion[] facts; each BalanceAssertionResult reports the exact difference. |
matchesEntryFilter(entry, filter) | True when an entry satisfies every present EntryFilter condition (logical AND). |
getCurrencyExponent(currency, overrides?) | ISO 4217 minor-unit exponent with caller overrides; unknown codes use 2. |
getNormalBalanceForType(type) | NormalBalance ('debit' | 'credit') for an AccountType. |
getStatementRoleForType(type) | StatementRole ('balance-sheet' | 'income-statement') for an AccountType. |
negateMinorUnits(n) | Exact negation that never produces -0. |
formatMinorUnits(amount, currency, options?) | Integer minor units to a display string via digit slicing; FormatMinorUnitsOptions carries sign and amountFormat. |
resolveAmountFormat(locale) | Resolves a locale tag to an AmountFormat preset once at the host boundary — renderers never consult Intl. |
escapeHtml(value) | HTML-escapes interpolated text; every renderer routes through it. |
applyHostColorScheme(container, colorScheme) | Applies or removes the inline color-scheme pin on a host element. |
statementsThemeVariables(roles) | Maps a @cynco/theme role set onto the --statements-theme-* variable layer. |
isValidMinorUnits(value) | True for safe-integer minor-unit amounts. |
warnInvalidMinorUnits(context, value) | Warn-once console report for an unsafe amount, keyed by context. |
warnIfInvalidTrialBalanceAmounts / warnIfInvalidIncomeStatementAmounts / warnIfInvalidBalanceSheetAmounts | Boundary sweeps over derived data entering a render. |
resetInvalidMinorUnitsWarnings() | Resets the warn-once state — exported for tests. |
renderTrialBalanceHTML / renderIncomeStatementHTML / renderBalanceSheetHTML internal | DOM-free statement renderers (driven by TrialBalanceRenderOptions / IncomeStatementRenderOptions / BalanceSheetRenderOptions). |
renderAmountCellsHTML / renderGroupHeaderHTML / renderGroupTotalHTML / renderLineRowHTML / renderUnclassifiedGroupHTML internal | Shared statement-table fragment builders. |
Constants
| Export | Value | Meaning |
|---|---|---|
STATEMENTS_TAG_NAME | 'statements-container' | The custom element every view renders into. |
MINUS_SIGN | '\u2212' | Proper minus for negative amounts and differences. |
AMOUNT_FORMAT_COMMA_DOT / AMOUNT_FORMAT_DOT_COMMA / AMOUNT_FORMAT_SPACE_COMMA / AMOUNT_FORMAT_APOSTROPHE_DOT / AMOUNT_FORMAT_INDIAN | frozen presets | 1,234.56, 1.234,56, 1 234,56 (U+202F), 1'234.56, 12,34,567.89. |
DEFAULT_CURRENCY_EXPONENTS | 26-entry table | The engine's canonical ISO 4217 minor-unit exception table. |
DEFAULT_OPENING_BALANCE_ACCOUNT | 'Equity:Opening-Balances' | Default equity offset account for opening balances. |
DEFAULT_ROOT_ACCOUNT_TYPES | 6-root map | Assets / Liabilities / Equity / Income / Revenue / Expenses → the five types. |
StatementsContainerLoaded | true | Custom-element registration flag — component wiring, not host API. |
Types
Component lifecycle props: TrialBalanceRenderProps,
IncomeStatementRenderProps, and BalanceSheetRenderProps — each pairs the
derived data with an optional container / parentNode mount target and a
forceRender escape from the data-reference fast path.
Everything else, grouped:
- Core data —
MinorUnits,EntryFlag('cleared' | 'pending' | 'flagged' | 'void'),Posting,LedgerEntry,EntryFilter,AmountFormat,ColorScheme. - Taxonomy —
AccountType('asset' | 'liability' | 'equity' | 'income' | 'expense'),NormalBalance,StatementRole,AccountClassification(type +contra+ derived facts),AccountTaxonomyOverride,AccountTaxonomy(the memoizedclassifyoracle). - Statement shapes —
StatementPeriod(label/dateFrom/dateTo),StatementDate(label/asOf/fiscalYearStart?),StatementLine(account + classification + presentation-signedamountsper column),UnclassifiedBalance(raw ledger-signed amounts, listed outside every total). - Derived data —
TrialBalanceData/TrialBalanceSection/TrialBalanceRow(with theabnormalreview flag),IncomeStatementData/IncomeStatementSection,BalanceSheetData/BalanceSheetSection— every section carries its computed proofs and ahasOverflowflag instead of silently losing precision. - Assertions & opening balances —
BalanceAssertion,BalanceAssertionResult,OpeningBalanceLine,OpeningBalanceOptions.