StabisoDocs

REST API

API behaviour and style guide

The API reference is in docs/api: getting started, conventions (authentication, idempotency keys, ETag and If-Match, the error envelope, decimals and dates, approvals and four eyes, limits, tenancy), end-to-end workflows whose examples run in CI, and the generated reference of every operation (parameters, schemas, scopes, examples), built from the code. The live OpenAPI document is at $ERP_URL/docs.

This page does not repeat any of that. It holds what is left:

  • the style guide every new endpoint follows, and how REST parity and the documentation checks are enforced;
  • the behaviour notes of the features, below: rules that span several endpoints and that a reference of single operations cannot give (what the approval policy accepts, how customers and vendors are controlled, how VAT returns move through their states, and so on).

Guides of single areas (rules that belong to one capability, linked here in one line each): account roles: who holds the account a service posts to, and the controls on changing it. year-end close: closing a fiscal year, the lock, reopening, appropriating the result and the catch-up. customer statements and dunning: a customer's open items on a day, tied to the ledger, as JSON, CSV or PDF, archived with its hash. recurring invoices: templates of invoices that repeat, their approval by a second person and the on-demand run. recurring journals: journal entries that repeat and accruals that reverse in the next period, activated by a person and posted on demand through the approval policy. consolidation: groups, member versions and their approval, translation to the reporting currency, the intragroup eliminations, the group statements and the group close checklist. intercompany: approved relationships between entities, the draft bill an invoice mirrors in the counterpart, its acceptance, the VAT group and the reconciliation. plans: budgets and forecasts, versions a person approves, their lines, the forecast roll from actuals and the comparison with what was posted or with another version. close tracker: the close calendar, the organization's own month-end tasks and where a period's close stands. US sales tax: a US company's registrations by state, local rates and tax areas, exempt customers and products, and the return per state, its filing and payment. close automation: the scheduled close job, the steps an entity lets it run and its log. CRM: the pipeline of deals with customers and prospects, their stages, the timeline of activities and the tasks. Products: what the entities sell, their list prices and standalone selling prices, and the product on an invoice line. Price lists: customer prices per currency in volume tiers, a customer's list per currency and the customer price a sales line proposes. Stock: warehouses, stock items, adjustments, transfers, counts and the valuation at moving average tied to the inventory accounts. Goods received: purchase order lines for stock items, receipts against the GR/IR account, the bill that clears it and the GR/IR open items tied to the ledger. Delivery notes: goods shipped to customers at moving average cost, invoicing them, goods returned with a credit note and what is delivered but not yet invoiced. Revenue contracts: IFRS 15 contracts, their obligations, the allocation by SSP, the recognition schedule and its approval. Subscriptions: a committed term of recurring products, its approval by a second person, the term's revenue contract and the billing run. Leases: a lessee's leases under IFRS 16, their schedule, activation, the period's lease entries, a change of payments and the early end. External auditors: read-only access that ends on a date, the auditor's AI connected over MCP, and the log of everything they opened.

REST is the product. Every ERP function is a REST operation. The web UI, the MCP tools and the built-in agents only do what those operations do, and CI checks it (see REST parity).

Organization and API keys

GET /v1/organization (read scope) returns the organization of the credential and whether the default approval policy or its own applies. PATCH /v1/organization with {"name": "…"} renames it; it needs a human key or user with the admin scope and is audited as org.update. Creating and suspending organizations is the platform operator's task, with a platform key on /v1/platform (see Platform); deleting one waits for the retention rules (see tenancy).

GET /v1/api-keys (admin scope; ?status=active|revoked) lists the organization's keys, and GET /v1/api-keys/{id} returns one: name, prefix, actor_type, scopes, created_at, last_used_at, revoked_at, status and bound_to, the person the key acts for (user:<id>; null for the organization's first key and every key created with it, which belong to no person and so never approve; ADR-0017). POST /v1/api-keys returns bound_to too, and so does GET /v1/me for any credential: a headless client can check before it tries to approve. The secret and its hash are never returned. The prefix is the middle part of the key (erp_<prefix>_…) and names the key in the audit trail (key:<prefix>).

POST /v1/api-keys issues keys for a human or an agent only (422 otherwise). System actors, which the approval policy trusts, exist only inside the server: the CLI, the organization's bootstrap and demo data. Release note: an organization that still has an API key of type system from before this rule should revoke it (DELETE /v1/api-keys/{id}); an operator checks production after the release.

Approval policy

The approval policy decides, for every posting and control action, whether the actor may do it directly, needs a second person, or is refused (see ledger). It is a decision table over four inputs and an optional fifth:

  • actor_type: human, agent or system (the server's own processes; never an API key);
  • action: post, approve, close_period, reopen_period, manage_accounts, manage_keys, release_payments, finalize_vat_return, activate_party, change_bank_details, change_party_details, release_own_payments, finalize_own_vat_return, send_document (sending an invoice or credit note to a customer, source = the document kind, denied to agents), reverse_entry (reversing a posted entry; the source and functional amount are the original entry's, denied to agents, and only manual entries are reversed at all, an entry another person made needing a second person: see ledger) or one of the thirteen actions of the depth series, all human-only: approve_ic_relationship, approve_ic_agreement, accept_own_ic_document, approve_group_structure, approve_consolidation_adjustment, finalize_consolidation, finalize_own_consolidation, activate_revenue_contract, satisfy_obligation, activate_recurring_invoice, release_dunning, close_fiscal_year and change_required_account_role (a change of the account that has a control or required role). change_company_bank_details (human-only, default require_approval) is a new bank account of a company after its first, or a new IBAN or QR-IBAN of one (ADR-0043). change_posting_limit (human-only, default require_approval) raises or removes a company's posting approval limit; lowering it applies at once, and so does any change by a person alone in the organization; the person who asked withdraws a waiting request with POST /v1/approvals/{id}/withdraw (see ledger). send_support_reply, close_support_case, record_quote_answer and erase_personal_data (human-only, default allow for humans, denied to agents by agents_no_support_or_crm_controls) govern sending or recording a support reply, closing or reopening a support case, recording a customer's quote answer, and erasing personal data of a contact or lead. release_own_payments, finalize_own_vat_return and the other *_own_* actions are segregation-of-duties questions (may a person release a payment they created, or finalise a VAT return they prepared): the default denies them, anything but allow refuses;
  • amount: total debits in functional currency;
  • source: for postings manual, ar, ap, fa, bank, fx, deferred_tax (the deferred-tax run), close (the year-end closing entry), appropriation (a resolution on the result), ic (intercompany), rev (revenue recognition), opening or vat; for the customer and vendor actions customer or vendor;
  • actor_name (optional, passed only to a table that declares it, never required): a system process's name (close for the scheduled close job, whose period-end runs the default rule close_job_runs_need_a_person sends for approval, and whose bank matches above 5,000 close_job_large_bank_matches), else the person's or agent's actor id. A rule tests it with a literal or in.

The first matching rule's decision (allow, require_approval or deny) applies; no match denies.

curl "${H[@]}" -D - "$ERP_URL/v1/approval-policy"      # the table in force; ETag: "policy-0"

# GET's table with one more rule before humans_allowed:
#   {"id": "human_large_manual",
#    "when": {"actor_type": "human", "action": "post", "source": "manual", "amount": {"gt": 10000}},
#    "then": {"decision": "require_approval", "reason": "Manual entries above 10,000."}}
curl "${H[@]}" -X PUT "$ERP_URL/v1/approval-policy" -H 'If-Match: "policy-0"' \
  -d "{\"comment\": \"four eyes on large manual entries\", \"table\": $(cat policy.json)}"

curl "${H[@]}" "$ERP_URL/v1/approval-policy/versions"   # every change, newest first
curl "${H[@]}" -X DELETE "$ERP_URL/v1/approval-policy"  # back to the default, as a new version
  • PUT and DELETE need a human with the admin scope. Each change is a new version (append-only: table, comment, who and when) and an approval_policy.update audit event. The database refuses a change to the policy in force that is not recorded as the latest version.
  • A change that relaxes any decision (deny to require_approval or allow, require_approval to allow, for any actor type, action, amount, source or actor name; a DELETE where the default is looser too) does not apply yet: the answer keeps the policy in force and names an approval request in pending_change (approval_id, requested_by, relaxed, at most 20 lines such as human change_posting_limit: require_approval -> allow, and relaxed_count). A second person with the admin scope approves it with POST /v1/approvals/{id}/approve (object_type approval_policy, action change_approval_policy); the requester and agents never can (403, code approval_policy_admin_only for someone without the admin scope). One waits at a time (409 approval_policy_change_waiting with its approval_id) until it is decided or the person who asked withdraws it with POST /v1/approvals/{id}/withdraw (admin scope; 403 approval_withdraw_own_only for anyone else, whatever their scopes, and for agents, 403 approval_withdraw_admin_only for the person who asked without the admin scope, 409 approval_not_withdrawable for another kind of request; the request becomes rejected with the comment withdrawn and withdrawn: true in ApprovalOut, audited as approval.withdrawn; the same for a posting_limit request). A person alone whose next change of the same kind applies at once has her own older request withdrawn the same way (audited with alone); so does a person who lowers the posting limit, or changes it as the policy allows. One based on a version replaced since is refused on approval (409 approval_policy_changed). A change that only tightens applies at once, and so does every change in an organization with fewer than two active people, unless another user was disabled or lost the admin scope in the last 30 days.
  • GET returns ETag: "policy-<version>". Send it as If-Match, and a change made in the meantime answers 412 instead of being overwritten.
  • A table is refused (422 with problems) unless:
    • it declares the four inputs (and at most actor_name besides), with every action listed, and a decision output;
    • every rule decides allow, require_approval or deny;
    • it still denies agents every action but post. An organization can tighten or loosen how people and agents post, but never let an agent approve, close or reopen a period, change the chart, manage keys, release payments, finalise VAT returns, activate a customer or vendor or change its bank or key details.
  • A table stored before the customer and vendor actions existed still applies to the other actions; for these the default decides (agents denied, a person's new vendor waits for a second person) until the organization saves its table again with them listed.
  • A table stored before the thirteen actions of the depth series existed gets the default's answer for them (agents denied; a second person for what the default sends to one) until the organization saves its table with them listed.
  • A table stored before release_own_payments and finalize_own_vat_return existed gets the default's answer for them (refused) until the organization saves its table with them listed.
  • The four-eyes rule and the scopes are enforced in code; a policy cannot change them. Four eyes compare people, not keys: another key or the browser session of the requester is still the requester, and a key the system issued never approves, releases or finalises (ledger). A refusal by the policy answers 403 with code policy_denied, the action and the rule_id.
  • The policy applies at once to every decision: postings from every subledger, approvals, period close and reopen, chart changes, payment release and VAT finalisation.

Customers and vendors

curl "${H[@]}" "$ERP_URL/v1/entities/CH01/parties?kind=vendor&status=draft"       # to review
curl "${H[@]}" "$ERP_URL/v1/entities/CH01/parties/V-NEU?kind=vendor"              # the card
curl "${H[@]}" -X PATCH "$ERP_URL/v1/entities/CH01/parties/V-NEU?kind=vendor" -d '{
  "payment_terms_days": 20, "language": "de", "iban": "CH03 0070 0110 0001 2345 6",
  "contacts": [{"name": "Anna Muster", "role": "Debitorenbuchhaltung", "email": "ap@neu.example"}]}'
curl "${H[@]}" -X POST "$ERP_URL/v1/entities/CH01/parties/V-NEU/activate?kind=vendor"
  • List (read scope): by code, filtered by kind, status (active, draft), country (ISO), vat_number (any part, ignoring spaces and dots, so 811569 finds DE 811 569 869) and q (name, code, VAT number or town); limit up to 1000. Each row carries balances (open and overdue amounts of posted documents per currency, credit notes negative; overdue as of as_of, default today in UTC) and pending_actions.
  • Card (GET …/parties/{party}, by code or id; kind when a customer and a vendor share a code, else 409): the master data (the structured address QR-bills and XRechnung use, language, iban, further bank_accounts, contacts, electronic address, Leitweg-ID), created_by and activated_by (from the audit trail), balances, pending (requests with who asked, the reason and, for bank details, the current and proposed values) and payment_blockers (why no payment to it can be released now).
  • Create (draft scope): the New customer and New vendor forms also accept the street, building number, postal code and town, so the invoice address can be entered at creation. These fields are optional at creation; invoice output still checks its required content. The IBAN is checked and stored without spaces. A customer is active. A vendor is a draft with an activation request: the default policy (activate_party with source vendor) asks for a second person, and an organization can change that in its policy. An agent's vendor, or any party with bank details an agent creates, is a plain draft.
  • Change (PATCH, draft scope): null leaves a field as is, "" clears it. Bank details (iban, bank_accounts as [{"iban", "bic", "label"}]) are not applied: they become a request for a second person (pending), and payments to the party are blocked until it is decided. 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_days, cash_discount_percent, cash_discount_days, credit_limit, withholding_code or withholding_rate is a request too (action change_party_details); a draft changes at once. Agents cannot change bank or key details. A currency without exchange rates (party_currency_without_rates) and an IBAN that is a bank account of the organization's own companies (party_own_bank_account) are refused (422), on create too. The card and the create answer name possible_duplicates: parties of the company with the same VAT number, an IBAN in common or the same name; an activation request carries them in its summary. status: "active" does what activate does; status: "draft" (humans only, policy action activate_party) blocks the party again. The audit event of every change records the previous and the new values.
  • Block, set inactive, make active again (POST …/parties/{party}/status?kind=, draft scope, people only, body {"status": "blocked" | "inactive" | "active", "reason": "…"}): a request (change_party_status) a second person decides under the change_party_details policy (ADR-0046). blocked: no new documents, credit notes, orders or subscriptions, its drafts are not posted and nothing is paid to it (409 party_blocked); what it pays still settles its open items, and payments to it are held already while the block waits. inactive: hidden from pickers and refused like a block, in both directions (409 party_inactive); only without open items, documents or payments waiting, open purchase orders or active subscriptions (409 party_has_open_items). A draft is activated, not set active; activation and PATCH status refuse a blocked or inactive party (409 party_status_move). The list filters status by blocked and inactive too.
  • Activate (POST …/activate, draft scope, humans only): a person with the approve scope who did not create the party activates it at once; anyone else's call becomes a request. Requests are decided with POST /v1/approvals/{id}/approve or …/reject by someone who is neither the requester nor, for an activation, the creator. These checks compare people: an API key counts as the person who created it, and a built-in agent as the person who ran it. A key the system issued (an organization's first admin key) belongs to no known person: it may request, but never activates or decides. A second call answers 409 (already active or already requested).
  • Existing data: a bill whose payment_iban (for example a vendor's QR-IBAN) is not the vendor's IBAN or one of its bank_accounts is not released until that account is added to the vendor and a second person confirms it.
  • Documents and payments: submitting or posting a document of a draft party answers 409; GET /v1/payments gives the reason a payment cannot be released (a draft party, bank details waiting for approval, an IBAN that is not one of the party's accounts) and POST /v1/payment-exports refuses it (422). See customer and vendor controls.
  • Contacts are a list on the party (name, role, email, phone); PATCH with contacts replaces it.

Platform: provisioning and lifecycle

For the platform operator, not for a tenant (ADR-0015). Two credentials work here, and only here: an operator's session of the portal (below), and a platform key (erpp_<prefix>_<secret>, scopes platform:read and platform:provision), which is issued with erp platform-key create NAME [--days 90] [--key-secret projects/<p>/secrets/<name>] over the owner connection (ERP_MIGRATION_DATABASE_URL; the runtime role cannot issue or revoke a key) (printed once, or stored in Secret Manager) and works only on /v1/platform; a tenant's key or session gets 401 there, and a platform credential gets 401 on every other path, so it reads no ledger.

Operation What it does
POST /v1/platform/organizations Provisions an organization in one transaction: the organization, its entities from templates (ch_sme, de_sme, generic: country, functional currency, chart, VAT method, fiscal calendar; each overridable, VAT settings saved), every account of the roles the services need, and the first people invited by email (the first admin with every scope, others by role, e.g. a controller: the second person that four eyes need). Needs an Idempotency-Key; all or nothing; no secret is created or returned; Google e-mails each invited person the link that sets their password (password_email_sent)
GET /v1/platform/organizations The organizations with their status (active or suspended), kind (customer or demo), who created them and why they were last suspended; never accounting data. Filters status and kind; keyset paging with limit and after; usage=true adds the counts (entities, people, entries, documents) and when each was last used, one tenant transaction per organization
GET .../{org_id} One organization: the above, its entities (code, name, country, currency), its people (address, role, status, last sign-in) and the evidence of use that decides whether it may be marked as a demo. Counts and master data only; opening it is recorded
GET /v1/platform/health How the two services are doing against their objectives (platform:read, a platform key or an operator session; counts and durations of requests, never tenant data): per service each SLO with its internal target, compliance over the rolling 28 days and the error budget left; the 24-hour p95 by request class; the uptime checks; the version, revision and time of the last deploy. Read from Cloud Monitoring and Cloud Run with the runtime identity and cached for 60 seconds; a part that cannot be read is named in problems (status degraded, or unavailable where monitoring is not configured). See SLOs and error budgets
POST .../{org_id}/suspend and /reactivate Read-only on or off, with a reason. A suspended organization's keys and sessions read and everything that changes state answers 403 suspended; safe to repeat
POST .../{org_id}/admins Invites an administrator, or shows again the invitation of one who has not signed in (already_invited); 409 for one who has signed in. Google e-mails them a (fresh) link that sets their password; the answer says where to sign in. Safe to repeat
POST .../{org_id}/mark-demo Makes an organization a demo (ADR-0022): confirm_name must be its exact name (422), and an organization that shows any sign of real use is refused with 409 customer_activity and the evidence: a database function does it and cannot be told to accept any (an organization that was used is marked by erp platform mark-demo --accept-activity on the owner connection). For a person (an operator session), 403 for a key. The purge is not an endpoint: it is erp platform purge-demo, which measures the evidence again and wants --accept-activity for a used one
GET /v1/platform/audit The operator's record (platform_audit), newest first, for one organization (org_id) or all: sign-ins and refused ones, provisioning, suspension, marking, invitations, opening an organization. Append-only
GET /v1/platform/me Who the credential is (operator:<address> for a session) and how long a session lasts
POST /v1/platform/auth/google, /auth/logout Sign in to the operator portal with a Google ID token, and out (below)

Provisioning is an audit event of the new organization (org.create, entity.create, user.invite, by platform:<prefix>, or by operator:<address> from the portal) and a security event, as is every suspension and reactivation; all of them are also rows of the operator's record. A provisioning answer has replayed: true when it is the stored answer of an earlier request with the same Idempotency-Key.

The operator portal's session (ADR-0032). An address on ERP_PLATFORM_OPERATORS signs in with Google at /admin/ (POST /v1/platform/auth/google; its Workspace must vouch for the address, and ERP_ALLOWED_EMAIL_DOMAINS applies) and gets the cookie erp_platform_session: HttpOnly, SameSite=Strict, Secure on https and in the cloud, Path=/v1/platform. It lasts 8 hours (ERP_PLATFORM_SESSION_HOURS), ends after 30 idle minutes (ERP_PLATFORM_IDLE_MINUTES), at sign-out, and when the address leaves the list. A change by cookie needs X-ERP-UI: 1 (403 csrf without it). A key never reads the cookie and a tenant never accepts it. The first administrator signs in with the Google account of the invited address and creates their own API key, which is bound to them. The end-to-end sequence is the onboarding workflow.

Data import

POST /v1/entities/{entity}/imports (multipart: file, type, mode, as_of) brings a customer's existing books in from CSV (UTF-8 or Windows-1252, , ; or tab delimited) or Excel .xlsx, at most 5 MB and 2,000 rows (ADR-0016). GET /v1/entities/{entity}/imports/templates/{type}?format=csv|xlsx returns the template of a type, whose first row names the columns; the onboarding workflow lists them. mode=dry_run (the default) runs the real code in a savepoint and returns every problem as (line, column, message) with a summary; mode=commit (with an Idempotency-Key) applies the file all or nothing, or answers 422 with the same problem list and changes nothing.

type Creates Controls that apply
customers, vendors parties, as POST .../parties a person's vendor is a draft with an activation request for a second person; agents refused
accounts accounts, as POST .../accounts admin scope; the policy's manage_accounts; codes and roles unique
opening_balances one entry per entity, source opening, dated as_of the approval policy (by default a second person approves it; the answer names the remedy for one-person organizations); period open; receivables and payables control accounts left to the open items; the remainder to opening_balance_clearing; a second opening entry is refused
open_receivables, open_payables a posted document per row (original number, dates, open gross amount) against the clearing account the customer or vendor exists and is active (a draft vendor blocks); numbers unique and not of this system's own shape; the period of as_of open

The accounting, the clearing account and why nothing is counted twice are in the ledger guide. Text cells that a spreadsheet would run as a formula (=, @, or +/- that is not a phone number or a number) are refused, and the templates are written so that no cell is one.

Analytics

Trends, key figures, breakdowns and insights for any date range are GET /v1/entities/{entity}/analytics/series, /kpis, /breakdowns and /insights: a closed set of metrics defined by report line, computed on the server from the posted ledger, each series tying to the profit and loss, trial balance, balance sheet or aging report for the same dates. The metrics, their formulas, the periods and the comparison are in Analytics; the operations in the generated reference. GET /v1/entities/{entity}/reports/aging is as of the day (a payment made later has not settled anything yet) and reports functional amounts (open_functional, totals_functional); GET /v1/documents takes a currency.

Close analytics

The figures the close-review agent and the MCP tools flux_analysis and unusual_entries use:

  • GET /v1/entities/CH01/analytics/flux?fiscal_year=2026&period=3 compares each account with the previous regular period: P&L accounts by movement, balance-sheet accounts by balance. A row is flagged when the variance reaches materiality (default 1000) and 10% of the comparison. Flagged rows come first.
  • GET /v1/entities/CH01/analytics/unusual-entries?fiscal_year=2026&period=3 lists the period's posted entries with risk flags: manual entry, created by an agent, round amount, large amount (ten times the materiality), weekend date, reversal.

REST parity

Every ERP function is a REST operation first; the UI, MCP and agents are clients of those operations. tests/erp/test_rest_parity.py enforces this in CI, without a database:

Check Rule When it fails
MCP tools Each tool names the operations it mirrors, @mcp.tool(meta=rest("GET /v1/…")); MCP clients see them as _meta.rest Add the REST endpoint, then declare it
Agent tools Each tool of the built-in agents names its operations, Tool(…, rest=("GET /v1/…",)). Tools that change no ERP data (record_issue) are listed in close_review.INTERNAL_TOOLS with the reason The same
Web UI Every api.GET/POST/…("/v1/…"), postForm, postJson, fetch and /v1/… link in ui/src is an operation of the OpenAPI document. The development sign-in is the one exemption Add the endpoint and run npm run gen:api
Generated client ui/src/api/schema.d.ts has exactly the paths of the OpenAPI document Run npm run gen:api in ui/
Documentation Every operation has a tag, a summary, a description (the handler's docstring) and a typed response: a response_model for JSON, TEXT_FILE or BINARY_FILE content for files; every JSON request body has an example, every request field and parameter a description (tests/erp/test_api_docs_source.py) Add what the message names
API reference docs/api (the OpenAPI documents and the Markdown reference) is exactly what the code generates; the curl examples of the guides and workflows run against the demo organization (tests/api/test_api_docs.py, tests/erp/test_docs_examples.py) Run uv run python -m tools.apidocs and commit; fix the page an example failed on

The CLI is the operator's tool. migrate, serve, openapi (the committed docs/api/erp-openapi.json) and seed-demo run the deployment. create-org provisions a tenant without the platform API (it prints a first admin key; prefer POST /v1/platform/organizations), and platform-key create | list | revoke manages the platform operator's keys, which no endpoint creates. invite-user does what POST /v1/users does, for the first admin of a new organization.

API style guide

For new endpoints; the existing ones follow it.

  • Paths: /v1/<plural-noun> for resources and /v1/entities/{entity}/… for what belongs to an entity ({entity} is its code). Actions on a resource are POST /v1/<resource>/{id}/<verb> (…/submit, …/approve, …/finalize). Lowercase and hyphenated. /v1 only changes additively. One recorded exception: on 2026-10-02 POST /v1/periods/{id}/reopen started requiring {"reason": "…"} (reopening a closed period without a reason was a control gap; release notes). Any future breaking change goes to /v2.
  • Tenant: always from the credential, never from the path or body.
  • Methods:
    • GET reads;
    • POST creates or acts, and takes an Idempotency-Key (required where the handler wraps its work in idempotent(...), as every creating POST does);
    • PUT replaces a settings resource, PATCH changes some fields, DELETE revokes or resets. These are idempotent by definition and take no key.
  • Scopes: every handler declares Depends(require("<scope>")): read for reads, draft for drafts and submissions, post or approve for decisions, and admin for settings and keys. Human-only actions are also checked in the service and the approval policy.
  • Transactions: handlers take ctx: Ctx = Depends(unit_of_work, scope="function"), so the tenant transaction commits before the response is sent. Every write goes through a service function that applies the posting engine, the approval policy (policy.decide_for) and ctx.audit(...).
  • Responses: every operation has a response_model (or typed file content), a tag, a summary and a description (the handler's docstring: what it does, what it refuses, what the answer means). Creating returns 201. Money is an Amount (a decimal string), never a float; dates are ISO dates.
  • Lists: a JSON array, newest or most relevant first, with filters as query parameters and limit (default 100, at most 1000) where a list can grow without bound. No list uses cursor pagination yet. When one needs it, it is added without breaking /v1: a cursor parameter in, a next_cursor field or Link header out.
  • Errors: the envelope above, raised as an ErpError subclass (NotFound, Forbidden, Conflict, PreconditionFailed, ValidationFailed). Keyword arguments become fields next to code and message (for example problems, violations). A request body that does not parse returns FastAPI's {"detail": [...]} with 422. RFC 9457 problem details would move every error to top-level type, title, status and detail, which breaks existing clients, so /v1 keeps the envelope. Problem details are a candidate for /v2, or as an opt-in with Accept: application/problem+json.
  • Concurrency: a resource with a version returns an ETag and honours If-Match (412 when stale). The approval policy does so today.
  • Parity: an MCP tool or agent tool comes after its REST operation and declares it.
  • Documentation: the reference is generated from the code, so say it in the code: describe every parameter and request field, give each JSON request body an example, then run uv run python -m tools.apidocs (see docs/api). A business process people follow gets its steps in a workflow, whose examples CI executes.

Document kinds and postings

Kind Posting
sales_invoice Dr receivables; Cr revenue per line; Cr output VAT (reverse charge and zero-rating post no VAT and add legal notes)
sales_credit_note The reverse of an invoice. It can credit a posted invoice (credits) and is capped at the open amount
purchase_bill Dr expense or asset; Dr input VAT; Cr payables. Reverse charge self-assesses: Dr input VAT and Cr reverse-charge VAT
purchase_credit_note The reverse of a bill. It can credit a posted bill (credits). Of a bill blocked for payment it posts without settling the bill: the credit stays open (a warning says so) and is applied to the bill once the variance is approved

A credit note line shows what it credits (both kinds alike). A line that names the line it credits (credits_line) and gives no unit_price credits that line as it was: it takes the line's price and, unless it gives a discount of its own, the line's discount, which the response states on the line (a percent as is; an amount, or a share of the document discount, as one amount by the quantity credited, the rest of a line credited in parts taking what is left). A line that gives a unit_price takes only the discount it gives. Where the credited line had a discount (its own or a share of the document discount), such a line must give one, 0 for none: without it the request is refused (422 credit_discount_required, "line 1 of INV-… has a discount of 10 %: send the discount to credit (0 for none), or leave out the price to credit the line as it was"), because before 2026-10-12 such a line took the credited line's discount and an unchanged request must not quietly credit a different amount. Lines whose credited line had no discount behave as before. A credit note without lines credits every line as it was; its own document discount is only one it gives. The credit note form fills each credited line's price and discount in (the discount in proportion to the quantity typed); untouched, it sends the line without price and discount, so the server credits it as it was, and a changed price or discount is sent as shown. Such a line is stored with as_credited: true (migration 0108), so a saved draft's line is sent as it was again, with a new quantity too, only while it is untouched; a line whose price or discount was typed is never taken for one, whatever its figures.

A printed VAT amount on a bill (printed_tax_amount) is compared with the determined VAT. If they differ, the printed amount is booked and a warning is recorded. VAT a vendor in another VAT area printed under its own country's law (Liechtenstein is in the Swiss VAT area) is not input VAT, and the bill's total is the vendor's invoice total (ADR-0049). Where we are liable at home (reverse charge, § 13b, Bezugsteuer), the line stays self-assessed as determined, same boxes of the return, and its tax.foreign_vat holds the foreign VAT, debited to the expense account on a line of its own; where the supply is taxed abroad or imported, the line's treatment is foreign_vat and its net amount and VAT are debited to the expense account. No VAT return reads either. VAT that may be ours (a vendor VAT id of our VAT area in the master data or in provenance.vendor_vat_ids, the e-invoice's BT-31 and BT-63; or one of our rates) is booked as determined, with a warning.

Auto-reversing journal entries. A journal entry can carry auto_reverse_on (an accrual reversed on the first day of the next period, for example). The reversal is posted on that date as soon as the entry posts, directly or when its approval is granted, without a second policy decision (ADR-0003), and the entry's reversed_by_id points to it. The date must be after entry_date and in an open period of an opened fiscal year: a year-end accrual needs the next fiscal year opened first. If the reversal's period was closed while the entry waited for approval, approving it returns 409 and the request stays pending (reject it, or reopen the period).

Without due_date, the due date is the document date plus the party's payment_terms_days. Set due_date_convention (following, modified_following, preceding or modified_preceding) to move that date onto a business day of the payment calendar for the entity's country, which data-vNext serves at /v1/calendars (CH: SIC; euro area: TARGET). The calendars and the unadjusted date are recorded in the document's provenance.due_date. An explicit due_date is never moved.

Invoices and bills

A document is draft, pending_approval, posted, rejected or void. Posting goes through the approval policy like any entry (?submit=true on create, or POST …/{id}/submit). Once posted, a document and its lines are frozen in the database; corrections are credit notes.

Operation What it does
GET /v1/documents?entity=CH01 Newest first. Filters: kind and status (repeat either: kind=sales_invoice&kind=sales_credit_note), party (code or id), start and end (document date), q (number, vendor invoice number, order or buyer reference, notes, party name or code), limit (default 100, at most 1000). status=open and status=paid select posted documents with or without an open amount
GET /v1/documents/{id} By id or number, with ETag: "document-<version>" (the database raises version on every change). Each document carries party_code, party_name, open_amount (posted only; pending allocations count as settled), order_reference and source_file (the stored original: name, type, size, SHA-256)
PATCH /v1/documents/{id} A draft only (409 otherwise): party, dates, currency, vendor invoice number, order and buyer reference, payment details, notes and lines (which replace every line). The tax of every line is determined again. Without due_date, a new date or party recomputes it from the payment terms; without currency, a new party brings its own. A VAT amount printed on a vendor document is kept. Send the ETag you read as If-Match: a document changed since answers 412. Audited as document.update with the changed fields, the previous total and a SHA-256 of the previous lines
POST /v1/documents/{id}/submit, …/void Submit a draft through the approval policy, or void it with {"reason": "…"} (required, audited). Both take the ETag you read as If-Match and answer 412 (with current_version) when the document changed since; the web card always sends it. A void document keeps no number, posts nothing and can no longer change; its vendor invoice number, or its e-invoice file, can be entered again. Idempotency-Key
POST /v1/documents/{id}/reopen A rejected document goes back to draft, with {"reason": "…"} (required; audited as document.reopen, with the rejected entry), to be corrected and submitted again; the rejected journal entry stays in the history. Idempotency-Key
POST /v1/documents/preview The body of POST /v1/documents, answered with the document it would create (tax per line, totals, warnings) without storing anything. Entry forms show live VAT with it
GET /v1/documents/{id}/file The stored original (e-invoice XML or PDF, or the PDF or image read by bill capture), byte for byte, with X-Content-SHA256. Shown inline only when its bytes are of an allowed type (PDF, PNG, JPEG, WebP, or XML without HTML or SVG markup); anything else, or ?download=true, is an attachment (application/octet-stream when the bytes are not what the stored type claims). Always Cache-Control: private, no-store, Cross-Origin-Resource-Policy: same-origin, X-Content-Type-Options: nosniff, and a CSP that lets only the app frame a PDF and sandboxes everything else. 404 when the document was entered by hand, and for another organization's documents
GET /v1/documents/{id}/pdf The invoice or credit note as an A4 PDF (a posted sales document; 409 before, 422 for a bill), in the customer's language (language=de|fr|it|en overrides): company header, customer address and references, lines with VAT, VAT per rate, totals, payment terms with cash discount, notes. A Swiss entity's open invoice gets the QR-bill payment part (qr_bill=false leaves it out). format=zugferd (German entities) is the hybrid PDF/A-3 with the EN 16931 XML inside as factur-x.xml; profile=en16931 (default) or xrechnung. The creation date is the document date, so asking twice gives the same bytes, and the first output is kept as issued and returned from then on (Document archive). ?download=true is an attachment. See Invoice output
GET /v1/documents/{id}/dispatches The sent log (when, to whom, subject, Message-ID, format, attachments with SHA-256, by whom) and what a send would do: sending_configured, suggested_recipient, default_format, zugferd_available, xrechnung_attached
POST /v1/documents/{id}/send E-mail the PDF (and the XRechnung XML to a German public customer with a Leitweg-ID) to to or the customer's e-invoice or billing address; post scope, a person (agents 403); 409 when no mail server is configured (sending is not configured) or the document is not posted; 502 mail_failed when the mail server refuses, which records nothing. Idempotency-Key
GET /v1/documents/{id}/allocations What settled the document: payments (number, date, status) and credit notes; for a credit note, the document it settled. Amounts, functional amounts and realised FX differences
GET /v1/documents/{id}/history The audit events of the document, of its journal entries (submission, approval or rejection, posting, also of earlier attempts) and of its stored original, oldest first

Concurrency. Changing, voiding, reopening and submitting a document lock its row first, so they wait for one another and the second one sees the first one's result (a 409, not a broken document). The database also reads the document row FOR SHARE before it accepts a line, so a line can never land on a document whose status changes at the same moment. Two uploads of the same file (e-invoice import or bill capture) wait for each other on a transaction lock on the organization and the file's SHA-256, so the second one is refused as a duplicate.

Lines outside the scope of VAT (vat_scope). A line is in_scope by default: its tax is determined by data-vNext. "vat_scope": "outside" with a vat_scope_reason (damages: compensation for damage such as interest and fees for late payment; vat_group: a supply within a Swiss VAT group, Art. 13 MWSTG; organschaft: an Innenumsatz of a German Organschaft, § 2 para. 2 no. 2 UStG) marks an amount that is no consideration for a supply. Such a line skips the determination (no call to data-vNext), has rate 0 and no VAT, and its journal line carries no tax tag, so the VAT report and the returns, which read tagged lines, never see it. The document prints the remark of the reason in its own language (de, fr, it or en). It takes no tax_category, printed_tax_amount or reverse_charge (422), and a document with such a line carries no cash discount (the party's terms are not copied; terms given with it are 422). A credit note without lines and an edited draft keep the scope of the lines they come from. As an e-invoice the line is category O (not subject to VAT) with the reason code VATEX-EU-O (the seller is then identified by its VAT number as a plain identifier, BT-29, because category O forbids BT-31); XRechnung allows no other VAT category on the same invoice, which its rule check reports (BR-O-11 to BR-O-14), so an invoice that mixes outside and taxed lines cannot be issued as XRechnung. The VAT report and the return's reconciliation show the revenue (or expenses) of such lines as a labelled reconciling item (reconciling_items, code outside_scope_sales or outside_scope_purchases): it is in the ledger and in no VAT turnover, so it explains the difference instead of being one. The database holds the invariants (migration 0025): the reason goes with outside and only with it, and an outside line has rate and VAT 0.

Order reference (BT-13). order_reference is the buyer's purchase order number: the customer's PO on an invoice, our PO on a bill. It is set on entry, taken from received e-invoices (BT-13, next to the buyer reference BT-10 in buyer_reference), and written to outbound XRechnung (BuyerOrderReferencedDocument) when set.

curl "${H[@]}" "$ERP_URL/v1/documents?entity=CH01&kind=sales_invoice&status=open&q=PO-4711"
curl "${H[@]}" -X PATCH "$ERP_URL/v1/documents/$ID" -d '{"order_reference": "PO-4711",
  "lines": [{"description": "Consulting", "quantity": "8", "unit_price": "150"}]}'
curl "${H[@]}" -H 'Idempotency-Key: v-1' "$ERP_URL/v1/documents/$ID/void" -d '{"reason": "duplicate"}'
curl "${H[@]}" -o original.xml "$ERP_URL/v1/documents/$BILL/file?download=true"

Invoice output

GET /v1/documents/{id}/pdf prints a posted sales invoice or credit note (src/erp/invoice_pdf, modules/invoice_output); POST /v1/documents/{id}/send e-mails it (the invoicing module's sending).

  • Layout. A4, one font (DejaVu Sans, bundled with its licence and embedded as a subset), black on white. The entity's name, address, contact and VAT number head the page, its footer carries the VAT number and IBAN; the customer's address is on the left (DIN 5008) or, for a Swiss entity, on the right (the window of a C5/C6 envelope), with the references (number, dates, customer number, the buyer reference BT-10, the purchase order BT-13, the customer's VAT number) on the other side. The lines table repeats its header over pages; below it the VAT per rate and the totals (the amount due), then the payment terms (days from the document date to the due date: Zahlbar innert 30 Tagen netto, fällig am ...), the cash discount (Skonto) with its last day and amounts, the bank details (the registered account's IBAN and BIC), notes and the legal remark of the tax treatment, in the document's language: the text data-vNext gives for the rule that decided the line, with its legal basis (a Swiss export of services: not subject to Swiss VAT, Art. 8 para. 1 MWSTG; an EU reverse charge or intra-Community supply: Art. 196 and Art. 138 of the Directive). A rule without a legend, such as the generic non-EU table, prints none. A credit note says what it credits and asks for no payment.
  • Mandatory content. An invoice or credit note leaves the system (PDF, send, ZUGFeRD, XRechnung) only with the content the law requires, checked by one function (invoice_content.ensure_issuable, rules in seed/invoice_content.yaml, each with its legal basis): Switzerland (Art. 26 para. 2 MWSTG) the name and place of both parties and the seller's UID when it is VAT-registered (its UID with MWST, TVA or IVA); Germany (§ 14 para. 4 and § 14a UStG) both full addresses, the seller's USt-IdNr or Steuernummer and, for a reverse charge or intra-Community supply, the USt-IdNr of the seller and of the buyer; any other country both names and addresses. 409 invoice_content_incomplete lists the missing fields (party seller or buyer, field, basis, record_id of the entity or customer to complete). The same list is missing_content on every sales document, so drafts, previews and the document card warn instead of refusing. The seller's address is the entity's own. GET /v1/entities/{entity}/invoice-content lists the same rules before any invoice lacks them: the seller and buyer fields, each with its basis and whether always (every invoice) or only some need it; the customer form and the company profile mark them. See ADR-0019.
  • Language and numbers. The customer's language, else the language of its country (CH, DE, AT, LI German, FR, IT), else the entity's country, else English. Swiss amounts are written 1'234.50, German and Italian 1.234,50, French 1 234,50, English 1,234.50.
  • QR-bill. A Swiss (or Liechtenstein) entity's open sales invoice gets the payment part with receipt from GET /v1/invoices/{id}/qr-bill: at the lower edge of the last page when 105 mm are free there, else on a page of its own. A credit note, a settled invoice and an entity without a registered Swiss account have none.
  • ZUGFeRD / Factur-X. For a German entity, format=zugferd writes a hybrid PDF (PDF/A-3b by construction, not yet verified) with the EN 16931 invoice embedded as factur-x.xml (AFRelationship /Alternative, text/xml), the Factur-X XMP block (DocumentType INVOICE, Version 1.0, ConformanceLevel EN 16931 or XRECHNUNG), an sRGB output intent and embedded fonts. The XML is the one the XRechnung endpoint writes, checked against the same rules (422 with the violations otherwise), with a cash discount added as BT-20. The hybrid has no QR-bill part. Verified by structural tests, not by a PDF/A validator (B-55).
  • Sending. The mail has the PDF attached (format is pdf by default, also for German entities; zugferd on request, until the hybrid is validated as PDF/A-3, B-55) and, for a German customer with a Leitweg-ID, the XRechnung XML. Text, subject and greeting are in the customer's language; the sender is ERP_MAIL_FROM with the entity's name, Reply-To is the entity's contact e-mail. to takes one to five addresses, each of them the customer's own (its e-invoice address, e-mail address or a contact's e-mail address: allowed_recipients); any other address is refused with 422 until it is added to the customer, an audited master-data change. Without to: the customer's e-invoice address (when it is an e-mail address, scheme EM), a contact whose role says billing, accounting, Buchhaltung, comptabilité and the like, then its e-mail address. Every send is logged in document_dispatch (append-only) and audited as document.send; a message the mail server refused leaves no row. The server is switched on in the deployment guide; without it the answer is 409. message is at most 1,000 characters. An organization sends at most ERP_MAIL_HOURLY_LIMIT messages an hour (default 60; 429 rate_limited with retry_after_seconds). Only a person sends: the policy action send_document is denied to agents by default and by the save-time probe, and an agent key answers 403 whatever the policy says. A document the opening-balance import created (provenance.opening) is never printed, sent or issued as XRechnung (409).
curl "${H[@]}" -o INV-2026-000001.pdf "$ERP_URL/v1/documents/INV-2026-000001/pdf?download=true"
curl "${H[@]}" -o zugferd.pdf "$ERP_URL/v1/documents/$ID/pdf?format=zugferd"
curl "${H[@]}" -H 'Idempotency-Key: s-1' "$ERP_URL/v1/documents/$ID/send" -d '{"to": ["ap@kunde.example"]}'

Document archive

What an auditor asks for: the invoice as the customer got it, the document the vendor sent, a scan added later. One stored file is one document row (content-addressed, ADR-0020); the table is append-only, and nothing in the application deletes a file.

Operation What it does
GET /v1/documents/{id}/files Every file of the document, oldest first: source received (the e-invoice a vendor sent), captured (read by bill capture), archived_output (the invoice as issued) or attached; name, type, size, SHA-256, who stored it and when
POST /v1/documents/{id}/files Attach a scanned or received original (multipart file; PDF, PNG, JPEG or WebP, at most 20 MB) to a document of any kind and status: scope draft, Idempotency-Key. The type is read from the bytes. Append-only: a second file is another attachment with its own document.attach event; the same bytes twice are 409; 422 for an empty, oversized or unsupported file
GET /v1/documents/{id}/files/{file_id} One file byte for byte, with X-Content-SHA256, inline only when its bytes are of a safe type (as GET .../file). 409 when the stored bytes no longer match their SHA-256
POST /v1/documents/{id}/mark-sent Record that a person handed a posted invoice or credit note over outside the system: channel (email, post, portal, other), recipient, handed_over_on and optionally file_id (an archived output; default the invoice PDF, archived now if it never was). Scope post, a person only (the policy action send_document), Idempotency-Key. The row is in the sent log (GET .../dispatches) with the SHA-256 of the file
GET /v1/archive/export?entity&start&end A ZIP of every file of the entity's posted documents in the range and a manifest (manifest.csv, manifest.json); scope audit (the Auditor role) or admin; streamed; an audit event
  • Issued as output. The first GET /v1/documents/{id}/pdf (or .../invoices/{id}/xrechnung) of an output stores its bytes with their SHA-256; every later request for the same output returns them and renders nothing. An output is the format and what the request chose: format, profile, language (omitted is the customer's language, then and later) and qr_bill; another choice is another output, archived the same way. The content gate (ADR-0019) decides the first output only; a stored output is returned even when the customer's record lacks content now. The mail that send sends carries the archived files. The first output needs the post scope and an organization that is not suspended: a viewer, an auditor or a suspended organization gets 409 not_issued (no file, no audit event) until a person who may post has downloaded it; what is archived is read with read.
  • Mark as sent is for a customer who gets invoices through a download while the server sends no mail: the user downloads the PDF, sends it, and records it. The sent log keeps channel (system_email for a message of this server, which also has a subject and message_id), the recipient, the day and the file's hash; recorded hand-overs do not count against the hourly e-mail limit.
  • Export manifest. One row per file: document_number, vendor_invoice_number, kind, document_date, party_code, party_name, currency, total, file_name, zip_path, content_type, size, sha256, source, variant, stored_by, stored_at, integrity (ok; a file that does not match its hash or cannot be read is left out of the ZIP and marked mismatch or unreadable; whatever a store raises, one file never cuts the ZIP short). A posted document without a file has a row with empty file columns. manifest.json also has expected_files, included_files and a completeness note: a ZIP without manifest.json is incomplete (the download was cut short); with it, the export is complete when the two counts are equal.
  • Retention. The documents bucket has an 11-year retention policy (OR Art. 958f: ten years from the end of the financial year; the policy counts from storage), not locked, and versioning is on.
curl "${H[@]}" -o invoice.pdf "$ERP_URL/v1/documents/INV-2026-000001/pdf?download=true"
curl "${H[@]}" -H 'Idempotency-Key: a-1' -F file=@scan.pdf "$ERP_URL/v1/documents/$ID/files"
curl "${H[@]}" -H 'Idempotency-Key: m-1' "$ERP_URL/v1/documents/$ID/mark-sent" \
  -d '{"channel": "email", "recipient": "ap@kunde.example", "handed_over_on": "2026-09-22"}'
curl "${H[@]}" -o archive.zip "$ERP_URL/v1/archive/export?entity=CH01&start=2026-01-01&end=2026-12-31"

Organization export

What a leaving customer takes away, and what an administrator can fetch at any time (ADR-0033): one package of the whole organization, for its own archive. It is a ZIP, built asynchronously from one consistent snapshot of the database.

Operation What it does
POST /v1/exports Ask for the package of your own organization: scopes admin and audit (an administrator has both; the Auditor role cannot), a person's key (never an agent's). Body (optional): language (en, de, fr, it: the README's; default the asker's, then the first entity's country) and note. Needs an Idempotency-Key. 202 with the export in status queued; 409 export_in_progress while another is queued or running. Allowed while the organization is suspended
GET /v1/exports, GET /v1/exports/{id} The exports, newest first, and one with its status: queued, running (stage, progress), succeeded (file name, size, sha256 of the ZIP, manifest_sha256, file_count, row_count, complete, the hand-over statement, expires_at), failed (error) or expired
GET /v1/exports/{id}/download The ZIP, streamed, with X-Content-SHA256 and Content-Length. 409 export_not_ready until it has succeeded and after it has expired
GET /v1/exports/{id}/link A signed link that downloads straight from the store without credentials, for seven days at most and never past the package's expiry (a bearer secret: treat it like a password). 409 no_signed_link on a deployment that cannot sign one
POST /v1/platform/organizations/{org_id}/exports The operator's side, for offboarding: the same package of any organization. platform:provision, Idempotency-Key required. The platform reads none of the books: the job builds the package over row-level security
GET /v1/platform/organizations/{org_id}/exports, .../{id} List and status of an organization's exports, whoever asked: platform:read
GET /v1/platform/organizations/{org_id}/exports/{id}/link and /download Hand-over: platform:provision
  • What is in the package. README.html (in the language asked for: what is inside, how to read it without our software, how to verify it, the retention rules of the countries of the organization's entities), verify.py, the manifests (manifest.json, manifest.csv, and manifest.sha256 in sha256sum format), every table as data/csv/<table>.csv and data/json/<table>.json (chart of accounts, journal entries and lines with their hash, previous hash, sequence, source and provenance, parties, documents and lines with VAT and exchange-rate fields, payments and allocations, bank statements and lines, fixed assets, depreciation books and schedules, VAT returns and lines, periods and fiscal years, approvals and the policy, users with name, e-mail and scopes, API keys without secrets, settings, the audit trail and the security events), schema/schema.csv and schema.json (every column's type, key, reference and meaning, what is not exported and why), data/csv/index.xml with its DTD when an entity is German (the Beschreibungsstandard of the German tax authorities, version 1.6; valid against the standard's DTD, but an import into IDEA has not been tested), the reports per entity and fiscal year as PDF and CSV (trial balance, balance sheet, profit and loss, general ledger, VAT summary, VAT returns), every stored file under documents/ checked against its SHA-256, and integrity/ (the hash-chain and trial-balance verification of every entity, the stored files that could not be included, how the ledger's hash is formed).
  • Formats. UTF-8 without a byte order mark, comma-separated, CRLF (RFC 4180); decimals exactly as stored, as text with their scale; dates YYYY-MM-DD; timestamps ISO 8601 UTC; NULL is an empty CSV field and null in JSON. Nothing is altered to protect a spreadsheet in the data files (import them as text); the report CSVs are protected and mark their sums in a row column. The package is a stream: nothing is held whole in memory.
  • Never in it. API key hashes, session token hashes, the sign-in provider's subject, replay records of requests, the provisioning replay record, the operator's internal remark on a suspension (tenant_registry.status_reason), the network addresses of refused requests (security_event.address: a third party's data), our internal storage locations. A test plants them and searches every file; schema/ lists each withheld column with its reason.
  • Complete means checked. Each table is counted in the snapshot and written twice; every stored file is read and compared with its SHA-256. A file that does not match or cannot be read is not hidden: it is listed in integrity/document-problems.csv, in document.csv, in the README and in the statement, and complete is false (the export still succeeds: what is there is whole). A broken hash chain is a finding in integrity/ and in the README; the export is complete.
  • Attested. The job records the statement (hashes, sizes, counts, complete, the chain result per entity) in platform_audit (org.export), append-only for every role: erp platform attestations --org-id ... lists it. Compare sha256 with the file you received (sha256sum), and run python3 verify.py in the unpacked folder (or on the ZIP: verify.py package.zip --zip-sha256 <hash>).
  • Kept 30 days in a private bucket (expires_at), then deleted by the bucket; ask for another.
  • Audit. export.start, export.complete (by the job), export.download and export.link are audit events of the organization (the customer's own access record); the operator's are also security events (platform.export.start, platform.export.download) and rows of the operator's record (org.export_start, org.export_download, org.export_link); what the job attested is there too (org.export, org.export_failed).
curl "${H[@]}" -H 'Idempotency-Key: x-1' -d '{"language": "de"}' "$ERP_URL/v1/exports"   # 202, queued
curl "${H[@]}" "$ERP_URL/v1/exports/$ID"                      # until "succeeded"; keep "statement"
curl "${H[@]}" -o archive.zip "$ERP_URL/v1/exports/$ID/download"
sha256sum archive.zip                                         # equals "sha256" of the export
unzip -q archive.zip -d archive && (cd archive && python3 verify.py)

Audit files for tax authorities

The files a company hands its auditors, for one company and one fiscal year (or a range of its closed periods): the tax auditor's file of its country (the FEC in France, the GoBD data carrier in Germany, the OECD SAF-T everywhere else) and, in every country, the audit evidence pack for the external (financial statement) auditor. The app never files anything: it makes the file and the user hands it over. Only posted entries are in them. A year that is not closed yet can be exported; the overview says so (year_closed, open_periods) and so do the GoBD index.xml, the SAF-T HeaderComment and the evidence pack's README.txt. Scope read; every download is an audit event (audit_file.export, with the format and file name). Screen: Reports → Audit files (UI).

Operation What it does
GET /v1/entities/{entity}/audit-files?fiscal_year=2026 What the files cover: the formats of the entity's country (fec, gobd or saft, then evidence), dates, whether the year is closed, open periods, posted entries, the file name of each format (file_names; file_name is the first's), and missing (siren)
GET /v1/entities/{entity}/audit-files/fec?fiscal_year=2026 France: the FEC, <SIREN>FEC<closing date>.txt
GET /v1/entities/{entity}/audit-files/gobd?fiscal_year=2026 Germany: the GoBD data carrier, GoBD_<entity>_<year>.zip
GET /v1/entities/{entity}/audit-files/saft?fiscal_year=2026 Every other country: the OECD SAF-T 2.0, SAF-T_<entity>_<year>.xml
GET /v1/entities/{entity}/audit-files/evidence?fiscal_year=2026 Every country: the audit evidence pack, Audit_Evidence_<entity>_<year>.zip

from_period and to_period export part of the year; every period in such a range must be closed (409 audit_periods_open), and the file name gets _Pxx-Pyy. A format that is not the entity's country's is refused with 422 audit_file_country ("The FEC ... is for French companies", "The SAF-T file is for companies outside France and Germany ...").

  • FEC (Livre des procédures fiscales art. L47 A, format of art. A47 A-1). The 18 fields in the legal order (JournalCode, JournalLib, EcritureNum, EcritureDate, CompteNum, CompteLib, CompAuxNum, CompAuxLib, PieceRef, PieceDate, EcritureLib, Debit, Credit, EcritureLet, DateLet, ValidDate, Montantdevise, Idevise) with a header record; tab-separated, CR LF; dates YYYYMMDD; amounts with a comma, no thousands separator, two decimals. One record per journal line, in posting (validation) order; EcritureNum is the entry's number in the books (gap-free per fiscal year). The opening balances come first as journal AN (entry <year>-000000: the balances carried from earlier entries in the app, per customer or vendor on the receivables and payables accounts), with any imported opening entries. Journals: AN, VT (sales), AC (purchases), BQ (receipts and payments), OD (everything else). CompAuxNum/Lib is the customer or vendor on receivables and payables lines; EcritureLet/DateLet the lettrage the app knows (an invoice, bill or credit note settled in full by posted payments or credit notes inside the file, the matched lines balancing); Montantdevise/Idevise for lines in another currency than the euro. The SIREN is taken from the entity's French VAT number (FR, two-character key, nine-digit SIREN; a numeric key is checked); an entity without one is refused with 422 siren_missing. Encoding: ISO 8859-15. UTF-8 could not be confirmed as accepted (the official texts were not reachable when this was built and secondary sources disagree); see the release note.
  • GoBD data carrier (AO § 147 para. 6, Z3 access). A ZIP with Sachkonten.csv (accounts with opening balance, debits, credits, closing balance), Buchungsjournal.csv (every line of every posted entry: date, number, line, account, text, debit, credit, currency amount and rate, tax code and rate, customer or vendor, document number, date and kind, source, reversed entry, who entered it, when and by whom it was posted), Debitoren.csv, Kreditoren.csv, Steuerschluessel.csv (the tax codes used, with lines and base) and Offene_Posten.csv (the documents open at the end), described by index.xml (Beschreibungsstandard 1.6, the same writer as the organization export's) and its DTD. Every column's meaning is in index.xml. CSV as in the organization export (UTF-8, comma, . as decimal separator). An import into IDEA has not been tested.
  • SAF-T (OECD Standard Audit File for Tax, version 2.0, namespace urn:OECD:StandardAuditFile-Tax:2.00), for every company outside France and Germany. None of those countries (CH, LI, AT, GB, US, CA, IT, ES) requires it the way Portugal, Norway, Poland, Romania or Lithuania do with their national schemas; it is the international format audit software reads (Austria accepts it on request). UTF-8 XML written with a safe writer. Header: AuditFileVersion 2.00, country, date created, software (Stabiso), the company (name, address, VAT number; Stabiso keeps no separate register number), currency, the selected dates and periods, a comment saying whether the year is closed. MasterFiles: the general ledger accounts with opening and closing balance (debit or credit; profit and loss accounts open at zero, the result before the range on the result account, as in the GoBD file), customers and suppliers with VAT number, address and their balance on the receivables or payables account, and the tax table of the codes used (the code is the books' VAT treatment with direction, territory, rate type and rate, domestic-sales-CH-standard-8.1, so each is unique). GeneralLedgerEntries: NumberOfEntries, TotalDebit and TotalCredit (equal), then one Journal per source (sales, purchases, manual, ...), a Transaction per entry and a Line per journal line with record id, account, value date, source document, customer or supplier, description, the debit or credit in the functional currency with CurrencyCode, CurrencyAmount and ExchangeRate for a line in another currency, and TaxInformation (TaxBase on a base line, TaxAmount on a VAT line). The file has not been validated against a national SAF-T schema (those differ from the OECD one).
  • Audit evidence pack, for every company: a ZIP of |-delimited UTF-8 text files with a header row (. as decimal separator, ISO dates, UTC timestamps; a field holding |, " or a line break is quoted), laid out after the AICPA Audit Data Standards (General Ledger standard, Order to Cash and Procure to Pay subledgers), and a README.txt that describes the scope, whether the year is closed, the control totals, and every file and column. GL_Detail.txt (one row per journal line: entry number, line, year, period, effective, entered and posted dates, entered by, approved by when the entry went through a four-eyes approval, posted by, account, debit, credit, currency and amount, rate, source, document kind and number, customer or vendor, tax code, texts, the entry it reverses), Trial_Balance.txt (opening, period debits and credits, closing per account; the debits and credits are the sums of GL_Detail), Chart_Of_Accounts.txt, Business_Unit_Listing.txt, User_Listing.txt (every person of the organization with rights, status, invited when and by whom, last sign-in, and when their rights last changed per the audit trail), Sign_Ins.txt (sign-ins and security events: failed and refused attempts, key and user administration), Audit_Trail.txt (every audit event of the organization in the range: time, actor, action, object, request id, detail), Master_Data_Changes.txt (the audit events on customers and vendors, their bank details, SEPA mandates, bank accounts, accounts and roles), Approvals.txt (four-eyes requests decided in the range or concerning an entry of it), Period_Closes.txt (closing and reopening of the year's periods with reasons, and the year-end close steps, whenever they happened), Receivables.txt and Payables.txt (the invoices, bills and credit notes dated in the range with what is open on each at the end), Sales_Deals.txt (deals won in the range with value, close date, owner and the invoices of the subscriptions they were won for: quotes, orders and other invoices are not linked to a deal in the books) and Ledger_Integrity.txt (the hash chain verified when the pack was made, as GET /v1/entities/{entity}/chain-verification, with the last entry's hash). The audit trail and the security events are the organization's (they hold its other companies' events too); "in the range" for them is from the start of the first to the end of the last day where the company is.
curl "${H[@]}" "$ERP_URL/v1/entities/FR01/audit-files?fiscal_year=2026"
curl "${H[@]}" -OJ "$ERP_URL/v1/entities/FR01/audit-files/fec?fiscal_year=2026"   # 901234567FEC20261231.txt
curl "${H[@]}" -OJ "$ERP_URL/v1/entities/DE01/audit-files/gobd?fiscal_year=2026"  # GoBD_DE01_2026.zip
curl "${H[@]}" -OJ "$ERP_URL/v1/entities/CH01/audit-files/saft?fiscal_year=2026"  # SAF-T_CH01_2026.xml
curl "${H[@]}" -OJ "$ERP_URL/v1/entities/CH01/audit-files/evidence?fiscal_year=2026"  # Audit_Evidence_CH01_2026.zip

Purchase orders and matching

A purchase order commits the entity to buying from an active vendor. It posts nothing (see ledger). Statuses: draft → pending_approval → open → partially_received / partially_invoiced → closed, or cancelled with a reason. An approved order can also be closed for the rest (closed with a close_reason, ADR-0046). Numbers come from a sequence per entity and year (PO-2026-000001, receipts GR-2026-000001).

Operation What it does
POST /v1/entities/{e}/purchase-orders A draft: vendor, order_date, optional expected_date, currency and payment_terms_days (default: the vendor's), buyer_reference, delivery_address, notes, and lines with description, line_type (goods or services), optional account (expense or asset), quantity, unit_price, rate_type (the tax code) and requires_receipt (default: goods yes, services no). Idempotency-Key, 201
GET /v1/entities/{e}/purchase-orders Newest first; filters status (repeat), vendor, start/end (order date), q (number, buyer reference, vendor), review (orders with a paid bill to review) and limit
GET /v1/purchase-orders/{order} By id or number, with ETag: "purchase-order-<version>": lines with ordered, received and invoiced quantities, receipts, the bills matched to it (with their match status and payment block) and the pending approval request
PATCH /v1/purchase-orders/{order} A draft only (409 otherwise; lines replaces every line). Send the ETag as If-Match (412 when stale)
POST /v1/purchase-orders/{order}/submit If-Match. By default the order waits for a second person (pending_approval, an approval request with action approve_purchase_order, decided with POST /v1/approvals/{id}/approve or …/reject; a rejected order goes back to draft). When the approval policy allows it, a person with the approve scope approves it at once. Agents may submit, never approve
POST /v1/purchase-orders/{order}/cancel {"reason": "…"}, If-Match, Idempotency-Key. Only while nothing was received or invoiced; a pending request is withdrawn
POST /v1/purchase-orders/{order}/close Close the rest (short close): {"reason": "…"}, If-Match, Idempotency-Key. An open, partially_received or partially_invoiced order only (409 otherwise, order_nothing_to_close when every line was received or invoiced in full). What was received or invoiced stays; the rest of each line becomes its closed_quantity and is no longer expected: receipts beyond it answer 422 (received_beyond_closed) and a bill beyond it is a quantity variance (payment blocked). The order is then closed and final. No second person (like cancelling); the reason, who and when are on the order and in purchase_order.close
POST /v1/purchase-orders/{order}/receipts Goods or services received: receipt_date, note, lines of line_no and quantity (negative corrects an earlier receipt; the received total stays between 0 and the ordered quantity). Approved orders only; people only (policy action record_receipt, agents are refused). No ledger entry. The order's unpaid bills are matched again. Idempotency-Key, 201, answers the order card
GET /v1/purchase-orders/{order}/history Audit events: creation, changes, submission, approval or rejection, receipts, status changes, cancellation
GET /v1/documents/{id}/match How a bill matches its order: status (matched, variance, unmatched), payment_block, problems, the tolerance, price and quantity variances, and per bill line the order line, how it was paired, 2-way or 3-way, the variances and what the tolerance allows. 404 for a bill never matched
POST /v1/documents/{id}/match Match the bill again (e.g. a draft before submitting). An approved variance stays approved
POST /v1/documents/{id}/match/approve {"reason": "…"}, scope approve: approve the variance and release the payment block; the same as approving its request (action approve_match_variance) in Approvals. Not the person who entered or submitted the bill; never an agent
GET/PUT /v1/entities/{e}/purchase-settings The tolerances: tolerance_percent, tolerance_amount per line and tolerance_bill_amount per bill (default 2, 5.00 and 20.00; at most 10, 1,000 and 10,000). PUT needs admin and is audited with previous and new values

Matching. A bill (purchase_bill) whose order_reference (BT-13, entered, from an e-invoice or read by bill capture) names an order is matched when it is submitted: to the order of that number of the same vendor in the entity (case is ignored). Bill lines pair with order lines in passes: the order line number an e-invoice line names (BT-132), the same description, the only order line with the same unit price, the most similar description (word overlap at least one half), or a bill of one line with the order's last unpaired line; of several order lines with the bill line's description, the one at its price. Each paired line keeps its evidence (similarity, runner_up_line_no); a pairing by price or position whose descriptions are less than half alike is a problem a person approves. Per paired line:

  • price variance = the bill line's net amount − order unit price × billed quantity;
  • quantity variance = (quantity invoiced beyond the limit) × order price, where the limit is the ordered quantity (2-way, lines without receipt) or the received quantity (3-way), counting first what earlier bills of the order invoiced, less what posted credit notes of those bills gave back of the line (a credit note line that names the bill line, credits_line, at the bill line's unit price; a line at another price is a price reduction and lowers the invoiced amount only);
  • allowed = the lower of tolerance_percent of the expected amount and tolerance_amount;
  • per bill, the overcharge (higher prices, larger quantities, lines not ordered) may be at most tolerance_percent of the expected amount and at most tolerance_bill_amount.

A variance beyond what is allowed (only unfavourable ones: a higher price, a larger quantity), a bill line not on the order, or an order that is not approved or in another currency, makes the outcome variance: the bill is blocked for payment (left out of payment proposals, refused by payments, applications of money on account or of a credit note and payment files, all with 409 bill_payment_blocked, also by the database; a credit note of it posts but stays open instead of settling it) until a second person approves the variance with a reason (not a person who entered, submitted or matched the bill). The approval holds for the bill and order as they were and at most the overcharge approved: changing or reopening the bill clears it, and a larger overcharge asks again. A reference that names no order of the vendor in the company (no such order, another vendor's, the same vendor's in another company) is unmatched: submitting the bill sends its posting to a second person whatever the policy allows, with the reason in words in the approval request and the submit result's reason (ADR-0049); approved, it posts and is not blocked for payment (held_approved_by on the match names who approved it). A submitted or posted bill blocked by a variance is never matched down to unmatched: it stays blocked until the variance is approved. Receipts match the order's unpaid bills again, so a bill that came before the goods is released by their receipt, and a negative receipt can block one again; a paid bill it would block is flagged for review (review on its match and the order card, ?review=true on the order list; cleared when the receipts no longer block it). An order closes when every line is invoiced and, where it needs a receipt, received.

PO=$(curl "${H[@]}" -H 'Idempotency-Key: po-1' "$ERP_URL/v1/entities/CH01/purchase-orders" -d '{
  "vendor": "V-BM", "order_date": "2026-09-22",
  "lines": [{"description": "Office chairs", "quantity": "10", "unit_price": "100"}]}' | jq -r .id)
curl "${H[@]}" -X POST "$ERP_URL/v1/purchase-orders/$PO/submit"          # waits for a second person
curl "${H[@]}" -H 'Idempotency-Key: gr-1' "$ERP_URL/v1/purchase-orders/$PO/receipts" \
  -d '{"receipt_date": "2026-09-23", "lines": [{"line_no": 1, "quantity": "10"}]}'
curl "${H[@]}" "$ERP_URL/v1/documents/$BILL/match"
curl "${H[@]}" "$ERP_URL/v1/documents/$BILL/match/approve" -d '{"reason": "new price list"}'

Fiscal calendars

A fiscal year is twelve calendar months unless it is opened with another calendar (see Fiscal calendars for the definitions):

# 4-4-5 weeks, years ending on the last Saturday of January, one adjustment period
curl "${H[@]}" "$ERP_URL/v1/entities/RT01/fiscal-years" -d '{"fiscal_year": 2027,
  "calendar": {"calendar": "4-4-5", "year_end": "last_weekday", "end_weekday": "saturday",
               "adjustment_periods": 1}}'

curl "${H[@]}" "$ERP_URL/v1/entities/RT01/fiscal-years"            # calendars and periods
curl "${H[@]}" "$ERP_URL/v1/entities/RT01/periods?on=2027-01-30"   # period 12, then 13

# an audit adjustment after period 12 is closed
curl "${H[@]}" -H 'Idempotency-Key: adj1' "$ERP_URL/v1/journal-entries" -d '{
  "entity": "RT01", "entry_date": "2027-01-30", "adjustment_period": 13,
  "description": "Audit adjustment: accrued audit fees",
  "lines": [{"account": "6500", "debit": "1200"}, {"account": "2300", "credit": "1200"}]}'

curl "${H[@]}" "$ERP_URL/v1/entities/RT01/reports/period-profit-and-loss?fiscal_year=2027&period=13"
  • calendar is monthly, 4-4-5, 4-5-4, 5-4-4 or 13x4; year_end is month_end (monthly only), last_weekday or nearest_weekday; end_weekday is a weekday name; and adjustment_periods is 0 to 2. Periods carry kind (regular or adjustment).
  • period-trial-balance covers every posting through the period, so on the last day of the year period 12 is the trial balance before adjustments and period 13 after them.
  • period-profit-and-loss returns current, year_to_date, prior_current and prior_year_to_date (the same period number of the previous fiscal year), with notes when the period and its comparative differ in length.

Tax books and deferred tax

# a tax book with ESTV rates that carries deferred tax at 19.6% (admin)
curl "${H[@]}" -H 'Idempotency-Key: b1' "$ERP_URL/v1/entities/CH01/depreciation-books" -d '{
  "code": "TAX-CH", "name": "ESTV", "regime": "ch.estv", "deferred_tax": true,
  "tax_rate_percent": "19.6", "deferred_tax_cadence": "year_end"}'

# put an asset in the book (also possible with "books" on POST /v1/assets)
curl "${H[@]}" -H 'Idempotency-Key: ab1' "$ERP_URL/v1/assets/FA-001/books" -d '{
  "book": "TAX-CH", "asset_class": "ch.estv.it_hardware_software"}'

curl "${H[@]}" "$ERP_URL/v1/assets/FA-001/books?as_of=2026-12-31"
curl "${H[@]}" "$ERP_URL/v1/entities/CH01/book-tax-differences?fiscal_year=2026&period=12"
curl "${H[@]}" -X POST "$ERP_URL/v1/periods/$DECEMBER/deferred-tax"
  • GET /v1/assets/{code}/books lists the accounting book (BOOK, posts_to_gl: true), the older single tax memo (MEMO, from tax_asset_class) if there is one, and each tax book. Each has its schedule by fiscal year (a tax book's years also show the accounting book's value and the difference), the depreciation to as_of and the carrying amount or tax base. Tax books add the temporary difference and, for the book that carries it, the deferred tax.
  • A tax book needs asset_class from the book's jurisdiction, or a method with useful_life_years or rate_percent. A reducing balance without a useful life (CH ESTV) ends with the asset: in the last fiscal year of its accounting schedule, when it is scrapped, what is left of the tax value is written off. A regime that pools assets (UK capital allowances) keeps it in the pool, shown for 30 years. periods shows a fixed number of years instead.
  • book-tax-differences lists, per asset and tax book, the carrying amount, tax base, temporary difference and deferred tax at the period end. Per book it gives the net deferred tax, the liability or asset, what has been posted so far and the movement that the next run would post.
  • POST /v1/periods/{id}/deferred-tax (draft scope) posts that movement once per period, after the period's depreciation, through the approval policy.
  • PATCH /v1/entities/{e}/depreciation-books/{code} changes name, tax_rate_percent or deferred_tax_cadence.

Bank statements

Upload an ISO 20022 camt.053 statement or camt.054 credit/debit notification (Swiss and EU banks; format=camt053|camt054), a SWIFT MT940 statement (format=mt940, with or without the SWIFT envelope; the :86: details read in the German structured form with SEPA keywords EREF+/SVWZ+ or as /EREF/, /REMI/, /NAME/ codes) or a CSV file with a csv_mapping (column names, date format, decimal comma, delimiter). An MT940 statement whose transactions do not add up from its opening to its closing balance is refused (mt940_balance_mismatch). Duplicate statements are refused and duplicate transactions are skipped: the transaction-level Account Servicer Reference identifies a transaction across camt.053 and camt.054, so a credit notified in camt.054 is not imported again from camt.053. camt.054 entries that are still pending (PDNG) are skipped, and collective bookings (for example QR-bill credits booked together) are split into one line per transaction.

Matching, in this order:

  1. A debit whose end-to-end id is a released payment (see pain.001 below) is linked to that payment; nothing is posted again.
  2. A structured reference (QR reference or ISO 11649 creditor reference) that identifies an open invoice (the references our QR-bills carry) or bill (the vendor's payment_reference) settles it, also partly when less is paid. An overpayment waits for a person.
  3. A reference that names an open invoice or bill, with exactly its open amount.
  4. A unique open amount from the identified counterparty (IBAN or name).

Everything else is waiting in bank-lines, with scored candidates, for a human, an agent or the reconciliation assistant. A credit or debit of the open amount less the document's cash discount (paid within the discount period) settles it in full and takes the discount, both in the rules above and in candidates (cash_discount, reason "open amount less cash discount").

Reconciling by hand. GET /v1/entities/{e}/bank-statements lists imported statements and notifications with their lines by status; bank-lines?statement=…&status=all lists one statement's lines. A line is matched to one or more documents of one party (POST /v1/bank-lines/{id}/match with allocations, each with amount and optional discount), posted to an account (…/post, for bank charges or interest), or ignored with a reason (…/ignore, post scope). POST /v1/bank-lines/{id}/reopen (post scope) undoes an ignore. A match or posting that is rejected in approval returns the line to unmatched; a posted one is not undone by reversing its entry (the journal refuses entries of the bank source, as the bank line and the documents it settled would stay posted). The reconciliation assistant (POST /v1/agents/entities/{e}/reconcile with "apply": false) only proposes; the web UI applies a proposal through match or post when the user confirms it.

Swiss payments

Standards: Swiss Implementation Guidelines QR-bill 2.3 (valid from 21 November 2025, structured addresses only) and Swiss Payment Standards 2025 for pain.001.001.09 (IG 2.2, schema pain.001.001.09.ch.03) and camt.053/054 (Cash Management IG 2.2).

Bank accounts. PUT /v1/entities/CH01/bank-accounts/1020 (admin) records the IBAN of a GL bank account, optionally its QR-IBAN, BIC and the bank's customer prefix for QR references, and the account holder's structured address (the creditor on QR-bills must match the account holder). Incoming statements find their account by this IBAN or QR-IBAN.

curl "${H[@]}" -X PUT "$ERP_URL/v1/entities/CH01/bank-accounts/1020" -d '{
  "iban": "CH93 0076 2011 6238 5295 7", "qr_iban": "CH64 3196 1000 0044 2155 7",
  "street": "Bahnhofstrasse", "building_number": "1", "postal_code": "8001", "town": "Zürich"}'

QR-bill. GET /v1/invoices/INV-2026-000001/qr-bill returns the Swiss QR Code data (payload) and its fields for the open amount of a posted sales invoice in CHF or EUR; format=svg or format=pdf returns the payment part with receipt (210 × 105 mm, Swiss cross, scissors) in language en, de, fr or it. With a QR-IBAN the reference is a QR reference: the bank prefix, zeros and the digits of the invoice number, plus the modulo 10 check digit (INV-2026-000001 → 00 00000 00000 00002 02600 00018). With an IBAN it is an ISO 11649 creditor reference (RF94INV2026000001). A receivable the opening-balance import brought in keeps the number of the old books (2025/42); its QR-bill shows the open amount only, and its reference is in a namespace of its own, so it can never equal one of an invoice this system issued (INV-2026-000042): a QR reference of the digit 9 and 15 digits of a fingerprint of the number, an ISO 11649 reference RF..OB and a fingerprint. Incoming payments are matched by the same derivation (swiss_payments.references). The customer is printed as debtor when the party has a postal code and town (street, building_number, postal_code, town on POST /v1/entities/{e}/parties).

Vendor QR-bills. A purchase bill can carry the vendor's payment_iban (for example a QR-IBAN) and payment_reference (QR or creditor reference). They are validated together: a QR-IBAN needs a QR reference, a QR reference needs a QR-IBAN.

pain.001. GET /v1/payments?entity=CH01 lists outgoing payments with their payment type, whether they were sent, and what prevents a release. POST /v1/payment-exports (post scope, Idempotency-Key) with {"entity": "CH01", "payments": [ids]} releases payments as one file:

  • only payments that passed the approval policy (status posted) qualify, so a payment that needed approval was approved by a second person (four eyes); agents cannot release payment files (policy action release_payments);
  • by a person who did not create the payment (segregation of duties, policy action release_own_payments, denied by default; an agent's payment counts as its caller's), and never with a key the system issued;
  • only to an active party whose bank details are not waiting for approval, and only to the party's IBAN or one of its further bank accounts (a bill's payment_iban must be one of them);
  • payment type D for CHF/EUR to a CH/LI IBAN (QR-IBAN with QRR, IBAN with SCOR or unstructured text) and S (SEPA, service level SEPA, charge bearer SLEV) for EUR to other SEPA countries; other transfers (type X) are refused;
  • the debit account is the registered IBAN of the payment's bank account, the debtor agent its BIC or, without one, the IID from the IBAN (CHBCC); the end-to-end id is the payment number;
  • the requested execution date (execution_date, default the payment date or today) is rolled forward (following) onto a business day of the payment's clearing calendars from data-vNext: SIC (ch.six.sic) for CHF, TARGET and SIC (eu.target2,ch.six.sic) for EUR, SIC for other currencies. 25 December 2026 (a Friday) becomes Monday 28 December, 1 January 2027 becomes 4 January. Payments whose dates differ go into separate PmtInf blocks; the export's execution_dates list each payment's requested and execution date and calendars;
  • the file, its SHA-256, the message id and who released it are stored append-only (GET /v1/payment-exports/{id}/file), each payment is released at most once, and the audit trail records payment.export (with the execution dates) and payment.sent (with requested_execution_date when the date was moved).

Cash discounts and payment proposals

Terms. A party can have cash discount (Skonto) terms next to its net days: "payment_terms_days": 30, "cash_discount_percent": "2", "cash_discount_days": 10 is "30 days net, 2% within 10 days" (PATCH …/parties/{party} with "cash_discount_percent": 0 removes them). Invoices and bills copy them, or take their own cash_discount_percent with cash_discount_days or cash_discount_date (0 for none). A document stores cash_discount_percent, cash_discount_date (document date plus the days, rolled onto a business day with due_date_convention like the due date, and never after the due date) and cash_discount_amount (the percentage of the gross total, rounded to the currency). Credit notes have no terms. The aging report shows each item's cash_discount still available on as_of.

Taking the discount. An allocation on POST /v1/payments (and on bank-line matches) can carry discount next to amount: the document is settled by amount + discount, and the payment is the sum of the amounts. A discount needs terms on the invoice or bill and a payment date on or before the discount date, and it is proportional: at most the terms' percentage of what the allocation settles (amount + discount; 2% of 500 settled is 10) and no more than what is left of the document's discount, so two partial payments take 21.62 of a 1,081.00 bill between them. The documents a payment settles are locked while it is booked, so concurrent payments cannot over-allocate or over-discount. The discount is booked as a proportional correction of the document's own posting (see Ledger: cash discounts), so VAT is corrected with it, dated on the payment: it falls in the payment's VAT period, and a payment with a discount dated in a period locked by a final VAT return is refused (amend the return or date the payment later).

Accounts. A discount is booked to the accounts with the roles cash_discount_granted and cash_discount_received. Entities created before these roles existed get them with POST /v1/entities/{e}/role-accounts (admin, Idempotency-Key; CLI erp add-role-accounts --org … --entity …): it adds the chart template's account for each required role the entity lacks, refuses (409) naming the codes when a template code is already in use, and adds nothing when repeated. Until then a document with terms carries a warning, a proposal that would take a discount is refused, and a payment with a discount fails, each naming the fix.

Payment proposal. POST /v1/entities/CH01/payment-proposals (read scope; nothing is stored) suggests vendor payments like Business Central's "Suggest Vendor Payments":

curl "${H[@]}" "$ERP_URL/v1/entities/CH01/payment-proposals" -d '{
  "payment_date": "2026-09-25", "due_until": "2026-10-01", "take_discounts": true,
  "vendors": ["V-BM"], "currency": "CHF"}'
  • every open posted bill due by due_until (the last payment date, default the payment date) is proposed (reason overdue or due); with take_discounts, so is a bill not yet due whose cash discount is still available on the payment date and would expire by due_until (reason cash_discount);
  • each line has the open amount, the discount taken on the payment date (the terms' percentage of the open amount, at most what is left of the document's discount), the amount to pay, the payment account and reference, and warnings (no IBAN, a draft vendor); totals_by_currency adds them up;
  • bills whose payments wait for approval are not proposed again (pending allocations reserve their amount);
  • bills of a blocked vendor, or of one whose block waits for approval, are left out and listed in skipped with the vendor, the open amount, the code (party_blocked, party_block_waiting) and the reason (ADR-0046); so are bills another process settles (bill_settled_elsewhere: a purchase on a company card, which the card issuer collects).

POST /v1/entities/CH01/payment-runs (draft scope, Idempotency-Key) creates the reviewed lines as payments, all or none:

curl "${H[@]}" -H 'Idempotency-Key: run-0925' "$ERP_URL/v1/entities/CH01/payment-runs" -d '{
  "payment_date": "2026-09-25", "bank_account": "1020",
  "lines": [{"document": "BILL-2026-000007", "amount": "1059.38", "discount": "21.62"}]}'

One payment per vendor, currency and payment account; a bill with a structured reference (QR or creditor reference) gets its own payment, because pain.001 carries one reference per payment. Each payment goes through POST /v1/payments' path: allocations, cash discount postings, the approval policy (a key without the post scope, or an agent above its limit, waits for approval) and the audit trail (payment_run.create). The result lists each payment with its status; posted ones are released with POST /v1/payment-exports as before.

SEPA direct debits

Collecting customer invoices by SEPA direct debit (pain.008), the other way round from the payment run (ADR-0076). SEPA in EUR only: invoices in another currency are not proposed, the account collected into must hold EUR, and other direct debit schemes (Swiss LSV+/CH-DD, UK Bacs) are not supported.

Creditor identifier. The company's SEPA creditor identifier is set on the EUR bank account it collects into: PUT /v1/entities/DE01/bank-accounts/1010 with "creditor_id": "DE98ZZZ09999999999" (admin). It is checked like an IBAN (ISO 7064 MOD 97-10 over the country, check digits and national identifier, the business code ignored); a wrong one is refused (creditor_id_invalid).

Mandates. POST /v1/entities/DE01/customers/C-HH/mandates (draft scope, Idempotency-Key) records a customer's mandate:

curl "${H[@]}" "$ERP_URL/v1/entities/DE01/customers/C-HH/mandates" -d '{
  "reference": "HH-2026-0001", "signed_on": "2026-01-15", "debtor_name": "Hamburg Handel GmbH",
  "iban": "DE44500105175407324931", "bic": "INGDDEFFXXX", "scheme": "B2B", "recurring": true}'
  • the reference (up to 35 characters of the SEPA set) is unique for the company; the signature date is not in the future; the IBAN is in a SEPA country; scheme is CORE or B2B; recurring false makes a one-off mandate; prenotification_days (default 14) is the notice the customer gets before a collection;
  • a mandate decides where money comes from, so it is a control like a customer's bank details: a person's new mandate waits for a second person (an approval request of type sepa_mandate, approved with POST /v1/approvals/{id}/approve; whoever recorded it cannot approve it), and so does a new IBAN (PUT /v1/mandates/{id}/account; the holder's name or the BIC alone apply at once). Agents never record, change, approve or revoke mandates; the system (the demo seed) applies at once;
  • GET …/customers/C-HH/mandates lists them with status: pending_approval, active, rejected, revoked (POST /v1/mandates/{id}/revoke), expired (no collection sent for 36 months, counted from the last one or from the signature) or used (a one-off mandate collected once), expires_on, the next_sequence and a waiting change.

Sequence types. All collections under a recurring mandate are RCUR, the first one too: FRST has been optional since the November 2016 rulebook and is not used, so the first collection needs the same lead time as the others. A one-off mandate's collection is OOFF. FNAL is not produced. A collection after a change of IBAN carries the amendment (AmdmntInd with the former IBAN, or SMNDA when the debtor moved to another bank).

Pre-notification. The invoice is the pre-notification: an invoice is collected only when its date plus the mandate's prenotification_days (14 calendar days unless agreed otherwise) is on or before the collection date. Any other is skipped with direct_debit_prenotification. An invoice is not collected before its due date either (direct_debit_before_due_date), except within its cash discount period, less its whole discount (direct_debit_discount_not_taken; without a cash discount account, direct_debit_discount_account). The collection date is the day the bank collects on if the file is released today: a date before the first possible one (D-1) or on a closed day is moved forward in the proposal (its collection_date) and the run, and a collection released so late that the bank would collect after the discount period is not sent (direct_debit_discount_period_ended).

Proposal and run. POST /v1/entities/DE01/direct-debit-proposals (read scope; nothing is stored) with {"collection_date": "2026-10-20"} (and optionally customers and bank_account) lists every open posted EUR sales invoice of a customer with an active mandate that is due by the collection date or still in its cash discount period, with the mandate, scheme, sequence type and pre-notification date; amount is the open amount less the discount taken. Invoices that cannot be collected are in skipped with a code and the reason (no mandate, mandate waiting/revoked/expired/used, a change waiting, the pre-notification too short); problems says what stops the run as a whole (no EUR account with a creditor id, no payments-in-transit account: direct_debit_no_transit_account). A run line takes the amount and the discount of its proposed line. POST /v1/entities/DE01/direct-debit-runs (draft scope, Idempotency-Key) collects the confirmed lines, all or none: one receipt per mandate through POST /v1/payments' path (allocations, approval policy, audit trail), dated the collection date and booked on the payments-in-transit account until the bank statement shows the money (ADR-0060). GET /v1/entities/DE01/direct-debits lists the collections with their mandate, sequence type, bank state and what stops a release.

pain.008. POST /v1/direct-debit-exports (post scope, Idempotency-Key) with {"entity": "DE01", "collections": [ids]} releases posted collections as one pain.008.001.08 file, under the rules of a pain.001 file: agents cannot release, nor the person who created the collection; a test copy makes no file; the file and its SHA-256 are stored append-only, each collection is released once, and the audit trail records payment.export and payment.sent. The collection date is the receipt's date, at the earliest the TARGET2 business day after the next one (D-1: the bank must have the file one TARGET2 business day before), rolled forward onto a TARGET2 business day (and a SIC day for a Swiss creditor account). Batches (PmtInf) are per creditor account, date, scheme and sequence type. When the debtor's bank or the creditor account is outside the EEA (EPC153-22), the file has the customer's postal address, and the company's own when its account is. A changed creditor identifier waits for a second person, and the next collection under each mandate carries the former one (OrgnlCdtrSchmeId). Released files are listed by GET /v1/payment-exports?kind=direct_debit and downloaded with GET /v1/payment-exports/{id}/file.

Returns. A debit line on a camt.053/054 statement that the bank marks as an R-transaction (a return or refund: RtrInf, or bank transaction code IDDT/UPDD or PRDD) with the receipt's number as end-to-end id is matched to the collection and reverses it, with the bank's reason code (for example MD06, AM04), so the invoices are open again. A return that nets the bank's fee (up to 50.00 more than the collection) is matched too, and the fee is booked on the bank_fees account. After a return with AC04 or AC06 nothing more is collected from that account, collections already made included (mandate_returned), until the customer's new IBAN is recorded; a collection made before the change is reversed and made again (direct_debit_account_changed). After MD01, MD07 or MS02 the mandate is not collected from at all (mandate_refused): revoke it and record a new one. A pain.002 rejecting a pain.008 file reverses its collections the way it does for payments (ADR-0062).

E-invoicing

Standards: EN 16931-1:2017 (semantic model, CEN) with the syntax bindings for UBL 2.1 Invoice and CreditNote and UN/CEFACT CII D16B; XRechnung 3.0 (KoSIT, 3.0.2, Schematron 2.6.0); ZUGFeRD 2.3 / Factur-X 1.07 hybrid PDFs (PDF/A-3 with an embedded factur-x.xml, zugferd-invoice.xml or xrechnung.xml). In Germany businesses must be able to receive e-invoices since 1 January 2025.

Receiving. POST /v1/documents/einvoice (multipart: entity, file, optionally vendor, create_vendor, submit; draft scope, Idempotency-Key) takes an XRechnung (UBL or CII) or a ZUGFeRD / Factur-X PDF and returns the format (label, e.g. e-invoice (XRechnung 3.0 CII)), the parsed invoice, every rule violation and the bill:

  • The file is stored content-addressed as the bill's source document; its SHA-256 and the format are in the bill's provenance.einvoice. The same file, or the same vendor invoice number (BT-1) for the vendor, is refused with 409.
  • An invoice that breaks a fatal rule is stored but not booked: status: invalid with the violations by rule id. MINIMUM and BASIC WL profiles have no lines and fail BR-16; they are not e-invoices in the EN 16931 sense.
  • A valid invoice becomes a draft purchase bill (a credit note, type 381, becomes a vendor credit note linked to the bill it names in BG-3), without a model call:
    • vendor by VAT identifier (BT-31), then by IBAN (BT-84), else created as a draft vendor from the seller's details. A second person reviews and activates it (see Customers and vendors); until then the bill stays a draft, also with submit=true (a warning says so);
    • each line goes to the account in the buyer accounting reference (BT-133 or BT-19) when that is a postable expense or asset account, else the vendor's default account, the account most used on the vendor's bills, or the entity's default expense account;
    • VAT categories map onto the tax determination: S picks the rate type (standard, reduced, …) whose determined rate equals the invoice's, Z and E the zero and exempt rate types; AE is booked as reverse charge, self-assessed at the domestic rate (also for UStG §13b between German businesses, with reverse_charge on the line); K is an intra-Community acquisition; G, O, L and M follow the determination. The printed VAT per category and rate (BT-117) is spread over its lines and booked; where it differs from the determined VAT, or the category contradicts the determination, the bill carries a warning. A foreign vendor's own VAT that we cannot reclaim is booked as cost (foreign_vat), so the bill's total is the invoice total;
    • document level allowances and charges (BG-20, BG-21) become their own lines (an allowance is a negative line); prepaid and rounding amounts are flagged.
  • submit=true passes the bill through the approval policy at once (posted, or queued); otherwise submit it later with POST /v1/documents/{id}/submit. Dated in a closed period, submit=true is refused with 409 period_closed (einvoice: true, a hint): import it without submitting, give the draft a date in an open period, then submit it.

POST /v1/einvoices/check (read scope) parses and checks a file without storing anything. Bill capture (POST /v1/agents/bill-capture) books an XML file or a PDF with embedded e-invoice XML from the XML (source: einvoice, no model call); other PDFs and images go to the model as before.

Rules checked (src/erp/einvoice/rules/, in Python, not the KoSIT validator):

  • EN 16931 presence and consistency: BR-01 to BR-33, BR-36 to BR-38, BR-41 to BR-51, BR-53, BR-55 to BR-57, BR-61 to BR-65;
  • the arithmetic BR-CO-10 to BR-CO-17 (line, allowance and charge sums; totals with and without VAT; amount due; VAT per category), and BR-CO-03, 04, 09, 18, 25, 26;
  • per VAT category, S, Z, E, AE, K (BR-IC), G, O, L (BR-AF) and M (BR-AG): -01 to -10 (breakdown, identifiers, rates, taxable amount per category and rate, tax amount, exemption reason), plus BR-O-11 to 14 and BR-IC-11/12;
  • at most two decimals in amounts (the BR-DEC rules for amounts);
  • XRechnung 3.0 when BT-24 names it: BR-DE-1 to 11, 14 to 21, 23-a to 25-b, 26 to 28, 30, 31, and BR-DEX-09 (third party payments of the extension).

Tolerances follow the CEN artefacts: sums must match to the cent; a VAT amount may differ from taxable amount × rate by less than one unit (BR-CO-17). Not checked: the XSD and the syntax rules (CII-SR, UBL-SR, UBL-CR), code list membership (BR-CL), attachments (BR-52, BR-DE-22), BR-CO-05 to 08 and 19 to 24, CVD and other extension rules, Italian split payment (BR-B).

Issuing. GET /v1/invoices/INV-2026-000001/xrechnung returns XRechnung 3.0 CII for a posted sales invoice or credit note of a German entity (xrechnung-INV-2026-000001.xml). The seller is the entity: PATCH /v1/entities/DE01 sets its address, contact (BT-41 to BT-43) and electronic address (BT-34, default its contact email); the payment instructions are the registered bank account (PUT /v1/entities/{e}/bank-accounts/{account}, code 58 for EUR). The buyer reference (BT-10) is the document's buyer_reference or the customer's leitweg_id (checked with its ISO 7064 check digits); with a Leitweg-ID and no other address the buyer's electronic address is the Leitweg-ID (scheme 0204). The output is checked with the same rules and refused with 422 and the violations when a fatal rule fails, for example BR-DE-15 for a customer without a buyer reference; format=json shows the check.

curl "${H[@]}" -X PATCH "$ERP_URL/v1/entities/DE01/parties/C-BUND?kind=customer" \
  -d '{"leitweg_id": "04011000-1234512345-06"}'
curl "${H[@]}" -o rechnung.xml "$ERP_URL/v1/invoices/INV-2026-000001/xrechnung"
curl -H "Authorization: Bearer $KEY" -H 'Idempotency-Key: ei-1' \
  -F entity=DE01 -F file=@zugferd.pdf "$ERP_URL/v1/documents/einvoice"

ZUGFeRD / Factur-X output is the hybrid of Invoice output. Not yet: the VAT total in the accounting currency (BT-111) for invoices in other currencies, UBL output, a cash discount (BT-20) in the XRechnung endpoint (the hybrid has it), and sending through Peppol.

VAT returns

Returns are computed from the tax-tagged ledger with the official forms that data-vNext curates (data/tax/returns, GET /v1/tax/indirect/return-forms). Every invoice and bill line is tagged at posting with the facts that decide the boxes: base or tax, sales or purchases, the treatment from tax determination, the taxing jurisdiction, rate and rate type, supply type and, for purchases, whether the expense is cost of sales (Swiss Ziffer 400) or investment and other operating expense (405). Each form's rules route a line's functional amount to boxes.

Input tax 400 or 405, and its account (ADR-0058). Which of the two a purchase line's input tax is decides a rule of the entity's chart (input_vat in src/erp/seed/charts/*.yaml, read by erp.modules.chart_rules), by the class (leading digits), report line or type of the account the line books to; the first rule that fits decides, an account none fits is 405. The Swiss KMU chart: class 4 (materials, goods for resale, third-party services, energy) is Ziffer 400 and its VAT is booked to 1170 (role vat_input); classes 1 (fixed assets), 5 and 6 (personnel, rent, administration, other overheads) are Ziffer 405 and booked to 1171 (role vat_input_investments). A chart without rules treats the accounts on the cost-of-sales report lines as 400, and an entity without a vat_input_investments account books all input tax to vat_input. The entity keeps the chart it started from (chart), so an account added later follows the rule. Self-assessed (acquisition) tax deducted again follows it too; a cash discount corrects the VAT account of the line it corrects.

Due dates. GET /v1/entities/{entity}/vat-periods gives each period's due_date: CH 60 days after the period (MWSTG Art. 71, 86), DE the 10th of the next month or a month later with the extension (ZM the 25th day), GB one month and seven days. A Swiss or German date on a Saturday, Sunday or public holiday moves to the next working day of the country's business-day calendar, the one payment dates use (CH SIC, DE TARGET; a year the calendar does not cover moves off weekends only); a British one does not move. A return whose earlier periods (this and last year, since the books began) have no final return yet carries the warning earlier_period_unfiled (details.periods); it may still be finalised.

Customers abroad. A sale to a customer in another VAT area is determined as B2B (the reverse charge, for a service) only when the customer has a VAT number or is marked business; otherwise as B2C, with the place-of-supply rules for a private person (a Swiss service abroad: no Swiss VAT, Ziffer 221; an electronic service: the customer country's VAT), and the document warns customer_not_business. A business without a VAT number keeps the reverse charge and the warning reverse_charge_without_vat_number asks for the number as proof.

Return Form Periods Files
CH, effective method ESTV MWST-Abrechnung (Ziffern 200 to 510 and 900/910 as on the specimen of the online return); tax per rate is turnover x rate, rounded only in Ziffer 500/510 quarter (month, year with ESTV approval) eCH-0217 V2.0.0 XML (effectiveReportingMethod, net turnover) for the ESTV portal upload
CH, net tax rate method ESTV MWST-Abrechnung, Saldosteuersatz (Ziffer 323 per approved activity, gross turnover; Bezugsteuer 383 at the statutory rate) half year (year) eCH-0217 XML (simpleTaxRateMethod with the ESTV's five-character activity code)
DE Umsatzsteuer-Voranmeldung 2026 (Kennzahlen of the Vordruckmuster USt 1 A, BMF-Schreiben vom 29.12.2025); bases in whole euros, the tax of lines 13 to 15 and 25 to 27 computed from the base month or quarter Kennzahlen as JSON and a printable form (no ELSTER transmission)
FR Déclaration de TVA CA3, formulaire n° 3310-CA3-SD 2026 (cerfa n° 10963*31), régime réel normal: every line of frames A and B by its case number (A1/0979, 08/0207, ...); bases and tax in whole euros (rounded), the tax of each rate line computed from its base; frame A must equal the bases of the rate lines (a difference of rounding goes to the largest line of frame A); imports self-assessed under CGI art. 1695 II on line I1 to I6 and deducted on line 19 or 20 (and 24); a credit of the last return enters line 22 as an adjustment month or quarter case numbers as JSON and a printable form in French (no EDI or EFI transmission). The annual CA12 (régime simplifié) is not built: the 2026 form could not be obtained from the DGFiP and the régime ends on 1 January 2027
DE Zusammenfassende Meldung: one row per customer VAT number and kind (L goods, S services, D triangulation), whole euros month or quarter BZStOnline-Portal CSV (#v1.0, header, rows; pass csv_version if the portal shows another version)

Every return can be downloaded as JSON, a printable HTML page or a PDF.

Settings. PUT /v1/entities/CH01/vat-settings (admin) sets the method (effective, net_tax_rate; standard outside Switzerland), the period (in France month or quarter, the CA3 periods; anything else is refused with vat_period_french), the ZM period, the German tax_number, the settlement account (default: the account with role vat_settlement) and, for the net tax rate method, the rates the ESTV approved:

curl "${H[@]}" -X PUT "$ERP_URL/v1/entities/CH01/vat-settings" -d '{
  "method": "net_tax_rate", "period": "half_year",
  "net_tax_rates": [{"series": "ch.vat.net_rate.treuhandbuero", "activity_id": "A1234",
                     "accounts": ["3400"]}]}'

The entity's vat_number is its UID (CHE-123.456.788 MWST) for the eCH-0217 file.

Foreign currency. The ledger books foreign-currency documents at the ECB fixing of the document date. The VAT on them follows the official rates instead, which data-vNext serves at /v1/fx/tax-rates:

Entity tax_fx Rate
CH monthly (default) ESTV monthly average (BAZG), MWSTV Art. 45 Abs. 3
CH daily ESTV daily foreign-exchange selling rate (BAZG) valid on the tax point
DE monthly (default) BMF monthly average of the month of the supply, UStG §16 Abs. 6
DE ecb_daily ECB reference rate of the day, only with the tax office's approval

Each foreign-currency document of a CH or DE entity stores the official rate for its tax point (the document date; a credit note keeps the rate of the document it corrects) in tax_fx (series, rate, quotation, month or fixing date, dataset version), with tax_fx_rate, the totals tax_fx_net_total and tax_fx_tax_total, and each line's tax_fx_net_amount and tax_fx_tax_amount. Its tax-tagged journal lines carry their amount at that rate, and returns put those amounts in the boxes. The rate method is kept for at least a whole tax period (MWSTV Art. 45 Abs. 5): changing tax_fx is refused while posted documents converted with the current method are not covered by a final return. tax_fx_booking: true also books documents at the official rate (the journal lines' fx.source is then the series, e.g. de-vat-monthly); by default the ECB translation stays, and the difference between the VAT booked and the VAT at the official rate is part of the reconciliation's computational difference (tax_fx_difference shows that part), cleared to rounding by the settlement. Each VAT account of the reconciliation shows what the return takes of it at the official rate (reported) and its own tax_fx_difference to the ledger: the acquisition tax and its input tax differ by the same amount, which cancels in the amount payable but not in Ziffer 383 and 405.

A rate that is not published yet leaves the document provisional (tax_fx.status, a warning on the document): the BMF announces a month's rates on the first working day of the next month, so a German invoice in USD is always provisional when it is posted in its month. A draft looks the rate up again when it is submitted; a posted document's journal lines name the series and tax point, and the return looks the rate up when it is computed (result.tax_fx lists every rate used, looked_up for those). While a rate is still missing, the return has the error tax_fx_not_published, shows the lines at the booking rate and cannot be finalised. A currency the authority does not publish stays provisional the same way (MWSTV Art. 45 Abs. 3bis: use a bank's selling rate, which is not automated).

Periods. Returns cover calendar months, quarters, half years or years by posting date, independent of the entity's fiscal calendar (4-4-5 periods and adjustment periods included: an adjustment-period entry counts on its date, the last day of the fiscal year).

Lifecycle.

  1. POST /v1/vat-returns with {"entity": "CH01", "period": "2026-Q3"} (or 2026-08, 2026-H2, 2026; "kind": "recapitulative_statement" for the ZM) computes a draft and stores which ledger line fills which box (GET …/boxes/303/lines). Boxes the ledger cannot fill (import VAT, Einlageentsteuerung, Sondervorauszahlung Kz. 39 …) take adjustments; those that change the amount payable need an offset account.
  2. The reconciliation compares the movements on the VAT accounts with the return: amounts reported, the computational difference (the form computes tax from the turnover, the ledger holds the VAT of each document; foreign-currency VAT is on the return at the official rate and in the ledger at the booking rate; under the net tax rate method this is the net tax rate difference), adjustments, lines no box takes and postings without a tax code. The last two are the unexplained difference: finalising needs it to be zero or an explanation.
  3. POST …/finalize (humans only; policy action finalize_vat_return; by a person who neither created nor last computed the return unless the policy allows finalize_own_vat_return, never with a key the system issued) recomputes the return, refuses it if the ledger changed since the draft, and creates the settlement entry (source vat) through the normal posting path: it clears the reported VAT from the VAT accounts to the settlement account and books the computational difference to rounding (net tax rate method: vat_net_rate_difference). A key without post sends it for approval and the return is pending_approval; the approver's posting re-checks the ledger. Once posted, the return is final and the database locks the period's VAT postings (see ledger). The entry is dated the period's last day, or the first open day after it when that period is closed (posting_date overrides). A ZM has no settlement.
  4. POST …/amend (approve scope) opens a correction: the original becomes amending and the period is unlocked; the correction is a full return (eCH-0217 typeOfSubmission 2), its settlement books the difference to the original, and finalising it marks the original superseded. A ZM correction lists only the rows that changed, with 0 for rows to cancel (BZSt Anleitung, Textziffer 12). Discarding a correction puts the original back in force.

Every step is in the audit trail (vat_return.compute, .finalize, .amend, .discard), and a final return, its result and its box lines cannot be changed.

Exchange rates

Seven read operations under /v1/entities/{entity}/fx/ (scope read) power Finance → Exchange Rates (reference). They read reference data from data-vNext through the reference-data client and change nothing in the books; they are scoped to an entity because the functional currency, the tax jurisdiction and what the company holds are the entity's. Every answer carries the dataset_version that produced it, the source and where it is published, and the fixing it used.

Operation Answers
GET .../rates?date every ECB currency against the functional currency: rate (1 currency = rate functional), inverse, whether the company uses the currency (in_use, with its documents, active bank accounts and open items; those first)
GET .../matrix?date&currencies the cross rates of up to 24 currencies, 1 row = x column; default: the functional currency, the ones in use, EUR, USD, CHF and GBP
GET .../history?base&quote&start&end a pair's daily rates over at most 1830 days with the first, last, minimum and maximum (and the day they occurred), the average and the change; month adds the IAS 21 average and closing rate of the latest completed month in the range
GET .../convert?amount&from&to&date the converted amount with the rate, its inverse, the route and the fixing
GET .../tax-rates?date&currencies per currency the accounting rate (the ECB's, what the books translate at: bookkeeping) beside the official VAT rate of the entity's country (what the VAT amounts and the turnover on the VAT return use) and their difference
GET .../exposure?date open receivables, payables and foreign-currency bank balances by currency, in the transaction currency and at the date's rate, against the booked amount, and the unrealised result
GET .../exposure/items?date&side&currency&limit&offset the positions behind each count of /exposure

The date and the fixing. date defaults to today (UTC). The ECB publishes on business days, so the latest fixing on or before the date is used, up to 7 days back: requested_date is what was asked, fixing_date what was used, and fallback is true when they differ (a weekend or a holiday). 404 when there is no fixing within 7 days. A currency the ECB does not publish, equal currencies, a reversed or overlong range and more than 24 matrix currencies are 422.

Cross rates: method and precision. The ECB publishes one rate per currency against the euro, so every other pair is triangulated through EUR: 1 A = (eur[B] / eur[A]) B. The division runs at the full precision of decimal (28 digits) on the published values and the result is rounded once, to 10 decimals, half-even (decimals, rounding say so in every answer). That is exactly how data-vNext's /v1/fx/convert rounds, so a rate here equals the rate there (the tests compare them). The inverse is the cross rate the other way round (eur[A] / eur[B]), rounded once, never 1 / rounded rate; a rate times its inverse therefore differs from 1 by the rounding of 10 decimals. An amount is converted as amount * rate rounded half-even to the minor unit of the target currency (JPY has none, KWD three); that is indicative, because the books round half-up and can differ by one minor unit on an exact tie. The average of a range is the arithmetic mean of the daily cross rates rounded the same way: the method of data-vNext's IAS 21 average (/v1/fx/period-rates), so over a whole calendar month the two agree; month.closing is the IAS 21 closing rate, the one the period-end revaluation uses.

Accounting rate and tax rate. The books translate a foreign-currency document at the ECB fixing of its date (the booking rate); VAT law prescribes another rate for the VAT amounts and the turnover on the return (see VAT returns and modules/tax_fx.py). /tax-rates shows both, as tax currency per one unit of the foreign currency. Switzerland: the BAZG monthly average (or its daily selling rate), MWSTV Art. 45; Germany: the monthly rates the BMF announces (derived from the ECB reference rates, read from the Bundesbank), UStG §16 Abs. 6 (or, with the tax office's approval, the daily ECB rate, then both are the same). The entity's VAT settings choose the method (tax.method). A rate not published yet is provisional with the reason; a country without official rates, or an entity that does not book in its tax currency, answers supported: false.

Exposure and the revaluation. /exposure selects what the period-end FX revaluation remeasures, from the same code (modules/fx_positions.py): posted documents in another currency than the functional one that are open at the date (their total less the payments and credit notes posted on or before it), and the balances of foreign-currency asset accounts. Each position is valued at the date's rate and rounded half up to the minor unit, as the revaluation books it. difference is revalued less booked; unrealised is the result a revaluation would post (a gain is positive: receivables and bank balances gain when the currency rises, payables lose), so at a period end totals.unrealised equals the balancing line of the revaluation entry. Credit notes count negative. Each count is the total of /exposure/items for the same side and currency.

Data sources

GET /v1/settings/data-sources (admin scope: a person or an API key) answers "which reference data do we need, and is it arriving?". It powers Settings → Data Sources in the UI.

curl -H "Authorization: Bearer $KEY" localhost:8090/v1/settings/data-sources
  • connection: how the ERP reaches data-vNext (mode in_process or http with base_url, and pinned_version when ERP_REFDATA_DATASET_VERSION pins every call), whether it answered (reachable), its dataset_version, the round trip of the register call (latency_ms) and the last error a reference-data call ran into (last_error, last_error_at).

  • needs: the data points the organization needs, derived from its entities and what they have set up. Each need has the reasons (entity, feature key and an English text such as "CH01 Demo AG: VAT on invoices and bills"), the resolved source and a state.

    Feature When Domain and jurisdiction
    vat_documents every entity indirect_tax, its country
    eu_vat_rules an entity in the EU indirect_tax, EU
    fx every entity (conversions go through the ECB rates) fx_reference, EU
    tax_fx an entity whose country prescribes VAT exchange rates (CH, DE) tax_fx, its country
    due_dates every entity calendars, its country (DE resolves to TARGET via the EU)
    vat_returns, net_tax_rates, recapitulative VAT settings; the net tax rate method; a ZM period tax_returns; indirect_tax; recapitulative_statements
    swiss_payments, payment_dates a CH or LI entity with a bank account e_invoicing CH; calendars CH (SIC) and EU (TARGET)
    xrechnung a German entity e_invoicing DE (KoSIT) and EU (EN 16931)
    depreciation fixed assets or tax books fixed_assets, its country (and each book's regime)

    Charts of accounts are not a need: each organization defines its own. They are listed in customer_defined per entity (kind: chart_of_accounts, active accounts, the template the entity started from, newest account) and never count as a gap.

    A need resolves like data-vNext does: the jurisdiction, its parents, then the groups it belongs to. Needs served by the same data point are merged. state is the data point's status from data-vNext (ok, stale, no_data, error, see data sources), gap when no authority is registered (charts of accounts today) and unknown when data-vNext could not be reached.

  • source (each need, and each entry of other, the data points not needed): the authority (authority_name, homepage, hosts), jurisdictions, method, cadence, url, used_by (where the ERP uses the domain, from the register), feeds with their newest date, freshness rule and last_run, records, latest_data, last_run, the oldest and newest verified_at of its citations and how many cited pages the source watcher checks.

  • summary counts the needs per state and the data points in the register.

The reference-data client reads GET /v1/sources/register and GET /v1/sources/status from data-vNext (or calls the same code in process).

Demo data

erp seed-demo (or the first infra/bootstrap.sh run) creates Demo AG (CH01, CHF, Swiss KMU chart) and Demo GmbH (DE01, EUR, IFRS chart). Both have a closed August 2026 with:

  • invoices to customers in five countries (the German one can be downloaded as XRechnung);
  • a reverse-charge cloud bill and a capitalised laptop (asset class IT hardware, accounts 1520 and 1529), also kept in an ESTV tax book (TAX-CH) that carries deferred tax at year end;
  • an agent's accrual that a second human approved;
  • share capital paid in through a reconciled camt.053 statement: 100,000 CHF for Demo AG and the 25,000 EUR Stammkapital of Demo GmbH (the minimum of § 5 para. 1 GmbHG), so neither bank account is overdrawn;
  • depreciation and FX revaluation;
  • for Demo GmbH, intra-Community business for the UStVA and the ZM: goods to France, services to the Netherlands, goods from the Netherlands and cloud services from Ireland;
  • VAT settings: CH01 the effective method by quarter (UID CHE-123.456.788 MWST), DE01 monthly UStVA and ZM.

The command then checks that the books tie out, that the CH01 third-quarter return and the DE01 August UStVA and ZM reconcile to the VAT accounts, and exits non-zero if they don't.

Stabiso, the Enterprise System of Record · generated from the code and its guides · release 6f03a5e846b3