Skip to content

feat(intent): declarative financial statement definitions (#6909) - #6912

Merged
delchev merged 1 commit into
masterfrom
intent-statement-reports
Aug 24, 2026
Merged

feat(intent): declarative financial statement definitions (#6909)#6912
delchev merged 1 commit into
masterfrom
intent-statement-reports

Conversation

@delchev

@delchev delchev commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes #6909.

What

A statutory statement — a balance sheet, an income statement — is a fixed line structure where every line is a formula over the chart of accounts. kind: balance (#6248) supplies the numbers underneath one but cannot express the form: its output is one row per dimension value, so there is no way to say this line is accounts 20*+21* netted to the debit side, or this line is the sum of those two.

kind: statement declares both:

reports:
  - name: BalanceSheet
    kind: statement
    source: JournalEntryItem            # the ledger line items - as for kind: balance
    date: journalEntry.entryDate
    debit: debit
    credit: credit
    account: account.code               # the account CODE the lines select on
    filter: "journalEntry.status == 2"
    lines:
      - { code: A.I,  label: Fixed assets, accounts: "20*,21*", measure: closingNetDebit }
      - { code: A.II, label: Receivables,  accounts: "41*",     measure: closingNetDebit }
      - { code: A,    label: Total assets, sum: [A.I, A.II] }
      - { code: B.I,  label: Payables,     accounts: "40-49",   measure: closingNetCredit }
      - { code: B,    label: Net assets,   sum: [A], less: [B.I] }

A line is either a leaf (accounts: selects, measure: says which balance to take) or computed (sum: / less: over other lines' codes) — never both.

The report is emitted as one query in the ordinary .report shapeCode / Label / Amount, with the balance report's own fromDate/toDate parameters — so the entire report pipeline (repository, controller, Harmonia page, security, i18n, dashboard) is reused unchanged. No new artefact, no new runtime, no synchronizer.

Why the SQL looks the way it does

Four decisions, each load-bearing:

  • The ledger is reduced to one balance per account first, in a WITH "ACCOUNT_BALANCES" CTE. A Net measure nets an account's two sides before the line sums it; netting after the sum reports gross turnover. This is what puts a both-type settlement account on the asset side when it is in debit and the liability side when it is in credit — closingNetDebit on the asset line and closingNetCredit on the liability line file each account where it belongs without the author knowing which way it went.
  • Computed lines are flattened at generation time into their leaves' own signed terms, so every emitted line is a single aggregate over that CTE and no line waits for another. That is why nested subtotals need no recursive SQL — and why a reference cycle must be a parse error rather than a stack overflow.
  • Lines are ordered by a selected-but-not-projected Ordinal. A statement's rows are a structure, and statutory codes sort lexicographically wrong (A.II before A.X).
  • No builder-owned joins/conditions are emitted. A statement's joins live inside its own subquery and the report editor's visual builder cannot rebuild a WITH, so a statement deliberately opens free-style — the safe half of the Report Editor silently rewrites and corrupts the query of generated .report files #6675 round-trip guard, not an oversight.

A range selector compares equally long code prefixes (60-69 takes 601 and 6999); a plain BETWEEN '60' AND '69' over whole codes would drop both. The account-code charset is closed (letters, digits, dot, underscore), so a quote or a LIKE wildcard can never reach the literal.

Testing

  • StatementReportSqlTest runs the emitted query against a real H2 ledger and reads the figures off — prefix, range, per-account netting of a both-type account, the filter, cross-line addition and subtraction, a negative line, the opening/period windows, plus the COUNT(*) wrap and the appended LIMIT the generated repository uses. A statement is arithmetic: a wrong selector or a mis-ordered netting produces well-formed SQL and a plausible wrong number, which no string assertion catches.
  • The same generated query was verified by hand on PostgreSQL 14, producing identical figures (both CI database legs).
  • ReportIntentGeneratorTest covers the emission and every rejection (dimensions/measures on a statement, duplicate codes, leaf+computed, neither, unknown measure, unresolvable reference, cycle, malformed range, a quote in a code, inputs without the kind).
  • IntentEngineIT carries a statement in its intent — asserting the emitted .report and the generated page — 58/58 green.
  • engine-intent unit suite: 867/867. formatter:validate and the release-profile javadoc are clean.

Design note

The issue asked whether lines: should live inline or in a seeded, tenant-editable definition entity. This PR takes the inline form: the account-to-line mapping is authored structure, reviewable and versioned with the model, and it fits the existing report pipeline without inventing a runtime. A tenant-editable variant can be layered over the same semantics later without invalidating anything here — recorded in the spec proposal.

Boundary

Consistent with what #6721 records: the statement's numbers are the platform's; the legally mandated print layout stays a hand-authored .print over the result.

Docs

Note on CI

The api shard is currently red on master (since #6906) on an unrelated ModelGenerationIT assertion — see the comment below.

@delchev

delchev commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

The api shard is red on master, unrelated to this PR

ModelGenerationIT.everyTemplateRendersItsModel has been failing on master since #6906 (run 32559822103, both the H2 and the PostgreSQL api legs), with:

[orders.glue with template-application-events-java/template/template.js]
gen/events/orders/OrderApprovalTrigger.java emitted a member access with no member,
so an interpolated field name was empty: s.)

It reproduces locally on this branch and on master alike, and nothing here touches the events template or the glue generator.

It is a false positive, not a defect in the generated code. ModelGenerationIT's heuristic is

private static final Pattern DANGLING_MEMBER_ACCESS = Pattern.compile("\\w\\.[ \\t]*[=;),!]");

and what it matches is a prose comment added by #6906 in Trigger.java.template, whose parenthetical sentence ends with a full stop immediately before its closing paren:

// (Read-modify-write, so not airtight; the same
// small window already exists between the guard above and the start, and losing it costs a
// duplicate start rather than a flow that never runs.)

runs.) matches \w\.[ \t]*[)]. Running the pattern against the template source finds exactly that one line and nothing else.

Two ways to fix it, and I'd take the first:

  1. Move the full stop outside the paren in the comment — ... never runs). — one character, and the heuristic keeps its full strength.
  2. Restrict the heuristic to non-comment lines, which makes it more code to maintain for no extra reach.

Happy to push either as its own small PR; it does not belong in this one. Until it lands, the api shard here will fail on that assertion.

A statutory statement - a balance sheet, an income statement - is a fixed
line structure where every line is a formula over the chart of accounts.
`kind: balance` supplies the numbers underneath it but cannot express the
form: its output is one row per dimension value, with no way to say "this
line is accounts 20*+21* netted to the debit side" or "this line is the sum
of those two".

`kind: statement` declares both. It takes the same ledger inputs as a
balance report plus `account:` - the field holding the account code - and
`lines:`, each line either a leaf (an `accounts:` selector and a `measure:`)
or computed (`sum:` / `less:` over other lines' codes).

It is emitted as ONE query in the ordinary `.report` shape - Code / Label /
Amount, the balance report's own fromDate/toDate parameters - so the whole
report pipeline is reused unchanged: no new artefact, no new runtime, no
synchronizer.

Four decisions shape the emitted SQL:

- The ledger is reduced to one balance per account FIRST, in a CTE, because
  a Net measure nets an account's two sides before the line sums it.
  Netting after the sum reports gross turnover; netting per account is what
  puts a both-type settlement account on the asset side when it is in debit
  and the liability side when it is in credit.
- Computed lines are flattened at generation time into their leaves' signed
  terms, so every line is one aggregate over that CTE and no line waits for
  another - which is why nested subtotals need no recursive SQL, and why a
  reference cycle has to be a parse error.
- The lines are ordered by a selected-but-not-projected ordinal: a
  statement's rows are a structure, and statutory codes sort
  lexicographically wrong (A.II before A.X).
- No builder-owned joins/conditions are emitted. A statement's joins live
  inside its own subquery and the report editor cannot rebuild a WITH, so it
  opens free-style - the safe half of the #6675 round-trip guard.

A range selector compares equally long code prefixes (`60-69` takes 601 and
6999); a plain BETWEEN over whole codes would drop both. The account-code
charset is closed, so a quote or a LIKE wildcard never reaches the literal.

StatementSupport holds the measure vocabulary and the selector grammar and
is shared by the parser and the generator, so what validates and what is
emitted cannot drift. StatementReportSqlTest RUNS the emitted query against
a real H2 ledger and reads the figures off - a statement is arithmetic, and
a wrong selector or a mis-ordered netting yields well-formed SQL and a
plausible wrong number that no string assertion catches. The same query was
verified by hand on PostgreSQL.

Boundary, consistent with #6721: the statement's numbers are the platform's;
the legally mandated print layout stays a hand-authored `.print`.
@delchev
delchev force-pushed the intent-statement-reports branch from 787eb3d to 0b2978d Compare August 24, 2026 11:52
@delchev
delchev merged commit d336aa7 into master Aug 24, 2026
9 checks passed
@delchev
delchev deleted the intent-statement-reports branch August 24, 2026 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

intent: financial statement definitions - declarative account-to-line mappings over the balance ledger (balance sheet / income statement)

1 participant