dodil/erp-method

modules/payments/module.yaml

# Payments & collections: the module source.
#
# PROVENANCE. Written 2026-10-18 for milestone M1 (Lead to Cash), conveyor steps 1-2, from:
#   - the ar spec version 1, whose receipt, allocation, collections, statement and credit-hold stories move here
#     with their ids kept in the text: AR-05 -> PAY-03, AR-06 -> PAY-09, AR-07 -> PAY-10, AR-08 -> PAY-11,
#     AR-10 -> PAY-30, AR-11 -> PAY-31, AR-16 -> PAY-12;
#   - ar-core (version 1's package), which implements receipts and allocation as ar_payments / ar_allocations;
#     they migrate here keyed the same;
#   - ISO 20022 camt.053 (BankToCustomerStatement), the bank statement format UK and EU banks export, as the
#     import format alongside CSV; ISO 4217 for currency codes and minor units;
#   - APQC PCF v8.0: 10744 (9.2.3 Process accounts receivable): 10800 (9.2.3.2 Receive/Deposit customer
#     payments), 10801 (9.2.3.3 Apply cash remittances); 10745 (9.2.4 Manage and process collections); 10742
#     (9.2.1 Process customer credit): 10793 (9.2.1.7 Reinstate or suspend accounts based on credit policies);
#   - the end-to-end process and customization level of a mainstream SME suite's payment registration,
#     reconciliation to invoices and follow-up levels, as a parity reference only.
# SIGNED OFF BY THE CUSTOMER: Seemo, DODIL (reference customer), 2026-10-18 (version 1), after the M1 review page.
# Each process is signed on its own (signoff/payments/<process>.json, erp-compose signoff verify); the decisions
# settled on the review are under `decisions:` below.
# VERSION 2, 2026-10-22, FOR SIGNATURE: brought up to the conventions of the tested product and projects specs:
#   - dated rows, no back-dating: "today" is the date in business_timezone; a receipt, a return and a refund
#     payment are facts dated by the bank (today or earlier, never later); an allocation, an undo, a credit limit
#     and a hold are dated when they are recorded (or later), never earlier; a correction is a reversing row or a
#     new row, never an edit; so a statement as at any past date re-computes to what it said then;
#   - the controls on the cashier (tolerance, auto-matching, credit holds, the time zone, the import cut-over)
#     under pay:settings:controls, never held by the cashier; machine work under exact-grant machine permissions;
#   - readiness gates {gate, met, reason, fix} on every transition with preconditions (workflows.readiness);
#   - the AI match is a suggestion row a person accepts, changes or rejects (PAY-14), audited on the allocation;
#   - the Odoo migration slice (migration:, PAY-15, PAY-44) for milestone M8.
# VERSION 3, 2026-10-22, the SAP full-scope pass, DRAFT for the one re-sign (extends erp-brick-payments as built; v2 is
# unchanged otherwise):
#   - every bank statement line is reconciled HERE, debits included: fees, interest, transfers between our own accounts,
#     supplier payments, refunds, HMRC payments, deposit batches, direct-debit collections, with owner-held rules and an AI
#     suggestion a person accepts (PAY-21..PAY-25, PAY-34). This CHANGES PAY-02's signed "debit lines are left for the
#     ledger's bank reconciliation" (the task: bank reconciliation of all lines in payments); credits still become receipts
#     as signed, except one from our own account or named by a rule
#   - house banks and several bank accounts per company (PAY-27); cash journals with a daily count (PAY-26); cheque
#     deposit batches (PAY-28); remittance and lockbox files (PAY-17); Bacs Direct Debit: mandates with AUDDIS, collection
#     runs in Bacs Standard 18, ARUDD returns and ADDACS amendments (PAY-18..PAY-20); the cash position and a 13-week
#     forecast (PAY-29); an open-banking feed as a provider (PAY-35)
#   - the cash (settlement) discount taken at allocation, with invoicing's VAT-correcting credit (PAY-16); CIS a contractor
#     customer deducted (PAY-45); a receipt that pays a down-payment request (PAY-36)
#   - realised FX differences posted at allocation (PAY-33), closing PAY-42's gap; PAY-42's journal in the ledger's GL-30
#     shape (source_module, source_document, event, purposes)
#   - unique names across the method: record_receipt -> record_customer_receipt, resolve_dispute -> resolve_payment_dispute
#     (purchasing has transitions of the same names)
#   - dunning (reminder levels) stays here; invoicing raises the interest note (ar AR-39)
#
# Three decisions:
#   - ONE allocation table settles every invoice: receipts, credit notes and write-offs all allocate here, so an
#     invoice's outstanding is one SUM and there is no second mechanism to keep in step
#   - settlement is DERIVED: outstanding = invoice total - SUM(allocations); open / part paid / paid / written
#     off follow from it; nothing stores "paid" and nothing increments a balance
#   - money going OUT (a refund) needs an approval, always; the AI never moves money

# Version 8 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 7 stays the signed one until it is signed):
#   - seams only, no process change: GET /bank-accounts/default (PAY-47) listed for pos (a sealed cash bag's bank
#     account); POST /payouts/{payout_ref}/cancel for payroll
#
# Version 7 (2026-10-25, DRAFT for sign-off, wave 4; PROCESS change in payout):
#   - PROCESS (payout): payroll's net pay as payouts (source_module payroll, clearing_purpose net_pay_payable) with a
#     batch_ref (the pay run), approved as one batch by an approver who is not a payee in it (approve_payout_batch);
#     the source told through payroll's POST /pay-runs/{pay_run_id}/payouts/{payout_ref}/paid, paid or returned
#     (PAY-52)
#   - the owner control payout_auto_approve_sources (default none): a source whose own approval payments trusts
#
# Version 6 (2026-10-25, DRAFT for sign-off; the fixes the bricks and the end-to-end wiring found, and wave 3's routes):
#   - PROCESS (deposit_batch): expect_deposit_batch, another module hands over a cash bag (POST
#     /deposit-batches/expected), matched to the statement credit like a cheque paying-in
#   - PROCESS (card_settlement, new): an expected card settlement (POST /card-settlements/expected) matched to the
#     provider's net credit, fees posted, card_clearing cleared
#   - PROCESS (payout, new): an outgoing payment to a person requested by another module (POST /payouts: an
#     employee's expense reimbursement), approved, paid in a payout run, confirmed from the statement and reported
#     back (expenses' POST /reports/{report_id}/paid); the bank details read from hr for payments' client only
#   - use cases (MCP tools) of the shared families name the module noun (tool_names)
#
id: payments
name: Payments & collections
short: Payments
version: 8
maturity: tested
summary: >-
  Record what customers pay (typed in or straight from the bank statement), match it to the invoices it
  settles, refund what is owed back, and chase what is late, so "who owes us what" is always one true answer.

decisions:                            # settled by the reference customer on the M1 review, 2026-10-18
  - decided: "AI receipt matching (ai_suggest_match) defaults to suggest; auto-with-review, for EXACT matches only, is an opt-in setting"
    by: "Seemo, DODIL (reference customer)"
    date: "2026-10-18"
  - decided: "credit holds live in payments (PAY-12, credit_status); sales-order and sales read them"
    by: "Seemo, DODIL (reference customer)"
    date: "2026-10-18"
  - decided: "reminders default to suggest: the AI drafts, the collector sends (reminder_mode, ai_draft_reminder)"
    by: "Seemo, DODIL (reference customer)"
    date: "2026-10-18"
  - decided: "the bank statement imports as ISO 20022 camt.053 and as CSV (PAY-02)"
    by: "Seemo, DODIL (reference customer)"
    date: "2026-10-18"
  - proposed: "v3: bank reconciliation of every statement line lives in payments; MT940 is read as well as camt.053 and CSV"
    status: "for the SAP full-scope re-sign (2026-10-22)"
  - proposed: "v3: a reconciliation rule suggests by default; a rule that posts without a person (auto) is the owner's control, listed for review the same day"
    status: "for the SAP full-scope re-sign (2026-10-22)"
  - proposed: "v3: a direct-debit run is approved by a second person before it is submitted to Bacs"
    status: "for the SAP full-scope re-sign (2026-10-22)"

facets:
  domain: finance
  industries: [any]
  customer_words:
    - "record a payment"
    - "who has paid"
    - "match the bank statement to invoices"
    - "chase late payers"
    - "refund the customer"
    - "statement of account"
    - "bank reconciliation"
    - "bank charges"
    - "direct debit"
    - "petty cash"
    - "paying in cheques"
    - "cash flow forecast"
    - "early payment discount taken"
    - "remittance advice"
  replaces: [ticking invoices off a bank statement by hand, a chasing spreadsheet, Xero bank reconciliation for receivables, Odoo payment registration and follow-ups]
  pillars: [sql]
  first_module_fit: high
  depends_on:
    - module: ar
      why: the invoices and credit notes being settled
      required: true
    - module: business-partner
      why: a receipt and a credit balance belong to a party
      required: true
    - module: gl
      why: posting receipts, refunds and FX differences
      required: optional
    - module: tax
      why: CIS a contractor customer deducted; the VAT on a cash expense; the VAT liability a bank debit pays (v3)
      required: optional
    - module: purchasing
      why: supplier payments and bills due, matched on the statement and in the forecast (v3)
      required: optional
  feeds: [ar, gl, sales-order, tax]
  owns_master_data: []
  references_master_data: [business_partner]

actors:
  - id: cashier
    name: Whoever records money (bookkeeper, office manager)
    does: records receipts, imports the bank statement, allocates receipts to invoices, requests refunds
    sees: every receipt and every open invoice
    permissions: [pay:receipt:read, pay:receipt:write, pay:receipt:import, pay:allocation:write, pay:refund:request, pay:statement_line:reconcile, pay:remittance:import, pay:mandate:write, pay:dd_run:write, pay:cash_journal:write, pay:deposit_batch:write, pay:payout:export]
    note: "composition role (L2C leftover): the cashier also holds ar:invoice:read, because payments reads invoice totals from invoicing under the caller"
  - id: collector
    name: Whoever chases money
    does: works the overdue list, records promises and disputes, sends or approves reminders
    sees: every open invoice and its history
    permissions: [pay:receipt:read, pay:collection:write, pay:collection_event:write, pay:reminder:send, pay:statement:write]
  - id: approver
    name: Owner / finance manager
    does: approves refunds, sets credit limits and holds, sets the reminder policy, holds the controls on the cashier (tolerance, auto-matching, holds, time zone), signs the migration's reconciliation; v3 - keeps house banks and bank accounts, the reconciliation rules, approves direct-debit runs and cash differences, reads the cash position
    sees: everything
    permissions: [pay:receipt:read, pay:refund:approve, pay:credit:write, pay:settings:write, pay:settings:controls, pay:migration:sign, pay:bank_account:write, pay:reconciliation_rule:write, pay:dd_run:approve, pay:cash_journal:approve, pay:forecast:read, pay:payout:approve]
  - id: customer
    name: The customer
    external: true
    does: pays, sends a remittance advice, promises or disputes
    sees: their own statement and reminders
    permissions: []
  - id: system
    name: The system
    does: imports statement lines, derives settlement and aging, suggests matches, runs the daily collections pass (reminders when told to, lapsed promises), takes allocations from invoicing, derives credit holds, runs the one-off Odoo migration; v3 - takes a bank feed, suggests statement-line matches, applies auto rules the owner allowed, collects direct debits on the entry day, posts realised FX differences
    sees: everything, through its own service account (one service client per job, each with only its exact grants)
    permissions: [pay:receipt:read, pay:receipt:ingest, pay:receipt:match, pay:collection:tick, pay:allocation:from_source, pay:migration:import, pay:statement:ingest, pay:statement_line:suggest, pay:dd:collect, pay:bank_account:read_unmasked, pay:deposit_batch:intake, pay:card_settlement:intake, pay:payout:intake]  # the last is granted to purchasing's service client alone (PAY-27 seam), never to the system actor's own jobs or a person

stories:
  # ── person ────────────────────────────────────────────────────────────────────────────────
  - id: PAY-01
    kind: person
    actor: cashier
    story: As the person recording money, I want to record a receipt with its date, amount, currency and reference, so that the money is on the customer before I decide what it pays.
    given: a customer with bp_id 7311 and three open invoices
    when: I record 10,000.00 GBP received 2026-11-20 by bank transfer, reference "INV-2026-0041 0042"
    then: a receipt exists, unallocated; the customer's open items show it as unallocated cash; recording it again with the same bank reference is refused as a duplicate; a received_on after today (business_timezone) is refused, because money not yet received is not a receipt; a wrong amount is corrected by reversing the receipt and recording the right one, never by editing it
    screen: Receipts
    path: { process: payments.receipt, choices: {} }
    reference: implemented (ar-core, as AR-05's receipt)
  - id: PAY-02
    kind: person
    actor: cashier
    story: As the cashier, I want to import the bank statement, so that every receipt is recorded from the bank, not typed.
    given: a camt.053 (or CSV) statement with 42 credit lines, 3 of them already imported last week
    when: I import it
    then: '39 new receipts keyed payment_id = "bank:<account>:<statement entry ref>", each dated by the bank''s booking date; the 3 are recognised and skipped; lines dated before statement_import_from (the migration''s cut-over) are skipped as already migrated. v3: every line of the statement, debit or credit, is also kept as a statement line and reconciled in PAY-21 (this replaces v2''s "debit lines are left for the ledger''s bank reconciliation"); a credit whose counterparty account is one of our own bank accounts, or that a reconciliation rule names (interest, a supplier''s refund), is not made a customer receipt; MT940 is read as well as camt.053 and CSV'
    screen: Receipts
    reference: not built
  - id: PAY-03
    kind: person
    actor: cashier
    story: As the cashier, 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.00 and invoices of 4,000.00, 3,500.00 and 3,000.00
    when: I allocate it across them
    then: 4,000.00 and 3,500.00 are paid, 2,500.00 goes to the third, which keeps 500.00 outstanding, and 0.00 is left unallocated; every allocation is dated today (business_timezone), never earlier; allocating more than the receipt's remainder or more than an invoice's outstanding is refused, naming both figures
    screen: Receipts
    reference: implemented (ar-core, AR-05)
  - id: PAY-04
    kind: person
    actor: cashier
    story: As the cashier, I want a difference within tolerance (a bank charge, a rounding) to close the invoice, so that 1.50 short does not stay on the chase list for ever.
    given: a tolerance of 2.00 and a receipt of 998.50 against an invoice of 1,000.00
    when: I allocate it
    then: 998.50 allocates, and the 1.50 is proposed as a write-off to invoicing (AR-09) within the threshold; above the tolerance the difference stays outstanding
    screen: Receipts
    reference: not built
  - id: PAY-05
    kind: person
    actor: cashier
    story: As the cashier, I want money that pays nothing yet to stay on the customer as credit, so that it is used against the next invoice.
    given: an unallocated 500.00 on a customer
    when: their next invoice of 800.00 issues and I allocate the credit
    then: 500.00 allocates to the new invoice; 300.00 outstanding; the credit is never applied across currencies
    screen: Receipts
    reference: not built
  - id: PAY-06
    kind: person
    actor: cashier
    story: As the cashier, I want to undo an allocation with a reason, so that a mistake is corrected without deleting history.
    given: a receipt allocated to the wrong invoice
    when: I unallocate it with the reason "wrong invoice"
    then: a reversing allocation row (negative, same source, dated today, naming the row it reverses) is added; both are kept; the invoice's outstanding rises back from today; a statement already sent for an earlier date still re-computes to what it said
    screen: Receipts
    reference: not built
  - id: PAY-07
    kind: person
    actor: cashier
    story: As the cashier, I want a returned payment (a bounced direct debit, a recalled transfer) reversed, so that the invoices it paid are owed again.
    given: a receipt of 1,200.00 allocated to two invoices
    when: the bank returns it and I record the return
    then: the receipt is reversed on the bank's return date (today or earlier, not before it was received), its allocations are reversed with it by reversing rows, both invoices are outstanding again, and the reversal posts in the ledger
    screen: Receipts
    reference: not built
  - id: PAY-08
    kind: person
    actor: cashier
    story: As the cashier, I want to refund a customer's overpayment or credit, so that money owed back goes back with an approval.
    given: a customer with 300.00 unallocated credit
    when: I request a refund of 300.00 to their bank account and the approver approves, then I record it paid with the bank reference
    then: the refund is allocated against the credit (source_kind refund), the customer's credit is 0.00, the refund posts in the ledger on the bank's payment date (today or earlier, not before the approval); refunding more than the credit is refused; nobody approves their own refund
    screen: Refunds
    path: { process: payments.refund, choices: {} }
    reference: not built
  - id: PAY-09
    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 (0-30, 31-60, 61-90, 90+), aged from the basis the business chose, with customer, outstanding, age, last contact, and the AI's late-payment risk when it is on
    screen: Collections
    reference: implemented (ar-core, AR-06)
  - id: PAY-10
    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 2026-12-05 covering all of them
    then: it is recorded once against the customer, shows on each invoice, and suppresses reminders until that date; a promise dated today or earlier is refused; a dispute suppresses them until it is resolved
    screen: Collections
    reference: implemented (ar-core, AR-07)
  - id: PAY-11
    kind: person
    actor: collector
    story: As the collector, I want to send a customer a statement of everything open, 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 2026-11-30
    then: it lists every document unsettled at the end of 2026-11-30 (documents and allocations dated on or before it) with a closing balance equal to their sum, and records that it was sent; produced again later for the same date it shows the same balance, whatever was allocated since
    screen: Collections
    reference: implemented (ar-core, AR-08)
  - id: PAY-12
    kind: person
    actor: approver
    story: As the owner, I want a customer put on hold when they are far enough past due or over their limit, so that we stop taking orders from someone who is not paying.
    given: a credit limit of 20,000.00 and a hold rule "any invoice over 60 days"
    when: a customer has an invoice 61 days overdue
    then: the hold is derived and shown with its reason to sales-order and to sales; releasing it early is my act, recorded with a reason and an end date after today, effective from the moment it is recorded, never earlier; a new credit limit takes effect from tomorrow at the earliest, so what the limit WAS on any date stays answerable
    screen: Credit
    reference: not built
    customer_varies: whether limits exist at all; many small businesses want the warning without the block
  - id: PAY-13
    kind: person
    actor: approver
    story: As the owner, I want the controls on the people who record money held by me, so that the cashier cannot widen their own tolerance or switch on automatic matching.
    given: a cashier holding pay:settings:write and an owner holding pay:settings:controls
    when: the cashier tries to raise match_tolerance from 2.00 to 50.00, then the owner does
    then: the cashier is refused, naming the permission; the owner's change applies to allocations made from now on and none already made; who changed it, from what, to what and when is recorded
    screen: Settings
    reference: not built
  - id: PAY-14
    kind: person
    actor: cashier
    story: As the cashier, I want to accept, change or reject the suggested match, so that nothing is allocated without a person deciding, and the record says what the AI proposed and who agreed.
    given: a suggestion (PAY-32) to allocate a 7,500.00 receipt to INV-2026-0041 and INV-2026-0042, confidence 0.97
    when: I accept it; on another receipt I change one invoice before accepting; on a third I reject it
    then: the allocations cite the suggestion, its confidence and me as the one who accepted; the changed one records both what was proposed and what I chose; the rejected one allocates nothing and stays unallocated; in mode auto-with-review, the exact matches the system allocated are listed for me to confirm or undo (PAY-06) the same day
    screen: Receipts
    reference: not built
  - id: PAY-15
    kind: person
    actor: approver
    story: As the owner moving off Odoo, I want the migrated receipts and allocations reconciled to Odoo per customer, with every difference explained, so that I can sign that what we are owed is the same on both sides.
    given: the migration (PAY-44) has loaded a test copy of the bucket from the Odoo database
    when: I open the reconciliation
    then: every check under migration.reconcile shows Odoo's figure, ours and the difference, per customer and currency; a check that must equal and does not blocks the sign-off; an explained difference names its reason; I sign it (pay:migration:sign) and the signature records the figures it signed
    screen: Migration
    reference: not built

  # ── v3: SAP full scope ────────────────────────────────────────────────────────────────────
  - id: PAY-16
    kind: person
    actor: cashier
    story: As the cashier, I want a customer who paid early to get the discount the terms promised, with the VAT corrected, so that their invoice closes without a short payment on the chase list.
    given: 'invoice INV-2026-0090 of 1,200.00 (1,000.00 + 200.00 VAT) issued 2026-11-02 with terms 2/10 net 30 (the discount until 2026-11-12, frozen on the invoice); a receipt of 1,176.00 received 2026-11-10'
    when: I allocate it
    then: '1,176.00 allocates, and the 24.00 difference is offered as the settlement discount (gate within_discount_window: received_on, the bank''s date, on or before the invoice''s settlement_discount_until; gate discount_matches_terms: exactly the percentage of the gross outstanding at receipt); applying it asks invoicing for the discount credit (ar AR-38: 20.00 + 4.00 VAT at the invoice''s rates), which is then allocated, so the invoice is paid; under settlement_discount_vat invoice_wording the 24.00 is an allocation with source_kind settlement_discount instead, which tax reads for the return; a receipt dated 2026-11-13 gets no discount and the 24.00 stays outstanding (or goes to the tolerance, PAY-04)'
    screen: Receipts
    reference: not built
  - id: PAY-17
    kind: person
    actor: cashier
    story: As the cashier, I want to load a customer's remittance advice or the bank's lockbox file, so that a large payment covering forty invoices is allocated from their list, not by searching.
    given: 'a receipt of 48,210.00 from a retailer; their remittance file (CSV, or ISO 20022 camt.054 with structured remittance, or a BAI2 lockbox file from a US bank) listing 40 invoice numbers, 2 deductions with reasons, and amounts'
    when: I import it
    then: 'remittance lines kept (keyed [remittance_id, line_no]) and linked to the receipt by amount, date and payer; a proposed allocation per named invoice is opened as a match suggestion with model "remittance" (not AI), which I accept, change or reject as in PAY-14; a deduction line becomes a dispute on its invoice (PAY-10) with the customer''s reason, never a write-off; an invoice number we do not know is listed, not guessed; a lockbox file whose items are the bank''s own record creates the receipts itself (keyed "lockbox:<account>:<batch>:<item>"); importing the same file again adds nothing'
    screen: Receipts
    reference: not built
  - id: PAY-18
    kind: person
    actor: cashier
    story: As the cashier, I want a customer's Direct Debit Instruction recorded and lodged with Bacs, so that we can collect their invoices on the due date.
    given: 'customer 7311 completes a DDI online: sort code 20-00-00, account 12345678, our Service User Number 123456 on house bank BARC-01'
    when: I record the mandate
    then: 'a mandate keyed by its reference (unique per SUN, 6-18 characters, e.g. DOD-7311-01) with the bank details stored masked except in the Bacs file, pending until lodged: the next submission carries it as AUDDIS 0N; with no AUDDIS rejection it is active; a rejection (e.g. "no account") records the reason and leaves it unusable; cancelling sends 0C; the payer is sent the DDI confirmation and the Direct Debit Guarantee; a mandate with no collection for 13 months is dormant and needs a new instruction'
    screen: Direct debits
    path: { process: payments.direct_debit, choices: {} }
    reference: not built
  - id: PAY-19
    kind: person
    actor: cashier
    story: As the cashier, I want a direct-debit run that collects what is due from customers with active mandates, so that subscription invoices are paid on the day without anyone typing a payment.
    given: 'three customers with active mandates and invoices due on or before collection date 2026-12-01 (a working day); advance notice of 10 working days (or the 3 agreed in the DDI terms)'
    when: I prepare the run, the approver approves it, the notices go, and I submit the file
    then: 'one run keyed "dd:<bank_account_id>:<collection_date>" naming each invoice; advance notices sent no later than the notice period before the collection date, else the invoice waits for the next run (gate advance_notice); the file is Bacs Standard 18 with transaction code 01 for a first collection, 17 regular, 19 final (SEPA pain.008 for a EUR account), kept as an object; on the entry day (collection date) each collection becomes a receipt keyed "dd:<run>:<mandate>" dated the collection date and allocated to its invoices with decided_how direct_debit; the approver may not be the preparer; a run already submitted is never rebuilt'
    screen: Direct debits
    reference: not built
  - id: PAY-20
    kind: person
    actor: cashier
    story: As the cashier, I want the bank's unpaid direct-debit report and mandate amendments applied, so that a bounced collection is owed again and a cancelled mandate is never collected.
    given: 'the run of 2026-12-01; an ARUDD report with one collection returned reason 0 (refer to payer) and one reason 1 (instruction cancelled); an ADDACS report with one account transferred (C) to new details'
    when: I import the reports
    then: 'each returned collection reverses its receipt by PAY-07 (the return dated by the report, the reason recorded), so its invoices are owed again; reason 0 may be re-presented once with code 18 in a later run, reason 1 cancels the mandate; the ADDACS C updates the mandate''s details from its date; a DDICA indemnity claim reverses the collection and marks the mandate disputed; every report line is applied once (keyed by the report''s reference and line)'
    screen: Direct debits
    reference: not built
  - id: PAY-21
    kind: person
    actor: cashier
    story: As the cashier, I want every line of the bank statement reconciled, debits as well as credits, so that the bank balance in the books is the bank's.
    given: 'November''s camt.053 for account BARC-01: 42 customer credits (receipts), 6 supplier payments from purchasing''s run, one transfer of 20,000.00 to our deposit account, the HMRC VAT direct debit of 31,350.00, bank charges of 12.50, interest received of 3.21, a paying-in of 4 cheques'
    when: I reconcile it
    then: 'each line ends matched or posted, never deleted: customer credits to their receipts; supplier payments to purchasing''s payments (which confirms them, PUR-29); the transfer to the matching credit on the deposit account (PAY-25); the HMRC debit to tax''s liability for 26AD; charges and interest posted by rule (PAY-24); the paying-in to its deposit batch (PAY-28); a line with no match stays open with the AI''s suggestion (PAY-22); the statement is reconciled only when every line is (PAY-34); undoing a match is a recorded reversal with a reason'
    screen: Bank reconciliation
    path: { process: payments.bank_reconciliation, choices: {} }
    reference: not built
  - id: PAY-22
    kind: person
    actor: cashier
    story: As the cashier, I want a suggested match or account for each line nobody has matched, so that I confirm rather than investigate, and nothing is booked without me.
    given: 'an open debit "AMZN MKTP UK 45.99" with no rule'
    when: the AI suggests "office supplies, account 7500, tax code S, a receipt image needed" from the payee and past postings, and I accept it, change the account, or reject it
    then: 'the suggestion is a row with its evidence, confidence and model; accepting posts the line with the account and tax code I confirmed (the VAT computed by tax with direction purchase, reclaimable only with a VAT receipt attached), decided_how accepted | changed and me as the decider; rejecting leaves the line open; the AI never posts; its suggestions are the eval set'
    screen: Bank reconciliation
    reference: not built
  - id: PAY-23
    kind: person
    actor: approver
    story: As the owner, I want reconciliation rules for the lines that recur (charges, interest, standing orders), so that the cashier reconciles the unusual ones and the books stay consistent.
    given: 'a rule "debit, text contains SERVICE CHARGE, account BARC-01 -> account 7900 bank charges, tax code E, mode suggest"'
    when: I save it, and later switch it to auto
    then: 'the rule is a dated row (from tomorrow); in suggest mode a matching line gets the rule''s proposal for the cashier to accept; auto mode needs pay:settings:controls and posts the line with decided_how auto, listed for the cashier to confirm or undo the same day; a rule never matches a line to a customer''s invoice (receipts go through allocation); two rules matching one line are both shown, neither applied automatically'
    screen: Bank reconciliation
    reference: not built
  - id: PAY-24
    kind: person
    actor: cashier
    story: As the cashier, I want bank charges, card fees and interest posted from their statement lines, so that the expense and the income are in the books on the bank's date.
    given: 'lines: charges 12.50 (debit), card-terminal fees 38.20 (debit), interest received 3.21 (credit)'
    when: they are posted by rule or by me
    then: 'one journal per line dated by the bank''s booking date (a document-dated fact): bank charges / bank 12.50 (tax code E), card fees / bank 38.20 (E), bank / interest income 3.21 (no VAT), journal id derived from the statement line, so posting twice changes nothing; the account comes from the rule or the person, never a default; a posted line is undone only by a reversing journal with a reason'
    screen: Bank reconciliation
    reference: not built
  - id: PAY-25
    kind: person
    actor: cashier
    story: As the cashier, I want a transfer between our own accounts matched on both statements, so that money in transit is neither lost nor counted twice.
    given: 'a debit of 20,000.00 on BARC-01 on 2026-11-14 to account BARC-02, and the credit on BARC-02 on 2026-11-15'
    when: the statements are reconciled
    then: 'the two lines are matched as one transfer (counterparty account, amount and currency equal, dates within transfer_window_days); each posts on its own date through the transfer clearing account (BARC-01 -> clearing on the 14th, clearing -> BARC-02 on the 15th), so clearing is zero once both arrive and shows the money in transit between; a debit with no credit yet stays as in transit, listed; amounts in different currencies are never matched as one transfer'
    screen: Bank reconciliation
    reference: not built
  - id: PAY-26
    kind: person
    actor: cashier
    story: As the person with the petty cash or the till, I want a cash journal that is counted and closed each day, so that cash in the drawer and in the books agree.
    given: 'cash journal PETTY-UK01 in GBP with an opening balance of 200.00; today a customer pays 50.00 in cash, I buy stamps for 12.00 (no VAT) and cleaning materials for 24.00 including 4.00 VAT with a receipt'
    when: I record the entries, count 214.00 and close the day
    then: 'the customer''s 50.00 is a receipt (method cash) allocated like any other; the expenses post to their accounts with the VAT computed by tax (direction purchase, reclaimable only with the receipt image kept); the expected balance is 214.00, counted 214.00, difference 0.00; a difference posts to cash over/short and needs the approver above cash_difference_threshold; a closed day refuses entries; entries are dated today (business_timezone), never later, and never in a closed day; a deposit to the bank is a transfer matched on the statement (PAY-25)'
    screen: Cash journal
    reference: not built
  - id: PAY-27
    kind: person
    actor: approver
    story: As the finance manager, I want our banks and every account we hold recorded per company, so that receipts, payments and direct debits use the right account and the invoice shows where to pay.
    given: 'companies UK01 and UK02; Barclays (house bank BARC, sort code 20-00-00, SUN 123456) holds UK01''s GBP current, GBP deposit and EUR accounts; Lloyds holds UK02''s GBP current'
    when: I record them and make the GBP current account UK01''s default for receipts in GBP
    then: 'house banks and bank accounts (bank_account_id, company_code, currency, purposes receive | pay | dd_collect, the ledger account, IBAN and BIC, account number shown masked); one default receiving account per (company, currency) as a dated row from tomorrow; invoicing prints the default''s details (PAY-47); a statement for an account never posts to another company; an account is closed from a date after today and keeps its history; purchasing pays out of the accounts with purpose pay'
    screen: Settings
    reference: not built
  - id: PAY-28
    kind: person
    actor: cashier
    story: As the cashier, I want the cheques I pay in together recorded as one deposit, so that the single bank credit matches them and each cheque still pays its own invoices.
    given: 'four cheques received 2026-11-18 for 1,200.00, 800.00, 450.00 and 2,050.00'
    when: I add them to a deposit batch, close it with paying-in slip 004512, and the statement shows one credit of 4,500.00
    then: 'each cheque is a receipt (method cheque) allocated to its invoices; the batch keyed "dep:<bank_account_id>:<slip>" totals 4,500.00; the statement line matches the batch, not the cheques, and is not made a receipt again; a bounced cheque later reverses its own receipt (PAY-07) and leaves the batch matched; a batch whose total differs from the line is never matched'
    screen: Receipts
    reference: not built
  - id: PAY-29
    kind: person
    actor: approver
    story: As the owner, I want today's cash position and a 13-week forecast, so that I see a shortfall before it happens.
    given: 'bank balances from the latest statements, open invoices with customers'' payment habits, a direct-debit run on 2026-12-01, supplier bills due, the VAT return 26AD due 2027-02-07 and CIS due on the 22nd'
    when: I open Cash position
    then: 'per currency (never summed across currencies): today''s position (each account''s latest closing balance plus unreconciled entries); then per week, expected receipts (open invoices by due date shifted by the customer''s average days late over the last 12 months, direct debits on their collection dates, payment requests), expected payments (purchasing''s open bills by due date and approved runs, the VAT and CIS liabilities from tax), and the running balance; each figure drills to its documents; derived on read, a snapshot kept only when I save one'
    screen: Cash position
    reference: not built

  # ── system ────────────────────────────────────────────────────────────────────────────────
  - id: PAY-30
    kind: system
    actor: system
    trigger: on read (derived)
    story: The system works out what is still outstanding on each invoice whenever anyone looks, so that "paid" is never stale and no job can be missed.
    given: an invoice of 1,000.00 with a 400.00 receipt and a 100.00 credit note allocated
    when: anyone opens it, the overdue list or a statement
    then: 500.00 outstanding, part paid, in the bucket its age falls in; all computed from the invoice total (invoicing's read route) and SUM(allocations); no stored paid flag
    reference: implemented (ar-core, AR-10)
  - id: PAY-31
    kind: system
    actor: system
    trigger: daily, for customers with reminders on
    story: The system sends (or drafts) a reminder at each level the business set, so that chasing does not depend on someone remembering.
    given: an invoice 7 days overdue, levels at 7, 14 and 30 days, reminder mode suggest
    when: the daily pass runs, twice
    then: one level-1 reminder draft for the collector, keyed (invoice, level), so the second pass adds nothing; in mode auto it is sent; in mode off nothing happens; a promise or dispute suppresses it
    reference: not built
  - id: PAY-32
    kind: system
    actor: system
    trigger: when a receipt is recorded or imported
    story: The system suggests which invoices a receipt pays, so that the cashier confirms rather than searches.
    given: a receipt of 7,500.00 with reference "INV41 + INV42" and open invoices INV-2026-0041 (4,000.00) and INV-2026-0042 (3,500.00)
    when: it is recorded
    then: a suggestion row (pay_match_suggestion) proposing both, with the evidence (reference text, exact sum), a confidence and the model that made it; nothing is allocated until the cashier decides (PAY-14), unless the mode is auto-with-review and the match is exact, and then the allocation records that the system made it from that suggestion and the day's list asks a person to review it
    reference: not built

  - id: PAY-33
    kind: system
    actor: system
    trigger: when an allocation (or its undo) links a receipt and an invoice whose rates to the functional currency differ
    story: The system posts the realised exchange difference when a foreign-currency invoice is paid, so that the receivable in sterling clears to zero.
    given: 'an invoice of EUR 1,000.00 issued at 0.8500 (GBP 850.00) and a receipt of EUR 1,000.00 received at 0.8600 (GBP 860.00, the ledger''s rate on received_on, kept on the receipt)'
    when: the receipt is allocated to the invoice
    then: 'a journal {source_document "alloc:<payment_id>:<invoice_id>:<seq>", event fx}: receivable 10.00 / fx_gain 10.00 (a loss the other way), dated the allocation date, posted once; the invoice''s functional receivable is then zero; undoing the allocation posts event fx_reversal; a same-currency functional allocation posts nothing; the rates are the invoice''s frozen rate (invoicing''s totals route) and the receipt''s rate, never re-read later'
    reference: not built
  - id: PAY-34
    kind: system
    actor: system
    trigger: when a statement is imported, and when its last line is reconciled
    story: The system checks each statement balances and joins the one before, and that the books agree with the bank, so that a missing or doubled statement is caught.
    given: 'statement 2026-11 for BARC-01 with opening 52,400.10 and closing 61,035.77; October''s closing was 52,400.10'
    when: it is imported and reconciled
    then: 'opening + SUM(lines) = closing exactly, or the import is refused naming the difference; its opening must equal the previous statement''s closing (gate statement_continuous) or a gap is named; once every line is reconciled, the ledger balance of the account''s gl account at the closing date (gl''s account balance route) equals the closing balance, or the difference is shown with the unreconciled items; the statement is reconciled only then'
    reference: not built
  - id: PAY-35
    kind: system
    actor: system
    trigger: daily, when an open-banking feed provider is composed
    story: A bank feed delivers statement lines like a file, so that reconciliation runs on yesterday's transactions without anyone downloading anything.
    given: 'an account-information provider capability (UK Open Banking, read-only consent granted by the owner for 90 days)'
    when: it posts the day's transactions
    then: 'the same import as PAY-02 (POST /statements/ingest with the lines and the booked balance), keyed by the bank''s transaction id, so a line delivered twice or later in a file is one line; the consent''s expiry is shown to the owner 7 days ahead; the provider never writes pay_ tables'
    reference: not built

  # ── integration ───────────────────────────────────────────────────────────────────────────
  - id: PAY-40
    kind: integration
    actor: system
    trigger: when invoicing, sales or sales-order asks for a balance
    story: Other modules read settlement, open items and the credit status through payments' routes.
    given: invoicing listing invoices, sales-order confirming an order
    when: they call settlement(invoice_ids) or credit_status(bp_id)
    then: outstanding, state and age per invoice (as at a date, today by default); hold, reason and balance per customer, the hold also as a readiness gate {gate credit_ok, met, reason, fix} sales-order shows on confirm; never a read of pay_ tables
    owner: payments
    reference: not built
  - id: PAY-41
    kind: integration
    actor: system
    trigger: when invoicing approves a credit note or a write-off (AR-30)
    story: Credit notes and write-offs allocate here, so that every reduction of a balance is in one table.
    given: an approved credit note of 100.00 against an invoice
    when: invoicing calls allocate(source_kind credit_note, source_id, invoice_id, amount)
    then: one allocation row; calling twice with the same (source, invoice, seq) adds nothing
    owner: payments
    reference: not built
  - id: PAY-42
    kind: integration
    actor: system
    trigger: when a receipt, a reversal or a refund is recorded and the ledger is installed
    story: Money in and out posts to the ledger.
    given: a receipt of 1,200.00 GBP to the bank account mapped to ledger account 1200
    when: it posts
    then: 'bank 1,200.00 / receivable 1,200.00, journal_id derived from the payment_id, so re-posting changes nothing; a foreign-currency receipt posts the realised FX difference against the invoice''s rate. v3: the body is the ledger''s GL-30 shape {source_module: "payments", source_document: payment_id | refund_id | statement line id | cash entry id | "alloc:<payment_id>:<invoice_id>:<seq>", event: receipt | receipt_reversal | refund | bank_line | cash_entry | cash_difference | fx | fx_reversal, lines with purpose receivable (bp_id) or account_id (the bank account''s gl_account_id)}; gl derives the id from (source_document, event); the FX difference is PAY-33'
    owner: gl
    reference: "partial: erp-brick-payments posts receipts, reversals and refunds in the GL-30 shape (wiring commit); the FX difference is not posted yet (PAY-33)"
  - id: PAY-43
    kind: integration
    actor: system
    trigger: when a card or direct-debit provider confirms a payment
    story: A payment provider's confirmation is a receipt like a bank line.
    given: a provider capability installed (card or direct debit)
    when: it confirms a payment for an invoice
    then: a receipt keyed "provider:<provider>:<payment ref>", allocated to the invoice it was taken for; a duplicate notification adds nothing
    owner: payments
    reference: not built
  - id: PAY-44
    kind: integration
    actor: system
    trigger: once, at the cut-over from Odoo (first into a test copy, then live)
    story: The migration loads Odoo's bank journals, customer payments, reconciliations, unreconciled bank lines and refunds as receipts, allocations and refunds, so that every open balance arrives with the history that explains it.
    given: an Odoo database read under migration.odoo.reads, and ar's migration already run (the invoices and credit notes exist, keyed by their Odoo numbers)
    when: the migration runs, then runs again
    then: rows as in migration.odoo.maps, each with origin "odoo:<model>:<id>", dated with Odoo's own dates (facts of the past, the one place back-dated rows are loaded) and never re-dated; the second run adds nothing (migration.odoo.idempotent_on); it writes through this module's use cases with pay:migration:import, never through the routes' person checks and never into ar_ tables
    owner: payments
    reference: not built

  - id: PAY-36
    kind: integration
    actor: system
    trigger: when the cashier allocates a receipt to a down-payment request (ar AR-35)
    story: A receipt that pays a payment request settles it into a deposit invoice and pays that, so that VAT follows the money.
    given: 'request DPR-2026-0004 for 6,000.00; a receipt of 6,000.00 received 2026-11-20'
    when: I allocate the receipt to the request
    then: 'payments calls invoicing''s POST /payment-requests/{request_id}/settle {payment_id, received_on, amount}, receives the deposit invoice id, and allocates the receipt to it (an ordinary allocation); a part payment settles a part; a retry settles once (invoicing keys on payment_id); the request itself never carries an allocation'
    owner: ar
    reference: not built
  - id: PAY-45
    kind: integration
    actor: system
    trigger: when a contractor customer pays our invoice net of a CIS deduction
    story: The CIS a contractor customer deducted is allocated against our invoice and recorded with tax, so that the invoice is paid and the deduction is claimed back.
    given: 'our invoice of labour 2,000.00 (reverse charge, no VAT) to contractor 7311; a receipt of 1,600.00 and their payment and deduction statement showing 400.00 deducted, tax month ending 2026-12-05'
    when: I allocate the receipt and record the deduction with the statement reference
    then: 'the receipt allocates 1,600.00; payments calls tax''s POST /cis/suffered, then allocates 400.00 with source_kind cis_deduction, source_id the statement reference, so the invoice is paid; tax refusing (more than 30% of the labour) leaves the 400.00 outstanding with tax''s reason; the same statement recorded twice adds nothing'
    owner: tax
    reference: not built
  - id: PAY-46
    kind: integration
    actor: system
    trigger: when tax prepares a VAT return under cash accounting, or a bad-debt repayment
    story: Tax reads which receipts paid which invoices and when, so that cash accounting and bad debt relief are computed from the one allocation table.
    given: a business on cash accounting
    when: tax calls GET /cash-basis?from=&to= and GET /allocations?invoice_ids=&from=
    then: 'receipt allocations with the receipt''s received_on and the allocation date, reversing rows included; never a read of pay_ tables'
    owner: payments
    reference: not built
  - id: PAY-47
    kind: integration
    actor: system
    trigger: when invoicing issues or renders an invoice
    story: Invoicing prints our receiving bank account from payments, so that the account on the invoice is the one we reconcile.
    given: UK01's default GBP receiving account BARC-01
    when: invoicing calls GET /bank-accounts/default?company_code=UK01&currency=GBP
    then: 'account name, sort code, account number (in full for printing, the caller holding pay:receipt:read), IBAN and BIC; no default answers 404 with {gate: receiving_account, fix: "Payments > Settings > Bank accounts"}'
    owner: payments
    reference: not built
  - id: PAY-48
    kind: integration
    actor: system
    trigger: when a statement debit is matched to a supplier payment
    story: A supplier payment found on the statement confirms purchasing's payment, so that "paid" is the bank's word and nobody confirms it twice.
    given: 'purchasing''s run paid Acme 4,200.00 from BARC-01 with reference PAY-RUN-0045-03'
    when: the statement debit is matched to it
    then: 'payments calls purchasing''s POST /supplier-payments/{payment_id}/confirm with {bank_ref, paid_on (the booking date)}; a payment already confirmed by its clerk is matched without a second confirmation; a rejected payment (a credit returning it) is matched to the reversal; payments never writes pur_ tables'
    owner: purchasing
    reference: not built
  # ── v6 (2026-10-25, wave 3): what another module hands payments to match or to pay ──────────
  - id: PAY-49
    kind: integration
    actor: system
    trigger: when a shop seals a cash bag for the bank (pos POS-26)
    story: Another module hands over a cash bag it has sent to the bank, so that the single bank credit matches the bag and the cash in transit clears without anyone re-keying it.
    given: 'shop LON-SOHO seals bag 004513 with 1,240.00 GBP for BARC-SHOP-UK, expected on 2026-11-16; the statement of 2026-11-16 shows a credit of 1,240.00 and, a week later, one of 597.50 for bag 004514 sent as 600.00'
    when: pos calls POST /deposit-batches/expected twice for bag 004513, then the statement lines are matched
    then: >-
      one deposit batch keyed batch_ref "dep:pos:LON-SOHO:004513", kind cash, deposited, total 1,240.00, clearing
      purpose cash_in_transit (the repeat answers the same batch); the credit of 1,240.00 matches it and posts bank
      1,240.00 / cash_in_transit 1,240.00 (journal derived from the batch_ref and event deposit_match, so posting twice
      changes nothing), and is never made a receipt; the 597.50 credit does not match bag 004514 (gate
      batch_total_equals_line) until an approver who is not the cashier accepts the 2.50 difference with a reason, which
      posts cash_over_short 2.50 (event deposit_difference); payments never writes pos_ tables
    owner: payments
    reference: not built
  - id: PAY-50
    kind: integration
    actor: system
    trigger: when a shop closes a card terminal's batch (pos POS-26), and when the provider's payout reaches the bank
    story: Another module tells payments what a card provider owes us for a day, so that the provider's net credit is matched, its fees are posted, and card clearing is empty when everything was paid.
    given: 'LON-SOHO''s terminal batch T1-2026-11-14 with gross 2,310.40 GBP from provider ACQ1; the owner''s card_fee_max_pct 2.50; the statement of 2026-11-17 shows a credit of 2,282.69 from ACQ1 for that day and LON-KX''s 1,000.00 batch of the same day (a combined payout of 3,267.69)'
    when: pos calls POST /card-settlements/expected for each batch, and the credit is matched
    then: >-
      two expected settlements keyed "card:pos:LON-SOHO:T1-2026-11-14" and "card:pos:LON-KX:T1-2026-11-14"; the one
      credit of 3,267.69 matches both (one provider, a credit may settle several expected settlements, never part of
      one); fees = 3,310.40 - 3,267.69 = 42.71 (1.29%, within 2.50%) split pro rata by gross, rounded once, the rest to the
      last; the journal: bank 3,267.69, card_fees 42.71 (tax code E, the fee being exempt), card_clearing 3,310.40
      (event card_settlement); a credit whose fees exceed card_fee_max_pct leaves the settlements in state differs,
      shown on the bank reconciliation screen until an approver accepts the difference (to card_fees) or records a
      claim against the provider with a reason; a repeated expected settlement answers the same row
    owner: payments
    reference: not built
  - id: PAY-51
    kind: person
    actor: approver
    story: As the finance manager, I want money another module owes a person (an approved expense claim) paid out from our bank in a payout run, approved by me, so that a reimbursement is paid once, to the right account, and the module that asked knows it was paid.
    given: 'expense claim UK01-EXP-2026-00042 rev 1 approved for 352.40 GBP to Jane Doe (person partner 9001, person P-77); her bank details held by hr; paying account BARC-01'
    when: 'expenses calls POST /payouts (payout_ref exp:UK01-EXP-2026-00042:r1:pay) twice; I approve it; the cashier exports the payout run of 2026-11-12; the statement of 2026-11-13 shows the debit'
    then: >-
      one payout keyed payout_ref, requested, then approved by me (I am not the payee, matched on email AND sub, and
      not the cashier who exports it); export writes a pain.001 (or Bacs Standard 18) file of the approved payouts of
      BARC-01 for that date, the payee's account read from hr at export (GET /persons/P-77/bank-account, payments'
      client only, never shown on a screen nor stored outside the file object); the matched debit (or the cashier's
      bank_ref and date, today or earlier) makes it paid and posts employee_payable 352.40 / bank 352.40 (event payout,
      journal derived from the payout_ref); after commit payments calls expenses' POST /reports/UK01-EXP-2026-00042/paid
      {payout_ref, payment_id, paid_on 2026-11-13, amount 352.40} and retries until it answers 2xx; a payout the bank
      returns is returned (the journal reversed, event payout_return) and the requester is told nothing paid; a
      requested or approved payout may be cancelled by its requester (POST /payouts/{payout_ref}/cancel) or by me with
      a reason, never once exported
    screen: Payouts
    reference: not built

  # ── v7 (2026-10-25, wave 4): net pay as a batch of payouts ──
  - id: PAY-52
    kind: integration
    actor: approver
    trigger: when payroll approves a pay run (payroll PRL-12)
    story: Payroll hands payments one payout per payslip with the pay run as their batch, so that salaries are approved once as a batch, paid on the pay date in one Bacs file, and each payslip knows it was paid.
    given: 'run UK01-M-2026-27-M08: 46 payouts with batch_ref UK01-M-2026-27-M08, net 98,412.60, due_on 2026-11-30, paying account BARC-01; the finance approver F is payee of one of them (person 2190)'
    when: 'payroll calls POST /payouts 46 times (and repeats them); F approves the batch; another approver approves F''s payout; the cashier exports on 2026-11-26; the statement of 2026-11-30 shows the Bacs debit'
    then: >-
      46 payouts requested, a repeat answering the same; F''s approve_payout_batch approves the 45 of which F is not
      the payee (email AND sub) and leaves F''s own for another approver; export (approve by 2026-11-25 for a Bacs pay
      date of 2026-11-30, gate bacs_lead) writes one Bacs Standard 18 file for the batch with each payee''s account read
      from hr (HR-59, payments'' client only); the matched debit makes each paid and posts net_pay_payable / bank
      (event payout, journal per payout_ref); after commit payments calls payroll''s POST
      /pay-runs/UK01-M-2026-27-M08/payouts/{payout_ref}/paid {status paid, payment_id, paid_on, amount} and retries until
      2xx; a payout the bank returns is returned and payroll is told with status returned; with the owner''s
      payout_auto_approve_sources naming payroll, the batch is approved by the request itself (approved_by "payroll")
    screen: Payouts
    owner: payments
    reference: not built

processes:
  - key: receipt
    pcf_id: 10744
    pcf_name: Process accounts receivable (AR)
    reference_names: ["Order to Cash", "Apply cash"]
    brick: payments
  - key: refund
    pcf_id: 10744
    pcf_name: Process accounts receivable (AR)
    reference_names: ["Customer refunds"]
    brick: payments
  - key: collection
    pcf_id: 10745
    pcf_name: Manage and process collections
    reference_names: ["Collections", "Dunning"]
    brick: payments
  - key: bank_reconciliation
    pcf_id: 10744
    pcf_name: Process accounts receivable (AR)
    reference_names: ["Electronic bank statement post-processing", "Bank reconciliation"]
    brick: payments
  - key: direct_debit
    pcf_id: 10744
    pcf_name: Process accounts receivable (AR)
    reference_names: ["Direct debit collection", "Payment run for receivables"]
    brick: payments
  - key: cash_journal
    pcf_id: 10744
    pcf_name: Process accounts receivable (AR)
    reference_names: ["Cash journal", "Petty cash"]
    brick: payments
  - key: payout
    pcf_id: null                       # APQC 9.6 (process expense reimbursements): id to confirm
    pcf_name: Pay a person on another module's request
    reference_names: ["Expense reimbursement payment", "Payment run for employees"]
    brick: payments

workflows:
  - entity: receipt
    states: [received, reversed]
    terminal: [received, reversed]
    transitions:
      - { key: customer_pays, name: The customer pays, from: none, to: received, by: customer, effect: "the money arrives in the bank or through a provider" }
      - { key: record_customer_receipt, name: Record the receipt, from: none, to: received, by: cashier, gate: "received_on today or earlier (business_timezone); the bank reference is not on another receipt", effect: "unallocated cash on the customer" }
      - { key: import_statement, name: Import the bank statement line, from: none, to: received, by: system, gate: "a statement was uploaded (camt.053 or CSV); the line is a credit dated on or after statement_import_from", effect: "keyed bank:account:entry, dated by the bank; a re-import adds nothing" }
      - { key: ai_suggest_match, name: "AI suggests the invoices it pays (suggest)", from: received, to: received, by: system, gate: "the receipt has unallocated money and the step is on", effect: "a suggestion row with its evidence, confidence and model; nothing allocated" }
      - { key: decide_match, name: Accept, change or reject the suggestion, from: received, to: received, by: cashier, gate: "an open suggestion; the receipt and the invoices still have the amounts it proposed", effect: "allocation rows citing the suggestion and the person who accepted it; the suggestion closed as accepted, changed or rejected" }
      - { key: allocate, name: Allocate to invoices, from: received, to: received, by: cashier, gate: "never more than the receipt's remainder or an invoice's outstanding; the same currency; dated today", effect: "allocation rows; settlement derived" }
      - { key: settle_difference, name: Propose the small difference as a write-off, from: received, to: received, by: cashier, gate: "the difference is within match_tolerance", effect: "a write-off proposal to invoicing (AR-09)" }
      - { key: unallocate, name: Undo an allocation, from: received, to: received, by: cashier, requires: reason, gate: "the row is not already reversed", effect: "a reversing allocation row dated today; history kept" }
      - { key: reverse_receipt, name: Reverse a returned payment, from: received, to: reversed, by: cashier, requires: bank_return_ref, gate: "the return date is today or earlier and not before received_on", effect: "its allocations reversed by reversing rows; the invoices are owed again" }
      - { key: apply_settlement_discount, name: "Apply the early-settlement discount (v3)", from: received, to: received, by: cashier, gate: "received within the invoice's discount window; the difference is exactly the terms' discount", effect: "invoicing's discount credit allocated (ar AR-38), or an allocation source_kind settlement_discount under invoice_wording (PAY-16)" }
      - { key: record_cis_deduction, name: "Record CIS the customer deducted (v3)", from: received, to: received, by: cashier, requires: statement_ref, gate: "tax accepts the deduction (at most 30% of the labour)", effect: "tax's CIS suffered recorded; an allocation source_kind cis_deduction (PAY-45)" }
      - { key: allocate_to_payment_request, name: "Allocate to a down-payment request (v3)", from: received, to: received, by: cashier, gate: "an issued request with something remaining", effect: "invoicing settles it into a deposit invoice; the receipt allocated to that invoice (PAY-36)" }
      - { key: post_fx_difference, name: "Post the realised exchange difference (v3)", from: received, to: received, by: system, gate: "an allocation whose receipt and invoice rates differ", effect: "receivable against fx_gain or fx_loss, keyed on the allocation (PAY-33)" }
    readiness:
      - { gate: received_not_future,  reason: "Received on {received_on}, which is after today ({today}, {business_timezone})", fix: "Record it on the day the bank shows it" }
      - { gate: reference_unused,     reason: "Bank reference {bank_reference} is already on receipt {payment_id}", fix: "Open receipt {payment_id}" }
      - { gate: within_remainder,     reason: "Allocating {amount}; the receipt has {remainder} left and the invoice {outstanding} outstanding", fix: "Allocate at most the smaller figure" }
      - { gate: same_currency,        reason: "The receipt is {receipt_currency}, the invoice {invoice_currency}", fix: "Allocate a receipt in the invoice's currency" }
      - { gate: within_tolerance,     reason: "The difference {difference} is above the tolerance {match_tolerance}", fix: "Leave it outstanding, or ask the owner (pay:settings:controls)" }
      - { gate: suggestion_current,   reason: "Since the suggestion, {invoice_id} was settled or the receipt allocated elsewhere", fix: "Ask for a fresh suggestion, or allocate by hand" }
      - { gate: return_dated_ok,      reason: "The return date {returned_on} is after today or before the receipt ({received_on})", fix: "Use the date on the bank's return advice" }
      - { gate: within_discount_window, reason: "Received on {received_on}; the discount ended on {settlement_discount_until}", fix: "No discount: the difference stays outstanding" }
      - { gate: discount_matches_terms, reason: "{difference} is not {pct}% of the gross outstanding {gross}", fix: "Allocate; the difference stays outstanding or goes to the tolerance" }
      - { gate: within_cis_rate,      reason: "The deduction {deduction} is above 30% of the labour {labour}", fix: "Check the contractor's statement" }
    note: >-
      How much of a receipt is allocated (unallocated, partly, fully) is DERIVED from its allocations. For the
      screens the brick answers each receipt's readiness: one {gate, met, reason, fix} per gate of its next
      transitions (met true or false, the reason in words with the figures, a fix that names the act or the
      link), so no screen re-derives a rule.
  - entity: refund
    states: [requested, approved, rejected, paid]
    terminal: [rejected, paid]
    transitions:
      - { key: request_refund, name: Request a refund, from: none, to: requested, by: cashier, gate: "the customer has unallocated credit at least the amount, in the refund's currency" }
      - { key: approve_refund, name: Approve the refund, from: requested, to: approved, by: approver, approval: true, gate: "the approver is not the requester; the credit is still at least the amount" }
      - { key: reject_refund, name: Reject the refund, from: requested, to: rejected, by: approver, requires: reason }
      - { key: pay_refund, name: Record the refund paid, from: approved, to: paid, by: cashier, requires: bank_ref, gate: "paid_on today or earlier and not before the approval", effect: "allocated against the credit; posted in the ledger" }
    readiness:
      - { gate: credit_covers,        reason: "Refund {amount}; the customer's unallocated credit in {currency} is {credit}", fix: "Refund at most {credit}" }
      - { gate: second_person,        reason: "You requested this refund", fix: "Another approver approves it" }
      - { gate: paid_dated_ok,        reason: "Paid on {paid_on}, after today or before the approval ({approved_on})", fix: "Use the date on the bank's payment" }
  - entity: collection
    states: [overdue, reminded, promised, disputed, resolved]
    terminal: [resolved]
    transitions:
      - { key: fall_overdue, name: The invoice falls overdue, from: none, to: overdue, by: system, gate: "the due date passed with outstanding above zero", timer: "the due date" }
      - { key: ai_draft_reminder, name: "AI drafts the reminder for this customer (suggest)", from: [overdue, reminded], to: reminded, by: system, gate: "reminders are on and a level is due", effect: "a draft in the collector's queue (suggest) or sent (auto)", timer: "the level's days after due" }
      - { key: send_reminder, name: Send the reminder, from: reminded, to: reminded, by: collector, gate: "mode suggest: a person sends the draft" }
      - { key: call_customer, name: Chase by hand, from: [overdue, reminded], to: [promised, disputed], by: collector, gate: "a promise date after today, or a dispute reason", effect: "a promise with a date, or a dispute with a reason" }
      - { key: customer_responds, name: The customer promises or disputes, from: reminded, to: reminded, by: customer }
      - { key: promise_lapses, name: The promise date passes unpaid, from: promised, to: reminded, by: system, gate: "outstanding above zero on the promised date", timer: "the promise date" }
      - { key: resolve_payment_dispute, name: Resolve the dispute, from: disputed, to: resolved, by: collector, requires: outcome, effect: "a credit note in invoicing, or reminders resume" }
      - { key: paid_in_full, name: Settled, from: [overdue, reminded, promised], to: resolved, by: system, gate: "outstanding is zero (derived)" }
    readiness:
      - { gate: promise_in_future,    reason: "The promise date {promise_date} is today or earlier", fix: "Record the date the customer promised, after today" }
      - { gate: level_due,            reason: "Level {level} is due {days_after_due} days after {due_date}", fix: "Wait for the date, or chase by hand" }
      - { gate: not_suppressed,       reason: "A promise until {promise_date} or an open dispute suppresses reminders", fix: "Resolve the dispute, or wait for the promise date" }
    note: "a collection is per customer and invoice; settlement and age are derived on read, never stored"
  - entity: remittance
    states: [imported, applied]
    terminal: [applied]
    transitions:
      - { key: import_remittance_file, name: "Import a remittance advice or lockbox file (v3)", from: none, to: imported, by: cashier, gate: "CSV, camt.054 or BAI2; not imported before (keyed on the file's own reference)", effect: "lines kept and linked to their receipt; a remittance suggestion opened per receipt; a lockbox file records its receipts (PAY-17)" }
      - { key: apply_remittance, name: Accept the allocation the remittance proposes, from: imported, to: applied, by: cashier, gate: "each line's invoice is open for at least the amount", effect: "allocations with decided_how remittance; deductions become disputes" }
    readiness:
      - { transition: import_remittance_file, gate: file_not_imported, reason: "This file ({file_ref}) was imported on {imported_on}", fix: "Open that import" }
      - { transition: apply_remittance, gate: invoice_open_for_amount, reason: "{invoice_no} has {outstanding} outstanding; the remittance pays {amount}", fix: "Change the line, or record the difference as a dispute" }
  - entity: bank_statement
    states: [imported, balanced, reconciled]
    terminal: [reconciled]
    transitions:
      - { key: import_bank_statement, name: "Import the statement, every line (v3)", from: none, to: imported, by: cashier, gate: "camt.053, MT940 or CSV for a known bank account; not imported before", effect: "a statement line per entry; customer credits also receipts (PAY-02); a bank feed (PAY-35) arrives the same way" }
      - { key: check_statement_balance, name: Check the statement balances and follows the last, from: imported, to: balanced, by: system, gate: "opening + lines = closing; opening = the previous closing", effect: "or the difference named (PAY-34)" }
      - { key: complete_bank_reconciliation, name: Close the statement as reconciled, from: balanced, to: reconciled, by: cashier, gate: "every line matched or posted; the ledger balance at the closing date equals the closing balance", effect: "the statement locked; a later change is a reversal on an open statement" }
    readiness:
      - { transition: check_statement_balance, gate: statement_balances, reason: "Opening {opening} + lines {lines} = {computed}, not the closing {closing}", fix: "Re-download the statement from the bank" }
      - { transition: check_statement_balance, gate: statement_continuous, reason: "The opening {opening} is not the previous closing {previous_closing} ({previous_statement})", fix: "Import the missing statement" }
      - { transition: complete_bank_reconciliation, gate: all_lines_reconciled, reason: "{open_count} lines are still open", fix: "Match or post each" }
      - { transition: complete_bank_reconciliation, gate: book_equals_bank, reason: "The ledger shows {book} for {gl_account_id} at {closing_date}; the bank {closing}", fix: "The unreconciled items are listed" }
  - entity: statement_line
    states: [open, matched, posted]
    terminal: []
    transitions:
      - { key: suggest_statement_match, name: "AI suggests a match or an account (suggest)", from: open, to: open, by: system, gate: "the line is open and the step is on", effect: "a suggestion row with evidence and confidence; nothing posted (PAY-22)" }
      - { key: apply_reconciliation_rule, name: Apply the rule that names the line, from: open, to: [open, posted], by: system, gate: "exactly one rule matches; in suggest mode it proposes, in auto mode (owner's control) it posts and lists the line for review", effect: "decided_how rule or auto (PAY-23)" }
      - { key: match_statement_line, name: "Match to a receipt, supplier payment, transfer, refund, deposit batch, cash deposit, direct-debit run or HMRC payment", from: open, to: matched, by: cashier, gate: "the same amount and currency; the counterpart is not matched to another line", effect: "the counterpart confirmed (purchasing PAY-48, tax's liability), the transfer clearing posted on each side (PAY-21, PAY-25, PAY-28)" }
      - { key: post_statement_line, name: Post the line to an account, from: open, to: posted, by: cashier, requires: account, gate: "an account and a tax code; VAT reclaimable only with a receipt image", effect: "a journal dated by the bank, keyed on the line (PAY-24)" }
      - { key: unmatch_statement_line, name: Undo a match or a posting, from: [matched, posted], to: open, by: cashier, requires: reason, gate: "its statement is not reconciled", effect: "a reversing journal for a posting; the counterpart released" }
    readiness:
      - { transition: apply_reconciliation_rule, gate: one_rule, reason: "Rules {rules} all match this line", fix: "Choose one, or narrow a rule" }
      - { transition: match_statement_line, gate: amounts_equal, reason: "The line is {amount} {currency}; {counterpart} is {other}", fix: "Match the right item, or post the difference" }
      - { transition: match_statement_line, gate: counterpart_free, reason: "{counterpart} is already matched to line {other_line}", fix: "Undo that match first" }
      - { transition: post_statement_line, gate: account_and_tax_code, reason: "A posting needs an account and a tax code", fix: "Choose them, or apply a rule" }
      - { transition: unmatch_statement_line, gate: statement_open, reason: "Statement {statement_id} is reconciled", fix: "Reverse on the current statement" }
  - entity: dd_mandate
    states: [pending, active, cancelled, dormant]
    terminal: [cancelled, dormant]
    transitions:
      - { key: record_dd_mandate, name: "Record a Direct Debit Instruction (v3)", from: none, to: pending, by: cashier, requires: mandate_ref, gate: "a reference unique for the SUN; the bank details; the payer's confirmation", effect: "pending until lodged (PAY-18)" }
      - { key: lodge_dd_mandate, name: Lodge the instruction with Bacs (AUDDIS 0N), from: pending, to: [active, cancelled], by: system, gate: "the next submission", effect: "active without a rejection; an AUDDIS rejection cancels it with the reason" }
      - { key: cancel_dd_mandate, name: Cancel the instruction (AUDDIS 0C), from: [pending, active], to: cancelled, by: cashier, requires: reason }
      - { key: apply_addacs_advice, name: "Apply the bank's amendment or cancellation (ADDACS)", from: active, to: [active, cancelled], by: system, effect: "details changed from the advice's date, or cancelled with the code (PAY-20)" }
      - { key: mark_mandate_dormant, name: Mark an unused instruction dormant, from: active, to: dormant, by: system, gate: "no collection for 13 months", timer: "daily" }
    readiness:
      - { transition: record_dd_mandate, gate: reference_unique, reason: "Mandate reference {mandate_ref} is already used for SUN {sun}", fix: "Use the next reference" }
      - { transition: record_dd_mandate, gate: house_bank_has_sun, reason: "{bank_account_id} has no Service User Number", fix: "Settings > Bank accounts" }
  - entity: dd_run
    states: [draft, approved, submitted, collected]
    terminal: [collected]
    transitions:
      - { key: prepare_dd_run, name: "Prepare a direct-debit run (v3)", from: none, to: draft, by: cashier, gate: "a collection date that is a working day; active mandates; invoices due on or before it", effect: "keyed dd:<bank_account_id>:<collection_date>, invoices named (PAY-19)" }
      - { key: approve_dd_run, name: Approve the run, from: draft, to: approved, by: approver, approval: true, gate: "the approver is not the preparer (email and sub)" }
      - { key: send_dd_notices, name: Send the advance notices, from: approved, to: approved, by: system, gate: "each notice at least the notice period before the collection date", effect: "an invoice whose notice is late moves to the next run" }
      - { key: submit_dd_run, name: Submit the Bacs file, from: approved, to: submitted, by: cashier, requires: submission_ref, gate: "the file produced (Standard 18 or pain.008) and submitted by the input day", effect: "the file kept as an object; the run frozen" }
      - { key: collect_dd_run, name: Record the collections on the entry day, from: submitted, to: collected, by: system, gate: "the collection date is today", effect: "a receipt per collection, allocated with decided_how direct_debit", timer: "the collection date" }
      - { key: apply_arudd_returns, name: Apply the unpaid direct-debit report (ARUDD), from: collected, to: collected, by: cashier, effect: "returned collections reversed (PAY-07); reason 0 may be re-presented once (code 18)" }
    readiness:
      - { transition: prepare_dd_run, gate: working_day, reason: "{collection_date} is not a Bacs working day", fix: "Pick the next working day" }
      - { transition: approve_dd_run, gate: second_person, reason: "You prepared this run", fix: "Another approver approves it" }
      - { transition: send_dd_notices, gate: advance_notice, reason: "The notice for {invoice_no} would reach the customer {days} working days before {collection_date}; {notice_days} are needed", fix: "It moves to the next run" }
      - { transition: submit_dd_run, gate: input_day, reason: "The file must be submitted by {input_day} for {collection_date}", fix: "Move the run's date" }
  - entity: cash_day
    states: [open, closed]
    terminal: [closed]
    transitions:
      - { key: open_cash_day, name: "Open the cash journal's day (v3)", from: none, to: open, by: cashier, gate: "the previous day is closed; today in business_timezone", effect: "the opening balance is the last counted balance" }
      - { key: record_cash_entry, name: Record a cash receipt, expense or deposit, from: open, to: open, by: cashier, gate: "dated today; an expense has an account and a tax code", effect: "a receipt, a posting with tax's VAT, or a transfer to the bank (PAY-26)" }
      - { key: count_cash, name: Count the cash, from: open, to: open, by: cashier, effect: "the counted amount, by denomination" }
      - { key: close_cash_day, name: Close the day, from: open, to: closed, by: cashier, gate: "counted; a difference above cash_difference_threshold approved by an approver who is not the cashier", effect: "the difference posted to cash over/short; the day refuses entries" }
      - { key: approve_cash_difference, name: Approve a cash difference, from: open, to: open, by: approver, approval: true, requires: reason, gate: "the approver is not the person who counted" }
    readiness:
      - { transition: record_cash_entry, gate: day_open_today, reason: "Entries are dated today ({today}); day {day} is closed", fix: "Record it today" }
      - { transition: close_cash_day, gate: counted, reason: "The cash has not been counted", fix: "Count the cash" }
      - { transition: close_cash_day, gate: difference_approved, reason: "The difference {difference} is above {cash_difference_threshold}", fix: "An approver who did not count approves it" }
  - entity: deposit_batch
    states: [open, deposited, matched]
    terminal: [matched]
    transitions:
      - { key: start_deposit_batch, name: "Start a paying-in (v3)", from: none, to: open, by: cashier, gate: "a bank account with purpose receive" }
      - { key: add_cheque_to_batch, name: "Add a cheque receipt to the paying-in", from: open, to: open, by: cashier, gate: "a receipt with method cheque not in another batch" }
      - { key: close_deposit_batch, name: Close the paying-in with its slip, from: open, to: deposited, by: cashier, requires: slip_no, effect: "keyed dep:<bank_account_id>:<slip>; the total fixed (PAY-28)" }
      - { key: match_deposit_batch, name: Match the batch to its statement credit, from: deposited, to: matched, by: cashier, gate: "the line's amount equals the batch total", effect: "v6: an expected batch (kind cash) posts bank / its clearing_purpose (cash_in_transit), event deposit_match; a cheque batch posts nothing (its receipts did)" }
      - { key: expect_deposit_batch, name: "Take a bag another module sent to the bank (v6, PAY-49)", from: none, to: deposited, by: system, gate: "batch_ref unique (a repeat answers the same); a bank account of the company with purpose receive, in the currency; amount above zero", effect: "a deposited batch of kind cash with no cheques, total = amount, clearing_purpose and source_module kept; matched like any batch" }
      - { key: accept_deposit_difference, name: Accept a counted difference on a bag, from: deposited, to: matched, by: approver, approval: true, requires: reason, gate: "an expected cash batch; the line's amount differs from the total; the approver is not the cashier (email AND sub)", effect: "bank (the line) and cash_over_short (the difference) / the clearing purpose (the total), event deposit_difference" }
    readiness:
      - { transition: match_deposit_batch, gate: batch_total_equals_line, reason: "The batch is {total}; the line {amount}", fix: "Find the cheque missing from the batch; for a cash bag, an approver accepts the bank's count (PAY-49)" }

  # ── v6 (2026-10-25, wave 3) ──
  - entity: card_settlement
    states: [expected, matched, differs]
    terminal: [matched]
    transitions:
      - { key: expect_card_settlement, name: "Take an expected card settlement (v6, PAY-50)", from: none, to: expected, by: system, gate: "settlement_ref unique (a repeat answers the same); the provider is a configured card provider of the company; gross above zero", effect: "an expected settlement: provider, company, currency, gross, business_date, transaction_count; card_clearing carries the gross (posted by the module that sold)" }
      - { key: match_card_settlement, name: Match the provider's credit, from: expected, to: matched, by: [system, cashier], gate: "a statement credit from the provider settles one or more expected settlements of that provider whole; fees = gross - credit, at or under card_fee_max_pct of the gross", effect: "journal bank (credit) + card_fees (tax code E) / card_clearing (gross), event card_settlement, derived from the statement line id; the fees split pro rata by gross, rounded once" }
      - { key: flag_card_difference, name: Hold a settlement whose fees are too high, from: expected, to: differs, by: system, gate: "the matched credit implies fees above card_fee_max_pct", effect: "listed on the bank reconciliation screen with the fee and the limit" }
      - { key: accept_card_difference, name: Accept the difference with a reason, from: differs, to: matched, by: approver, approval: true, requires: reason, gate: "the approver is not the cashier who matched it (email AND sub)", effect: "posted as match_card_settlement, the whole difference to card_fees, or to a claim against the provider (purpose card_claim) when the reason is a claim" }
    readiness:
      - { transition: match_card_settlement, gate: fees_within_limit, reason: "the fees are {fee} ({fee_pct}%), above card_fee_max_pct {max}%", fix: "an approver accepts it, or claim it from the provider" }
      - { transition: accept_card_difference, gate: second_person, reason: "you matched this credit (same email or sign-in)", fix: "another approver" }
  - entity: payout
    states: [requested, approved, exported, paid, returned, cancelled]
    terminal: [paid, returned, cancelled]
    transitions:
      - { key: request_payout, name: "Take a payout another module asks for (v6, PAY-51)", from: none, to: requested, by: system, gate: "payout_ref unique (a repeat answers the same); the payee is a person partner (business-partner kind person); a paying bank account of the company in the currency; amount above zero; due_on today or later", effect: "a payout with its source_module and source_document, waiting for an approver" }
      - { key: approve_payout, name: Approve the payout, from: requested, to: approved, by: approver, approval: true, gate: "the approver is not the payee nor the requester's approver of the source document (email AND sub)", effect: "who and when" }
      - { key: approve_payout_batch, name: "Approve a batch of payouts (v7, PAY-52)", from: requested, to: approved, by: [approver, system], approval: true, gate: "every payout of the batch_ref whose payee is not the approver (email AND sub); by system only when payout_auto_approve_sources names the source_module", effect: "the approver's own payout, if any, stays requested for another approver" }
      - { key: export_payout_run, name: Export the payout run, from: approved, to: exported, by: cashier, gate: "the payee's bank account read from hr (GET /persons/{person_id}/bank-account) or, without it, typed and confirmed by a second person; the cashier is not the approver", effect: "a pain.001 or Bacs Standard 18 file of the approved payouts of one paying account and payment date, stored as an object; the bank details only in the file" }
      - { key: confirm_payout_paid, name: Confirm what the bank paid, from: exported, to: paid, by: [system, cashier], gate: "a matched statement debit, or a bank_ref and paid_on today or earlier given by the cashier", effect: "journal <clearing_purpose> / bank (employee_payable for expenses, net_pay_payable for payroll), event payout, derived from the payout_ref; after commit the source is told (expenses' POST /reports/{source_document}/paid; v7 payroll's POST /pay-runs/{source_document}/payouts/{payout_ref}/paid), retried until it answers 2xx" }
      - { key: record_payout_return, name: Record a payout the bank returned, from: paid, to: returned, by: [system, cashier], gate: "a statement credit naming the payout, or the cashier with the bank's return reference", effect: "the journal reversed (event payout_return); the source is told it was not paid (v7: payroll's route with status returned)" }
      - { key: cancel_payout, name: Cancel the payout, from: [requested, approved], to: cancelled, by: [system, approver], requires: reason, gate: "not exported; by system only for the module that requested it (POST /payouts/{payout_ref}/cancel)" }
    readiness:
      - { transition: approve_payout, gate: second_person, reason: "you are the payee, or approved the claim it pays (same email or sign-in)", fix: "another approver" }
      - { transition: export_payout_run, gate: payee_bank_known, reason: "no bank account for {payee} from hr", fix: "hr records it (the person's sensitive data), or type it and have a second person confirm it" }
      - { transition: export_payout_run, gate: exporter_not_approver, reason: "you approved a payout in this run", fix: "another cashier exports it" }

entities:
  - id: receipt
    meaning: money received from a customer
    natural_key: 'payment_id: derived from where it came from ("bank:<account>:<statement entry ref>", "provider:<provider>:<ref>"), else a uuid with the bank reference checked for duplicates (shared.yaml keys.payment_id)'
    key_attributes: [bp_id, currency, amount, received_on, "method (transfer | card | direct_debit | cheque | cash | lockbox)", bank_account_id, bank_reference, remittance_text, status, "batch_id (the deposit batch, v3)", "fx_rate (the ledger's rate on received_on, kept, v3)", returned_on, bank_return_ref, "return_reason_code (ARUDD, v3)", "statement_line_id (v3)", origin, recorded_by, recorded_at]
    relationships: ["-> business_partner", "0..n allocation"]
    master_data: module-owned
    reference_table: pay_receipt
  - 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, currency, allocated_on, allocated_by, reason, reverses_seq, suggestion_id, "decided_how (manual | accepted | changed | auto | remittance | direct_debit, v3)", "fx_journal_id (the realised difference posted, v3)", origin]
    master_data: module-owned
    reference_table: pay_allocation
    note: >-
      source_kind is receipt | credit_note | writeoff | refund, and from v3 settlement_discount (under invoice_wording)
      and cis_deduction (source_id the contractor's statement reference). An undo is a negative row that names the row it
      reverses; nothing is deleted or edited. allocated_on is the day it was recorded (business_timezone), never
      earlier. outstanding(invoice, as_at) = total - SUM(amount) over rows with allocated_on <= as_at.
      decided_how is manual | accepted | changed | auto (auto-with-review), with suggestion_id when an AI
      suggestion was involved: the audit of what the AI proposed and who agreed.
  - id: match_suggestion
    meaning: what the AI proposed a receipt pays, and what a person decided
    natural_key: suggestion_id (derived "<payment_id>#<n>")
    key_attributes: [payment_id, proposed_lines (invoice_id, amount), evidence, confidence, model, created_at, status (open | accepted | changed | rejected | superseded), decided_by, decided_at]
    master_data: module-owned
    reference_table: pay_match_suggestion
    note: "a suggestion is never an allocation; it is kept after the decision, so the eval set (ai_steps) is the table itself"
  - id: refund
    meaning: money paid back to a customer
    natural_key: refund_id
    key_attributes: [bp_id, currency, amount, status, requested_by, requested_at, approved_by, approved_at, reason, bank_ref, paid_on, origin]
    master_data: module-owned
    reference_table: pay_refund
  - id: collection_event
    meaning: a reminder sent, a promise made, a dispute raised or resolved
    natural_key: [bp_id, kind, level, ref_id]
    key_attributes: [invoice_id (nullable), occurred_on, promise_date, reason, outcome, note, draft_text]
    master_data: module-owned
    reference_table: pay_collection
    note: "a promise is usually per CUSTOMER covering several invoices; a reminder is per invoice and level"
  - 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: pay_statement
  - id: credit_policy
    meaning: a customer's credit limit from a date
    natural_key: [bp_id, valid_from]
    key_attributes: [credit_limit_amount, currency, reason, set_by, set_at, origin]
    master_data: module-owned (configuration)
    reference_table: pay_credit
    note: "dated rows: a new limit is a new row with valid_from after today; the limit on a date is the row with the latest valid_from on or before it; nothing is edited"
  - id: credit_override
    meaning: a person's manual hold or early release on a customer
    natural_key: [bp_id, kind, starts_at]
    key_attributes: [ends_on, reason, set_by]
    master_data: module-owned
    reference_table: pay_credit_override
    note: "kind hold | release; starts_at is the moment it is recorded, never earlier; ends_on after today; lifting one early is a new row, not an edit. The hold itself is DERIVED (limit, oldest overdue, overrides)"
  - id: house_bank
    meaning: "a bank the business holds accounts with (v3)"
    natural_key: house_bank_id (the business's own code, e.g. BARC)
    key_attributes: [name, bic, sort_code, address, "sun (Bacs Service User Number, for direct debits)", "creditor_id (SEPA, for EUR direct debits)"]
    master_data: module-owned (configuration)
    reference_table: pay_house_bank
  - id: bank_account
    meaning: "an account the business receives into or pays from (v3: purchasing pays out of the same accounts)"
    natural_key: bank_account_id (the business's own code)
    key_attributes: [name, currency, iban_or_sort_code_masked, gl_account_id, "house_bank_id, company_code (shared.yaml keys.company_code), purposes (receive | pay | dd_collect), account_name, status (open | closed), closed_from (v3)"]
    master_data: module-owned (configuration)
    reference_table: pay_bank_account
    note: "v3: an account belongs to one company; the full account number is held for the Bacs file and the invoice print only, shown masked elsewhere; closing is from a date after today, history kept"
  - id: bank_account_read
    meaning: "the audit row written for every unmasked read of one of our bank accounts (PAY-27 seam)"
    natural_key: 'read_id (derived from bank_account_id, caller and requested_at)'
    key_attributes: [bank_account_id, caller (the machine identity), requested_at, purpose]
    master_data: module-owned
    reference_table: pay_bank_account_read
    note: "append-only; no person route reads or edits it except pay:settings:controls holders reading the log"
  - id: default_bank_account
    meaning: "the receiving account invoices print for a company and currency, from a date (v3)"
    natural_key: [company_code, currency, valid_from]
    key_attributes: [bank_account_id, set_by]
    master_data: module-owned (configuration)
    reference_table: pay_default_bank_account
  - id: bank_statement
    meaning: "one statement of one account as the bank sent it (v3)"
    natural_key: 'statement_id = "<bank_account_id>:<the bank''s statement id or the period>"'
    key_attributes: [bank_account_id, from_date, to_date, opening_balance, closing_balance, format (camt053 | mt940 | csv | feed), file_key, lifecycle, reconciled_by, reconciled_at]
    master_data: module-owned
    reference_table: pay_bank_statement
  - id: statement_line
    meaning: "one entry of a statement, credit or debit, and what it was reconciled to (v3)"
    natural_key: 'line_id = "bank:<bank_account_id>:<the bank''s entry reference>" (the same key a credit''s receipt takes)'
    key_attributes: [statement_id, booked_on, value_on, amount (signed), currency, counterparty_name, counterparty_account_masked, text, bank_tx_code, lifecycle, "matched_kind (receipt | supplier_payment | transfer | refund | deposit_batch | cash_deposit | dd_run | tax_liability | rule | manual)", matched_ref, account_id, tax_code, cost_centre, journal_id, decided_how, decided_by, decided_at]
    master_data: module-owned
    reference_table: pay_statement_line
  - id: line_suggestion
    meaning: "what the AI or a rule proposed for a statement line, and the decision (v3)"
    natural_key: 'suggestion_id = "<line_id>#<n>"'
    key_attributes: [line_id, source (ai | rule), proposal (matched_kind, matched_ref, account_id, tax_code), evidence, confidence, model, status (open | accepted | changed | rejected | superseded), decided_by, decided_at]
    master_data: module-owned
    reference_table: pay_line_suggestion
  - id: reconciliation_rule
    meaning: "a recurring line's treatment, from a date (v3)"
    natural_key: [rule_id, valid_from]
    key_attributes: [bank_account_id (or any), direction (debit | credit), text_contains, counterparty_contains, amount_min, amount_max, account_id, tax_code, cost_centre, "mode (suggest | auto)", set_by]
    master_data: module-owned (configuration)
    reference_table: pay_reconciliation_rule
    note: "auto needs pay:settings:controls; a rule never allocates to an invoice"
  - id: remittance
    meaning: "a remittance advice or lockbox file and its lines (v3)"
    natural_key: [remittance_id, line_no]
    key_attributes: [file_ref, format (csv | camt054 | bai2), payer, payment_id, invoice_ref, amount, deduction_reason, status]
    master_data: module-owned
    reference_table: pay_remittance
  - id: dd_mandate
    meaning: "a customer's Direct Debit Instruction (v3)"
    natural_key: [sun, mandate_ref]
    key_attributes: [bp_id, bank_account_id (ours), payer_sort_code, payer_account_masked, payer_account_name, scheme (bacs | sepa_core), lifecycle, lodged_on, auddis_result, cancelled_reason, last_collected_on]
    master_data: module-owned
    reference_table: pay_dd_mandate
  - id: dd_run
    meaning: "one direct-debit collection run (v3)"
    natural_key: 'run_id = "dd:<bank_account_id>:<collection_date>"'
    key_attributes: [collection_date, input_day, lifecycle, prepared_by, approved_by, file_key, submission_ref, total, count]
    relationships: ["1..n dd_collection"]
    master_data: module-owned
    reference_table: pay_dd_run
  - id: dd_collection
    meaning: "one mandate's collection in a run (v3)"
    natural_key: [run_id, mandate_ref]
    key_attributes: [bp_id, invoice_ids, amount, transaction_code (01 | 17 | 18 | 19), notice_sent_on, payment_id, return_reason_code, returned_on]
    master_data: module-owned
    reference_table: pay_dd_collection
  - id: cash_journal
    meaning: "a till or petty-cash box (v3)"
    natural_key: cash_journal_id
    key_attributes: [company_code, currency, gl_account_id, name, status]
    master_data: module-owned (configuration)
    reference_table: pay_cash_journal
  - id: cash_entry
    meaning: "one cash receipt, expense or deposit (v3)"
    natural_key: [cash_journal_id, entry_date, seq]
    key_attributes: [kind (receipt | expense | deposit | difference), amount, payment_id, account_id, tax_code, vat_amount, receipt_image_key, transfer_to_bank_account_id, journal_id, recorded_by]
    master_data: module-owned
    reference_table: pay_cash_entry
  - id: cash_count
    meaning: "a day's count and close (v3)"
    natural_key: [cash_journal_id, day]
    key_attributes: [opening, expected, counted, denominations_json, difference, counted_by, approved_by, closed_at]
    master_data: module-owned
    reference_table: pay_cash_count
  - id: deposit_batch
    meaning: "cheques paid in together on one slip (v3)"
    natural_key: 'batch_id = "dep:<bank_account_id>:<slip_no>"'
    key_attributes: [lifecycle, total, cheque_count, deposited_on, statement_line_id, "kind (cheque | cash; v6)", "batch_ref (v6: the handing module's key, dep:<module>:<shop_id>:<bag_no>; batch_id = batch_ref for an expected batch)", "source_module (v6)", "clearing_purpose (v6: cash_in_transit)", "expected_on (v6)"]
    master_data: module-owned
    reference_table: pay_deposit_batch
  - id: card_settlement
    meaning: "what a card provider owes us for one terminal batch, matched to its net credit (v6)"
    natural_key: 'settlement_ref ("card:<module>:<shop_id>:<terminal_batch_ref>")'
    key_attributes: [provider, company_code, currency, "gross (NUMERIC 18,2)", business_date, transaction_count, lifecycle, statement_line_id, "fees (NUMERIC 18,2)", source_module]
    master_data: module-owned
    reference_table: pay_card_settlement
  - id: payout
    meaning: "money we pay a person on another module's request (an expense reimbursement), approved and paid in a run (v6)"
    natural_key: 'payout_ref (the requester''s: "exp:<report_id>:r<revision>:pay", v7 "prl:<pay_run_id>:<person_id>:net[:<n>]")'
    key_attributes: [payee_bp_id, person_id, company_code, currency, "amount (NUMERIC 18,2)", due_on, reference, source_module, source_document, clearing_purpose, batch_ref (v7), lifecycle, paying_bank_account_id, approved_by, approved_sub, run_object_key, payment_id, bank_ref, paid_on, notified_at]
    master_data: module-owned
    reference_table: pay_payout
    note: "the payee's bank details are never a column here: read from hr at export into the file object only"
  - id: reminder_level
    meaning: one step of the follow-up policy
    natural_key: [policy_id, level]
    key_attributes: [days_after_due, channel, template, mode (off | suggest | auto)]
    master_data: module-owned (configuration)
    reference_table: pay_reminder_level
  - id: settings
    meaning: how this business takes and chases money
    natural_key: key
    key_attributes: [value]
    master_data: module-owned (configuration)
    reference_table: pay_settings

master_data:
  owns: [pay_receipt, pay_allocation, pay_match_suggestion, pay_refund, pay_collection, pay_statement, pay_credit, pay_credit_override, pay_bank_account, pay_reminder_level, pay_settings, pay_house_bank, pay_default_bank_account, pay_bank_statement, pay_statement_line, pay_line_suggestion, pay_reconciliation_rule, pay_remittance, pay_dd_mandate, pay_dd_run, pay_dd_collection, pay_cash_journal, pay_cash_entry, pay_cash_count, pay_deposit_batch]
  references:
    - entity: business_partner
      owner: business-partner
      how: pay_receipt.bp_id = business_partner.bp_id
      if_absent: "a receipt names a customer by text, and a statement cannot be produced"
    - entity: invoice
      owner: ar
      how: pay_allocation.invoice_id, totals read through invoicing's route
      if_absent: "nothing to allocate to; payments without invoicing records cash only"
  warning: >-
    Two places that each know "what has been paid" is how a customer is chased for an invoice they paid last
    week. Allocations live here and nowhere else; invoicing and sales read them.

jobs:
  - story: PAY-30
    trigger: on read
    runs: settlement and aging
    where_options: [derived on read]
    idempotent: "nothing stored"
  - story: PAY-31
    trigger: daily
    runs: reminders at each customer's levels
    where_options: [the composition's clock worker calling the brick's tick route (--reserved 1 --max-replicas 1), external scheduler]
    idempotent: "keyed (bp_id, kind reminder, level, invoice_id): a second pass sends nothing further"
  - story: PAY-32
    trigger: when a receipt is recorded or imported
    runs: the match suggestion
    where_options: [in the same request, for an import: after the import commits]
    idempotent: "a suggestion only, keyed <payment_id>#<n>; re-running supersedes an open suggestion, never a decided one"
  - story: PAY-44
    trigger: once at cut-over (test copy, then live)
    runs: the Odoo migration (migration.odoo)
    where_options: [a one-off job run by the composition's migration client, from the operator's machine]
    idempotent: "migration.odoo.idempotent_on: a second run matches every row and adds nothing"
  - story: PAY-19
    trigger: on each run's collection date (the entry day)
    runs: collect_dd_run, and daily mark_mandate_dormant
    where_options: [the composition's clock worker calling the brick's dd route]
    idempotent: "receipts keyed dd:<run>:<mandate>; collecting twice adds nothing"
  - story: PAY-22
    trigger: after a statement import commits
    runs: suggest_statement_match for each open line, then apply_reconciliation_rule
    where_options: [after the import commits, in the same worker as PAY-32]
    idempotent: "a suggestion keyed <line_id>#<n>; re-running supersedes an open one, never a decided one; an auto rule posts a line once (journal keyed on the line)"
  - story: PAY-33
    trigger: when an allocation or its undo commits
    runs: post_fx_difference
    where_options: [in the same request, after the allocation commits]
    idempotent: "keyed alloc:<payment_id>:<invoice_id>:<seq> + event"
  - story: PAY-35
    trigger: daily, by the feed provider