StabisoDocs

Accounting and finance

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 after as_of has not settled anything yet, and a document dated after as_of is 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 open zero and reserved true, because the ledger still carries it until the payment is booked.
  • An item settled in full on or before as_of is not listed. A document that is open on as_of and was paid later is listed as it was open on that day.
  • Foreign currency. open is in the document's currency; open_functional is 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 the ar_control account in posted entries dated up to as_of.
  • unrealised_fx: the period-end FX revaluation (source fx) that sits on the control account on as_of and 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|en chooses 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 is as_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.kind customer_statement; the entity, the customer, the day and the language are in its metadata; the audit event is statement.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 created false and writes nothing: the archive wins, as for invoices. Another language is another file.
  • GET .../statement?as_of=...&format=pdf returns 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 with 403 whatever 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 tool customer_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 opening through its lines to closing; the closing balances are the open items of the last day (open_items, the open-items statement's items), and closing_functional is the customer's balance on the receivables control account, the open-items statement's reconciliation.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; language chooses de, fr, it or en), 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_activity reads 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, business on 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_RATES in features/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_parts on the proposal's invoices and the letter's items; interest_rate is 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's interest, interest_from, interest_until, interest_rate, flat_fee) and the letter (ReminderOut.fee, its items) carry the same amounts; total includes 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.py is 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
Stabiso, the Enterprise System of Record · generated from the code and its guides · release 20b7dd5b0c4d