# Invoicing & collections (AR) — the module source.
#
# Written from the pattern (/modules/pattern.md) rather than from code: there is no AR package
# yet, and this is what one would be generated from. It is the module every sales seam points at,
# and the reason the shared customer record exists.
#
# Four decisions here are the ones an accountant checks first, so they are stated early:
# - the invoice keeps ONE immutable id for life; the invoice NUMBER is stamped at issue
# - a stored lifecycle (a person's decision) is separate from a derived settlement (arithmetic)
# - payments, credit notes and write-offs all reduce a balance through ONE allocation table
# - tax is per line with a per-rate summary, and the rounding rule is code, not configuration
id: ar
name: Invoicing & collections
short: Invoicing
version: 1
maturity: tested # ar-core implements it; 32 tests pass on a bucket — pattern §14
summary: >-
Send invoices for what you sold, record what customers pay, and know who owes you money and for
how long — without re-typing anything the sales side already captured.
facets:
domain: finance
industries: [] # cross-industry; overlays would name real ones
customer_words:
- "send invoices"
- "who owes us money"
- "chase late payments"
- "stop re-typing quotes into the accounts"
- "statements for customers"
replaces: [spreadsheets, Xero, QuickBooks, Sage, a Word template and a folder]
pillars: [sql]
first_module_fit: high
depends_on:
- module: business-partner
why: an invoice is addressed to a party; sales and invoicing must mean the same customer
required: true
- module: crm
why: invoicing from an accepted quote (AR-20) and showing a rep that a customer is overdue (AR-12)
required: optional
- module: gl
why: posting invoices, receipts, credits and write-offs to the books
required: optional
feeds: [gl]
owns_master_data: []
references_master_data: [business_partner, product, tax_code]
actors:
- id: biller
name: Whoever sends the invoices (office, bookkeeper)
does: drafts invoices, issues them, sends them, records receipts
sees: every invoice and payment
permissions: [ar:invoice:read, ar:invoice:write, ar:invoice:issue, ar:invoice:send, ar:payment:write, ar:credit_note:write, ar:writeoff:write]
- id: collector
name: Whoever chases money
does: works the overdue list, records promises and disputes, allocates receipts
sees: every open invoice and its history
permissions: [ar:invoice:read, ar:payment:allocate, ar:dunning:write]
- id: approver
name: Owner / finance manager
does: approves credit notes and write-offs, sets terms, numbering and the account map
sees: everything
permissions: [ar:invoice:read, ar:invoice:void, ar:credit_note:approve, ar:writeoff:approve, ar:settings:write]
- id: sales
name: Sales (read-only)
does: checks whether a customer is overdue before quoting them again
sees: invoice status and balance for a customer; never lines or margin
permissions: [ar:invoice:read]
- id: system
name: The system
does: derives settlement and aging, sends reminders, raises recurring invoices, posts to the ledger
sees: everything, through its own service account
permissions: []
stories:
# ── person ────────────────────────────────────────────────────────────────────────────────
- id: AR-01
kind: person
actor: biller
story: As the person who invoices, I want an invoice created from an accepted quote in one step, so that nothing is re-typed and the customer is billed what they agreed.
given: an accepted quote with lines, a currency and the customer's tax treatment
when: I raise the invoice
then: a draft exists with the quote's lines, keyed invoice_id = "quote:<quote_id>", tax recomputed at this invoice's tax point; raising it again returns the same invoice rather than a second one
screen: Invoice
reference: not built
- id: AR-02
kind: person
actor: biller
story: As the biller, I want to raise an invoice by hand for something that never had a quote, so that ad-hoc work can still be billed.
given: a customer, and a line with a description, quantity, price and tax code
when: I save it
then: line totals, the per-rate tax summary and the total are computed by the system, never typed, and the invoice is a draft
screen: Invoice
reference: not built
- id: AR-03
kind: person
actor: biller
story: As the biller, I want issuing an invoice to fix its number, its dates and its contents, so that what the customer received cannot silently change.
given: a draft invoice with at least one line and a customer with an address and terms
when: I issue it
then: it is stamped with a unique number from the series, an issue date, a tax point and a due date from the terms; the contents are frozen and every later edit is refused with "credit it instead"
screen: Invoice
reference: not built
customer_varies: the number format, whether it restarts each year, and whether drafts are used at all
note: >-
"unique", not "gap-free-sequential": on this platform the number comes from a high-water-mark
row plus MAX(number), with the row's primary key as the uniqueness guard and a retry on
conflict — and that guard is currently weakened by an open fault (see platform_constraints),
so the number is verified by re-reading after issue.
- id: AR-04
kind: person
actor: biller
story: As the biller, I want to correct an issued invoice with a credit note rather than an edit, so that the audit trail and the tax records survive.
given: an issued invoice, whether paid or not
when: I credit it in full or in part
then: the credit note has its own number, lines and per-rate tax, is allocated against the invoice, reduces its balance, and the original stays readable
screen: Credit note
reference: not built
- id: AR-05
kind: person
actor: collector
story: As the person recording money, I want one receipt to settle several invoices, so that a customer paying three bills with one transfer is handled properly.
given: a receipt of 10,000 and three open invoices
when: I allocate it across them
then: each invoice's balance falls by its allocation, the unallocated remainder stays on the customer as credit, and allocating more than the receipt or more than an invoice's balance is refused
screen: Payments
reference: not built
- id: AR-06
kind: person
actor: collector
story: As the person chasing money, I want one list of what is overdue and by how long, so that I know who to call this morning.
given: invoices past their due date
when: I open the overdue list
then: I see them in the configured aging buckets, aged from the basis the business chose, with customer, amount outstanding, age and last contact
screen: Collections
reference: not built
- id: AR-07
kind: person
actor: collector
story: As the collector, I want to record what a customer promised or disputed, so that the next person picking it up is not starting again.
given: a customer with several overdue invoices
when: I log one promise to pay on a date, covering all of them
then: it is recorded once against the customer, shows on each invoice, and suppresses reminders until that date; a dispute suppresses them until it is resolved
screen: Collections
reference: not built
- id: AR-08
kind: person
actor: biller
story: As the biller, I want to send the customer a statement of everything outstanding, so that they can reconcile their side.
given: a customer with open invoices, credit notes and unallocated receipts
when: I produce an open-item statement as at a date
then: it lists every unsettled document with a closing balance equal to their sum, and records that it was sent
screen: Customer
reference: not built
- id: AR-09
kind: person
actor: approver
story: As the owner, I want to write off a remainder that will never be paid, so that it stops appearing on the chase list and the books show the loss.
given: an invoice with a balance below the write-off threshold, or any balance with my approval
when: I write it off with a reason
then: the write-off is allocated against the invoice like a payment, the balance clears, and the loss is posted to the ledger as bad debt
screen: Invoice
reference: not built
- id: AR-12
kind: person
actor: sales
story: As a sales rep, I want to see whether a customer is overdue before I quote them again, so that I am not discounting for someone who has not paid.
given: a customer with an invoice 40 days past due
when: I open them in the sales system
then: I see the overdue amount, the oldest age, and whether they are on credit hold — read-only, through invoicing's own route
screen: Customer
reference: not built
- id: AR-13
kind: person
actor: biller
story: As the biller, I want to send the invoice to the customer and know it went, so that "did you get it?" has an answer.
given: an issued invoice
when: I send it
then: the document is produced, the send is recorded with the date and address used, and re-sending records a second send rather than a second invoice
screen: Invoice
reference: not built
customer_varies: the delivery route (attached to an email, a portal, a national e-invoicing network) and the document format
- id: AR-14
kind: person
actor: biller
story: As the biller, I want to cancel an invoice that should never have existed, so that the numbering stays explainable.
given: an issued invoice with no allocations
when: I void it, where the jurisdiction allows voiding at all
then: it is marked void with a reason, keeps its number, posts a reversing entry, and drops out of every balance; where voiding is not allowed, a full credit note is the only path and the app says so
screen: Invoice
reference: not built
- id: AR-15
kind: person
actor: biller
story: As the biller, I want to invoice a deposit before the work, so that "50% up front" is a real invoice rather than a note.
given: an agreement to bill half in advance
when: I raise a deposit invoice and later the final one
then: the deposit is a normal invoice with its own tax treatment, and the final invoice shows the deposit already allocated so only the balance is due
screen: Invoice
reference: not built
customer_varies: whether deposits are billed at all, and whether tax falls at the deposit or at delivery
- id: AR-16
kind: person
actor: collector
story: As the collector, I want a customer put on credit hold when they are far enough past due, so that we stop shipping to someone who is not paying.
given: a customer over their credit limit or past the hold threshold
when: sales tries to quote or accept for them
then: the sales side sees the hold and the reason; releasing the hold is an approver's act and is recorded
screen: Customer
reference: not built
customer_varies: whether credit limits exist at all — many small businesses want the warning without the block
- id: AR-17
kind: person
actor: biller
story: As the biller, I want to see the list of invoices with their state and what is outstanding, so that I can find one without searching for it.
given: invoices in every state
when: I open the list
then: I can filter by state, customer, currency and date, and each row shows the number, the customer, the total and what is still outstanding
screen: Invoices
reference: not built
# ── system ────────────────────────────────────────────────────────────────────────────────
- id: AR-10
kind: system
actor: system
trigger: on read (derived)
story: The system works out what is still outstanding and how old it is whenever anyone looks, so that "overdue" is never stale and no job can be missed.
given: an invoice of 1,000 with a 400 receipt allocated and a 100 credit note
when: anyone opens it or the overdue list
then: it shows 500 outstanding, part paid, in the bucket its age falls in — all computed, with no stored paid flag and no nightly job
reference: not built
- id: AR-11
kind: system
actor: system
trigger: nightly, for customers on a reminder schedule
story: The system sends a payment reminder at the intervals the business set, so that chasing does not depend on someone remembering.
given: an invoice 7 days overdue on a 7/14/30-day schedule
when: the nightly run completes
then: exactly one reminder for that step exists — keyed (invoice, kind, step), so a re-run sends nothing further — and a promise or dispute suppresses it
reference: not built
- id: AR-18
kind: system
actor: system
trigger: on the day each recurring agreement falls due
story: The system raises the invoices that repeat — retainers, subscriptions, service contracts — so that nobody bills them by hand every month.
given: a monthly retainer due on the 1st
when: the run completes on the 1st
then: one draft invoice exists for that period, keyed (agreement, period), and a second run creates nothing
reference: not built
customer_varies: whether recurring billing exists at all, and whether the drafts are issued automatically or reviewed first
# ── integration ───────────────────────────────────────────────────────────────────────────
- id: AR-20
kind: integration
actor: system
trigger: when a quote is accepted in sales
story: Sales asks invoicing to raise a draft invoice for the accepted quote, so that nothing is re-keyed.
given: an accepted quote
when: sales calls invoicing's route
then: a draft invoice exists against the same business partner with the quote's LINES (tax recomputed here, not copied), keyed invoice_id = "quote:<quote_id>"; calling twice returns the same invoice
owner: ar
reference: not built
- id: AR-21
kind: integration
actor: system
trigger: when an invoice is issued, voided, a receipt allocated, a credit note approved, or a write-off approved
story: Invoicing posts the accounting entries to the ledger, so that the books agree with the invoices without a monthly re-keying exercise.
given: an issued invoice of 1,000 plus 50 tax, with the account map configured
when: it posts
then: a balanced journal (receivable 1,050 / revenue 1,000 / tax 50) is posted through the ledger's own route with journal_id derived from the document id, so re-posting changes nothing; an open AR subledger item is created for the invoice and cleared when it settles; posting into a closed period is refused and the refusal names the period
owner: gl
reference: not built
- id: AR-22
kind: integration
actor: biller
trigger: when a customer is invoiced for the first time
story: The customer comes from the shared record rather than being typed again.
given: business-partner is installed
when: an invoice is raised for a company that already exists in sales
then: it uses the same partner id, and no second customer row is created
owner: business-partner
reference: not built
workflows:
- entity: invoice
states: "lifecycle (stored, a person's decision): draft → issued → void | cancelled"
transitions:
- { from: draft, to: issued, by: biller, gate: "at least one line; a customer with address, terms and tax treatment", effect: "number, issue date, tax point and due date stamped; contents frozen (AR-03); posts to the ledger (AR-21)" }
- { from: draft, to: cancelled, by: biller, effect: "a draft that was never issued leaves no number behind" }
- { from: issued, to: void, by: approver, gate: "no allocations, and the jurisdiction permits voiding", requires: reason, effect: "reversing journal; drops out of every balance (AR-14)" }
note: >-
SETTLEMENT is derived, never stored: outstanding = total − SUM(allocations of payments,
credit notes and write-offs), and open / part_paid / paid / written_off follow from it.
There is no "paid" column to set, and nothing increments a balance.
- entity: credit_note
states: [draft, approved, allocated, void]
transitions:
- { from: draft, to: approved, by: approver, gate: "lines and tax present; where it names an invoice, the credit does not exceed that invoice's total", effect: "posts to the ledger" }
- { from: approved, to: allocated, by: [biller, collector], effect: "allocated against one or more invoices, or left on the customer as credit" }
note: "a credit note may name no invoice at all — goodwill, or a credit spanning several bills"
- entity: dunning
states: [none, reminded, promised, disputed, resolved, escalated]
transitions:
- { from: [none, reminded], to: promised, by: collector, requires: promise_date, effect: "suppresses reminders until that date (AR-07)" }
- { from: [none, reminded, promised], to: disputed, by: collector, requires: reason, effect: "suppresses reminders" }
- { from: disputed, to: resolved, by: collector, requires: outcome, effect: "reminders resume, or a credit note settles it" }
- { from: [reminded, promised, resolved], to: escalated, by: collector, effect: "credit hold considered (AR-16)" }
entities:
- id: invoice
meaning: a demand for payment, addressed to a customer
natural_key: 'invoice_id — immutable for life, derived where it has a source ("quote:<quote_id>", "rec:<agreement>:<period>") and a uuid otherwise. The invoice NUMBER is an attribute stamped at issue, never the key'
key_attributes: [bp_id, lifecycle, invoice_no, currency, fx_rate, issue_date, tax_point_date, due_date, terms_id, subtotal, tax_total, total, total_functional, source_kind, source_id]
relationships: ["-> business_partner", "1..n invoice_line", "1..n invoice_tax", "0..n allocation", "0..n send"]
master_data: module-owned
reference_table: ar_invoices
- id: invoice_line
meaning: one billed item
natural_key: [invoice_id, line_no]
key_attributes: [sku, description, qty, unit_price, discount_pct, tax_code, net_amount, tax_amount]
master_data: module-owned
reference_table: ar_invoice_lines
- id: invoice_tax
meaning: the tax summary the document must print — one row per rate on the invoice
natural_key: [invoice_id, tax_code]
key_attributes: [rate_pct, net_amount, tax_amount]
master_data: module-owned
reference_table: ar_invoice_tax
note: "an invoice mixing 20% and 0% must show both; rounding happens here, once per rate"
- id: payment
meaning: money received from a customer
natural_key: 'payment_id — derived from the bank reference where there is one ("bank:<statement>:<line>"), otherwise from the import batch and row so a re-import is still idempotent'
key_attributes: [bp_id, currency, fx_rate, amount, received_on, method, bank_reference, batch_id]
master_data: module-owned
reference_table: ar_payments
- id: credit_note
meaning: a tax document reducing what a customer owes
natural_key: credit_id (immutable); credit_no stamped at approval
key_attributes: [bp_id, invoice_id (nullable), currency, reason, status, subtotal, tax_total, total]
relationships: ["1..n credit_note_line", "1..n credit_note_tax", "0..n allocation"]
master_data: module-owned
reference_table: [ar_credit_notes, ar_credit_note_lines, ar_credit_note_tax]
- id: writeoff
meaning: a balance the business accepts it will not collect
natural_key: writeoff_id
key_attributes: [invoice_id, amount, reason, approved_by, approved_at]
master_data: module-owned
reference_table: ar_writeoffs
- id: allocation
meaning: what settled what — the single mechanism behind every reduction in a balance
natural_key: [source_kind, source_id, invoice_id, seq]
key_attributes: [amount, allocated_on, allocated_by]
master_data: module-owned
reference_table: ar_allocations
note: >-
source_kind is payment | credit_note | writeoff. `seq` allows a second, later allocation of
the same receipt to the same invoice, which happens whenever a credit reverses part of a bill.
The balance is one SUM over this table — there is no second mechanism to keep in step.
- id: dunning_event
meaning: a reminder sent, a promise made, a dispute raised or resolved
natural_key: [bp_id, kind, step, ref_id]
key_attributes: [invoice_id (nullable), occurred_on, promise_date, reason, outcome, note]
master_data: module-owned
reference_table: ar_dunning
note: "a promise is usually made per CUSTOMER covering several invoices; a reminder is per invoice and step"
- id: send
meaning: a record that the document went out
natural_key: [invoice_id, sent_at]
key_attributes: [channel, address, format, result]
master_data: module-owned
reference_table: ar_sends
- id: statement
meaning: an open-item statement as at a date, as it was sent
natural_key: [bp_id, as_at]
key_attributes: [closing_balance, currency, sent_at]
master_data: module-owned
reference_table: ar_statements
- id: recurring_agreement
meaning: something billed on a repeating schedule
natural_key: agreement_id
key_attributes: [bp_id, cadence, next_due, lines_json, active]
master_data: module-owned
reference_table: ar_recurring
optional: true
- id: number_series
meaning: the counter behind invoice and credit-note numbers — STATE, not a setting
natural_key: series_id (e.g. "INV-2026")
key_attributes: [high_water_mark, updated_at]
master_data: module-owned (state)
reference_table: ar_number_series
note: "nobody edits the counter; the format lives in ar_settings, the counter lives here"
- id: terms
meaning: when payment is due
natural_key: terms_id
key_attributes: [days, basis, description]
master_data: module-owned (configuration)
reference_table: ar_terms
- id: settings
meaning: how this business invoices — everything an office manager may change
natural_key: key
key_attributes: [value]
master_data: module-owned (configuration)
reference_table: ar_settings
- id: account_map
meaning: which ledger account each posting hits
natural_key: [purpose, tax_code]
key_attributes: [account_id]
master_data: module-owned (configuration); the accounts themselves belong to the ledger
reference_table: ar_account_map
note: "purpose is receivable | revenue | tax | bad_debt | fx_gain | fx_loss | deposit"
- id: tax_code
meaning: a rate and its treatment (standard, zero, exempt, reverse charge)
natural_key: tax_code
key_attributes: [rate_pct, treatment, description]
master_data: SHARED — this module owns it until a ledger exists, then the ledger does
reference_table: tax_code
master_data:
owns: []
references:
- entity: business_partner
owner: business-partner
how: ar_invoices.bp_id = business_partner.bp_id
if_absent: "invoicing keeps its own customer list — the exact duplication this module exists to avoid"
- entity: product
owner: "whichever module is the sole writer for this customer (usually sales)"
how: ar_invoice_lines.sku
- entity: tax_code
owner: "this module until the ledger is installed; the ledger afterwards — decided once and written down"
how: ar_invoice_lines.tax_code
warning: >-
Invoicing is where a duplicated customer list finally costs money: the statement will not match
the sales view and nobody can say which is right. Install business-partner first.
jobs:
- story: AR-11
trigger: nightly
runs: reminders at each customer's schedule
where_options: [always-on worker app (--reserved 1 --max-replicas 1), external scheduler calling a route]
idempotent: "keyed (bp_id, kind, step, invoice) — a re-run sends nothing further"
- story: AR-18
trigger: daily
runs: raise the invoices that repeat
where_options: [the same worker as AR-11, external scheduler]
idempotent: 'keyed invoice_id = "rec:<agreement>:<period>"'
seams:
- story: AR-20
with: crm
owner: ar
tested: "code/crm-suite-app/v1/tests/test_suite.py — an accepted quote raises one draft invoice, at the quoted money, and sales cannot issue it"
rule: sales calls invoicing's route; it never writes invoice tables
- story: AR-21
with: gl
owner: gl
tested: "code/ar-core/v1/tests/test_seam.py — 7 tests, both apps against one bucket"
rule: >-
invoicing calls POST /journal-entry/journals (permission gl:journal:post) with a balanced
journal, and POST /subledger-reconciliation/subledger-items for the open AR item. Both ids
are BIGINTs invoicing derives itself — int.from_bytes(blake2b(<invoice_id>, digest_size=7))
— so re-posting the same invoice changes the ledger once. The ledger owns journals, ledger
and subledger tables; invoicing never writes them.
- story: AR-22
with: business-partner
owner: business-partner
tested: "code/crm-suite-app/v1/tests/test_suite.py — the party is created once, with the customer role"
rule: invoicing reads partners and calls promote; it never writes them
- story: AR-12
with: crm
owner: ar
rule: "sales reads status and balance through invoicing's read route — never a direct read of ar_invoices, and never a view (DataK3 rejects CREATE VIEW)"
- story: AR-16
with: crm
owner: ar
rule: invoicing owns the hold; sales reads it and refuses to accept a quote while it stands
screens:
- { id: invoices, name: Invoices, stories: [AR-17], actors: [biller, collector] }
- { id: invoice, name: Invoice, stories: [AR-01, AR-02, AR-03, AR-09, AR-13, AR-14, AR-15], actors: [biller, approver] }
- { id: credit_note, name: Credit note, stories: [AR-04], actors: [biller, approver] }
- { id: payments, name: Payments, stories: [AR-05], actors: [biller, collector] }
- { id: collections, name: Collections, stories: [AR-06, AR-07], actors: [collector] }
- { id: customer, name: Customer, stories: [AR-08, AR-12, AR-16], actors: [biller, collector, sales] }
- { id: settings, name: Settings, stories: [], actors: [approver],
note: "every config_row below: number format, terms, tax codes, aging buckets, reminder schedule, write-off threshold, account map, document details" }
permissions:
namespace: ar
catalog:
- ar:invoice:read
- ar:invoice:write # draft only
- ar:invoice:issue # irreversible: assigns a legally significant number
- ar:invoice:send
- ar:invoice:void
- ar:payment:write # record a receipt
- ar:payment:allocate # decide what it settles — often a different person
- ar:credit_note:write
- ar:credit_note:approve
- ar:writeoff:write
- ar:writeoff:approve
- ar:dunning:write
- ar:settings:write
row_visibility:
rule: "finance sees every invoice; sales sees status, balance and hold for a customer, never lines or margin"
enforced: "the sales-facing read route projects a narrow shape; it is a different route, not a filter on the full one"
not_found_not_forbidden: true
config_rows:
- { table: ar_settings, changes: "number format and whether it restarts each year, aging buckets and whether age runs from the due date or the invoice date, the reminder schedule, the write-off threshold, credit-limit behaviour (warn or block), statement wording, and the company details on the document" }
- { table: ar_terms, changes: "payment terms — 30 days, end of month following, 50% up front" }
- { table: tax_code, changes: "tax codes, rates and treatment — until the ledger owns them" }
- { table: ar_account_map, changes: "which ledger account receivables, revenue, each tax code, bad debt and FX differences post to" }
in_code_not_rows:
- "that an issued invoice cannot be edited — corrections are credit notes"
- "that the outstanding balance is one SUM over allocations, never a stored flag and never incremented"
- "that an allocation cannot exceed its source or the invoice balance"
- "how tax rounding works: per rate, on the tax summary, so subtotal + tax = total always holds"
- "that a write-off above the threshold needs an approver"
- "that the number series counter is state, not a field anyone types"
tests:
acceptance: "one per story id above, named for it"
access:
- "sales reads status and balance but never lines; issuing, sending and allocating are refused"
- "a biller cannot approve their own credit note or write-off where the business requires an approver"
- "a request with no gateway identity is rejected"
invariants:
- "outstanding = total − SUM(allocations); no stored paid flag exists anywhere"
- "subtotal + tax_total = total, and the tax summary sums to tax_total, at every rate mix"
- "an issued invoice refuses every edit; a void one refuses allocations"
- "an allocation never exceeds its source's remaining amount or the invoice's balance"
- "raising the invoice for the same accepted quote twice yields one invoice"
- "re-importing the same bank statement creates no duplicate payments"
- "re-posting a document to the ledger changes the ledger once"
- "amounts are never summed across currencies; the functional-currency total is carried separately"
- "a reminder for a given invoice and step is sent once"
scale:
reads: "the tables reader serves 16 concurrent reads; hold a client-side semaphore below it (DB_CONCURRENCY=10) and retry on SQLSTATE 40001/08006/58030/XX000 plus the message markers for misclassified blips"
hot_paths: ["the overdue list with aging", "a customer statement", "outstanding per invoice (a SUM over allocations)"]
workers: "reminders and recurring billing, pinned warm; everything else derives on read"
volumes: >-
tens of thousands of invoices a year is comfortable. Above that, keep a materialised balance
per invoice — RE-DERIVED with SUM() in a second committed transaction after each allocation,
never incremented — and reconcile it nightly against the allocation table.
platform_constraints:
- constraint: "Gap-free sequential invoice numbers are a legal requirement in many places, and DataK3 has no sequences."
today: >-
Use a high-water-mark row plus MAX(invoice_no), with the invoice row's own primary key as the
uniqueness guard and a retry on conflict; raise the mark in its own committed transaction, and
do NOT use SELECT … FOR UPDATE (it produced sustained write conflicts on a dev bucket). An
open fault currently lets concurrent inserts of the same key all acknowledge with one row
surviving, so issue through a single writer, re-read the number after issuing, and tell the
customer what the guarantee actually is today.
- constraint: "ALTER TABLE … ADD COLUMN unsettles reads on that table for minutes."
today: "migrate the test copy first, and change the live one at a quiet hour"
- constraint: "No read-your-writes inside an open transaction, and rows committed seconds earlier can be briefly invisible."
today: "allocate, commit, then re-derive the balance with SUM() in a second transaction; re-read before acting on an empty result"
- constraint: "DataK3 rejects CREATE VIEW."
today: "the sales-facing balance is a route, not a view"
# What differs between customers — with something for them to react to, and where one exists,
# something they can look up.
#
# A business user can rarely recall a policy on demand and can always react to one. "Most firms
# like yours chase at 7, 14 and 30 days — does that match?" gets a truer answer, faster, than
# "when do you chase?". Read it out, let them correct it, write down what they said.
#
# Two kinds of answer, and they must never be confused:
#
# standard: a NAMED external authority the customer can look up and hold us to — a
# directive, an accounting standard, a statute. Adopt it, or decide deliberately
# to differ and know why. If a line here cannot be cited, it does not belong.
# typical: arrangements we have SEEN. No authority, no survey behind them, no implication
# that a customer doing otherwise is doing it wrong. Conversation, not evidence.
#
# Calling convention a standard is how an implementation ends up defending a choice nobody
# ever made.
customer_varies:
- question: Do you invoice from an accepted quote, or is billing separate from sales?
changes: whether AR-01 and AR-20 are in scope at all
- question: What should an invoice number look like, and does it restart each year?
changes: ar_settings (format), ar_number_series (the counter)
standard: >-
EU VAT Directive 2006/112/EC art. 226 — an invoice must carry a sequential number, from one or more series, that uniquely identifies it. Most VAT regimes outside the EU require the same shape.
typical:
most_vat_regimes: one unbroken sequence per company, no gaps — a deleted invoice is voided, never removed
uk_eu: a prefix plus a running number, restarting each financial year
groups: a prefix per trading entity so two companies never share a number
- question: When is payment due — 30 days, end of month, half up front?
changes: ar_terms, and whether deposit invoices (AR-15) exist
standard: >-
EU Directive 2011/7/EU on late payment: 30 days unless expressly agreed, 60 maximum between businesses. UK: Late Payment of Commercial Debts (Interest) Act 1998 gives a statutory right to interest.
typical:
b2b_services: net 30 from the invoice date
construction: net 30 to 60, with retention held until practical completion
saas: paid in advance, monthly or annually
retail_consumer: paid at the point of sale; an invoice is the exception
- question: Do customers pay several invoices with one transfer?
changes: allocation (AR-05); without it, receipts can be one-to-one
- question: Does anything repeat every month?
changes: AR-18 and whether any scheduled job exists at all
- question: Who may approve a credit note or write off a balance?
changes: the approver role and the write-off threshold
- question: Do you want reminders sent automatically, or do you prefer to chase by hand?
changes: AR-11
typical:
saas: automatic at 7, 14 and 30 days overdue, then the service pauses
professional_services: nothing automatic — a partner calls before anything is sent
smaller_firms: a statement once a month rather than per-invoice chasing
- question: Do you keep your books here, or in Xero/QuickBooks?
changes: AR-21 — posting to the ledger, or a hand-off and a reconciliation instead
typical:
under_20_staff: Xero or QuickBooks, with an accountant who expects to keep using it
larger_or_multi_entity: books here, because consolidation across entities is the reason they moved
- question: Do you invoice in more than one currency?
changes: fx_rate and the functional-currency total; realised FX differences post in the ledger
- question: Do customers ever deduct tax at source, or take a discount for paying early?
changes: withholding and settlement-discount handling — see known_gaps before promising either
reference:
packages:
- { id: ar-core, kind: app, what: "invoices, tax per rate, receipts and allocation, credit notes, write-offs, collections and statements", url: /code/ar-core/ }
contracts: []
build_guide: null
known_gaps:
- "ar-core covers AR-01 to AR-09, AR-12 to AR-14, AR-17 and AR-21; recurring invoices (AR-18), scheduled reminders (AR-11), deposits (AR-15) and credit holds (AR-16) are not built"
- "AR-21 posts an issued invoice, a receipt and a write-off; a VOID does not yet post its reversing journal, so a voided invoice leaves its original entry in the books until someone reverses it in the ledger"
- "the ledger posting credits tax per rate when the per-rate rows read back, and as one line against the default tax account when they do not — a rate breakdown in the books is best-effort, the total never is"
- "GET /api/invoices has no pagination and no source filter: it materialises every invoice in the bucket and derives settlement per row, so it stops answering inside a client timeout at a few hundred invoices"
- "the column named bp_id is really a bp_KEY: invoicing accepts whatever a customer calls their customers (ACME-01), while the party master derives a BIGINT bp_id from that key. Both meanings share one column, told apart only by whether they parse as a number, so the cross-module join needs a CAST. Retyping it would break standalone invoicing; the four-release fix is in design/ar-bp-id-change-request.md"
- "withholding tax (the customer legally pays less than the invoice) is not modelled: without it the balance never clears"
- "early-settlement discounts are not modelled — in most jurisdictions they also require a credit note for the tax"
- "interest and late fees are not modelled"
- "no document format for national e-invoicing (Peppol, ZUGFeRD, ZATCA, FatturaPA) — ask the jurisdiction before promising one"
- "no bank feed: statement import is a file the biller uploads, and idempotence depends on the bank supplying a stable reference"
- "invoice numbering is only as strong as the platform fault above allows"