# Invoicing & collections — module spec (generated from modules/ar/module.yaml)
# A starting vocabulary for phase 2: correct it with the customer, then write ERP_AR.md.
schema: dodil.module-spec/v1
id: ar
name: Invoicing & collections
short: Invoicing
version: 1
maturity: tested
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: []
  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:
  - 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
  - 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
  - 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
    - ar:invoice:issue
    - ar:invoice:send
    - ar:invoice:void
    - ar:payment:write
    - ar:payment:allocate
    - 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
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: https://blog.dodil.io/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
lifecycle: https://blog.dodil.io/modules/lifecycle.md
module_pattern: https://blog.dodil.io/modules/pattern.md
design_template: https://blog.dodil.io/modules/template.md
