dodil/erp-brick-payments
DODIL ERP brick: payments
src/brick_payments/application/payouts.py
"""A payout (PAY-51): money another module owes a person (an approved expense claim), paid from our bank in a payout run, approved here, confirmed from
the statement (or by the cashier's bank_ref) and reported back to the module that asked (expenses' POST /reports/{id}/paid, retried until it answers 2xx).
The payee's bank account is read from hr at export (GET /persons/{person_id}/bank-account, payments' client only, audited by hr), written into the payment
file object and nowhere else: it is never stored in pay_ tables, never shown, and the module that asked never sees it."""
import hashlib
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
from typing import Literal, Optional
from xml.sax.saxutils import escape
from erp_platform.bricks import Refused
from brick_payments.application import statements as st
from brick_payments.application import use_cases as uc
from brick_payments.application.accounts import purposes_of
from brick_payments.application.inputs import BigId, Code, Day, Money, Reason, Ref
from brick_payments.domain import directdebit as dd
from brick_payments.domain.errors import RuleBroken
from brick_payments.infrastructure import seams
PAY = uc.PAY
INTAKE, APPROVE, EXPORT = "pay:payout:intake", "pay:payout:approve", "pay:payout:export"
STATES = ("requested", "approved", "exported", "paid", "returned", "cancelled")
def _gate(gate: str, reason: str, fix: str, status: int = 422) -> Refused:
return Refused(status, reason, gate=gate, fix=fix)
def _payout(repo, payout_ref: str) -> dict:
p = repo.expected.payout(payout_ref)
if p is None:
raise Refused(404, f"no payout {payout_ref}")
return p
def _view(p: dict) -> dict:
return {"payout_ref": p["payout_ref"], "payout_id": p["payout_id"], "status": p["lifecycle"], "payee_bp_id": p["payee_bp_id"], "person_id": p["person_id"],
"company_code": p["company_code"], "currency": p["currency"], "amount": f"{Decimal(str(p['amount'])):.2f}", "due_on": p["due_on"].isoformat(),
"reference": p["reference"], "source_module": p["source_module"], "source_document": p["source_document"], "bank_account_id": p["bank_account_id"],
"approved_by": p["approved_by"] or None, "exported_on": p["exported_on"].isoformat() if p["exported_on"] else None,
"paid_on": p["paid_on"].isoformat() if p["paid_on"] else None, "export_key": p["export_key"] or None}
def _paying_account(repo, company: str, currency: str) -> Optional[dict]:
for b in repo.config.banks():
if ("pay" in purposes_of(b) and b.get("company_code") == company and b["currency"] == currency and (b.get("status") or "open") != "closed"
and not b.get("closed_from")):
return b
return None
@PAY.use_case(kind="command", route="POST /payouts", permission=INTAKE, idempotent=True, tool=False, status=201)
def request_payout(caller: dict, payout_ref: Ref, payee_bp_id: BigId, person_id: Ref, company_code: Code, currency: uc.Currency, amount: Money, due_on: Day,
reference: Ref, source_module: Literal["expenses"], source_document: Ref, clearing_purpose: Literal["employee_payable"]) -> dict:
"""PAY-51: another module asks for a payout. One payout keyed payout_ref, requested, waiting for an approver; the repeat answers the same (another amount
for a known payout_ref is 409). The payee is a person partner (gate payee_is_person), the company has a paying account in the currency
(gate paying_account), the amount is above zero and due_on today or later."""
with uc.repository() as repo, uc._decide():
cur = uc._cur(currency)
have = repo.expected.payout(payout_ref)
if have is not None:
if Decimal(str(have["amount"])) != amount:
raise Refused(409, f"{payout_ref} is already a payout of {Decimal(str(have['amount'])):.2f}, not {amount:.2f}")
return {"payout_ref": payout_ref, "payout_id": have["payout_id"], "status": have["lifecycle"], "replay": True}
due = uc._date(due_on, "due_on")
if due < uc._today(repo):
raise _gate("bounds", f"due_on {due} is in the past", "name today or a later date")
if seams.PARTNERS.installed:
r = seams.PARTNERS.get(f"/partners/{int(payee_bp_id)}", actor="payments")
if r.status == 404 or (r.ok and r.data.get("kind") not in (None, "", "person")):
raise _gate("payee_is_person", f"partner {payee_bp_id} is not a person partner", "name the employee's person partner")
bank = _paying_account(repo, company_code, cur)
if bank is None:
raise _gate("paying_account", f"company {company_code} has no bank account that pays out in {cur}", "set one up (Settings > Bank accounts, purpose pay)")
row = {"payout_ref": payout_ref, "payout_id": f"payout:{hashlib.sha256(payout_ref.encode()).hexdigest()[:16]}", "payee_bp_id": payee_bp_id,
"person_id": person_id, "company_code": company_code, "currency": cur, "amount": amount, "due_on": due, "reference": reference[:35],
"source_module": source_module, "source_document": source_document, "clearing_purpose": clearing_purpose, "lifecycle": "requested",
"bank_account_id": bank["bank_account_id"], "requested_by": uc._who(caller), "approved_by": "", "approved_sub": "", "approved_on": None,
"exported_by": "", "exported_sub": "", "exported_on": None, "export_key": "", "paid_on": None, "bank_ref": "", "statement_line_id": "",
"journal_id": "", "returned_on": None, "cancel_reason": "", "cancelled_by": "", "told_source_at": None}
repo.expected.add_payout(row, uc._now())
return {"payout_ref": payout_ref, "payout_id": row["payout_id"], "status": "requested", "replay": False}
@PAY.use_case(kind="command", route="POST /payouts/{payout_ref}/approve", permission=APPROVE, idempotent=True)
def approve_payout(caller: dict, payout_ref: Ref) -> dict:
"""PAY-51: a payments approver approves the payout (who and when). The payee and the approver of the source document may not approve (email AND sub): the
payout carries no identity of either, so what is checked here is that the approver did not request it."""
with uc.repository() as repo, uc._decide():
p = _payout(repo, payout_ref)
if p["lifecycle"] == "approved":
return _view(p)
if p["lifecycle"] != "requested":
raise RuleBroken(f"Payout {payout_ref} is {p['lifecycle']}: only a requested payout is approved")
if set(uc._ids(caller)) & {p["requested_by"]}:
raise Refused(409, "you are the payee, or approved the claim it pays (same email or sign-in)", gate="second_person", fix="another approver")
p = {**p, "lifecycle": "approved", "approved_by": uc._who(caller), "approved_sub": (caller.get("sub") or "").strip().lower(), "approved_on": uc._today(repo)}
repo.expected.save_payout(p)
return _view(p)
def _account_xml(acct: dict) -> str:
if acct.get("iban"):
return f"<IBAN>{escape(acct['iban'].replace(' ', ''))}</IBAN>"
return f"<Othr><Id>{escape((acct.get('sort_code') or '') + (acct.get('account_number') or ''))}</Id></Othr>"
def _pain001(*, run_id: str, debtor_name: str, debtor_account: str, debtor_bic: str, pay_date: date, currency: str, lines: list) -> str:
total = sum((x.amount for x in lines), Decimal("0.00"))
tx = "".join(
f"<CdtTrfTxInf><PmtId><EndToEndId>{escape(x.reference)[:35]}</EndToEndId></PmtId><Amt><InstdAmt Ccy=\"{currency}\">{x.amount:.2f}</InstdAmt></Amt>"
f"{('<CdtrAgt><FinInstnId><BIC>' + escape(x.bic) + '</BIC></FinInstnId></CdtrAgt>') if x.bic else ''}<Cdtr><Nm>{escape(x.account_name)}</Nm></Cdtr>"
f"<CdtrAcct><Id>{x.account_xml}</Id></CdtrAcct><RmtInf><Ustrd>{escape(x.reference)}</Ustrd></RmtInf></CdtTrfTxInf>" for x in lines)
return ("<?xml version=\"1.0\" encoding=\"UTF-8\"?><Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:pain.001.001.03\"><CstmrCdtTrfInitn><GrpHdr>"
f"<MsgId>{escape(run_id)[:35]}</MsgId><NbOfTxs>{len(lines)}</NbOfTxs><CtrlSum>{total:.2f}</CtrlSum><InitgPty><Nm>{escape(debtor_name)}</Nm></InitgPty>"
f"</GrpHdr><PmtInf><PmtInfId>{escape(run_id)[:35]}</PmtInfId><PmtMtd>TRF</PmtMtd><NbOfTxs>{len(lines)}</NbOfTxs><CtrlSum>{total:.2f}</CtrlSum>"
f"<ReqdExctnDt>{pay_date.isoformat()}</ReqdExctnDt><Dbtr><Nm>{escape(debtor_name)}</Nm></Dbtr><DbtrAcct><Id>{debtor_account}</Id></DbtrAcct>"
f"<DbtrAgt><FinInstnId><BIC>{escape(debtor_bic)}</BIC></FinInstnId></DbtrAgt>{tx}</PmtInf></CstmrCdtTrfInitn></Document>")
@PAY.use_case(kind="command", route="POST /payout-runs/export", permission=EXPORT, idempotent=True)
def export_payout_run(caller: dict, bank_account_id: Code, pay_date: Day) -> dict:
"""PAY-51: the cashier exports the approved payouts of one paying account for a payment date as ONE file (pain.001, or Bacs Standard 18 where the setting
payout_file_format says bacs18), stored as an object. Each payee's bank account is read from hr now (gate payee_bank_known when hr holds none) and
written into the file only. The exporter is not an approver of any payout in the run (gate exporter_not_approver). Each payout becomes exported."""
with uc.repository() as repo, uc._decide():
bank = repo.config.bank(bank_account_id)
if bank is None or "pay" not in purposes_of(bank):
raise Refused(404, f"no paying bank account {bank_account_id}")
when = uc._date(pay_date, "pay_date")
mine = [p for p in repo.expected.payouts("approved") if p["bank_account_id"] == bank_account_id and p["due_on"] <= when]
if not mine:
raise RuleBroken(f"No approved payout is due on {bank_account_id} by {when}")
ids = set(uc._ids(caller))
if any(ids & {p["approved_by"], p["approved_sub"]} for p in mine):
raise Refused(409, "you approved a payout in this run", gate="exporter_not_approver", fix="another cashier exports it")
fmt = repo.settings.current().payout_file_format
lines = []
for p in mine:
acct = seams.payee_bank_account(p["person_id"], when)
if not acct:
raise Refused(409, f"no bank account for {p['person_id']} from hr", gate="payee_bank_known",
fix="hr records it (the person's sensitive data), or type it and have a second person confirm it")
if fmt == "bacs18":
try:
sort, number = dd.valid_sort_code(acct.get("sort_code") or ""), dd.valid_account_number(acct.get("account_number") or "")
except RuleBroken:
raise Refused(409, f"hr holds no UK sort code and account number for {p['person_id']}: a Bacs file needs them", gate="payee_bank_known",
fix="use pain.001 (payout_file_format) or have hr record the UK account") from None
lines.append(SimpleNamespace(code="99", sort_code=sort, account_number=number, account_name=acct.get("account_holder") or "", amount=Decimal(str(p["amount"])),
reference=p["reference"]))
else:
lines.append(SimpleNamespace(account_xml=_account_xml(acct), bic=acct.get("bic") or "", account_name=acct.get("account_holder") or "",
amount=Decimal(str(p["amount"])), reference=p["reference"]))
who, now = uc._who(caller), uc._now()
n = repo.claims.take("payout_run", f"{bank_account_id}:{when}", now, who, "export")
run_id = f"payout-run:{bank_account_id}:{when}:{n}"
house = repo.bank.house_bank(bank.get("house_bank_id") or "") or {}
if fmt == "bacs18":
body = dd.bacs_standard18(sun=house.get("sun") or "", service_user_name=bank.get("account_name") or bank.get("name") or "",
origin_sort=dd.valid_sort_code(house.get("sort_code") or ""), origin_account=dd.valid_account_number(bank.get("account_ref") or ""),
processing_on=when, lines=lines)
ext, ctype = "std18", "text/plain"
else:
account = (f"<IBAN>{escape((bank.get('iban') or '').replace(' ', ''))}</IBAN>" if bank.get("iban") else
f"<Othr><Id>{escape((house.get('sort_code') or '').replace('-', '') + (bank.get('account_ref') or ''))}</Id></Othr>")
body = _pain001(run_id=run_id, debtor_name=bank.get("account_name") or bank.get("name") or "", debtor_account=account, debtor_bic=house.get("bic") or "",
pay_date=when, currency=bank["currency"], lines=lines)
ext, ctype = "xml", "application/xml"
key = f"payout-runs/{bank_account_id}/{when}/{n}.{ext}"
repo.objects.put(key, body, ctype, hashlib.sha256(body.encode()).hexdigest(), now)
sub = (caller.get("sub") or "").strip().lower()
for p in mine:
repo.expected.save_payout({**p, "lifecycle": "exported", "exported_by": who, "exported_sub": sub, "exported_on": uc._today(repo), "export_key": key})
return {"run_id": run_id, "format": fmt, "file_key": key, "count": len(mine), "total": f"{sum((Decimal(str(p['amount'])) for p in mine), Decimal(0)):.2f}",
"payouts": [p["payout_ref"] for p in mine]}
def _post(repo, p: dict, event: str, on: date, who: str, debit_payable: bool) -> str:
bank = repo.config.bank(p["bank_account_id"]) or {}
if not bank.get("gl_account_id"):
raise RuleBroken(f"Bank account {p['bank_account_id']} has no ledger account, so the payout cannot be posted. Set it in the settings")
g = f"{Decimal(str(p['amount'])):.2f}"
payable = {"purpose": "employee_payable", "bp_id": str(p["payee_bp_id"])}
side = seams.account_line(bank["gl_account_id"])
lines = [{**payable, "debit": g}, {**side, "credit": g}] if debit_payable else [{**side, "debit": g}, {**payable, "credit": g}]
posted = seams.post_journal(p["payout_ref"], event, on, p["currency"], f"Payout {p['reference']}"[:200], lines, who, p["company_code"])
if not posted.get("posted"):
raise RuleBroken(f"The ledger did not take the posting ({posted.get('reason', 'no reason given')}). Nothing was confirmed; try again")
return posted["journal_id"]
def _tell(payout_ref: str) -> dict:
"""After the commit: tell the module that asked the payout was paid; retried (retry_payout_notices) until it answers 2xx."""
with uc.repository() as repo:
p = repo.expected.payout(payout_ref)
if p is None or p["lifecycle"] != "paid" or p["told_source_at"]:
return {"payout_ref": payout_ref, "told": bool(p and p["told_source_at"])}
told = seams.tell_expenses_paid(p["source_document"], p["payout_ref"], p["payout_id"], p["paid_on"], Decimal(str(p["amount"])))
if told["ok"]:
repo.expected.save_payout({**p, "told_source_at": uc._now()})
return {"payout_ref": payout_ref, "told": told["ok"], **({} if told["ok"] else {"reason": told.get("reason")})}
@PAY.use_case(kind="command", route="POST /payouts/{payout_ref}/confirm-paid", permission=st.RECONCILE, idempotent=True)
def confirm_payout_paid(caller: dict, payout_ref: Ref, bank_ref: Optional[Ref] = None, paid_on: Optional[Day] = None, line_id: Optional[Ref] = None) -> dict:
"""PAY-51: confirm what the bank paid: a matched statement debit (line_id: the line equals the payout) or the cashier's bank_ref and paid_on (today or
earlier). The journal is employee_payable / bank (event payout, derived from the payout_ref); after the commit expenses is told (POST /reports/{id}/paid),
retried until it answers 2xx."""
with uc.repository() as repo, uc._decide():
p = _payout(repo, payout_ref)
if p["lifecycle"] == "paid":
return {**_view(p), "told_source": bool(p["told_source_at"])}
if p["lifecycle"] != "exported":
raise RuleBroken(f"Payout {payout_ref} is {p['lifecycle']}: only an exported payout is confirmed")
who, today = uc._who(caller), uc._today(repo)
line = None
if line_id:
line = st._line(repo, line_id)
if line["lifecycle"] != "open" or Decimal(str(line["amount"])) >= 0:
raise RuleBroken(f"Line {line_id} is not an open debit")
if abs(Decimal(str(line["amount"]))) != Decimal(str(p["amount"])) or line["currency"] != p["currency"] or line["bank_account_id"] != p["bank_account_id"]:
raise RuleBroken(f"The line is {abs(Decimal(str(line['amount']))):.2f} {line['currency']} on {line['bank_account_id']}; the payout is "
f"{Decimal(str(p['amount'])):.2f} {p['currency']} on {p['bank_account_id']}. A match needs the same amount, currency and account")
paid, ref = line["booked_on"], line_id
else:
if not bank_ref or not paid_on:
raise RuleBroken("Confirming a payout needs the statement debit (line_id) or the bank's reference and the date it was paid")
paid, ref = uc._date(paid_on, "paid_on"), bank_ref
if paid > today:
raise RuleBroken(f"paid_on {paid} is in the future: a payment is dated today or earlier")
journal = _post(repo, p, "payout", paid, who, debit_payable=True)
if line is not None:
n = st._guard(repo, line_id, who, "match")
repo.claims.take("counterpart", f"payout:{payout_ref}", uc._now(), who, line_id)
repo.bank.save_line({**line, "lifecycle": "matched", "matched_kind": "payout", "matched_ref": payout_ref, "journal_id": journal,
"decided_how": "manual", "decided_by": who, "decided_on": today})
p = {**p, "lifecycle": "paid", "paid_on": paid, "bank_ref": ref if not line_id else "", "statement_line_id": line_id or "", "journal_id": journal}
repo.expected.save_payout(p)
told = _tell(payout_ref)
return {**_view(p), "journal_id": journal, "told_source": told["told"]}
@PAY.use_case(kind="command", route="POST /payouts/{payout_ref}/return", permission=st.RECONCILE, idempotent=True)
def record_payout_return(caller: dict, payout_ref: Ref, bank_ref: Reason) -> dict:
"""PAY-51: the bank returned a paid payout (the cashier names the bank's return reference): the journal is reversed (employee_payable / bank the other way
round, event payout_return) and the payout is returned."""
with uc.repository() as repo, uc._decide():
p = _payout(repo, payout_ref)
if p["lifecycle"] == "returned":
return _view(p)
if p["lifecycle"] != "paid":
raise RuleBroken(f"Payout {payout_ref} is {p['lifecycle']}: only a paid payout is returned")
today = uc._today(repo)
journal = _post(repo, p, "payout_return", today, uc._who(caller), debit_payable=False)
p = {**p, "lifecycle": "returned", "returned_on": today, "bank_ref": bank_ref}
repo.expected.save_payout(p)
return {**_view(p), "journal_id": journal}
def _cancel(repo, p: dict, reason: str, who: str) -> dict:
if p["lifecycle"] == "cancelled":
return {"payout_ref": p["payout_ref"], "status": "cancelled"}
if p["lifecycle"] not in ("requested", "approved"):
raise Refused(409, f"payout {p['payout_ref']} is {p['lifecycle']}", gate="not_exported", fix="a payout in an exported run waits for the payment and a recovery")
if not reason.strip():
raise RuleBroken("Cancelling a payout needs a reason")
repo.expected.save_payout({**p, "lifecycle": "cancelled", "cancel_reason": reason.strip(), "cancelled_by": who})
return {"payout_ref": p["payout_ref"], "status": "cancelled"}
@PAY.use_case(kind="command", route="POST /payouts/{payout_ref}/cancel", permission=INTAKE, idempotent=True, tool=False)
def cancel_payout(caller: dict, payout_ref: Ref, reason: Reason) -> dict:
"""PAY-51: the module that requested a payout cancels it (a reversed claim) before it is exported; 409 {gate: not_exported} once it is in a run. Only a payout
this caller requested; a repeat answers the same."""
with uc.repository() as repo, uc._decide():
p = _payout(repo, payout_ref)
if p["requested_by"] != uc._who(caller):
raise Refused(403, f"payout {payout_ref} was not requested by you: it is not yours to cancel")
return _cancel(repo, p, reason, uc._who(caller))
@PAY.use_case(kind="command", route="POST /payouts/{payout_ref}/withdraw", permission=APPROVE, idempotent=True)
def withdraw_payout(caller: dict, payout_ref: Ref, reason: Reason) -> dict:
"""PAY-51 (cancel_payout by an approver): an approver cancels a requested or approved payout with a reason, never once it is exported."""
with uc.repository() as repo, uc._decide():
return _cancel(repo, _payout(repo, payout_ref), reason, uc._who(caller))
@PAY.use_case(kind="command", route="POST /payouts/notify", permission=uc.TICK, idempotent=True, tool=False)
def retry_payout_notices(caller: dict) -> dict:
"""The clock worker's retry: tell every module whose payout was paid and not yet acknowledged (expenses' POST /reports/{id}/paid), until it answers 2xx."""
with uc.repository() as repo:
waiting = [p["payout_ref"] for p in repo.expected.unannounced()]
done = [_tell(ref) for ref in waiting]
return {"tried": len(done), "told": sum(1 for d in done if d["told"]), "still_waiting": sum(1 for d in done if not d["told"])}
@PAY.use_case(kind="query", route="GET /payouts", permission=uc.READ)
def list_payouts(caller: dict, lifecycle: Optional[Code] = None) -> dict:
"""Payouts by state (requested, approved, exported, paid, returned, cancelled). The payee's bank account is never shown or stored."""
with uc.repository() as repo:
return {"payouts": [_view(p) for p in repo.expected.payouts(lifecycle)]}