dodil/erp-brick-payments

DODIL ERP brick: payments

src/brick_payments/application/process.yaml

# The brick's process - module.yaml's workflows (payments v4), copied (pattern.md section 15). PAY.declare_process serves
# it as BPMN 2.0 at GET /process/bpmn; it is identical to `erp-compose bpmn --from module.yaml`.
# A transition's key is the BPMN id and is frozen once signed. Signed 2026-10-23 (payments v4).
id: payments
name: Payments & collections
version: 6
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
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
    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
    gate: the row is not already reversed
    effect: a reversing allocation row dated today; history kept
    requires: reason
  - key: reverse_receipt
    name: Reverse a returned payment
    from: received
    to: reversed
    by: cashier
    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
    requires: bank_return_ref
  - 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
    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)
    requires: statement_ref
  - 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)
- 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
    gate: the approver is not the requester; the credit is still at least the amount
    approval: true
  - 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
    gate: paid_on today or earlier and not before the approval
    effect: allocated against the credit; posted in the ledger
    requires: bank_ref
- 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
    effect: a credit note in invoicing, or reminders resume
    requires: outcome
  - key: paid_in_full
    name: Settled
    from:
    - overdue
    - reminded
    - promised
    to: resolved
    by: system
    gate: outstanding is zero (derived)
- 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
- 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
- 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
    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)
    requires: account
  - key: unmatch_statement_line
    name: Undo a match or a posting
    from:
    - matched
    - posted
    to: open
    by: cashier
    gate: its statement is not reconciled
    effect: a reversing journal for a posting; the counterpart released
    requires: reason
- 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
    gate: a reference unique for the SUN; the bank details; the payer's confirmation
    effect: pending until lodged (PAY-18)
    requires: mandate_ref
  - 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
- 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
    gate: the approver is not the preparer (email and sub)
    approval: true
  - 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
    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
    requires: submission_ref
  - 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)
- 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
    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
    gate: the approver is not the person who counted
    requires: reason
    approval: true
- 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
    effect: keyed dep:<bank_account_id>:<slip>; the total fixed (PAY-28)
    requires: slip_no
  - 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
    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
    requires: reason
    approval: true
- 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
    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
    requires: reason
    approval: true
- 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
    gate: the approver is not the payee nor the requester's approver of the source document (email AND sub)
    effect: who and when
    approval: true
  - 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 employee_payable / bank, event payout, derived from the payout_ref; after commit the source is told (expenses' POST /reports/{source_document}/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
  - key: cancel_payout
    name: Cancel the payout
    from:
    - requested
    - approved
    to: cancelled
    by:
    - system
    - approver
    gate: not exported; by system only for the module that requested it (POST /payouts/{payout_ref}/cancel)
    requires: reason