Customer statements and dunning
A customer's statement of account lists what the customer still owes us on a day: the open invoices, the credit notes that stand alone as negative items, the balance and its age, per currency. It is the first slice of ADR-0028; reminder letters, their fees and default interest and the dunning block follow below. The controls are in the control matrix (section "Customer statements", DU1 to DU7).
Everything here is a REST operation first (/v1, tag "dunning" in the
generated reference); the web UI (a customer's card, Statement) and the MCP
tool customer_statement call the same operations. A statement posts nothing and is not a request
for payment: it informs. Statements for vendors are the vendors' own and are not built.
What a statement is
GET /v1/entities/{entity}/customers/{party}/statement?as_of=2026-09-30 (the day is always named; there
is no "today" default, because a statement is evidence of a day).
| Part | Meaning |
|---|---|
items |
The customer's posted invoices and credit notes with something open on as_of, oldest due date first: document, dates, currency, amount (the gross total), open, days_overdue, bucket. A credit note is a negative item: its amount and open are negative. |
balances |
Per currency: balance, overdue (every band but current), the number of items and of overdue items, and buckets: the open amount per age band (current, 1-30, 31-60, 61-90, 90+ days past the due date), which add up to the balance. Every item is a row of one currency; a customer that was invoiced in francs and euros has two balances. |
totals_functional |
The same age bands and a total in the entity's functional currency: the carrying amounts the ledger holds. |
reconciliation |
The tie to the ledger (next section). |
issued |
The statements of this customer and day that were archived: file, language, hash, who and when. |
The rules are the aging report's, not a second set. The statement is the aging of
Receivables restricted to one customer, read from the same positions
(modules/trade/open_items, modules/trade/aging):
- The cut-off is
as_of. An allocation (a payment, a credit note, a cash discount taken) counts from the day it took effect: the payment's date, or the credit note's. A payment dated afteras_ofhas not settled anything yet, and a document dated afteras_ofis not on the statement. - A part payment lowers the open amount of its invoice from its day; the item stays, with what is left.
- A credit note that credits an invoice settles it from the credit note's date (the invoice's open amount falls, the credit note is not listed: it has nothing open). A credit note that credits nothing stays a negative item and reduces the balance.
- A payment waiting for approval reserves its amount (as in the aging): the item is listed with
openzero andreservedtrue, because the ledger still carries it until the payment is booked. - An item settled in full on or before
as_ofis not listed. A document that is open onas_ofand was paid later is listed as it was open on that day. - Foreign currency.
openis in the document's currency;open_functionalis the carrying amount in the functional currency (what the books hold: the booked total less the share each posted payment relieved), so the statement ties to the ledger whatever the rates did since.
It ties to the ledger
The statement's total is not computed from documents alone; it is compared with the books.
reconciliation sets totals_functional.total against this customer's own lines on the receivables
control account up to as_of (every line on that account carries its counterparty):
control_balance = totals_functional.total + unrealised_fx (unexplained = 0)
control_balance: the sum of the customer's debit and credit lines on thear_controlaccount in posted entries dated up toas_of.unrealised_fx: the period-end FX revaluation (sourcefx) that sits on the control account onas_ofand has not been reversed; it carries the customer but no document, so it is named, not hidden. It is zero for a customer in the functional currency, and zero after the revaluation's auto-reversal on the first day of the next period.pending_settlements: a memo, the functional amount of payments waiting for approval; they are in neither side.unexplained: what is left. It is zero while every line on the control account belongs to a document; anything else is a posting no document carries, and the statement says so instead of tying by force.
A test sums the journal itself (not through the code under test) and compares it with the statement on
twelve different days, for a customer with a part payment, a settling credit note, a standalone credit
note, a payment after the day, two currencies with a revaluation, and a payment waiting for approval
(tests/erp/features/invoicing/test_statements.py).
Formats
format=json (default), csv or pdf on the same operation.
- CSV: one row per open item (
document,type,document_date,due_date,currency,amount,open, open_functional,days_overdue,age_band,reserved), UTF-8, comma, CRLF, amounts as decimal strings. - PDF: an A4 page in the customer's language (the customer's
language, else the language of its country, else the entity's;language=de|fr|it|enchooses another): the sender, the customer's address, one table per currency with its balance, the age of what is open, and the line "this statement lists the open items as of the day; payments and credit notes after it are not included; it is not a reminder". It is built from the invoice PDF's parts (font, sender, recipient, footer;features/invoicing/dunning_pdf), so a statement and an invoice to one customer look alike. It is a pure function of what is on the statement: the same ledger gives the same bytes, and the file's creation date isas_of. No QR-bill: it asks for nothing to be paid.
curl "$ERP_URL/v1/entities/CH01/customers/C-CH/statement?as_of=2026-09-30" -H "Authorization: Bearer $ERP_KEY"
curl "$ERP_URL/v1/entities/CH01/customers/C-CH/statement?as_of=2026-09-30&format=pdf&download=true" \
-H "Authorization: Bearer $ERP_KEY" -o statement.pdf
Issuing: the archive
POST /v1/entities/{entity}/customers/{party}/statement/issue with {"as_of": "2026-09-30"} (and
optionally "language") stores the PDF in the document archive
(ADR-0020), so that what a customer was told on a day can be
shown later.
- The exact bytes are kept with their SHA-256 (
document.kindcustomer_statement; the entity, the customer, the day and the language are in its metadata; the audit event isstatement.issue, with the hash and the balances). The bucket's retention and the append-only table are the archive's. - One file per customer, day and language (a unique index). A second issue returns the first file with
createdfalse and writes nothing: the archive wins, as for invoices. Another language is another file. GET .../statement?as_of=...&format=pdfreturns the archived file when one was issued for that customer, day and language (X-Statement-Archived: true,X-Content-SHA256), even if the ledger has moved since (a payment entered late, dated before the day, changes the JSON statement of that day, not the PDF the customer received). Otherwise it renders now and stores nothing: a read changes nothing.- Only a person issues. The scope is
post(as for the first output of an invoice), and an agent key is refused with403whatever scopes it holds: freezing a file that leaves the system is a person's act. An agent may read the statement (REST and the MCP toolcustomer_statement), as JSON, CSV or PDF. - A statement for a day that has not come is refused (
422): it would not be what the customer was told. - The issue needs an
Idempotency-Key; a retry returns the first answer.
The files are in the organization export (ADR-0033), which
reads every document row. The auditor's archive export (GET /v1/archive/export) lists the files of
posted documents and does not include statements (they belong to a customer, not to a document); see the
open points in the ADR.
In the web UI
A customer's card has a Statement action (Finance → Customers → the customer). It opens the statement
view (/customers/{code}/statement): the day (today by default, any earlier day, kept in the address as
?as_of=), the balance and the age bands per currency, the open items, the tie to the ledger, and the
issued files. Every figure opens the list it counts: a band's tile filters the items to that band and the
list shows the same count; a document number opens the document. Download PDF downloads the archived
file when one exists and otherwise the rendered one; Issue statement (a person who may post) archives
it. Every text is in the four catalogs (English, de-CH, fr-CH, it-CH).
A customer's activity over a period
The statement's second tab, Activity (/customers/{code}/activity?start=&end=, the first of the year to
today by default; also an action on the customer's card), shows how the balance came about: per currency
the opening balance, every invoice, credit note and payment received (a reversed payment, and an amount on
account or a credit note applied later, ADR-0045, as two lines that cancel) with the running balance, and
the closing balance; then the balance in the functional currency and the open items at the end. It is the
vendor statement's reading (features/invoicing/dunning/activity.py) on the receivables side, served by
features/invoicing/dunning/customer_activity.py:
curl "$ERP_URL/v1/entities/CH01/customers/C-CH/activity?start=2026-07-01&end=2026-09-30" -H "Authorization: Bearer $ERP_KEY"
curl "$ERP_URL/v1/entities/CH01/customers/C-CH/activity?start=2026-07-01&end=2026-09-30&format=pdf&download=true" \
-H "Authorization: Bearer $ERP_KEY" -o activity.pdf
- It ties. Each currency runs from
openingthrough itslinestoclosing; the closing balances are the open items of the last day (open_items, the open-items statement'sitems), andclosing_functionalis the customer's balance on the receivables control account, the open-items statement'sreconciliation.control_balance(tests/erp/features/invoicing/test_customer_activity.py). - The PDF is on the statement's letterhead in the customer's language (its own, else its country's;
languagechoosesde,fr,itoren), worded from us to the customer: "Your account with us", opening balance, invoice, credit note, payment received, "Closing balance (amount due)". The CSV has a row per movement and an opening and a closing row per currency. Both are rendered on request and not archived. - The MCP tool
customer_activityreads the JSON. Read-only: it posts nothing and is not a request for payment.
Vendor statements (ADR-0048)
A vendor's card has Statement too (/vendors/{code}/statement?start=&end=, the year to today by
default): what we owe the vendor between two days, per currency from the opening to the closing balance,
every bill, credit note and payment with the running balance, then the bills and credit notes still open.
It is read from the vendor's own lines on the payables control account (features/invoicing/dunning/activity.py), so
the closing balance is the one in the books; the open items are statement.build for the payables side. A
field per currency takes the closing balance of the statement the vendor sent and says whether ours agrees,
or by how much it differs; nothing is imported or stored. Download PDF (in the vendor's language) and
Download CSV render it now; nothing is archived.
Reminder letters (ADR-0048)
Sales → Payment Reminders (/reminders) lists, for today, the customers with overdue invoices due a
reminder, one row per customer and currency, at a level: payment reminder, second reminder, final reminder.
A Swiss company (any company outside Germany) reminds 10 days after the due date, then 20 days after the
previous letter was handed over, then 10; a German one 7, 14 and 10. Each letter gives 10 days to pay.
Make letter archives the PDF in the customer's language (over the API a letter needs no currency: it
takes the one the customer is due a reminder in, its own of several); then Send by e-mail (when the server sends
mail; a test copy never does) or Record as sent (a day within the last 7). A letter is dated today with
today's amounts, so the clock runs on real days. A letter that is not handed over starts no clock: it
stays under letters not sent yet, and its invoices wait. Once an amount on it changes or it is more than 7
days old it is out of date: it can no longer be sent, and Withdraw (with a reason) takes it back so a
new letter can be made. Each credit of the customer is deducted on one letter only. An overdue invoice's card has Send reminder:
the letters that named it, and a letter naming only it when one is due today, or why none is. A person
makes and sends a letter, never an agent.
Fees and default interest
A letter charges what its level is set to charge, and prints it: a fee per letter, a flat fee once
per invoice to a business customer, and default interest on each invoice. Payment Reminders → Fees
and interest (/reminders/settings; GET/PUT /v1/entities/{entity}/reminder-settings, saving needs the
admin permission) sets them per level; the fees are in the company's currency, and a letter in another
currency converts them at the letter's day's rate. Until someone saves, the country's defaults apply, and
the screen offers them again with Use the country's defaults:
| Country | Fee per letter | Flat fee per invoice (business customers) | Default interest |
|---|---|---|---|
| Switzerland, Liechtenstein | none (a fee must be agreed with the customer) | none | 5 % a year on every level (Art. 104 OR; § 1000 ABGB) |
| Germany | none | EUR 40, on the first reminder (§ 288 para. 5 BGB) | base rate + 9 points for businesses, + 5 points for consumers (§ 288 BGB), following the base rate of each day (see below) |
| any other | none | none | none |
- Interest runs on what is open each day (a part payment lowers it from its day), from the day after
the due date to the letter's day, counting the actual days over 365, rounded once to the cent: CHF
8,000.00 for 45 days at 5 % is 8,000.00 × 5 % × 45 / 365 = 49.32. A later letter counts from the last
day an earlier letter charged, so no day is charged twice; a withdrawn letter charged nothing, so the
next one counts its days. Each invoice is charged at the rate of the level it reaches with the letter
(the consumer rate when the customer is not a business,
businesson the customer). - The German base rate (Basiszinssatz, § 247 BGB) changes on 1 January and 1 July. The Bundesbank's
rates are a dated table in the code (
DE_BASE_RATESinfeatures/invoicing/dunning/fees.py: −0.88 % from 1 July 2016, 1.62 % from 1 January 2023, 3.12 %, 3.62 %, 3.37 %, 2.27 %, 1.27 % from 1 July 2025, 1.27 % from 1 January 2026, 1.52 % from 1 July 2026), and a new rate is added there when it is announced. While a German company keeps the defaults, each day is charged at the base rate of that day plus 9 or 5 points: interest over days that span a change is split at the change, each part at its own rate, on its own line of the letter and the charge invoice (interest_partson the proposal's invoices and the letter's items;interest_rateis then the rate of the letter's day). For example EUR 1,190.00 due 20 June 2025 and reminded on 10 July: 10 days at 11.27 % (3.67) and 10 days at 10.27 % (3.35). The screen shows the rates of today. Saving keeps a rate left at the default following the base rate (stored empty); only a rate a person changed is fixed, so saving a fee does not freeze the rates. - The flat fee is charged once per invoice, on the first letter whose charge stands (a letter withdrawn before it was handed over did not charge it). It is set on a level (Germany: the first reminder); when that letter's charge invoice is rejected or voided, the next letter charges the flat fee instead. Fees and interest are not charged on a letter's own charge invoice (no interest on interest).
- A blocked customer is reminded but charged nothing: no new document is made for it.
- The letter prints a line per charge in the customer's language (for instance "Mahngebühr (2. Mahnung)
20.00", "Verzugszins Rechnung INV-2026-000001: 5.00 % p.a. vom 01.08.2026 bis 14.09.2026 (45 Tage)
49.32", "Verzugspauschale Rechnung ..."), the new total, and the remark that fees and interest carry no
VAT (compensation for damage, not consideration for a supply). The proposal (
ReminderDue.fee, each invoice'sinterest,interest_from,interest_until,interest_rate,flat_fee) and the letter (ReminderOut.fee, its items) carry the same amounts;totalincludes them.
Booking. Nothing is booked when the letter is made. When it is first handed over (sent by e-mail or
recorded), its charges become a sales invoice to the customer (ReminderOut.charge: its number and
status), dated the day of the hand-over and due on the letter's deadline, one line per charge, outside
the scope of VAT (reason damages, so the VAT returns do not count it and the VAT report shows it as a
reconciling item): debit receivables, credit the account of the role dunning_income (Swiss chart 6955
Verzugszinsen und Mahngebühren, German 7305, French 763800, Italian 7120, Spanish 769100, the IFRS, US, UK
and Canadian charts 7030). It goes through journals.submit, so the approval policy applies as to any
invoice. The charge invoice is an open item like any other: the customer pays it, it is on the statement,
and once overdue it is reminded in turn. A letter withdrawn before it was handed over books nothing.
A charge that does not stand. When the approver rejects the charge invoice (or it is withdrawn from
approval), or it is reopened and voided, the letter charged nothing after all: the letter shows it as not
charged (ReminderOut.charge.charged false, with the invoice's status), and the next letter charges the
same interest days and the flat fee again. The fee per letter stays with the letter it was set for; the
next letter charges its own level's fee. While the invoice waits for approval, or is reopened as a draft to
be submitted again, the charge holds and no later letter charges those days a second time.
Stopping the reminders of an invoice
An open invoice's card has Stop reminders (POST /v1/documents/{document_id}/dunning-block with a
reason; a person with the post permission, never an agent: the policy action release_dunning). While
the stop holds, no letter names the invoice and it is charged no fee and no interest; it stays open and
payable and is on the statement. The reminder run lists it under Reminders stopped with the reason
(ReminderProposal.blocked). Resume reminders (DELETE on the same address) lifts the stop: the
invoice is due its next letter as if nothing had happened, and interest counts from the due date or the
last day a letter charged. Each stop is kept with who set it and who lifted it (table dunning_block, and
the audit events dunning.block.set and dunning.block.clear). The payment_blocked of a vendor bill is
another thing: a payment hold on our side.
What comes next (slice 3 of ADR-0028)
- the legal-interest dataset in data-vNext in place of the German base-rate table in the code, and a dunning
policy for the levels' days (
features/invoicing/dunning/levels.pyis where the presets are read); - the statement's memo line for the interest accrued and not yet charged; it is an additive field of
Statement.
Where the code is
src/erp/features/invoicing/dunning/ |
statement (the open items and their tie to the ledger, either side), activity (a party's movements between two days), files (the archived statements), output (the PDF, the CSV, issue, the vendor statement), customer_activity (a customer's movements as JSON, CSV and PDF), levels (the presets, the entity's today), fees (the levels' fees and rates, the country defaults, the interest of a span of days), blocks (stopping an invoice's reminders), proposal (who is due which level, the credits each letter deducts), charges (what a letter charges, and its charge invoice), letters (letters as they stand, out of date or not), notices (make, hand-over, e-mail, withdraw); __init__ is the service API |
src/erp/features/invoicing/dunning_pdf/ |
the pure PDF: view, text (four languages), statement, activity (vendor statement and customer activity), notice (reminder letter) |
src/erp/features/invoicing/models.py, src/erp/migrations/sql/0064_dunning_notices.sql, 0114_dunning_charges.sql |
the letters, their invoices and hand-overs (append-only); the levels' fee settings, the charge invoice of a letter and the dunning blocks |
src/erp/features/invoicing/api/reminders.py, reminder_settings.py, contracts/reminders.py, contracts/dunning_fees.py, contracts/vendor_statement.py, contracts/customer_activity.py |
REST for letters, the vendor statement's and the customer activity's models |
ui/src/features/invoicing/pages/VendorStatement.tsx, CustomerActivity.tsx (with StatementTabs.tsx), Reminders.tsx, ReminderActions.tsx, InvoiceReminder.tsx, ReminderSettings.tsx, DunningBlock.tsx |
the screens |
src/erp/modules/trade/ |
open_positions(party_id=...), aging.reconcile(party_id=...), aging.bucket_of, aging.listed: what the aging and the statement share |
src/erp/features/invoicing/api/dunning.py, contracts/dunning.py, mcp/dunning.py |
REST, its models, the MCP tool |
src/erp/migrations/sql/0029_customer_statement.sql |
the archive's rules for the new kind (provisional number; the lead renumbers at merge) |
tests/erp/features/invoicing/test_statement_pdf.py, tests/erp/features/invoicing/test_reminder_letters.py, tests/erp/features/invoicing/test_statements.py, tests/erp/features/invoicing/test_vendor_statements.py, tests/erp/features/invoicing/test_customer_activity.py, tests/erp/features/invoicing/test_reminders.py, tests/erp/features/invoicing/test_dunning_charges.py, tests/erp/cross_tenant_areas/dunning.py |
tests |