dodil/erp-brick-payments
DODIL ERP brick: payments
src/brick_payments/application/deposits.py
"""Deposit batches of cheques (PAY-28). Each cheque is a receipt (method cheque) allocated to its invoices like any other; a batch gathers
the ones paid in on one paying-in slip, fixes their total when it is closed, and is then matched to the ONE statement credit the bank
shows for the slip - the line matches the batch, never the cheques, and is not made a receipt again. A cheque that bounces later reverses its
own receipt (PAY-07) and leaves the batch as it was. A batch whose total differs from the line is never matched.
One guard key per batch (scope batch): adding a cheque and closing the batch take it, so a cheque added while the batch closes collides (409).
A cheque is in ONE batch: its own claim (scope cheque) is a plain INSERT."""
import uuid
from decimal import Decimal
from typing import Optional
from erp_platform.bricks import Refused
from erp_platform.types import text
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, Ref
from brick_payments.domain.errors import RuleBroken
PAY = uc.PAY
WRITE = "pay:deposit_batch:write"
def _batch(repo, batch_id: str) -> dict:
b = repo.deposits.get(batch_id)
if b is None:
raise Refused(404, f"no deposit batch {batch_id}")
return b
def view(repo, b: dict) -> dict:
cheques = repo.deposits.cheques(b["final_batch_id"] if b["lifecycle"] == "closed" and b["final_batch_id"] else b["batch_id"])
return {"batch_id": b["batch_id"], "bank_account_id": b["bank_account_id"], "currency": b["currency"], "lifecycle": b["lifecycle"],
"slip_no": b["slip_no"] or None, "total": None if b["total"] is None else f"{Decimal(str(b['total'])):.2f}", "cheque_count": b["cheque_count"],
"deposited_on": b["deposited_on"].isoformat() if b["deposited_on"] else None, "statement_line_id": b["statement_line_id"] or None,
"final_batch_id": b["final_batch_id"] or None,
"cheques": [{"payment_id": c["payment_id"], "bp_id": c["bp_id"], "amount": f"{Decimal(str(c['amount'])):.2f}", "status": c["status"],
"received_on": c["received_on"].isoformat(), "bank_reference": c["bank_reference"]} for c in cheques]}
@PAY.use_case(kind="command", route="POST /deposit-batches", permission=WRITE)
def start_deposit_batch(caller: dict, bank_account_id: Code) -> dict:
"""Start a batch of cheques to pay in to one of our accounts that takes receipts. It is named dep:<account>:<slip> when it is closed."""
with uc.repository() as repo, uc._decide():
bank = repo.config.bank(bank_account_id)
if bank is None:
raise Refused(404, f"no bank account {bank_account_id}")
if "receive" not in purposes_of(bank):
raise RuleBroken(f"Account {bank_account_id} does not take receipts, so cheques cannot be paid in to it")
if bank.get("closed_from") and bank["closed_from"] <= uc._today(repo):
raise RuleBroken(f"Account {bank_account_id} is closed from {bank['closed_from']}")
row = {"batch_id": f"dep:{bank_account_id}:open-{uuid.uuid4().hex[:10]}", "bank_account_id": bank_account_id, "currency": bank["currency"], "slip_no": "",
"lifecycle": "open", "total": None, "cheque_count": 0, "deposited_on": None, "statement_line_id": "", "final_batch_id": "",
"opened_by": uc._who(caller)}
repo.deposits.add(row, uc._now())
return view(repo, row)
@PAY.use_case(kind="command", route="POST /deposit-batches/{batch_id}/cheques", permission=WRITE)
def add_cheque_to_batch(caller: dict, batch_id: Ref, payment_id: Ref) -> dict:
"""Add a cheque receipt (method cheque, not returned, same currency, received on this account) to an open batch. A cheque is in ONE batch."""
with uc.repository() as repo, uc._decide():
b = _batch(repo, batch_id)
if b["lifecycle"] != "open":
raise RuleBroken(f"Batch {batch_id} is {b['lifecycle']}; cheques are added to an open batch")
r = repo.receipts.get(payment_id)
if r is None:
raise Refused(404, f"no receipt {payment_id}")
if r.method != "cheque":
raise RuleBroken(f"Receipt {payment_id} was paid by {r.method or 'no method'}, not by cheque")
if not r.live:
raise RuleBroken(f"Receipt {payment_id} was returned, so it cannot be paid in")
if r.batch_id:
raise RuleBroken(f"Receipt {payment_id} is already in batch {r.batch_id}")
if r.currency != b["currency"] or r.bank_account_id != b["bank_account_id"]:
raise RuleBroken(f"Receipt {payment_id} is {r.amount:.2f} {r.currency} on account {r.bank_account_id or '(none)'}; the batch is "
f"{b['currency']} on {b['bank_account_id']}")
who, now = uc._who(caller), uc._now()
repo.claims.take("batch", batch_id, now, who, f"add:{payment_id}")
repo.claims.take("cheque", payment_id, now, who, batch_id)
r.batch_id = batch_id
repo.receipts.save(r)
row = {**b, "cheque_count": b["cheque_count"] + 1}
repo.deposits.save(row)
return {**{k: row[k] for k in ("batch_id", "bank_account_id", "currency", "lifecycle", "cheque_count")}, "added": payment_id}
@PAY.use_case(kind="command", route="POST /deposit-batches/{batch_id}/close", permission=WRITE, idempotent=True)
def close_deposit_batch(caller: dict, batch_id: Ref, slip_no: text(40, min_length=1)) -> dict:
"""Close the batch with the paying-in slip number: its total is fixed (the cheques added) and it is keyed dep:<bank_account_id>:<slip_no>."""
with uc.repository() as repo, uc._decide():
b = _batch(repo, batch_id)
slip = slip_no.strip()
if b["lifecycle"] == "closed" and b["slip_no"] == slip:
return view(repo, repo.deposits.get(b["final_batch_id"]))
if b["lifecycle"] != "open":
raise RuleBroken(f"Batch {batch_id} is {b['lifecycle']}; only an open batch closes")
final = f"dep:{b['bank_account_id']}:{slip}"
cheques = repo.deposits.cheques(batch_id)
if not cheques:
raise RuleBroken("A batch with no cheques has nothing to pay in. Add the cheques first")
if any(c["status"] != "received" for c in cheques):
raise RuleBroken("A cheque in this batch was returned. Its receipt stays reversed; it cannot be on the slip")
if repo.deposits.get(final):
raise RuleBroken(f"Paying-in slip {slip} was already used on {b['bank_account_id']} (batch {final})")
who, now, today = uc._who(caller), uc._now(), uc._today(repo)
repo.claims.take("batch", batch_id, now, who, "close")
total = sum((Decimal(str(c["amount"])) for c in cheques), Decimal(0))
done = {**b, "batch_id": final, "slip_no": slip, "lifecycle": "deposited", "total": total, "cheque_count": len(cheques),
"deposited_on": today, "final_batch_id": ""}
repo.deposits.add(done, now)
paid_in = repo.receipts.get_many(c["payment_id"] for c in cheques)
for r in paid_in.values():
r.batch_id = final
repo.receipts.save_many(paid_in.values())
repo.deposits.save({**b, "lifecycle": "closed", "slip_no": slip, "total": total, "cheque_count": len(cheques), "deposited_on": today,
"final_batch_id": final})
return {**{k: f"{Decimal(str(v)):.2f}" if k == "total" else v for k, v in done.items() if k in ("batch_id", "bank_account_id", "currency",
"slip_no", "lifecycle", "total", "cheque_count")},
"deposited_on": today.isoformat(), "cheques": [c["payment_id"] for c in cheques]}
@PAY.use_case(kind="command", route="POST /deposit-batches/{batch_id}/match", permission=WRITE)
def match_deposit_batch(caller: dict, batch_id: Ref, line_id: Ref) -> dict:
"""Match a deposited batch to the statement credit for it: the credit must equal the batch total. The line is the batch, not the cheques,
and is not made a receipt again."""
with uc.repository() as repo, uc._decide():
return st.match_core(repo, caller, line_id, "deposit_batch", batch_id)
@PAY.use_case(kind="query", route="GET /deposit-batches", permission=uc.READ)
def list_deposit_batches(caller: dict, lifecycle: Optional[Code] = None, bank_account_id: Optional[Code] = None) -> dict:
"""Deposit batches of cheques, by state (open, closed, deposited, matched) and account."""
with uc.repository() as repo:
return {"batches": [view(repo, b) for b in repo.deposits.search(lifecycle, bank_account_id)]}
@PAY.use_case(kind="query", route="GET /deposit-batches/{batch_id}", permission=uc.READ)
def get_deposit_batch(caller: dict, batch_id: Ref) -> dict:
"""One batch with its cheques, total, slip and the statement line it was matched to."""
with uc.repository() as repo:
return view(repo, _batch(repo, batch_id))