Statement of account template for JavaScript
A statement of account demands no new money. It restates a period against one customer’s account — the invoices already issued, the payments already received, the credit note in between. It shows the balance they leave. What makes it awkward to build is length. A quarter of movements runs past a page, so the running balance has to be right on every row. The column labels have to come back on page two, and every page has to say which page it is. This is a ready-made quario statement — copy it, point it at your ledger, and render to HTML, PDF, or Excel.
The template
The whole statement is a definition — data you can version and review. The letterhead, the opening balance, the movement table with its running balance, the ageing summary, and the page furniture are all in here. Nothing about this document lives in code. This is the exact file the preview below renders:
{
"data": "$.statement.movements[*]",
"aggregates": {
"charges": "sum:[email protected] ?? 0",
"credits": "sum:[email protected] ?? 0",
"current": "sum:[email protected] <= 30 ? (@.open ?? 0) : 0",
"days60": "sum:[email protected] > 30 && @.age <= 60 ? (@.open ?? 0) : 0",
"days90": "sum:[email protected] > 60 && @.age <= 90 ? (@.open ?? 0) : 0",
"over90": "sum:[email protected] > 90 ? (@.open ?? 0) : 0"
},
"run": {
"balance": "sum:=(@.charge ?? 0) - (@.credit ?? 0)"
},
"style": { "family": "sans", "size": 10 },
"header": [
{
"type": "split",
"slots": [
{ "type": "text", "value": "Statement No. {{ $.input.statement.number }}", "width": 60,
"style": { "family": "serif", "size": 19, "bold": true, "color": "#1e1f22" } },
{ "type": "text", "value": [{ "value": "Issued " }, { "value": "{{ $.input.statement.date }}" }],
"style": { "size": 8, "color": "#989a97", "align": "right", "format": "date" } }
]
},
{
"type": "split",
"style": { "spaceAfter": 4 },
"slots": [
{ "type": "text", "value": "Account {{ $.input.statement.account }}", "width": 60,
"style": { "size": 8, "color": "#989a97" } },
{ "type": "text", "value": [{ "value": "{{ $.input.statement.period.from }}" }, { "value": " to " }, { "value": "{{ $.input.statement.period.to }}" }],
"style": { "size": 8, "color": "#989a97", "align": "right", "format": "date" } }
]
},
{
"type": "split",
"style": { "spaceBefore": 34 },
"slots": [
{ "type": "text", "value": "From", "width": 50,
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97" } },
{ "type": "text", "value": "Statement for",
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97" } }
]
},
{
"type": "split",
"slots": [
{ "type": "text", "value": "{{ $.input.statement.seller.name }}", "width": 50,
"style": { "bold": true, "color": "#1e1f22" } },
{ "type": "text", "value": "{{ $.input.statement.customer.name }}",
"style": { "bold": true, "color": "#1e1f22" } }
]
},
{
"type": "split",
"slots": [
{ "type": "text", "value": "{{ $.input.statement.seller.street }}\n{{ $.input.statement.seller.city }}\n{{ $.input.statement.seller.country }}", "width": 50,
"style": { "color": "#5e6062" } },
{ "type": "text", "value": "{{ $.input.statement.customer.street }}\n{{ $.input.statement.customer.city }}\n{{ $.input.statement.customer.country }}",
"style": { "color": "#5e6062" } }
]
},
{
"type": "split",
"style": { "spaceAfter": 30 },
"slots": [
{ "type": "text", "value": "VAT {{ $.input.statement.seller.vatId }}", "width": 50,
"style": { "size": 8, "color": "#989a97" } },
{ "type": "text", "value": "VAT {{ $.input.statement.customer.vatId }}",
"style": { "size": 8, "color": "#989a97" } }
]
},
{
"type": "split",
"style": { "spaceAfter": 6 },
"slots": [
{ "type": "text", "value": [{ "value": "Opening balance at " }, { "value": "{{ $.input.statement.period.from }}" }], "width": 86,
"style": { "color": "#5e6062", "format": "date" } },
{ "type": "text", "value": "{{ $.input.statement.opening }}",
"style": { "bold": true, "color": "#1e1f22", "align": "right", "format": "currency" } }
]
}
],
"detail": {
"header": {
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97",
"borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" }
},
"row": { "style": { "color": "#5e6062" } },
"columns": [
{ "header": "Date", "value": "{{ @.date }}", "width": 15,
"style": { "format": "date" } },
{ "header": "Reference", "value": "{{ @.reference }}", "width": 14,
"style": { "family": "mono", "size": 8 } },
{ "header": "Description", "value": "{{ @.description }}", "width": 33 },
{ "header": { "value": "Charge", "style": { "align": "right" } }, "value": "{{ @.charge }}", "width": 12,
"style": { "align": "right", "format": "currency" } },
{ "header": { "value": "Credit", "style": { "align": "right" } }, "value": "{{ @.credit }}", "width": 12,
"style": { "align": "right", "format": "currency" } },
{ "header": { "value": "Balance", "style": { "align": "right" } }, "value": "{{ round($.input.statement.opening + run.balance, 2) }}", "width": 14,
"style": { "align": "right", "color": "#1e1f22", "format": "currency" } }
],
"total": {
"style": { "bold": true, "color": "#1e1f22", "align": "right", "paddingTop": 5, "paddingBottom": 0,
"borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
"rows": [
{
"cells": [
{ "value": [{ "value": "Closing balance at " }, { "value": "{{ $.input.statement.period.to }}" }], "span": 5, "style": { "format": "date" } },
{ "value": "{{ round($.input.statement.opening + $.charges - $.credits, 2) }}", "style": { "format": "currency" } }
]
}
]
}
},
"footer": [
{
"type": "split",
"style": { "spaceBefore": 30 },
"slots": [
{ "type": "text", "value": "Current", "width": 25,
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97", "align": "right" } },
{ "type": "text", "value": "31 to 60 days", "width": 25,
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97", "align": "right" } },
{ "type": "text", "value": "61 to 90 days", "width": 25,
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97", "align": "right" } },
{ "type": "text", "value": "Over 90 days",
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97", "align": "right" } }
]
},
{
"type": "split",
"slots": [
{ "type": "text", "value": "{{ round($.current, 2) }}", "width": 25,
"style": { "color": "#5e6062", "align": "right", "format": "currency" } },
{ "type": "text", "value": "{{ round($.days60, 2) }}", "width": 25,
"style": { "color": "#5e6062", "align": "right", "format": "currency" } },
{ "type": "text", "value": "{{ round($.days90, 2) }}", "width": 25,
"style": { "color": "#5e6062", "align": "right", "format": "currency" } },
{ "type": "text", "value": "{{ round($.over90, 2) }}",
"style": { "color": "#5e6062", "align": "right", "format": "currency" } }
]
}
],
"page": {
"header": [
{
"type": "text",
"value": "Statement No. {{ $.input.statement.number }} · {{ $.input.statement.customer.name }} · continued",
"visible": "=page.number > 1",
"style": { "size": 8, "color": "#989a97", "paddingBottom": 6, "spaceAfter": 18,
"borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" }
}
],
"footer": [
{
"type": "split",
"style": { "paddingTop": 10,
"borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
"slots": [
{ "type": "text", "value": "Closing balance", "width": 14,
"style": { "size": 8, "color": "#989a97" } },
{ "type": "text", "value": "{{ round($.input.statement.opening + $.charges - $.credits, 2) }}", "width": 10,
"style": { "size": 8, "color": "#1e1f22", "format": "currency" } },
{ "type": "text", "value": "payable to {{ $.input.statement.seller.iban }}, quoting account {{ $.input.statement.account }}.", "width": 56,
"style": { "size": 8, "color": "#989a97" } },
{ "type": "text", "value": "Page {{ page.number }} of {{ page.total }}",
"style": { "size": 8, "color": "#989a97", "align": "right" } }
]
}
]
}
}The running balance is a run declaration. Where an aggregate holds one value for the whole
document, a runner holds a different value on every row, accumulated in render order. run.balance
is the sum of charge minus credit up to and including this row. The Balance column adds the opening
balance to it, wrapped in round(…, 2). A running sum of exact amounts drifts onto inexact floats
within a few rows. Where a page presents the number, the workbook stores it. The closing balance is
a detail.total row reading the report aggregates $.charges and $.credits. It agrees with the
last row’s running balance by construction — there is no second number to reconcile.
The Date column declares "format": "date" on a bare {{ @.date }}, so the page reads 1 Jun 2026 and the workbook gets a real typed date. The header lines and the closing-balance label
carry their dates as styled runs. Each date sits in a run of its own beside the words. The same
kind presents them there too, and the definition registers no date function at all.
Charge and credit are two fields, one of them null on every row. A null under "format": "currency" presents as nothing, so a payment row shows an empty Charge cell without a conditional
in the template. The workbook gets an empty cell rather than a zero.
The ageing summary is four conditional aggregates. Each is a sum over the row’s open remainder
gated on its age in days — sum:[email protected] > 30 && @.age <= 60 ? (@.open ?? 0) : 0 — and a credit
row, which carries neither, contributes nothing. Both fields come from the ledger: quario does no
date arithmetic, and a statement restates what the ledger already knows.
The page bands are what this document is in the gallery for. page.header is one line — statement
number, customer, continued — with "visible": "=page.number > 1", so it appears from the second
page on and costs nothing on the first. page.footer is a split. The remittance line sits on the
left, with the closing balance in a slot of its own so "format": "currency" can present it. Page {{ page.number }} of {{ page.total }} goes on the right. A page band reads the report aggregates, so
the amount is the same $.charges - $.credits the total row shows. The column labels repeat on every
page the table runs onto, and nothing asks them to.
The data
A definition renders nothing on its own — you hand it a document. This is the sample the preview
below uses. $.statement.movements[*] in the definition selects its rows, and $.input reads the
rest. Invoice 2026-1042 is the one from the invoice template:
{
"statement": {
"number": "S-2026-09",
"date": "2026-09-01",
"period": {
"from": "2026-06-01",
"to": "2026-08-31"
},
"account": "A-0417",
"opening": 1840,
"seller": {
"name": "Quire Press Ltd.",
"street": "Binnenkant 42",
"city": "1011 BM Amsterdam",
"country": "Netherlands",
"vatId": "NL8123.45.678.B01",
"iban": "NL91 ABNA 0417 1643 00"
},
"customer": {
"name": "Folio & Sons",
"street": "12 Marlborough Street",
"city": "Dublin 1",
"country": "Ireland",
"vatId": "IE6388047V"
},
"movements": [
{"date": "2026-06-01", "reference": "2026-1021", "description": "Letterpress business cards, 500", "charge": 312.18, "credit": null, "age": 92, "open": 62.18},
{"date": "2026-06-02", "reference": "R-26-0611", "description": "Payment received, thank you", "charge": null, "credit": 1840},
{"date": "2026-06-05", "reference": "2026-1022", "description": "Wedding suite, 120 sets", "charge": 1385.45, "credit": null, "age": 88, "open": 0},
{"date": "2026-06-09", "reference": "2026-1023", "description": "Chapbook, saddle-stitched, 300", "charge": 968, "credit": null, "age": 84, "open": 0},
{"date": "2026-06-11", "reference": "2026-1024", "description": "Proof copies, courier", "charge": 58.08, "credit": null, "age": 82, "open": 0},
{"date": "2026-06-16", "reference": "R-26-0619", "description": "Payment received, thank you", "charge": null, "credit": 250},
{"date": "2026-06-18", "reference": "2026-1025", "description": "Poster run, A2, 250", "charge": 726, "credit": null, "age": 75, "open": 0},
{"date": "2026-06-19", "reference": "2026-1026", "description": "Foil-stamped folders, 200", "charge": 484, "credit": null, "age": 74, "open": 87.2},
{"date": "2026-06-23", "reference": "R-26-0627", "description": "Payment received, thank you", "charge": null, "credit": 1385.45},
{"date": "2026-06-25", "reference": "2026-1027", "description": "Case binding, quarter cloth, 150", "charge": 1161.6, "credit": null, "age": 68, "open": 0},
{"date": "2026-06-26", "reference": "2026-1028", "description": "Typesetting, 64 pp", "charge": 774.4, "credit": null, "age": 67, "open": 0},
{"date": "2026-06-30", "reference": "R-26-0634", "description": "Payment received, thank you", "charge": null, "credit": 968},
{"date": "2026-07-02", "reference": "2026-1029", "description": "Notebooks, Smyth-sewn, 400", "charge": 1936, "credit": null, "age": 61, "open": 0},
{"date": "2026-07-03", "reference": "2026-1030", "description": "Envelopes, lined, 1,000", "charge": 605, "credit": null, "age": 60, "open": 0},
{"date": "2026-07-07", "reference": "R-26-0641", "description": "Payment received, thank you", "charge": null, "credit": 58.08},
{"date": "2026-07-08", "reference": "2026-1031", "description": "Bookplates, 2,000", "charge": 290.4, "credit": null, "age": 55, "open": 0},
{"date": "2026-07-10", "reference": "CN-26-014", "description": "Credit note, short delivery", "charge": null, "credit": 96.8},
{"date": "2026-07-14", "reference": "R-26-0648", "description": "Payment received, thank you", "charge": null, "credit": 726},
{"date": "2026-07-15", "reference": "2026-1032", "description": "Exhibition catalogue, 500", "charge": 3872, "credit": null, "age": 48, "open": 0},
{"date": "2026-07-17", "reference": "2026-1033", "description": "Proof copies, courier", "charge": 48.4, "credit": null, "age": 46, "open": 48.4},
{"date": "2026-07-21", "reference": "R-26-0655", "description": "Payment received, thank you", "charge": null, "credit": 300},
{"date": "2026-07-22", "reference": "2026-1034", "description": "Menu cards, die-cut, 300", "charge": 471.9, "credit": null, "age": 41, "open": 471.9},
{"date": "2026-07-24", "reference": "2026-1035", "description": "Reprint, chapbook, 200", "charge": 605, "credit": null, "age": 39, "open": 605},
{"date": "2026-07-28", "reference": "R-26-0661", "description": "Payment received, thank you", "charge": null, "credit": 1161.6},
{"date": "2026-07-30", "reference": "2026-1036", "description": "Gift wrap, two colours, 500", "charge": 363, "credit": null, "age": 33, "open": 363},
{"date": "2026-08-03", "reference": "R-26-0668", "description": "Payment received, thank you", "charge": null, "credit": 774.4},
{"date": "2026-08-04", "reference": "2026-1037", "description": "Letterpress invitations, 250", "charge": 1149.5, "credit": null, "age": 28, "open": 1149.5},
{"date": "2026-08-06", "reference": "2026-1038", "description": "Slipcases, 150", "charge": 1210, "credit": null, "age": 26, "open": 1210},
{"date": "2026-08-10", "reference": "R-26-0674", "description": "Payment received, thank you", "charge": null, "credit": 1936},
{"date": "2026-08-11", "reference": "2026-1039", "description": "Bookmarks, 1,000", "charge": 181.5, "credit": null, "age": 21, "open": 181.5},
{"date": "2026-08-13", "reference": "2026-1040", "description": "Typesetting, 128 pp", "charge": 1548.8, "credit": null, "age": 19, "open": 1548.8},
{"date": "2026-08-14", "reference": "2026-1042", "description": "Case binding, quarter cloth, 250", "charge": 4456.43, "credit": null, "age": 18, "open": 4456.43},
{"date": "2026-08-18", "reference": "R-26-0682", "description": "Payment received, thank you", "charge": null, "credit": 605},
{"date": "2026-08-19", "reference": "2026-1043", "description": "Proof copies, courier", "charge": 58.08, "credit": null, "age": 13, "open": 58.08},
{"date": "2026-08-21", "reference": "2026-1044", "description": "Poster run, A1, 100", "charge": 544.5, "credit": null, "age": 11, "open": 544.5},
{"date": "2026-08-25", "reference": "R-26-0689", "description": "Payment received, thank you", "charge": null, "credit": 290.4},
{"date": "2026-08-26", "reference": "2026-1045", "description": "Chapbook, second series, 300", "charge": 968, "credit": null, "age": 6, "open": 968},
{"date": "2026-08-27", "reference": "2026-1046", "description": "Foil stamping, spine, 300", "charge": 399.3, "credit": null, "age": 5, "open": 399.3},
{"date": "2026-08-31", "reference": "R-26-0695", "description": "Payment received, thank you", "charge": null, "credit": 3872}
]
}
}What it renders
This isn’t a screenshot. quario compiles the definition and data above, and @quario/viewer renders
them in your browser, right here. The PDF button hands you the same statement as real @quario/pdf
bytes, no browser rendering involved. The sheet is the layout’s own pages painted on a canvas. The
page breaks, the continuation header, and the page numbers you see are the ones the PDF carries. One
target doesn’t. @quario/html renders an unpaginated fragment and ignores page bands. An HTML
render of this definition has no page numbers and no continuation header. Put anything that must
reach the HTML in the report header or footer instead. The DOCX button hands the same
definition to @quario/docx, where Word paginates and resolves the faces itself. The page
numbers survive that trip as fields Word recomputes:
Use it
npm install quario @quario/pdfimport { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import statement from './statement.report.json' with { type: 'json' }
const q = quario({ locale: 'en-IE', currency: 'EUR' }) // what `format` presents through
const report = q.report(statement)
const data = await getStatement(id) // your ledger
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)
writeFileSync('statement.pdf', bytes)The same compiled report renders to a workbook with nothing to change — see JSON to Excel. The running balance arrives as a number in every row.
Make it yours
Because the statement is declarative, changing it means changing data. Add a group on the month with
a subtotal in its footer. Drop the ageing block for a customer on immediate terms. Move the
remittance line into the report footer so it reaches the HTML render. They’re all edits to the
definition, all diff-able in a pull request. One customer per page for a monthly run is a group with
"by": "=@", "break": "page" and "reset": "page", so each statement is page 1 of its own count.
And it renders without eval, so building statements from ledger data doesn’t hand you an
injection problem. Security →
FAQ
Can I generate the statement as a PDF?
Yes — @quario/pdf renders the same definition straight to PDF bytes, with no browser anywhere,
and it’s the target that draws the page bands. Full example →
Can I export the statement to Excel?
Yes — @quario/xlsx renders the same definition to a workbook, with nothing to change. Charge,
Credit, and Balance declare "format": "currency", so every cell is a real number carrying a
currency number format, the running balance included. The empty side of each movement is an empty
cell rather than a zero. The Date column declares "format": "date", so it lands as a typed date
under dd mmm yyyy that sorts and filters as one. Full example →
Is a statement an invoice?
No. An invoice demands payment for named work. A statement restates the invoices already issued and the payments already received against them. That’s why this definition carries no VAT and no due date. Its remittance line asks for the closing balance rather than an amount of its own.
Where do age and open come from?
From your ledger. quario has no date arithmetic, and the unpaid remainder of an invoice depends on how payments landed against it, which only the ledger knows. Compute both where you build the data document and the ageing summary follows.
Where are the other templates?
Browse the template gallery → — invoices, receipts, purchase orders, and more.