Escape
HomeTemplatesStatement

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:

statement.report.json
{
  "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.data.json
{
  "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:

Rendering live with @quario/viewerA4

Use it

shell
npm install quario @quario/pdf
statement.js
import { 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.

Fold a quarter of movements into this statement.

npm install quario
Getting started
© 2026 quario · KvK 61815977