The ledger and its controls
The guarantees in the first table are enforced inside PostgreSQL, with triggers,
constraints, row-level security and grants. A bug in the application, or an agent that
misbehaves, cannot break them. The later sections describe rules that the posting engine and
service layer enforce. Most rules have a test in tests/erp; the control matrix
says which, and which do not yet.
Double entry
| Invariant | Mechanism |
|---|---|
| Every posted entry balances in functional currency, with at least two lines | A trigger on journal_entry checks the lines when the entry becomes posted |
| Each line is either a debit or a credit, never negative | CHECK constraints |
| Postings go only to open periods | A trigger compares the entry date with period.status |
| A date has exactly one regular period | A trigger refuses regular periods that overlap; an adjustment period is a single day (CHECK) |
| Posted entries and their lines are immutable | A trigger blocks UPDATE and DELETE once posted. Corrections of manual entries are reversals (POST /v1/journal-entries/{id}/reverse, a person, with a reason, through the approval policy); subledger entries are corrected through their document, payment or period run |
| Posted and void invoices and bills are immutable, and lines change only on drafts | Triggers on trade_document and trade_document_line. Corrections are credit notes; a voided draft stays as it was |
| Numbers are gap-free per entity and fiscal year | A number_sequence row locked in the posting transaction (2026-000042); uniqueness is a database constraint |
| Tampering is evident | A SHA-256 hash chain per entity: each posted entry hashes its content plus the previous hash. The application computes it and the database requires it; GET /v1/entities/{e}/chain-verification (and the close checklist) recompute it. The hash is versioned (hash_version): entries posted since migration 0017 are hashed over every column of the entry and its lines (version 2), older ones keep verifying with the content-only version 1, and a version never goes back (ADR-0002) |
| The audit trail is append-only | erp_app has no UPDATE or DELETE on audit_event, and a trigger blocks them for every role |
| Only active customers and vendors are booked and paid | A trigger refuses to post a document whose party is a draft, and to release a payment (pain.001 item) to a draft party or one whose new bank details wait for approval. See customer and vendor controls |
| Released payment files are kept as sent, and a payment is sent once | payment_export (the pain.001 or pain.008 file and its SHA-256) and its items are append-only in the same way; payment_export_item.payment_id is unique |
| A final VAT return locks its period's VAT postings | A trigger refuses to post an entry with tax-tagged lines or lines on VAT accounts dated in the period of a final return (its own settlement excepted) until the return is amended; a final return and its box lines are immutable |
| Tenants are isolated | Row-level security with FORCE on every table, keyed on app.org_id |
| The runtime cannot change the schema | Migrations run as erp_owner; the API runs as erp_app with DML only |
Explainable numbers
Each entry stores its provenance (JSON):
- the source document (invoice, bill, payment, bank line, asset or run);
- the actor who created it and the actor who posted it;
- the data-vNext dataset version;
- for tax lines, the rate ids, treatment, rule trace and legal reference;
- for FX lines, the ECB fixing date and rate, the official VAT rate when the entity books
documents at it, or
manualwhen a rate was supplied; - for tax lines of foreign-currency documents in Swiss and German entities, the amount at the
official VAT exchange rate (
tax.tax_fx), or the series and tax point while it is not published yet.
Trade documents keep the tax determination of each line. The VAT report is built from these
tagged lines and reconciled against movements on the VAT accounts; any
unexplained_difference is shown. VAT returns (CH ESTV, DE UStVA and ZM, FR CA3, ES Modelo
303, 390 and 349, IT LIPE and Dichiarazione IVA, GB VAT100, SE momsdeklaration, FI
kausiveroilmoitus, DK momsangivelse, NO MVA-melding) are built the same
way with the official box mappings and the official VAT exchange rates, keep the ledger lines
behind every box, and settle the VAT through a normal posting; see the
API guide.
Nordic VAT returns
Sweden, Finland, Denmark and Norway file their VAT returns from the tagged ledger lines like the
others (data/tax/returns/se.yaml, fi.yaml, dk.yaml, no.yaml, ADR-0100). A VAT-registered
importer in each of them reports the import VAT on the return, so an import bill books it as VAT
due and as input VAT. Each return is recorded as submitted and paid here and filed by hand on the
authority's e-service. Filing and payment are due on the same day, moved off a weekend (public
holidays are not known, so a date may be shown a day early, never late).
- Sweden: momsdeklaration (SKV 4700), per quarter (the default), month or year, in whole kronor. Taxable sales in ruta 05 with their VAT by rate in rutor 10 to 12 (25, 12, 6 %); goods to other EU countries in 35, exports in 36, services to EU businesses in 39, other services abroad in 40; purchases where the company is liable as buyer in 20 to 24 with their VAT in 30 to 32; imports in ruta 50 with their VAT in 60 to 62; input VAT in ruta 48 and the result in ruta 49, paid to or refunded from the tax account. Due on the 12th of the second month after a quarter (17 August for the second), the 26th of the month after a month, the 26th of the second month after a year. Not derived: self-supply (06), the margin scheme (07), voluntary liability on letting (08), triangular trade (37); the periodisk sammanställning is not prepared.
- Finland: kausiveroilmoitus, per month (the default), quarter or year. The VAT on domestic sales per rate (25.5, 13.5 and 10 %) and the VAT owed as the customer: on goods and services from other EU countries, on building work and scrap metal under the domestic reverse charge, and on imports. Services bought from outside the EU are declared with the domestic sales at their rate. All deductible VAT is in one field, and the result is paid to or refunded from the verotili. Beside the VAT it lists the zero-rated turnover, sales and purchases with other EU countries, imports and the reverse-charged building work. Due on the 12th of the second month after the period, a year by 28 February. The margin schemes and restricted deductions are adjustments.
- Denmark: momsangivelse, per quarter (the default for a new business), half year or month, in whole kroner: salgsmoms, moms af varekøb i udlandet (goods from other EU countries and imports), moms af ydelseskøb i udlandet (services bought abroad under the reverse charge), købsmoms and the momstilsvar, with rubrik A (purchases from other EU countries), rubrik B (sales to other EU countries) and rubrik C (exports, services to customers outside the EU, zero-rated sales and domestic reverse-charge sales). The energy duties a business deducts are entered by hand. Due on the 1st of the third month after a quarter or half year, the 25th of the month after a month (17 August for June). EU-salg uden moms is not prepared.
- Norway: MVA-melding, per term of two months (
2026-T1, January and February, to2026-T6), the default, or per year for a business allowed the årstermin. Each mva-kode with its basis and VAT: 3, 31 and 33 for sales at 25, 15 and 12 %, 5 for zero-rated sales, 52 for exports and services to customers abroad (the EU included: Norway is not in its VAT area), 1, 11 and 13 for the VAT deducted, 81 and 83 for imports and 86 and 88 for services bought from abroad, each with its deduction. Codes without deduction and the raw fish rate are adjustments. Due one month and ten days after the term (31 August for May and June), a year by 10 March. Norway has no One-Stop Shop and no EC sales list.
The box mappings follow the forms' published guidance, which could not be opened where they were
written: the sources carry no verified_at, and a local accountant is still to review them.
Spanish VAT returns
A Spanish company files the Modelo 303 per quarter (per month for the large companies, VAT
groups and REDEME), the Modelo 349 of intra-Community operations and the annual Modelo
390, all computed from the tagged ledger lines with the casilla numbers of the forms
(data/tax/returns/es.yaml, each rule citing the Ley 37/1992 del IVA). They are recorded as
submitted and paid like the other returns and filed by hand on the AEAT's sede electrónica.
- Sales at 21, 10 and 4 % fill casillas 07, 04 and 01; the cuota (09, 06, 03) is the base times the rate. Credit notes go to casillas 14 and 15 (IVA due) and 40 and 41 (IVA deducted), as the AEAT asks for facturas rectificativas (art. 80), not netted in the rate rows.
- Intra-Community acquisitions of goods and services received from other Member States are self-
assessed in 10 and 11 and deducted in 36 and 37; other reverse-charged purchases (services from
outside the EU, the domestic inversión del sujeto pasivo of art. 84.Uno.2.º) in 12 and 13 and
28 and 29. Capital goods (bienes de inversión, art. 108: property, plant and equipment of at
least 3,005.06 euros a unit) go to 30/31 and 38/39; journal lines carry
capital_goodsandcorrectionfor this. - Import IVA is paid at customs on the DUA, so casillas 32 to 35 are entered as adjustments with the account it is booked to.
- A negative result before the year's last period is carried forward (a compensar, art. 99.Cinco):
the settlement debits Hacienda Pública, deudora por IVA (470000, role
vat_carry_forward) and the return is done once submitted. The next 303 fills casilla 110 with what the last final 303 left (its 87 plus its negative 71) and applies it in 78 up to casilla 66, crediting 470000; the last period applies all of it, and a negative result there is refunded (art. 115): the settlement debits Hacienda Pública, deudora por devolución de impuestos (470900, rolevat_refund_receivable), and the refund received is linked to the return as its payment. Entering 110 or 78 by hand replaces the amounts carried. Without an account of rolevat_carry_forward(a custom chart) a negative result is an error, never booked to the settlement account; when the amount carried differs from that account's balance (an earlier return amended, a manual posting) the return warns. The IFRS chart has both roles too (1420 and 1430), for a Spanish company that keeps it. - The Modelo 390 is a return of its own kind,
annual_summary: it settles nothing. Beside the ledger it takes from the year's final 303s their import IVA (52 to 55 from 32 to 35), the regularisations (63, 522 and 658 from 43, 44 and 76), their results (95, 97 and 98) and the previous year's compensation applied (85), and warns when its casillas 64 and 65 differ from the sums of their 45 and 46. Blocks whose per-rate casillas are not filled show their totals. Monthly filers keep their books on the SII and file no 390, so it is not scheduled for them. - The Modelo 349 lists one row per operator and clave, in euros and cents. A correction is a
declaración sustitutiva: it lists every operator again (the German ZM's correction lists the
changed rows alone). A quarterly 349 whose supplies of goods and services (claves E, M, H and S) pass
50,000 euros carries the warning
monthly_statement: from that month it is filed monthly (RIVA art. 81). - The printouts are in Spanish: casilla, base imponible and cuota, amounts as 1.000,00, dates as dd/mm/aaaa.
- The Canary Islands, Ceuta and Melilla are outside the Spanish IVA: goods shipped there are exports (casilla 60), services to a business there are not subject (120), goods bought from there are imports and services bought from there are reverse charged.
Due dates: the 303 of a quarter by the 20th of the next month, of a month by the 30th, the last period by 30 January; the 349 by the 20th; the 390 by 30 January. A weekend moves a date to the Monday; national holidays are not known. Paid by direct debit (domiciliación), a return is due by the 15th, or 25 January, instead.
Subledgers and control accounts
Receivables, payables, bank, fixed-asset cost and accumulated depreciation are control
accounts. The posting engine lets only their subledger (documents, payments, assets, bank lines) post to them.
Control lines carry subledger_type and subledger_id. As a result, the aging reports equal
the control accounts, and the close checklist verifies that they do.
The documents of a customer or vendor that is another entity of the organization post to a pair of
control accounts of their own, ic_ar_control and ic_ap_control (receivables from and payables to
group companies, shown on their own balance sheet lines), chosen by trade.control_role; the aging and
the control checks take the group account together with the ordinary one, and the partner of a line is
read through the view ic_journal_line. Loans, accrued interest and group dividends have group accounts of
their own too (ic_loan_*, ic_other_*), whose lines name the other entity as the partner. See
intercompany.
Payments in transit
An outgoing payment is booked to the bank account only when the bank executes it
(ADR-0060). Until then its entry credits the
control account payments_in_transit (Swiss KMU 1091 "Zahlungen unterwegs (Geldtransfer)", HGB
2490 "Geldtransit", 1090 "Payments in transit" in the other templates), beside the bank in the cash
group:
| Step | Entry |
|---|---|
| Payment created (proposal run or Record payment), approved | payables (2000) debit, payments in transit (1091) credit |
| Bank line matched (camt.054 or camt.053, by the matcher or a person) | payments in transit debit at what the payment booked there, bank (1020) credit; a conversion by the bank: the bank at the line's amount, the difference as realised FX (6949 / 6999) |
| Match undone | the line's entry reversed: back in transit, the bill stays paid |
| Payment reversed while in transit | the payment's entry reversed: payables credit, payments in transit debit |
| Rejected by the bank (pain.002 status report) | the payment's entry reversed, as above, with the bank's reason |
| Returned by the bank (a credit line after the execution) | the payment's entry reversed by the line, but the bank (1020) debited by what came back instead of payments in transit; a conversion by the bank: the difference as realised FX |
A payment recorded from a bank line, or recorded as already executed (executed: true, "The
bank already executed it": a company that pays in e-banking, by card or in cash and makes no payment
files), goes to the bank account at once and into no payment file (payment_recorded_paid). Receipts
always go to the bank account. The statement balance check therefore compares the bank with what the
bank executed, and the close check payments_in_transit warns of payments still waiting for their
bank line more than 10 days after their date. The warning that a payment takes the bank account below zero is given
when the payment is made, on the bank's balance less what is still in transit from it
(overdrawn_in_transit), and not again when the bank executes it.
A payment in transit in a foreign currency is remeasured by the period-end FX revaluation at the
closing rate, per currency, like the bank balance it will leave; the revaluation reverses on the
first day of the next period, so the bank line still moves it to the bank at the payment's own rate.
A payment the bank rejected or returned is reversed by the bank's own message (ADR-0062), and the bills it paid are open again: Bank statements imports the bank's status report on a payment file (pain.002, Format "Payment status report"), and the matcher takes a credit line the bank marks as a return, or that carries the payment's number as its end-to-end id and comes from the payee, as that payment's return; a person links any other return from the line's candidates ("Payments that came back"). The reversal follows the approval policy and the four-eyes rules; the bank's message stands for the confirmation that it did not pay. Vendor Payments says why ("Rejected by the bank on …: the account is closed"). Importing the same report or statement twice changes nothing.
Chart templates
An entity's accounts are copied from a template in src/erp/seed/charts when it is created.
POST /v1/entities uses the request's chart, ifrs_generic if it names none. Provisioning a
customer (POST /v1/platform/organizations, the operator portal's New customer wizard) instead
starts each entity on the standard chart of its country: the template whose countries name it
(ch_kmu for CH and LI, de_hgb for DE, fr_pcg for FR, it_civil_code for IT, es_pgc for
ES, se_bas for SE, fi_kpl for FI, dk_standard for DK, no_ns4102 for NO, uk_frs102 for GB,
us_gaap for US, ca_standard for CA), else ifrs_generic (setup.standard_chart;
GET /v1/charts?country= and GET /v1/platform/charts?country= mark it standard, and the wizard
preselects it). A customer whose administrator sets it up at the first sign-in (ADR-0041) starts on
the chart of the accounting standard they chose (GET /v1/setup/options). After that the chart is
the customer's: accounts are added (POST /v1/entities/{entity}/accounts or the accounts import),
renamed, moved to another statement line, given or relieved of a role, and deactivated
(PATCH /v1/entities/{entity}/accounts/{code}); an account's number, type and currency are fixed.
Account names in the reader's language. An account has one name, the one the company books
under (name in GET /v1/entities/{entity}/accounts; what archive exports, documents and filings
print). The API answers with it as stored; showing it in the reader's language is the UI's job,
like every other text of the app: the web UI shows a standard account name (one of the shipped
chart templates, or one the app makes itself such as the US sales tax account of a state) in the
language its user picked, from its own translation files (ui/src/i18n/terms.json), and a
standard name followed by a code in capitals keeps the code ("Bankguthaben EUR" reads "Bank EUR").
Any other name, one the company made up or changed, shows as it is in every language. A test
holds every template account name to all five UI languages.
The German template is our own, not DATEV's SKR03 or SKR04 (a company that keeps one of those with its Steuerberater imports it, B-65). Its accounts follow the order of the HGB balance sheet (§ 266) and income statement (§ 275, Gesamtkostenverfahren), and the first digit names the part: 1 Anlagevermögen, 2 Umlaufvermögen with the aktive Rechnungsabgrenzung and aktive latente Steuern, 3 Eigenkapital, 4 Rückstellungen, Verbindlichkeiten, passive Abgrenzung and latente Steuern (4900 clears the opening balances, 4910 is the suspense account), 5 Umsatzerlöse and sonstige betriebliche Erträge, 6 Material, Personal, Abschreibungen and sonstige betriebliche Aufwendungen, 7 Finanzergebnis and Steuern. Exchange differences and disposal results are sonstige betriebliche Erträge and Aufwendungen (§ 275), not the financial result. HGB writes off fixed assets directly; the fixed-asset subledger keeps cost and cumulative depreciation apart (1300 and 1309), and the balance sheet nets them. A German accountant is still to review it (B-79). The French, Italian and Spanish templates follow their national charts. The French one is the Plan comptable général (règlement ANC 2014-03 as amended by 2022-06, in force for fiscal years from 2025): the official account numbers padded to six digits (411000 Clients, 445710 TVA collectée, 512000 Banques), cash discounts in the financial result (665 and 765), and, under 2022-06, gains and losses on disposals in the current result (757 and 657), no longer exceptional. The Spanish one is the PGC PYMES (Real Decreto 1515/2007, as amended by RD 1/2021), numbered the same way (430000 Clientes, 477000 HP IVA repercutido, 475000 HP acreedora por IVA, 572000 Bancos), with cash discounts as 606 and 706 and disposals (671, 771) in the operating result. In both, accumulated depreciation is in class 28 (218300 and 281830, 217000 and 281700). Italy has no official numbered chart: the Italian template is our own, like the German one, in the order of the Civil Code statements (art. 2424 and 2425 c.c. as amended by D.Lgs. 139/2015, which removed the extraordinary section), its first digit the part (1 immobilizzazioni, 2 attivo circolante, 3 patrimonio netto, 4 fondi, TFR and debiti, 5 valore della produzione, 6 costi della produzione, 7 proventi e oneri finanziari and imposte), cost and fondo ammortamento paired as 1300 and 1309. Names are in the chart's language with English, German, French and Italian translations. The three charts use the same statement lines as the others, so their statements group the accounts by those lines, not by the national form's own headings. VAT is booked at the country's rates (20, 22 and 21 percent standard) and reported per rate. France files the CA3 from the books (input VAT on fixed assets, class 2, goes to 445620 for line 19), Spain the Modelo 303, 390 and 349 (see Spanish VAT returns); there is no Italian VAT return form yet, and the setup wizard says so. A local accountant is still to review them.
The Swedish, Finnish, Danish and Norwegian templates (ADR-0100) start a company on its national
chart, IFRS the other choice, with four-digit accounts named in the country's language. Sweden's
se_bas is an abridged BAS-kontoplan for K2 and K3 (classes 1 tillgångar to 8 finansiella poster:
1510 receivables, 2440 payables, 1930 bank, 2610 output VAT, 2614 VAT as buyer, 2640 input VAT,
2650 Redovisningskonto för moms, 1630 skattekontot); BAS splits output VAT by rate, the ledger keeps
one account and the rate on each line. Finland's fi_kpl is our own, as Finnish law prescribes no
chart, in the order of the Kirjanpitoasetus statements (1 vastaavaa, 2 vastattavaa, 3 liikevaihto
to 9 rahoitustuotot ja -kulut and tuloverot; 2930 myynnin alv, 1763 ostojen alv, 2939 alv-tilitys).
Denmark's dk_standard follows the order of Erhvervsstyrelsen's standardkontoplan under
bogføringsloven 2022 with numbers of our own, the income statement first (1 to 4) and then the
balance sheet (5 aktiver, 6 passiver; 6800 salgsmoms, 6810 købsmoms, 6840 momsafregning). Norway's
no_ns4102 follows NS 4102 (1 eiendeler, 2 egenkapital og gjeld, 3 salgs- og driftsinntekt to 8
finansposter og skatt; 2700 utgående, 2710 inngående, 2740 oppgjørskonto merverdiavgift) and has no
One-Stop Shop account, Norway being outside the EU. In all four each class of fixed assets keeps
its accumulated depreciation on the partner ending in 9 (1220 and 1229 in BAS); the balance sheet
nets them. Leases are expensed (leases.terms.LOCAL_CHARTS). Local accountants are still to review
them.
tests/unit/test_chart_templates.py holds every template to the same rules: an account's type
matches its statement line (assets on bs.assets, expenses on pl.opex, ...), every system role
(setup.REQUIRED_ROLES, VAT settlement, deferred tax) is on exactly one account of the type the role
needs, the control accounts are marked, and a contra account (accumulated depreciation,
Wertberichtigung) is an asset on the line and next to the cost it corrects.
The Swiss template follows the Schweizer Kontenrahmen KMU (Sterchi, Mattle, Helbling; veb.ch and Verlag SKV, Ausgabe 1/2013; the official Schulversion is published by SwissAccounting on kmu.admin.ch). The classes are 1 and 2 (20 to 26 liabilities, 28 and 29 equity) the balance sheet, 3 revenue, 4 and 5 expense, 6 other operating expense, depreciation (6800) and the financial result (6900 Finanzaufwand, 6950 Finanzertrag, sub-accounts up to 6999), 7 Betrieblicher Nebenerfolg (Nebenbetrieb and Liegenschaften), 8 betriebsfremder, ausserordentlicher, einmaliger oder periodenfremder Aufwand und Ertrag (8000 and 8100, 8500 and 8510) and 8900 Direkte Steuern. The template's accounts for the system roles, as of 2026-10-02:
| Role | Swiss template | Basis |
|---|---|---|
bank_fees |
6940 Bankspesen | sub-account of 694 Übriger Finanzaufwand: W&W Branchenstandard (with H. Mattle), treuland |
fx_loss, fx_gain |
6949 Währungsverluste, 6999 Währungsgewinne | the same two sources |
fx_unrealized |
6948 Unrealisierte Währungsdifferenzen | extension in group 694 (net, either side) |
asset_disposal_loss, asset_disposal_gain |
8504, 8514 Ausserordentlicher Verlust, Gewinn aus Veräusserung von Anlagevermögen | sub-accounts of 8500 and 8510: treuland |
rounding |
6799 Rundungsdifferenzen | KMU has none; group 679 Sonstiger betrieblicher Aufwand |
opening_balance_clearing, suspense |
2298, 2299 | group 22 Übrige kurzfristige Verbindlichkeiten: 29 is equity in KMU |
ic_ar_control, ic_ap_control |
1110, 2010 Forderungen und Verbindlichkeiten aus L+L gegenüber Konzerngesellschaften (generic: 1210, 2010) | our proposal in groups 11 and 20 for intercompany, not in the public publications (B-66) |
ic_other_receivable, ic_other_payable |
1150, 2160 Übrige kurzfristige Forderungen und Verbindlichkeiten gegenüber Konzerngesellschaften (generic: 1220, 2020) | accrued interest and declared dividends between group companies; our proposal in groups 11 and 21 |
ic_loan_receivable, ic_loan_payable |
1445 Darlehen an, 2455 Darlehen von Konzerngesellschaften (generic: 1810, 2610) | sub-accounts of 1440 and 2450 Darlehen in groups 14 and 24 |
ic_interest_income, ic_interest_expense, ic_dividend_income |
6952 Zinsertrag, 6902 Zinsaufwand, 6951 Beteiligungsertrag von Konzerngesellschaften (generic: 7010, 7110, 7020) | extensions in groups 690 and 695 |
withholding_tax_payable, withholding_tax_receivable |
2206, 1176 Verrechnungssteuer (generic: 2230, 1410) | the KMU chart's own accounts in groups 22 and 117: the tax a dividend deducts and the recipient's claim (year-end close) |
vendor_withholding_payable |
2207 Einbehaltene Quellensteuer auf Lieferantenrechnungen (generic: 2231) | our extension in group 22 beside 2206: the tax kept back from vendor bills (withholding tax) |
fixed_asset_cost, accumulated_depreciation |
1500 Maschinen und Apparate, 1509 Wertberichtigungen | default pair; 1510/1519 Mobiliar, 1520/1529 Büromaschinen, Informatik, Kommunikationstechnologie, 1530/1539 Fahrzeuge |
6900 Finanzaufwand and 6950 Finanzertrag are plain accounts (interest). The full book is sold by
Verlag SKV and is not public: the veb.ch and SwissAccounting publications list the groups and main
accounts, the sub-accounts above come from charts built on it. Earlier versions of the template had
bank charges at 6840 (in KMU 68 is depreciation), a Finanzertrag at 6900, fx gains and losses at
6940 and 6950, unrealised differences at 6960, rounding at 6990 and one account typed revenue at 7900
for gains and losses on disposal (class 7 is Betrieblicher Nebenerfolg, and a loss was a debit on a
revenue account), and the data-import clearing and suspense accounts at 2998 and 2999, in the equity
range. Entities created from the old template keep their numbers: accounts with postings are never
renumbered, and no migration changes a chart. Their services keep working through the roles; an
entity that has only the old role asset_disposal books gains and losses on it, and
that role counts as both new ones, so POST /v1/entities/{e}/role-accounts and the opening-balance import add nothing for it. 1480
(Aktive latente Ertragssteuern) is in the KMU range of Beteiligungen, and 2650 and 8910 are not
listed by the sources used: B-66.
An asset class has its own cost and accumulated-depreciation accounts. The roles
fixed_asset_cost and accumulated_depreciation name only the default pair. An asset of another
class names both accounts when it is registered (cost_account and accumulated_account: 1520 and
1529 for a laptop, booked on its purchase bill to the same cost account); naming one without the
other, the same account twice or an account that is not an asset is refused. Depreciation,
disposal and the deferred-tax run read the accounts from the asset. A table that maps an asset class
to its accounts (and its depreciation expense account) is B-64.
A gain and a loss on a disposal have their own accounts (roles asset_disposal_gain and
asset_disposal_loss): ausserordentlicher Ertrag and Aufwand in the Swiss chart, other income and
other expense (7510 and 7520) in the generic one, where IFRS has no extraordinary items.
The chart of accounts belongs to the customer: the templates are starting points, and a company can instead import its own (a German company typically the SKR03 or SKR04 chart its Steuerberater keeps) with the accounts import and assign the system roles. The German demo company uses the generic (IFRS) chart in English.
Opening balances and open items
A customer's existing books enter through the data import (ADR-0016). The accounting is built so that nothing is counted twice:
| Step | Posting |
|---|---|
Opening balances (opening_balances), one entry per entity dated as_of, source opening |
Dr/Cr each account of the old trial balance, except the receivables and payables control accounts (their balances are the open items). The difference of the posted rows goes to the account with the role opening_balance_clearing (2298 in the Swiss KMU chart, 2980 in the generic one) |
Open receivable (open_receivables), one posted document and one entry each |
invoice: Dr receivables control (linked to the document) / Cr clearing; credit note: the mirror image |
Open payable (open_payables) |
bill: Dr clearing / Cr payables control (linked to the document); credit note: the mirror image |
With a whole trial balance the clearing account carries receivables - payables after the opening
entry, the open items clear it, and it is zero when the trial balance and the open items
agree. A remainder is a difference between the two lists of the old books and a finding for a
person (the import answer shows the balance). The control accounts equal their subledgers from the
first day (the close checks ar_control_reconciled and ap_control_reconciled pass), the
trial balance of the new books equals the old one, and the close checklist's opening_clearing
check wants the clearing account at zero. VAT is untouched: open items carry no tax lines, and the
VAT report and the return reconciliation exclude opening entries as they do settlements (a VAT
account's opening balance is no movement of a return period), so the old period's VAT return stays
with the old system. A Swiss entity gets a warning with the import of open receivables and payables: the
import assumes the old books accounted for VAT on the agreed fees (vereinbarte Entgelte); on
collected fees (vereinnahmte Entgelte) the VAT on those receivables falls due, and the input tax on the payables becomes deductible, when they are paid (Art. 40 para. 2 MWSTG),
and this system will not report it.
A document keeps its number (a customer's invoice) or its vendor number (a bill, which also gets a
BILL- number of this system), the document and due date of the original and the open gross amount
as a single line; it is posted, immutable, settled by payments and credit notes like any other.
The entry date is the opening date (the period must be open), so the original date stays on the
document only. Foreign-currency items carry the rate of the file (or the ECB rate of the opening
date). The default approval policy sends an opening entry to a second person (rule
opening_balances_need_a_second_person; four eyes between people); a one-person organization allows
post for the source opening in its own policy before it imports, and the import's answer says so.
Open items post at once. Documents created by an import carry provenance.opening (file, line,
opening date): they are not documents this system issued, so output (printing, sending,
e-invoicing) must refuse them.
Vendors imported by the first administrator are drafts: their bills are imported only after a second person has activated them (customer and vendor controls). Fixed assets are not imported: the cost and depreciation accounts of the trial balance post their balances, but the asset register is loaded separately (gap analysis, P2).
A bank account below zero
A payment (POST /v1/payments), a payment run and an intercompany loan's payout, repayment or
interest settlement that takes a bank account below zero in the books is made, with a warning:
warnings in English and warning_reasons by code (bankBalance.warning.overdrawn, with the
account, the entity, the balance before, the balance after, the currency and the day), which the
screens word in the person's language. The money may be in the bank and not booked yet (an overdraft
facility, a receipt not recorded), so it is never refused. The balance is
bank_accounts.book_balance: the account's posted entries up to the day of the payment, in the
currency the account holds (a EUR bill paid from the CHF account counts with its CHF amount), without
the new entries; they count whether they are posted or wait for approval. A run warns once per
account for all its payments. Before the run, the proposal gives each currency's paying account and
its balance (bank_balances), and the screen warns as the bills are ticked; the loan's payout dialog
warns before the payout where the lender's account holds the loan's currency. A receipt never warns.
A manual journal entry cannot post to a bank account (bank accounts are control accounts), so it has
no such warning.
Cash discounts
A cash discount (Skonto) taken on a payment is a reduction of the consideration (CH MWSTG Art. 41, DE UStG §17), so it is booked as a proportional correction of the document's own posting. With the ratio discount / gross total, every revenue or expense line and every VAT line of the invoice's or bill's entry is scaled and booked on the opposite side in the payment's entry, keeping its tax tag:
| Document | Payment entry (2% on 1,081.00 = 1,000.00 + 81.00 VAT) |
|---|---|
| Purchase bill | Dr payables 1,081.00 / Cr bank 1,059.38 / Cr cash discounts received 20.00 / Cr input VAT 1.62 |
| Sales invoice | Dr bank 1,059.38 / Dr cash discounts granted 20.00 / Dr output VAT 1.62 / Cr receivables 1,081.00 |
- Revenue and expense lines go to the accounts with the roles
cash_discount_grantedandcash_discount_received(in both chart templates: 3801 / 4900 in the Swiss SME chart, 4900 / 5900 in the IFRS chart; other charts add them withPOST /v1/entities/{e}/accounts). - Reverse-charge bills correct both sides of the self-assessed VAT; zero-rated and exempt lines reduce their base only. Rounding goes to the largest revenue or expense line, so the lines add up to the discount exactly.
- The tagged lines feed the VAT report and the VAT returns. The Swiss return keeps Ziffer 200
as declared and shows the discount granted as a deduction in Ziffer 235 (Entgeltsminderungen
wie Skonti, Rabatte, Verluste); the turnover per rate (303 to 343, or 323 under the net tax
rate method, gross) falls by it, so the tax is corrected through the rate boxes. The German
UStVA nets the base in Kz 81/86 (§17 UStG), Spain reports it in its correction boxes (LIVA
art. 80; casillas 14/15), France and Italy net the base of the rate (a bad-debt write-off,
unlike a discount, goes to line 21 of the French CA3: see bad debts). The
input tax boxes fall by the corrected VAT. The rules are the line fact
reduction(discountorwrite_off) and the rule outputdeductindata/tax/returns/ch.yaml. The official VAT exchange rate of the document applies to foreign-currency discounts; on the cash basis, where the law converts what is paid at the rate of the payment (DE, ES), the discount and a bad-debt write-off are converted at the rate of their own day, as the part they settle is, so an unpaid invoice written off leaves nothing on the return. Money received after a write-off takes its correction back as a correction that makes the VAT due again (not a reduction): see bad debts. - After a credit note, the discount corrects what is still supplied: the invoice's lines net of its credit notes' lines, scaled by discount / (total less what was credited). Under the net tax rate method the correction stays in the activity of the account it corrects (MWSTG Art. 37).
- The allocation records
amount(paid) anddiscount; the document's open amount falls by both, and its carrying amount in functional currency is relieved completely. - A discount is proportional to what a payment settles (at most the terms' percentage of amount
- discount) and to what is left of the document's discount; the document's row is locked while a payment is booked. The correction is dated on the payment, so it falls in the payment's VAT period; in a period locked by a final VAT return it is refused like any VAT posting.
- Each correction line takes the rate of the document line it corrects (ECB, or the official VAT rate where the entity books at it), so corrected VAT is booked at the rate it was booked with.
Withholding tax on vendor bills
Some laws make the payer keep tax back from what it pays a vendor and pay it to the tax authority
itself: the Italian ritenuta d'acconto on a professional's fee, the Spanish IRPF retención, the
German tax of Paragraph 50a EStG on a royalty paid abroad, the US NRA and backup withholding. A
vendor carries a withholding code of the company country's table (GET /v1/entities/{entity}/withholding-tax/codes; the rate comes with it) or custom with a rate of its
own (a treaty rate, a Swiss cantonal source tax on an artist's fee). Switzerland has none by default.
| Code | Rate | What |
|---|---|---|
DE-50A |
15.825 % | Paragraph 50a EStG: 15 % plus the 5.5 % solidarity surcharge on it; Bundeszentralamt für Steuern |
IT-RA-20, IT-NR-30 |
20 %, 30 % | ritenuta d'acconto on a professional's fee (art. 25 DPR 600/1973), on a non-resident's; F24 code 1040 |
ES-IRPF-15, ES-IRPF-7 |
15 %, 7 % | IRPF on a professional's fee, 7 % in the first three years (Modelo 111) |
ES-IRNR-24, ES-IRNR-19 |
24 %, 19 % | non-resident income tax, 19 % for EU and EEA residents (Modelo 216) |
FR-182B-25 |
25 % | art. 182 B CGI on services used in France paid to a non-resident (form 2494) |
GB-ROY-20 |
20 % | income tax on royalties paid to a non-resident (CT61) |
US-NRA-30, US-BACKUP-24 |
30 %, 24 % | IRC 1441/1442 (Form 1042-S), IRC 3406 (Form 945) |
CA-XIII-25 |
25 % | Part XIII tax on payments to non-residents (NR4) |
The bill keeps it back (SAP's classic withholding at the invoice). When a bill of such a vendor
is drafted, its net amount before VAT times the rate, rounded to the currency, is its
withholding_amount; its total is net plus VAT less it, what the vendor is paid. It posts:
| Italian professional, EUR 1,000.00 + 22 % IVA, ritenuta 20 % | Debit | Credit |
|---|---|---|
| 6110 Consulenze e compensi professionali | 1,000.00 | |
| 2150 IVA a credito | 220.00 | |
| 4300 Debiti verso fornitori | 1,020.00 | |
4431 Erario c/ritenute su compensi da versare (vendor_withholding_payable) |
200.00 |
- Payments, payment proposals and the pain.001 file pay the open amount, 1,020.00; the tax stays on the withholding account until the company pays the authority.
- A credit note of such a bill gives the tax back at the bill's code and rate on the net amount it credits (Dr the withholding account); the credit note that credits the rest of the bill takes the rest of its tax, so the two end at zero to the cent. A credit note of no bill takes the vendor's setting.
- A cash discount reduces the consideration, so it reduces the base and the tax with it, in proportion: on the bill above with 2 % within 10 days, paying 999.60 and taking 20.40 books Dr payables 1,020.00, Cr bank 999.60, Cr cash discount received 20.00, Cr VAT 4.40 and Dr the withholding account 4.00: the ritenuta ends at 196.00, 20 % of 980.00, and the company files and certifies 196.00.
- In a foreign currency every line, the tax included, is converted at the rate the bill books at. The authority is owed the company's own currency at the rate of the day the vendor is paid, so each payment moves the part of the tax it releases from the bill's rate to its own on the withholding account, against realised FX gain or loss; a payment's amount on account applied to the bill later does so at the payment's rate.
- The vendor's code and rate are key details: once the vendor is past draft or a document of it is
posted, a change waits for a second person (
change_party_details). A bill keeps the code and rate it was drafted with. A bill cannot switch the tax off: change the vendor (with a second person) instead. - Paying the authority: the withholding account is not a control account, so a bank line is booked to it (or a journal entry debits it). The report says how much.
The report (GET /v1/entities/{entity}/withholding-tax?start=&end=, Reports → Withholding
tax, CSV with format=csv) lists every posted bill and vendor credit note that kept tax back:
vendor, bill and the vendor's number, net amount, rate, the tax in the document currency and as
booked in the functional currency, the code and whom it is paid to, with totals per code and
currency. basis=bill_date (default) counts the documents dated in the period, and the cash
discounts taken in it (a row cash_discount of the bill, dated the payment day, with the base and
tax it took off), and its functional total is what they booked on the account at the bills' rates;
basis=payment_date counts the part of each bill's tax that payments made in the period released
(its tax times what was paid over what it left to pay, the last payment all that is left, net of
what a cash discount took off), in the functional currency at each payment day's rate, as Italy,
Germany, Spain and the US are owed the tax for the month or quarter the vendor is paid; its base
and tax are the bill's less the cash discounts up to the period's end, the vendor certificate's
figures.
Auto-reversing entries
An entry with auto_reverse_on (an accrual, the FX revaluation) is reversed on that date when
it is posted, directly or on approval, in the same transaction: the reversal swaps debits and
credits, is posted without a second approval and links both ways (reverses_id,
reversed_by_id). It runs once, even if posting hooks run again. The date must be after the
entry date and in an open period (a year-end accrual needs the next fiscal year opened); a
reversal period closed while the entry waits for approval refuses the approval (409) and leaves
it pending. Entries posted before manual entries were auto-reversed
(October 2026) are not reversed retroactively; a person reverses them with
POST /v1/journal-entries/{id}/reverse, "on" and a "reason".
Reversing a manual entry
POST /v1/journal-entries/{id}/reverse is for manual entries and for people: agents are
refused (403, policy action reverse_entry). It needs a reason (kept on the reversal and in the
audit trail) and an open period. Whoever created, posted or approved the entry reverses it alone;
if another person did, or the approval policy limits what one person posts (the policy is asked
for reverse_entry and for post of the same source and amount), or the entry is above the
company's posting approval limit, the answer is 202 with an
approval request of type journal_reversal. A second person approves it (never the requester);
the entry, the period and the policy are checked again at that moment, and the reversal is posted
only then. The opening entry of a data import is reversed too, always after a second person approves (the
default rule opening_reversal_needs_a_second_person, and the service insists whatever a policy says);
it is the correction path of an import: reverse it, then import the balances again. The reversal leaves
the open items and the opening balance clearing account in place, so the close check opening_clearing
fails until a new import (or corrections of the open items) brings the clearing account back to zero.
A request nobody decides blocks the close of the entry's period (the approvals check) until it is
approved or rejected. Entries of a subledger or process (ar, ap, fa, bank, fx, close,
appropriation, vat) are refused with 409 that names where the correction belongs, so that the
documents, payments and assets and the ledger cannot drift apart. Internal reversals (the
auto-reversal) call the posting engine directly after their own policy decision; a test pins the
list of callers.
Credit note defaults
A new credit note copies only the quantities remaining after posted credits and credits waiting for approval. Fully credited lines are omitted, discounts follow the remaining quantity, and a vendor credit takes the remaining printed VAT. The form shows the amount still available; once none remains the invoice's Credit note action is disabled. This allowance is independent of payments: a paid invoice can still be credited. A prior price correction or an unlinked credit can make the remaining quantities ambiguous; enter those credit lines manually instead of copying the original amount. The final posting checks the allowance again.
Foreign currency
- Documents are converted at the ECB fixing for the document date. The document stores its functional total. A credit note of a document is booked at the rate that document is carried at (its functional total over its total; the last credit note takes what is left), so it relieves exactly what the books hold for the part it credits and no exchange difference is left on the control account with nothing open (IAS 21.23, 21.28).
- VAT on foreign-currency documents of Swiss and German entities is converted at the
official rate instead (CH: ESTV monthly average or daily selling rate, MWSTV Art. 45; DE: BMF
monthly average, UStG §16 Abs. 6), stored on the document and its lines next to the booking
rate and used by the VAT returns. The ledger keeps the ECB translation unless the entity
elects to book at the official rate (
tax_fx_bookingin the VAT settings); the difference is a computational difference of the return, cleared with the settlement. See Foreign currency in the API guide. - Payments settle documents at their historical rates. The difference is posted as realised FX gain or loss.
- Revaluation at period end remeasures open foreign-currency items at the closing rate
(IAS 21.23): every posted invoice and bill in a foreign currency, receivable or payable, each
foreign-currency asset account (bank accounts), what is in transit on the payments-in-transit
account in each foreign currency, and each foreign-currency liability account
(loans, accruals, deposits; the receivables and payables control accounts are left to the
documents, and the intercompany loan and balance accounts to the intercompany provider, so
none is remeasured twice). Each family of items is a registered position provider
(
modules/fx_positions/providers, one file each) that the exposure view reads too, so a capability with balances of its own adds one without editing the run, and the view shows what the run will book. The lines carry no transaction-currency amount, only a functional amount. The entry reverses automatically on the first day of the next period.
Fiscal calendars
Each fiscal year is opened with a calendar (POST /v1/entities/{e}/fiscal-years with
calendar, or fiscal_calendar when the entity is created). Fiscal year N is the year whose
year-end month falls in calendar year N; the year-end month is the month before the entity's
fiscal_year_start_month.
calendar |
Periods |
|---|---|
monthly (default) |
Twelve calendar months. Years opened before calendars existed are monthly |
4-4-5, 4-5-4, 5-4-4 |
A 52/53-week year: four 13-week quarters, each split into periods of 4, 4 and 5 weeks (or 4-5-4, 5-4-4) |
13x4 |
Thirteen periods of four weeks |
A week-based year ends on the same weekday every year (end_weekday, default Saturday):
year_end: last_weekday(default): the last such weekday of the year-end month, e.g. the last Saturday of January;year_end: nearest_weekday: the such weekday nearest the last day of the year-end month, up to three days into the next month (the US retail "Saturday nearest 31 January").
A year starts the day after the previous one ends, so it has 364 days, or 371 when the weekday
has drifted a full week (every five or six years). The 53rd week goes to the last period
(period 12 has 6 weeks on 4-4-5, or period 13 has 5 weeks on 13x4). Reports note when a period
and its comparative have different lengths. Changing calendar is possible from one year to the
next: the new year starts the day after the old one ended, and its first (monthly) or last
(week-based) period absorbs the difference. Without calendar, a new year keeps the calendar of
the latest year opened with one. A year's calendar cannot be changed once it is open.
Adjustment periods. adjustment_periods (0 to 2) adds one-day periods on the last day of
the year: periods 13 and 14 on a twelve-period calendar, 14 and 15 on 13x4. They take audit
and closing adjustments after the last regular period is closed. A posting dated the last day
goes to the regular period unless it names the adjustment period
("adjustment_period": 13 on POST /v1/journal-entries). Each adjustment period is closed and
reopened like any other; the close checklist runs on it without depreciation, FX revaluation
and bank checks (those belong to the last regular period), and it counts as later than every
regular period of its year.
Reports by period. reports/period-trial-balance and reports/period-profit-and-loss take
fiscal_year and period and select entries by the period they were posted in, not by date.
On the last day of the year the last regular period gives the trial balance before
adjustments, an adjustment period after them. The income statement has the period, year to
date, and the same period of the previous fiscal year (same number and kind) as comparatives.
Date-based reports (trial-balance?as_of=, profit-and-loss?start=&end=) still work and
include every period on those dates.
Depreciation is spread over a fiscal year's regular periods: evenly per month on a monthly
calendar (as before), by days on a week-based one; adjustment periods get none. Only the months
inside the useful life share in it: from the month in service (half of it under mid_month) to
the end of the life, so an asset in service from July with a five-year life is charged January to
June of its last year and nothing after (IAS 16.55). A disposal stops the charge after its period
without moving the rest of the year's amount into the months before it. A run books what the
year's schedule reaches by the end of its period less what is already posted to the asset in that
fiscal year, never less than zero: a month booked under another spread (before this rule, or by
hand) is caught up in the next run, and the year books its scheduled amount once its last period
has run. Where a later period has already run (a run rejected and made again), the schedule counts
to that period.
Runs go in order. A period's run is refused (409 depreciation_earlier_period, naming the
month) while an earlier open period of the fiscal year still has depreciation to book: its run not
made, or an asset that still owes it. Each month's depreciation is booked in that month, rather
than October booking September with it and September then nothing. A closed earlier period does
not hold the run up: the next open run books what it lacks.
An asset registered late is depreciated in its own months. Once a period has run, an asset
registered afterwards with an in-service date in that period or earlier owes its share: the close
check Depreciation posted fails for the period ("run depreciation again"), the next period's
run is refused until it is done, and running the period again makes a top-up that books only
what is missing (the year's schedule through the period less what is posted), in an entry
DEP-YYYY-NN-T1 (-T2 …) dated at the period end and recorded under the same run
(summary.top_ups). With nothing missing, running a period again answers 409. Because the check
fails, the close job makes the top-up on its day like any run that is due.
Dates and values of an asset. A residual value at or above the cost is refused (422
salvage_not_below_cost), so is an in-service date before the acquisition date
(in_service_before_acquisition). The account an acquisition posted with the asset is credited
to follows the rule for the accounts of documents (entry_checks.document_account): not a
control account (receivables, payables, bank, the asset accounts), not a tax account, neither
revenue nor expense. A disposal is dated on or after the in-service date
(disposal_before_in_service) and not after today where the company is (disposal_in_future).
Reversing an acquisition. An asset registered or posted by mistake, with no depreciation and
no disposal, is taken off the register with Reverse acquisition on its card
(POST /v1/assets/{asset}/reverse-acquisition, a reason required). Its acquisition entry is
reversed like a journal entry: dated on its own date unless another is given, through the
approval policy, and with four eyes when someone else created, posted or approved it. Once the
reversal is posted the asset is reversed: the register shows it with 0, and nothing more is
booked to it. An asset whose cost came with a purchase bill has no entry of its own to reverse:
it leaves the register at once, and the bill is corrected with a credit note. The journal still
refuses to reverse an asset entry directly and points to the card.
A disposal books the asset's depreciation up to the end of its period (what that period's run
would have booked) in the disposal entry itself, then derecognises the cost and all of the
accumulated depreciation, so the gain or loss is against the carrying amount on the disposal date
and nothing stays on the subledger. A disposal dated before periods of its fiscal year that
already ran takes back what they booked after its date in the same entry (IAS 16.55); one dated
before depreciation booked in a later fiscal year is refused (409): reject that entry first, or
date the disposal in the later year. Runs skip an asset whose disposal waits for approval; if the
disposal is rejected, the next run books what the asset missed. A disposal is refused (409) while
a depreciation entry of the asset or an earlier disposal of it waits for approval (decide that
entry first), or while its acquisition entry is not posted. Runs and the year's schedule check
leave out an asset whose acquisition entry waits for approval or was rejected: its cost is not on
the books. A run and a disposal of the same asset at the same time wait
for each other, so the depreciation is booked once: the run locks the entity's active assets
until its transaction ends (for the close review agent with run_adjustments, until the review
ends), and a disposal of one of them waits that long.
Once the year's last regular period has run, the close check Depreciation matches the year's
schedule (depreciation_schedule) compares every active asset's year with its schedule. The
last period's run is due while any asset booked more than its schedule, even with no share of
its own in that period. It is
not a run to do itself (a shortfall also fails Depreciation posted, which the close job runs),
but the year-end close is not proposed while it fails. It fails when:
- an asset is short of its schedule (registered after the run, or a disposal rejected after it),
or booked more than it (by hand, or a schedule changed after its runs): run depreciation for the
last period again. That top-up books only the difference (the excess as Dr accumulated
depreciation / Cr depreciation expense), in an entry
DEP-YYYY-NN-T1(-T2…) recorded under the same run (summary.top_ups). Two top-ups at once book it once: the second answers 409. A run again with nothing to book answers 409. When the period is closed, reopen it first; - an asset's disposal waits for approval and the year reads differently from the schedule: the check names it until the approver decides.
Each year's schedule is booked in the currency's minor unit, rounded so that the years add up: what the schedule reaches by the end of the year, rounded, less what it reached at the start. A schedule at two decimals (333.33, 333.33 and 333.34 for 1,000 over three years) books 333, 334 and 333 yen in JPY, so the life books the full 1,000. In a currency with two or more decimals each year books the schedule's own amount.
Depreciation stops at cost less salvage, rounded down to the currency's minor unit (a cost of 1,000.5 yen depreciates 1,000). An asset that holds exactly that is never short of a year's schedule, since no run can book more: a cost or salvage finer than the currency's minor unit (1,000.5 yen, or a salvage of 0.005 BHD under a two-decimal schedule).
The schedule is at two decimals whatever the currency, so a BHD or KWD cost with a third decimal (1,000.001) never schedules its last fils.
Run deferred tax after a top-up, since it reads the ledger's accumulated depreciation.
Book and tax depreciation
A new asset takes your own asset tag as its code, or, left out, the company's next asset number
(FA-00001, FA-00002, ..., counted without gaps; src/erp/domain/master_numbers.py).
An asset is depreciated in the accounting book (BOOK: its own schedule under the
entity's framework, IFRS or the Swiss CO; the only book that posts to the ledger) and in any
number of tax books of its entity (POST /v1/entities/{e}/depreciation-books, e.g.
TAX-CH for ESTV Merkblatt A 1995 declining-balance rates, TAX-DE for the AfA tables,
TAX-US for MACRS; without a code, one is made from the book's name, and the screen names a new
book after the tax rules chosen, Tax book Germany (AfA)). A tax book's schedule for an asset comes from the data-vNext depreciation
engine for a data-vNext asset class (ch.estv.it_hardware_software, us.macrs.gds.5y …) and is
stored with its dataset version. A reducing balance (ESTV) has no end of its own, so its schedule
ends with the asset's accounting schedule: in that last year the asset is scrapped and what is
left of its tax value is written off. A pool (UK capital allowances) keeps it, and so does a CH
class with land (only the building is depreciated). The end of the accounting life is a projection
of the disposal, not the disposal itself: an asset kept in use after its book value reaches zero is
still on hand, and the schedule has then deducted the rest a little early. Where that matters
(a long-lived asset kept well beyond its life), give the tax book periods instead. Tax books post
nothing.
Deferred tax (IAS 12). One tax book per entity (a unique index) can carry deferred tax with
a tax_rate_percent. At a period end, per asset:
- carrying amount = cost - accumulated depreciation in the ledger;
- tax base = cost - the tax schedule's depreciation to that period (earlier years in full, the current year spread over its periods like book depreciation);
- temporary difference = carrying amount - tax base; deferred tax = difference x rate.
The book's total is shown net (IAS 12.74, one taxation authority): positive is a deferred tax
liability, negative a deferred tax asset. POST /v1/periods/{id}/deferred-tax posts the change
since the last run as an ordinary entry through the posting engine and approval policy:
Cr deferred tax liability / Dr deferred tax asset against Dr deferred tax expense (or Cr income
when it falls). Lines on the liability and asset accounts carry subledger_type=deferred_tax and
the book's id, so the next run knows what is already posted (as of its own period end). Periods
run in order: a run is refused while the latest earlier period with deferred tax due (at year
end only, the previous year end) is open and not run. A change of tax rate
(PATCH …/depreciation-books/{code}) is remeasured by the next run. The run refuses to go
before the period's depreciation. Accounts come from the roles deferred_tax_liability,
deferred_tax_asset and deferred_tax_expense (in both chart templates) or are named on the
book.
Period close
GET /v1/periods/{id}/close-checklist reports each check as pass, fail, warn or n/a:
- no pending approvals and no drafts;
- depreciation run;
- FX revaluation run (or nothing foreign to revalue);
- deferred tax run, when a tax book carries deferred tax: every period (
deferred_tax_cadence: period) or only the last regular period of the year (year_end, the default); - AR and AP subledgers equal their control accounts;
- every bank statement line reconciled, and statement closing balances equal the ledger (the
account's balance in the currency it holds: on an account in the functional currency the
functional amounts, so a EUR payment from the CHF account counts with what it booked in CHF);
the bank account holds only what the bank executed, outgoing payments waiting on
payments_in_transituntil their bank line is matched; - no outgoing payment waits more than 10 days for the bank to execute it (
payments_in_transit, a warning naming the payments, the first day and the amounts); - trial balance and balance sheet balance;
- hash chain intact;
- earlier periods closed (a warning, not a failure); the periods of a year whose year-end close is posted count as closed, since the year lock holds them;
- on the last period of a fiscal year, the year-end closing entry posted (
year_close, a warning); - a closed year whose general meeting is overdue is appropriated (
appropriation_pending, a warning). Both are described in year-end close; - the organization's own close tasks of the period are done (
close_tasks, a warning naming the tasks not done, never a failure: see close tracker).
The screen's summary says "No check fails: the period can be closed" only when nothing warns either; with a warning it says how many checks warn, and the period can still be closed.
POST /v1/periods/{id}/close locks the period only when no check fails. It needs a human
key with the approve scope. Reopening follows the same rule and needs a reason
(POST /v1/periods/{id}/reopen with {"reason": "…"}), kept in the period.reopen audit event.
A period of a fiscal year that a year-end close holds is reopened only by reopening the year close
(409 otherwise): see year-end close.
Who approves: the AI reviewer or a second person
Settings → Approval policy → Who approves sets who decides a step that would wait for a second
person (ADR-0101; PUT /v1/settings/controls, an
administrator, recorded as controls.change):
- The AI reviewer (the default), for a company run alone or with a small team. Fixed checks
decide each step. It clears what they pass: a new customer or vendor (duplicates, own bank
accounts, the VAT register), and the entry of a period-end run that Autopilot or the close job
makes (depreciation, the FX revaluation, deferred tax), when it is the run's own entry,
unchanged since and booked only once; the reviewer posts it as
system:reviewer. Everything else that would wait (an agent's entry, an entry above the posting limit, a step it has no checks for yet) waits under Approvals, its review in the audit trail. Where the person who asked, or whose agent asked, is the only person who may approve, they release it themselves on the screen with a passkey touch in the last five minutes (review.release); nobody outside the company is asked. In a company with more people, another person decides. - A second person (four eyes), for a company whose auditor or law requires it (a Swiss ordinary audit, an SEC registrant): another person of the company decides, never the one who asked.
Switching to the AI reviewer decides at once what was waiting for a second person, and every deploy does the same for requests asked before the reviewer had checks for their step.
Approval policy
The policy is a data-vNext decision table (src/erp/seed/policies/default.yaml). A human admin
can replace it with the organization's own table (PUT /v1/approval-policy; every change is a
version and audited, and agents stay denied everything but posting; see
API). The default, evaluated first match wins:
- Agents never approve, close or reopen periods, manage keys, release payment files (pain.001), or finalise or amend VAT returns.
- Agents never activate customers or vendors or change their bank or key details.
- Agents cannot change the chart of accounts.
- Manual journal entries created by agents always need approval.
- Agent postings above 5,000 (functional currency) need approval.
- Other agent postings (routine subledger documents) are allowed.
- System processes are allowed (demo data and migrations create active vendors).
- Segregation of duties: the person who created a payment does not release it
(
release_own_payments), and the person who prepared a VAT return does not finalise it (finalize_own_vat_return). An organization without a second person can allow either. - A person's new vendor waits for a second person (
activate_party, sourcevendor). - A purchase order is approved by a second person (
approve_purchase_order); agents never approve purchase orders or invoice variances (approve_match_variance) nor record receipts (record_receipt). - Segregation of duties between people of the depth series (ADR-0023 to ADR-0029): the person who
posted the origin of an intercompany document does not accept its mirror
(
accept_own_ic_document), and the person who prepared a consolidation does not finalise it (finalize_own_consolidation); both are refused by default. - A second person approves the terms of an intercompany relationship (built, guide) or agreement, the group
structure, a revenue contract and a recurring invoice (
approve_ic_relationship,approve_ic_agreement,approve_group_structure,activate_revenue_contract,activate_recurring_invoice:require_approval), a change of the account that holds a control or required role (change_required_account_role), a new bank account of the company after its first or a new IBAN or QR-IBAN (change_company_bank_details, ADR-0043), raising or removing a company's posting approval limit (change_posting_limit), and the year-end entries: a posting from the sourceclose(the closing entry and the catch-up) orappropriationwaits for a second person (close_entries_need_a_second_person). Agents are denied all of these actions, andapprove_consolidation_adjustment,finalize_consolidation,satisfy_obligation,release_dunning,close_fiscal_year,send_support_reply,close_support_case,record_quote_answeranderase_personal_dataare human-only too. - Humans with the required scope are allowed.
Changing the policy needs a second administrator when it relaxes it. The policy decides what
needs a second person, so one administrator alone must not be able to switch that off (for
example allow change_posting_limit, remove the limit, post CHF 5,405,000.00 alone and restore
both; or do the same with bank details or account roles). A change, from Settings > Approval
Policy or PUT / DELETE /v1/approval-policy, is compared with the policy in force over every
actor type (human, agent, system), action, amount, source and actor name the two tables tell
apart (policy.relaxations). If it relaxes any decision (deny to require_approval or allow,
or require_approval to allow), it is an approval request (action change_approval_policy,
audited as approval_policy.request) that a second person with the admin scope approves under
Approvals; the person who asked never does, and agents never do. Until then the policy in force
holds, and the request shows on the settings page and in pending_change with what it relaxes.
One such change waits at a time (409 approval_policy_change_waiting) until it is decided or the
person who asked withdraws it (below); approving one made on a
version that has been replaced since is refused (409 approval_policy_changed): reject it and
ask again. A return to the default table counts the same way: where the default is looser than
the organization's table, it waits. A change that only tightens applies at once. An organization
with fewer than two active people (signed-in users; keys and agents are not people) applies every
change at once, since nobody could approve it, unless another user was disabled or lost the
admin scope in the last 30 days (else an admin could do that first and relax the policy alone);
the audit event approval_policy.update lists what
it relaxed, and, after an approval, who asked and the request.
The person who asked withdraws her own request. A request to raise or remove a posting
approval limit or to relax the policy that nobody else may decide (an administrator whose
colleagues are controllers, accountants or viewers) would otherwise wait forever and block every
later one. Four eyes is about approving: taking back one's own request relaxes nothing, so the
person who asked withdraws it with Withdraw on the settings page (the posting approval limit
on Settings > Company, the waiting change on Settings > Approval Policy) or
POST /v1/approvals/{approval_id}/withdraw (admin scope). Nobody withdraws for someone else
(403 approval_withdraw_own_only, also for someone without the admin scope; a second person
rejects instead), the person who asked needs the admin scope still (403
approval_withdraw_admin_only), agents never do, and other requests are not withdrawn this way
(409 approval_not_withdrawable). The request becomes rejected, decided by the person who
asked, with the comment withdrawn, and is audited as approval.withdrawn; the limit or policy
in force stays, and a new request may be asked. When she is alone in the organization, her next
change of the same kind applies at once and withdraws her own older request the same way, audited
with alone (a request someone else made stays for a second person to reject). A change of the
posting approval limit that applies at once because it lowers the limit or the policy allows it
withdraws her own older limit request too (without alone). The API shows a
withdrawn request with withdrawn true, and Approvals with a Withdrawn badge under
Rejected, not as a rejection by the person who asked: withdrawn is derived in one place
(withdrawn_requests.shown) from the status, the comment and the person who decided (whichever
key or session), so a second person who writes "withdrawn" as her reason still shows as a
rejection.
A company one person runs
One's own request always needs a second person: nobody approves what they asked for, created or
prepared, through any key, session or agent acting for them, and no setting changes that
(ADR-0096). A screen never offers
Approve on one's own request: it says "You asked for this or prepared it: another person decides".
When nobody else in the organization may approve yet (no other active or invited user with the
approve right), the refusal says so (four_eyes with nobody_else: true; GET /v1/me gives
other_approvers), and the refusal, Approvals and Users offer Invite a person to approve,
which opens the invitation with the Controller role, for the company's accountant or another
person the administrator trusts.
Giving a person the right to approve (inviting a Controller or Administrator, or raising someone
to one of those roles) is itself an audited act
(ADR-0098). The administrator says who the person
is: full name, relationship to the company (external accountant, board member, partner, employee
or other) and their firm, and confirms that it is a different real person and that the
declaration is recorded for the auditor (approver on POST /v1/users and PATCH /v1/users/{user_id}; without it approver_declaration_missing). A second account of the same
person is refused (approver_same_person): the administrator's own mailbox with a +tag or dots,
or the administrator's own name. The person named confirms once, at sign-in, that their approvals
carry their name and are recorded (POST /v1/me/approver-acknowledgement); until then they decide
nothing (approver_acknowledgement_needed). Settings → Approver register
(GET /v1/approvers, for administrators and auditors) lists everyone who may approve, what was
declared, by whom and when, when they confirmed, and every change of their access.
Posting approval limit
Each company has a posting approval limit in its functional currency. A new company starts at
about CHF 100,000 in its currency, as a round figure: 100,000 CHF, EUR, USD, GBP or CAD;
1,000,000 SEK or NOK; 750,000 DKK; 2,500,000 CZK; 40,000,000 HUF; 450,000 PLN; 500,000 RON;
15,000,000 JPY (the table is DEFAULTS in erp.modules.posting_limit; a currency it does not name
starts at 100,000, which is never more than about CHF 100,000). The companies that existed in
October 2026 got the same figure for their currency. A test copy keeps each company's limit.
When a person submits an entry whose functional amount (its total debits) is above the limit, the entry does not post: it waits for a second person, whatever the approval policy would allow. That is everything a person posts through the ledger: manual journals; bills, invoices and credit notes; payments, receipts, refunds and applications; bank postings; intercompany postings; asset acquisitions and disposals; and the runs a person starts: the VAT settlement, the FX revaluation, depreciation, deferred tax, revenue recognition and the year-end close (where the policy already asks a second person, the request names both reasons). A document in another currency is compared after conversion at the rate it is booked at (a EUR 200,000.00 bill at 0.9438 is CHF 188,760.00). The approval request says why, for example Four eyes: CHF 5,405,000.00 is above the company's posting approval limit of CHF 100,000.00; a second person approves it. A second person with the approve scope approves it under Approvals, and it posts then; the person who asked never approves it. Reversing an entry above the limit waits for a second person too. An entry at the limit (100,000.00) posts directly.
Agents keep the approval policy's own limits (rule 5 below), and the system's processes (the scheduled close job, imports the system runs) their own rules: the limit holds people. A posting that an earlier approval already named (the credit notes of a subscription's early end) is not asked again.
The limit is on Settings > Company, where everyone sees it and a request to change it that
waits. Only a person with the admin scope changes it (PATCH /v1/entities/{entity} with
posting_approval_limit; 403 posting_limit_admin_only otherwise, agents always):
- Lowering it (or setting one where there was none) applies at once.
- Raising or removing it (null: no limit, meant for a one-person company; the screen asks
first) is the policy action
change_posting_limit: by default it waits for a second person, who approves it under Approvals; the person who asked never does, and agents are denied it. Until then the old limit holds, and one request per company waits at a time (409posting_limit_change_waiting) until it is decided or the person who asked withdraws it (above).GET /v1/entities/{entity}shows it inposting_limit_request. If the limit was lowered meanwhile, approving is refused (409posting_limit_changed): reject or withdraw it and ask again. - A person alone in the organization changes it at once, with the default policy: one active
person (signed-in users; keys and agents are not people) and no other user disabled or stripped
of the admin scope in the last 30 days (
people.alone, the same test as for the policy). A one-person company removes its limit with No limit and nothing waits. An organization that wants it without a second person anyway allowschange_posting_limitin its approval policy (with more than one active person, that change waits for a second administrator like any change that relaxes the policy).
Each change is audited as entity.posting_limit with the value before and after (and, after an
approval, who asked and the request; alone when it applied without a second person because
nobody else was there); each request as entity.posting_limit_request.
Approvals apply the four-eyes rule: nobody approves their own request, nor a journal entry
they drafted. A key without the post scope can still submit, and its work always waits for
approval.
Four eyes compare people, not credentials (erp.modules.people, ADR-0011). A user is a
person; a built-in agent counts as the person it ran for; an API key counts as the person who
created it (its api_key.create audit event). One person with two keys, or a key and a browser
session, is one person. A key the system issued (an organization's first admin key, and every
key created with it) belongs to no known person: it may submit and request, but never approves,
releases payments or finalises VAT returns. The same rule decides approvals of journal entries,
customers and vendors, payment release (who created the payment) and VAT finalisation (who
created or last computed the return).
Customer and vendor controls
Numbers. A new customer or vendor gets the company's next number by itself: C-00001,
C-00002, ... for customers and V-00001, ... for vendors, counted per company and kind
(src/erp/domain/master_numbers.py). Nobody types one, on the screen, over the API or MCP, from
a bill, an e-invoice, a converted lead or an import. A creation that fails gives its number back,
so the numbers follow one another without gaps. Only an import (or an API call) that brings the
number of the system before keeps it; the count skips a number someone wrote that way. A number
never changes.
A customer or vendor is draft, active, blocked or inactive. The controls
(src/erp/modules/party_controls.py, party_rules.py, ADR-0010, ADR-0046) keep an unreviewed
or blocked party, or an IBAN nobody checked, away from the ledger and the bank.
The New customer and New vendor forms accept the street, building number, postal code and town
at creation. Enter them there to avoid completing the address separately when issuing the first
invoice. An address is optional at creation; the invoice output checks its required content.
An activation cleared by the AI reviewer names system:reviewer as the activator;
the person who requested it remains recorded separately.
What the database enforces (migration 0016): a trade document is never inserted or updated
as posted while its party is not active; a payment-file item is never inserted for a payment
to a party that is not active or whose bank details wait for approval; at most one pending
request of each kind per party.
What the services enforce (the database does not yet, backlog B-31):
| Control | How |
|---|---|
| Drafts are not booked or paid | Submitting or posting a draft party's document answers 409; its payments are not released |
| Who starts as a draft | Every vendor a person creates (default policy: activate_party for vendors is require_approval; an organization can allow it), with an activation request; vendors created from an e-invoice; vendors, and parties with bank details, created by agents. E-invoice import and bill capture keep such a bill as a draft with a warning |
| Activation: four eyes | A human with the approve scope who is not the person who created the party activates it (POST …/parties/{party}/activate). Anyone else, including the creator, only requests it, and a second person decides in Approvals or on the card; the approver can be neither the requester nor the creator |
| Activation: the AI reviewer (ADR-0101) | In the ai_reviewer controls mode (the default) the AI reviewer decides an activation at once: when its checks (possible duplicates, a company's own bank account, the VAT register) pass it activates the party as system:reviewer; when one warns, the request waits with the reviewer's reasons for another person, or for the owner who runs the company alone to release it with a fresh passkey touch. Requests that were already waiting for a second person are decided the same way when an administrator switches to the AI reviewer, and by the migrate job of every deploy |
| Deactivation | Back to draft (humans only, policy action activate_party) blocks the party's documents and payments again |
| Block (ADR-0046) | A blocked party gets no new documents (credit notes included), purchase orders, subscriptions, recurring invoices, contracts or opportunities, its drafts are not submitted or posted, and nothing is paid to it: payment proposals leave its bills out (skipped), payments to it are refused when created and approved, and it is a payment blocker. What it pays still settles its open items. While a block request waits, payments to it are held |
| Inactive (ADR-0046) | An inactive party is refused like a blocked one, incoming payments too, and hidden from pickers. Only a party with no open items, no documents or payments waiting for approval, no open purchase orders and no active subscriptions or recurring invoices becomes inactive (checked again on approval) |
| Status changes: four eyes | Blocking, unblocking, setting inactive and making active again are requests (change_party_status, POST …/parties/{party}/status, with a reason) a second person decides under the change_party_details policy; activation and PATCH status cannot move a blocked or inactive party. Audit: vendor.status_change.request and the decision (customer.* for customers) |
| Bank details: four eyes | A new IBAN or further bank account is a request (change_bank_details) a second person decides; while it waits, payments to the party are blocked |
| Key details: four eyes | Once the party is past draft (active, blocked or inactive) or a document of it is posted or pending approval, a new name, VAT number, country, subdivision, currency, payment terms, cash discount, credit limit or a vendor's withholding tax is a request (change_party_details) a second person decides; the card shows the values now and after approval. A draft changes at once, since its activation needs a second person anyway. Other fields (address, contacts, language, e-invoicing) change at once |
| Credit limit | See Customer credit limits below: an invoice above a customer's limit waits for a second person, a sales order above it is not confirmed |
| Currency | A party's currency is the company's functional currency or one the European Central Bank publishes rates for (422 party_currency_without_rates otherwise, e.g. for XXX), on create and on change. Left out, it is its country's when the company has rates for it, else the company's (what the system fills in) |
| Our own bank accounts | An IBAN or further bank account of a customer or vendor that is a bank account or QR-IBAN of any company of the organization is refused (422 party_own_bank_account: "CH93 0076 2011 6238 5295 7 is your own company's bank account, not the vendor's"), on create, on a bank-detail request and again when it is approved. Group parties are not checked: their accounts are the other entity's |
| Possible duplicates | Another party of the same kind and company with the same VAT number (compared as a registration), an IBAN in common or the same name (case, punctuation and legal forms such as AG or GmbH ignored) is named on the card, in the answer that creates the party (possible_duplicates) and in its activation request, for the approver: "Possible duplicate of V-ALP Alpen Werkzeug AG: same VAT number and IBAN". It is a warning; the second person decides |
| Bills recorded twice | A vendor invoice number is recorded once per supplier: case, spaces and punctuation are ignored, and vendors with the same VAT number or an IBAN in common count as one supplier (409 vendor_invoice_twice_supplier: "vendor invoice alp 1001 was recorded again (BILL-2026-000003 of V-ALP Alpen Werkzeug AG, the same supplier by VAT number or IBAN); void one of them", when the draft is created, submitted or reopened). A bill of the same vendor, total, currency and date as another bill posted or waiting for approval (another number, or none) does not post directly: its posting waits for a second person with the reason "possible duplicate of BILL-2026-000012: same vendor, amount 777.00 EUR and date 2026-09-24" |
| Pay only confirmed accounts | A payment is released only to the party's IBAN or one of its further bank accounts, so a bill (for example an e-invoice from someone posing as a known vendor) cannot redirect it |
| People, not credentials | Four eyes compare the person behind an actor: a user; a built-in agent counts as its caller; an API key as the person who created it (its api_key.create audit event). One person with two keys, or a key and a session, is one person. A key the system issued (an organization's first admin key) belongs to no known person: it may request but never activates or decides |
| Agents | Never activate, deactivate, change bank or key details, or decide, whatever an organization's policy says |
| Audit trail | vendor.create (the creator the checks use), vendor.update with the previous and new values of every changed field, approval.request, vendor.activate, vendor.deactivate, vendor.bank_change.request / .approve / .reject, vendor.details_change.*, approval.approved / .rejected (customer.* for customers) |
Who created and who activated a party is read from the append-only audit trail, not from a column the runtime could change. Parties that existed before these controls keep their status; a bill of an existing vendor whose payment IBAN (for example a QR-IBAN) is not in the vendor's bank accounts is not released until the account is added and confirmed.
Residual risks: people are only as distinct as their sign-ins. An administrator can invite a second user at an address they control, and the holder of the first admin key can request a change that their own user session then approves. A customer created by a person (with an IBAN) is active at once.
Customer credit limits
A customer may have a credit limit in its own currency (credit_limit on the customer card and
in the parties API; empty for none; src/erp/features/invoicing/credit_limits.py, migration 0114). Setting
it on a new customer applies at once; changing or removing it once the customer is past draft or has a
document posted or waiting for approval is a key detail a second person approves.
Credit exposure is what the customer would owe if everything under way went through, in its currency:
| Part | What counts |
|---|---|
Owed now (receivables) |
Open posted invoices less open credit notes and payments kept on account: the balance its card shows |
Invoices awaiting approval (pending_invoices) |
The full total of each invoice waiting for a second person |
Open orders (open_orders) |
What is left to invoice on confirmed sales orders, VAT included. A draft invoice made from an order takes its part out of the order; any other draft invoice counts once it is submitted |
An amount in another currency is converted at today's ECB reference rate, today being the day where the company is. Available credit is the limit less the exposure (negative above the limit).
The checks. When an invoice is submitted and the exposure plus the invoice's total is above the
limit, the invoice does not post: it waits for a second person (the submit check credit_limit,
through the ordinary approval of the posting), with the reason in plain words, for example "Credit
limit: Stockholm Design AB would owe SEK 128,000.00, above its credit limit of SEK 100,000.00; a
second person approves." The approver sees the limit and what would be owed in the request. Exactly
at the limit still posts. When today's rate is missing for an amount in another currency, the
invoice waits for a second person too, saying so. A sales order has no approval of its own, so
confirming one (or accepting a quote into one) above the limit is refused (409 credit_limit_exceeded, naming what would be owed and the limit); the order stays a draft until
payments come in or a second person raises the limit. Credit notes and receipts are never held;
they lower the exposure.
Both checks lock the customer's row (SELECT ... FOR NO KEY UPDATE) before they add up the
exposure, so two invoices of one customer submitted at the same moment are checked one after the
other: the second sees the first and waits for a second person when together they go above the
limit, instead of both passing on room that was there for one.
Where to see it. The customer card's Credit limit tab shows the limit, each part of the
exposure, the total and the credit available, with "Over the limit" when it is. The Credit
exposure screen (Sales, GET /v1/entities/{entity}/credit-exposure) lists every customer with a
limit, those over it first, and downloads the same as CSV (format=csv; columns customer,
name, currency, credit_limit, receivables, pending_invoices, open_orders, exposure,
available, over_limit, note). The organization export carries the limit as
party.credit_limit.
Purchase orders and matching
Purchase orders, receipts and matching (the purchasing module, features/purchasing/: orders,
matching, variances, status, rules in rules; ADR-0013) post nothing. An order is a
commitment, not a liability, and a receipt of goods or services is not booked either: the
liability is the vendor bill, posted as before. Accruing goods received but not invoiced
(GR/IR) at period end is a later step.
What they control is paying a bill: a bill whose order reference names a purchase order and that varies from it beyond the entity's tolerance is blocked for payment until a second person approves the variance. See API for the rules.
What the database enforces (migration 0018): an order's lines change only while it is a
draft, and once submitted only its status, approval and cancel reason change; a cancelled order
does not change; closing an approved order for the rest (migration 0062, ADR-0046) sets each
line's closed quantity once and the order's close reason, after which it does not change, and
receipts stay within the ordered quantity less what was closed; an order is approved only while its vendor is active and only with an approver
recorded; receipts are append-only and keep each line's received quantity between zero and the
ordered quantity, on approved orders only; purchase_match.payment_block is derived (a variance
not approved), an approval is stored with its digest and amount, and no allocation (a payment,
money on account or a credit note, migration 0107) and no payment-file item is accepted for a
blocked bill; the tolerances stay within 10 %, 1,000 per
line and 10,000 per bill. Bank reconciliation does not propose blocked bills, and matching a
bank line to one is refused (409), the line staying unmatched.
What the services enforce:
| Control | How |
|---|---|
| Orders need a second person | Submitting asks the policy (approve_purchase_order); by default it waits for a person who neither created nor submitted the order. Agents submit but never approve |
| Matching | On submitting a bill and after each receipt; deterministic, 2-way against ordered and 3-way against received quantities, earlier bills counting first |
| Payment block | Payment proposals leave blocked bills out; a payment, money on account or a credit note applied to one and the payment-file release refuse it with the same words, all through purchase_port.ensure_payable (bill_payment_blocked); a credit note of the bill posts but does not settle it (hooks.settlement_held): the credit stays open, with a warning, to apply once the variance is approved |
| Credit notes lower what an order invoiced | A posted credit note of a bill matched to an order, on a line that names the bill line it credits, lowers that order line's invoiced amount, and its invoiced quantity when the line is at the bill line's unit price (goods returned, or the line credited as it was; a price reduction gives back no quantity, so it never makes room for more units than ordered); order card, matching of later bills, the order's status |
| Variance approval: four eyes, with a reason | approve_match_variance; not a person who entered, changed, submitted or matched the bill or requested the approval; a key the system issued never decides; the reason is kept on the match and in the audit trail |
| An approval is bound to what was approved | The digest of the bill and its order and the overcharge approved are stored; a changed draft or a reopened bill clears the approval (purchase_match.approval_cleared), a larger overcharge at the next match, and the bill is blocked until approved again |
| Pairing evidence | Similarity and runner-up per line; a pairing by price or position with a different description needs approval |
| Closing the rest | An approved order partly received or invoiced is closed for the rest with a reason (no second person, as cancelling: it only lowers the commitment). Each line keeps what was received or invoiced, the larger; the rest is closed and no longer expected, so 2-way matching counts the ordered quantity less what was closed and 3-way the received quantity, which receipts keep within it. Bills are matched again |
| Receipts | People only (record_receipt); every unpaid bill of the order is matched again; a paid bill a receipt would block is flagged for review on its match (order card, order list filter) until the receipts no longer block it |
| Audit trail | purchase_order.create / .update / .approve / .reject / .cancel / .close / .receive / .status, approval.* on the order; purchase_match.match, approval.request, approval.withdraw, purchase_match.variance_approved / _rejected on the bill |