dodil/erp-brick-payments
DODIL ERP brick: payments
src/brick_payments/application/expected.py
"""What another module hands payments to match (v6): a cash bag sent to the bank (PAY-49) and a card provider's expected settlement (PAY-50).
A bag is a deposit batch of kind cash: one statement credit matches it whole (bank / cash_in_transit, event deposit_match) and a counted difference
needs an approver who is not the cashier (accept_deposit_difference, cash_over_short). A card settlement is what a provider owes us for one terminal
batch: ONE credit from the provider settles one or more expected settlements, whole; fees = gross - credit are posted to card_fees (tax code E) and
the gross cleared from card_clearing (event card_settlement), and fees above card_fee_max_pct wait for an approver (accept_card_difference).
pos never writes pay_ tables and payments never writes pos_ tables: both arrive over these routes."""
from decimal import ROUND_HALF_UP, Decimal
from typing import Literal, Optional
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 Code, Day, Money, Ref, Reason
from brick_payments.domain.errors import RuleBroken
from brick_payments.domain.money import ZERO, money
from brick_payments.infrastructure import seams
PAY = uc.PAY
DEPOSIT_INTAKE, CARD_INTAKE = "pay:deposit_batch:intake", "pay:card_settlement:intake"
APPROVE = "pay:cash_journal:approve" # the approver's grant for a counted or card difference (no new permission is named for it)
READ = uc.READ
def _gate(gate: str, reason: str, fix: str, status: int = 422) -> Refused:
return Refused(status, reason, gate=gate, fix=fix)
def _line_for_credit(repo, line_id: str) -> dict:
line = st._line(repo, line_id)
if line["lifecycle"] != "open":
raise RuleBroken(f"Line {line_id} is already {line['lifecycle']}. Undo that first, with a reason")
if Decimal(str(line["amount"])) <= 0:
raise RuleBroken("This is matched to a credit; this line is money out")
return line
def _importer(repo, line: dict) -> str:
s = repo.bank.statement(line["statement_id"]) or {}
return (s.get("imported_by") or "").strip().lower()
# ── PAY-49: a cash bag ──────────────────────────────────────────────────────────────────────────────────
@PAY.use_case(kind="command", route="POST /deposit-batches/expected", permission=DEPOSIT_INTAKE, idempotent=True, tool=False, status=201)
def expect_deposit_batch(caller: dict, batch_ref: Ref, kind: Literal["cash"], bank_account_id: Code, company_code: Code, currency: uc.Currency, amount: Money,
expected_on: Day, clearing_purpose: Literal["cash_in_transit"], source_module: Literal["pos"]) -> dict:
"""PAY-49: another module hands over a cash bag it sent to the bank. One deposited batch keyed batch_ref, total = amount, no cheques, clearing purpose
cash_in_transit; the repeat answers the same batch (a different amount for a known batch_ref is 409). The statement credit is matched to it like a
paying-in slip (match_deposit_batch) and posts bank / cash_in_transit; the account must take receipts in the currency (gate bank_account_receives)."""
def run():
with uc.repository() as repo, uc._decide():
cur = uc._cur(currency)
have = repo.deposits.get(batch_ref)
if have is not None:
if Decimal(str(have["total"])) != amount or have["bank_account_id"] != bank_account_id:
raise Refused(409, f"{batch_ref} is already a batch of {Decimal(str(have['total'])):.2f} on {have['bank_account_id']}, not {amount:.2f} on {bank_account_id}")
return {"batch_id": batch_ref, "status": have["lifecycle"], "total": f"{Decimal(str(have['total'])):.2f}", "replay": True}
bank = repo.config.bank(bank_account_id)
if bank is None or "receive" not in purposes_of(bank) or bank["currency"] != cur or (bank.get("company_code") or company_code) != company_code:
raise _gate("bank_account_receives", f"{bank_account_id} does not take {cur} receipts for company {company_code}",
"name a bank account of the company that takes receipts in that currency")
when = uc._date(expected_on, "expected_on")
repo.deposits.add({"batch_id": batch_ref, "bank_account_id": bank_account_id, "currency": cur, "slip_no": "", "lifecycle": "deposited",
"total": amount, "cheque_count": 0, "deposited_on": when, "statement_line_id": "", "final_batch_id": "", "opened_by": uc._who(caller),
"kind": "cash", "batch_ref": batch_ref, "source_module": source_module, "clearing_purpose": clearing_purpose, "expected_on": when,
"company_code": company_code}, uc._now())
return {"batch_id": batch_ref, "status": "deposited", "total": f"{amount:.2f}", "replay": False}
return run()
def post_deposit_match(repo, line: dict, batch: dict, who: str, n: int, difference: Decimal = ZERO) -> str:
"""bank / the batch's clearing purpose (event deposit_match, derived from the batch_ref); with an accepted difference the bank gets the line's amount,
the clearing purpose the batch total and cash_over_short the difference (event deposit_difference)."""
bank = repo.config.bank(line["bank_account_id"]) or {}
if not bank.get("gl_account_id"):
raise RuleBroken(f"Bank account {line['bank_account_id']} has no ledger account, so the bag cannot be posted. Set it in the settings")
credit = Decimal(str(line["amount"]))
total = Decimal(str(batch["total"]))
clearing = {"purpose": batch.get("clearing_purpose") or "cash_in_transit"}
lines = [{**seams.account_line(bank["gl_account_id"]), "debit": f"{credit:.2f}"}]
if difference: # the bank counted `credit`; the bag said `total`: the gap is cash over / short
over_short = {"purpose": "cash_over_short"}
lines += [{**over_short, "debit": f"{abs(difference):.2f}"}] if difference < 0 else [{**over_short, "credit": f"{abs(difference):.2f}"}]
lines.append({**clearing, "credit": f"{total:.2f}"})
event = "deposit_difference" if difference else "deposit_match"
posted = seams.post_journal(batch["batch_id"] + (f"@{n}" if n > 1 else ""), event, line["booked_on"], line["currency"],
f"Cash bag {batch['batch_ref'] or batch['batch_id']}"[:200], lines, who, bank.get("company_code") or batch.get("company_code") or "")
if not posted.get("posted"):
raise RuleBroken(f"The ledger did not take the posting ({posted.get('reason', 'no reason given')}). Nothing was matched; try again")
return posted["journal_id"]
@PAY.use_case(kind="command", route="POST /deposit-batches/{batch_id}/accept-difference", permission=APPROVE)
def accept_deposit_difference(caller: dict, batch_id: Ref, line_id: Ref, reason: Reason) -> dict:
"""PAY-49: an approver accepts the bank's count of a cash bag (gate batch_total_equals_line met by consent): the line is matched, bank takes the line's
amount, the clearing purpose the batch total, and cash_over_short the difference (event deposit_difference). The approver is not the cashier who
imported the statement (email); an expected cash batch only."""
with uc.repository() as repo, uc._decide():
b = repo.deposits.get(batch_id)
if b is None:
raise Refused(404, f"no deposit batch {batch_id}")
if b.get("kind") != "cash" or b["lifecycle"] != "deposited":
raise RuleBroken(f"Batch {batch_id} is {b.get('kind') or 'cheque'} / {b['lifecycle']}: only an expected cash bag awaiting its credit takes an accepted difference")
if not reason.strip():
raise RuleBroken("Accepting a difference needs a reason")
line = _line_for_credit(repo, line_id)
if line["bank_account_id"] != b["bank_account_id"] or line["currency"] != b["currency"]:
raise RuleBroken(f"Batch {batch_id} was paid in to {b['bank_account_id']} in {b['currency']}; the line is on {line['bank_account_id']} in {line['currency']}")
credit, total = Decimal(str(line["amount"])), Decimal(str(b["total"]))
if credit == total:
raise RuleBroken("The line equals the batch total: match it, there is no difference to accept")
who = uc._who(caller)
if _importer(repo, line) and _importer(repo, line) in uc._ids(caller):
raise Refused(409, "you imported this statement and counted this bag (same email): another approver must accept the difference", gate="second_person",
fix="another approver")
n = st._guard(repo, line_id, who, "match")
repo.claims.take("counterpart", f"deposit_batch:{batch_id}", uc._now(), who, line_id)
journal = post_deposit_match(repo, line, b, who, n, difference=credit - total)
repo.deposits.save({**b, "lifecycle": "matched", "statement_line_id": line_id, "difference": credit - total, "accepted_by": who,
"accepted_sub": (caller.get("sub") or "").strip().lower()})
repo.bank.save_line({**line, "lifecycle": "matched", "matched_kind": "deposit_batch", "matched_ref": batch_id, "journal_id": journal,
"decided_how": "manual", "decided_by": who, "decided_on": uc._today(repo)})
return {"batch_id": batch_id, "line_id": line_id, "lifecycle": "matched", "difference": f"{credit - total:.2f}", "journal_id": journal, "reason": reason}
# ── PAY-50: a card settlement ───────────────────────────────────────────────────────────────────────────
@PAY.use_case(kind="command", route="POST /card-settlements/expected", permission=CARD_INTAKE, idempotent=True, tool=False, status=201)
def expect_card_settlement(caller: dict, settlement_ref: Ref, provider: Code, company_code: Code, currency: uc.Currency, gross: Money, business_date: Day,
transaction_count: int, source_module: Literal["pos"]) -> dict:
"""PAY-50: another module tells payments what a card provider owes us for one terminal batch (card_clearing carries the gross, posted by the module that
sold). Keyed settlement_ref; a repeat answers the same row; the provider must be one of the settings card_providers (gate provider_known)."""
with uc.repository() as repo, uc._decide():
cur = uc._cur(currency)
have = repo.expected.card(settlement_ref)
if have is not None:
return {"settlement_ref": settlement_ref, "status": have["lifecycle"], "replay": True}
known = [p.strip() for p in repo.settings.current().card_providers.split(",") if p.strip()]
if provider not in known:
raise _gate("provider_known", f"{provider} is not a card provider of company {company_code}", "add it to the card providers (settings card_providers)")
repo.expected.add_card({"settlement_ref": settlement_ref, "provider": provider, "company_code": company_code, "currency": cur, "gross": gross,
"business_date": uc._date(business_date, "business_date"), "transaction_count": transaction_count,
"source_module": source_module, "lifecycle": "expected", "bank_account_id": "", "statement_line_id": "", "fees": None,
"fee_pct": None, "matched_by": "", "matched_sub": "", "accepted_by": "", "accepted_sub": "", "accept_reason": "", "claim": ""},
uc._now())
return {"settlement_ref": settlement_ref, "status": "expected", "replay": False}
def _split_fees(rows: list, fees: Decimal) -> dict:
"""Fees pro rata by gross, each rounded once, the rest to the last."""
total = sum((Decimal(str(r["gross"])) for r in rows), ZERO)
out, given = {}, ZERO
for i, r in enumerate(rows):
share = fees - given if i == len(rows) - 1 else money(fees * Decimal(str(r["gross"])) / total)
out[r["settlement_ref"]] = share
given += share
return out
def _post_settlement(repo, line: dict, rows: list, fees: Decimal, who: str, n: int, claim: bool) -> str:
bank = repo.config.bank(line["bank_account_id"]) or {}
if not bank.get("gl_account_id"):
raise RuleBroken(f"Bank account {line['bank_account_id']} has no ledger account, so the settlement cannot be posted. Set it in the settings")
gross = sum((Decimal(str(r["gross"])) for r in rows), ZERO)
credit = Decimal(str(line["amount"]))
lines = [{**seams.account_line(bank["gl_account_id"]), "debit": f"{credit:.2f}"}]
if fees:
lines.append({"purpose": "card_claim" if claim else "card_fees", **({} if claim else {"tax_code": "E"}), "debit": f"{fees:.2f}"})
lines.append({"purpose": "card_clearing", "credit": f"{gross:.2f}"})
posted = seams.post_journal(f"{line['line_id']}@{n}", "card_settlement", line["booked_on"], line["currency"],
f"Card settlement {rows[0]['provider']}"[:200], lines, who, bank.get("company_code") or rows[0]["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 matched; try again")
return posted["journal_id"]
def _settle(repo, caller: dict, line: dict, rows: list, fees: Decimal, who: str, *, claim: bool = False, reason: str = "", approved: bool = False) -> dict:
n = st._guard(repo, line["line_id"], who, "match")
for r in rows:
repo.claims.take("counterpart", f"card_settlement:{r['settlement_ref']}", uc._now(), who, line["line_id"])
journal = _post_settlement(repo, line, rows, fees, who, n, claim)
split = _split_fees(rows, fees)
sub = (caller.get("sub") or "").strip().lower()
for r in rows:
repo.expected.save_card({**r, "lifecycle": "matched", "statement_line_id": line["line_id"], "bank_account_id": line["bank_account_id"],
"fees": split[r["settlement_ref"]], "claim": ("claim" if claim else "fees") if approved else "",
**({"accepted_by": who, "accepted_sub": sub, "accept_reason": reason} if approved else {})})
refs = ",".join(r["settlement_ref"] for r in rows)
repo.bank.save_line({**line, "lifecycle": "matched", "matched_kind": "card_settlement", "matched_ref": refs, "journal_id": journal,
"decided_how": "manual", "decided_by": who, "decided_on": uc._today(repo)})
return {"line_id": line["line_id"], "lifecycle": "matched", "settlements": refs, "fees": f"{fees:.2f}", "journal_id": journal}
def _rows(repo, refs: list, states: tuple) -> list:
rows = []
for ref in dict.fromkeys(refs):
r = repo.expected.card(ref)
if r is None:
raise Refused(404, f"no card settlement {ref}")
if r["lifecycle"] not in states:
raise RuleBroken(f"Card settlement {ref} is {r['lifecycle']}")
rows.append(r)
if len({(r["provider"], r["company_code"], r["currency"]) for r in rows}) != 1:
raise RuleBroken("A credit settles settlements of ONE provider, company and currency")
return rows
@PAY.use_case(kind="command", route="POST /card-settlements/match", permission=st.RECONCILE, idempotent=True)
def match_card_settlement(caller: dict, line_id: Ref, settlement_refs: list[Ref]) -> dict:
"""PAY-50: a statement credit from the provider settles one or more expected settlements of that provider whole, never part of one. fees = gross -
credit, split pro rata by gross (each rounded once, the rest to the last), at or under card_fee_max_pct of the gross: the journal is bank (the credit)
+ card_fees (tax code E) / card_clearing (the gross), event card_settlement. Fees above the limit leave the settlements in state differs (flag_card_
difference) on the bank reconciliation screen until an approver accepts the difference (to card_fees) or records a claim against the provider.
By the cashier (pay:statement_line:reconcile)."""
with uc.repository() as repo, uc._decide():
line = _line_for_credit(repo, line_id)
rows = _rows(repo, settlement_refs, ("expected", "differs"))
gross = sum((Decimal(str(r["gross"])) for r in rows), ZERO)
credit = Decimal(str(line["amount"]))
if line["currency"] != rows[0]["currency"]:
raise RuleBroken(f"The line is {line['currency']}; the settlements are {rows[0]['currency']}")
if credit > gross:
raise RuleBroken(f"The credit {credit:.2f} is more than the gross {gross:.2f} the provider owes: fees cannot be negative")
who, sub = uc._who(caller), (caller.get("sub") or "").strip().lower()
fees = gross - credit
pct = (fees * 100 / gross).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
limit = repo.settings.current().card_fee_max_pct
if pct > limit:
for r in rows:
repo.expected.save_card({**r, "lifecycle": "differs", "statement_line_id": line_id, "bank_account_id": line["bank_account_id"],
"fees": fees if len(rows) == 1 else None, "fee_pct": pct, "matched_by": who, "matched_sub": sub})
return {"line_id": line_id, "lifecycle": "open", "settlements": [r["settlement_ref"] for r in rows], "state": "differs", "fees": f"{fees:.2f}",
"fee_pct": f"{pct}", "card_fee_max_pct": f"{limit}", "gate": "fees_within_limit",
"reason": f"the fees are {fees:.2f} ({pct}%), above card_fee_max_pct {limit}%", "fix": "an approver accepts it, or claim it from the provider"}
out = _settle(repo, caller, line, rows, fees, who)
for r in rows:
repo.expected.save_card({**repo.expected.card(r["settlement_ref"]), "matched_by": who, "matched_sub": sub, "fee_pct": pct})
return {**out, "state": "matched", "fee_pct": f"{pct}"}
@PAY.use_case(kind="command", route="POST /card-settlements/accept-difference", permission=APPROVE)
def accept_card_difference(caller: dict, line_id: Ref, reason: Reason, claim: bool = False) -> dict:
"""PAY-50: an approver, who is not the cashier who matched the credit (email AND sub), accepts fees above card_fee_max_pct with a reason: posted as the
match, the whole difference to card_fees, or to a claim against the provider (card_claim) when `claim` is true."""
with uc.repository() as repo, uc._decide():
line = _line_for_credit(repo, line_id)
rows = [r for r in repo.expected.cards("differs") if r["statement_line_id"] == line_id]
if not rows:
raise RuleBroken(f"No card settlement waits on line {line_id}: match the credit first")
if not reason.strip():
raise RuleBroken("Accepting a difference needs a reason")
if set(uc._ids(caller)) & {x for r in rows for x in (r["matched_by"], r["matched_sub"]) if x}:
raise Refused(409, "you matched this credit (same email or sign-in): another approver must accept the difference", gate="second_person", fix="another approver")
gross = sum((Decimal(str(r["gross"])) for r in rows), ZERO)
fees = gross - Decimal(str(line["amount"]))
return _settle(repo, caller, line, rows, fees, uc._who(caller), claim=claim, reason=reason, approved=True)
@PAY.use_case(kind="query", route="GET /card-settlements", permission=READ)
def list_card_settlements(caller: dict, lifecycle: Optional[Code] = None) -> dict:
"""Expected card settlements by state (expected, matched, differs): what the bank reconciliation screen lists."""
with uc.repository() as repo:
return {"settlements": [{k: (f"{Decimal(str(r[k])):.2f}" if k in ("gross", "fees") and r[k] is not None else
(r[k].isoformat() if k == "business_date" and r[k] else r[k]))
for k in ("settlement_ref", "provider", "company_code", "currency", "gross", "business_date", "transaction_count", "lifecycle",
"statement_line_id", "fees")} for r in repo.expected.cards(lifecycle)]}