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