dodil/erp-brick-payments
DODIL ERP brick: payments
src/brick_payments/application/sources.py
"""What else settles an invoice, and the reads other modules make of allocations (PAY-36, PAY-45, PAY-46, PAY-47).
* PAY-36 a receipt paid against a down-payment REQUEST: invoicing settles the request into a deposit invoice (ar keys the settlement on
the payment, so a retry settles once), then the receipt is allocated to that invoice - an ordinary allocation. The request itself
never carries one.
* PAY-45 a contractor customer pays net of a CIS deduction: tax records what was suffered FIRST; only when it accepts is the deduction
allocated (source_kind cis_deduction, source_id the statement reference). tax refusing leaves the deduction outstanding.
* PAY-46 the allocation reads tax (cash accounting, bad-debt repayment) and ar (late interest) make - never a read of pay_ tables.
* PAY-47 the receiving account invoicing prints.
"""
from datetime import date
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 use_cases as uc
from brick_payments.application.inputs import Code, Currency, Day, LineIn, Money, Money0, Ref
from brick_payments.domain import settlement as stl
from brick_payments.domain.errors import RuleBroken
from brick_payments.domain.money import ZERO
from brick_payments.domain.readiness import fmt
from brick_payments.infrastructure import seams
PAY = uc.PAY
def _refusal(got: dict) -> Refused:
words = " ".join(x for x in (got.get("reason"), got.get("fix")) if x)
return Refused(404 if got.get("status") == 404 else 422 if got.get("status") == 422 else 409, words or "the neighbour refused")
# ═══ PAY-36: a receipt against a down-payment request ════════════════════════════════════════════════════════
@PAY.use_case(kind="command", route="POST /receipts/{payment_id}/allocate-to-request", permission=uc.ALLOC)
def allocate_to_payment_request(caller: dict, payment_id: Ref, request_id: Ref, amount: Money) -> dict:
"""Allocate (part of) a receipt to a down-payment request: invoicing settles the request into a deposit invoice for the amount
received, then the receipt is allocated to that invoice. A part payment settles a part; asking again for the same receipt settles
once (invoicing keys on the payment) and allocates once."""
with uc.repository() as repo, uc._decide():
r = uc._receipt(repo, payment_id)
if not r.live:
raise RuleBroken(f"Receipt {payment_id} was returned on {r.returned_on}. A returned payment settles nothing")
if r.bp_id is None:
raise RuleBroken("This receipt is not on a customer yet. Name the customer first (assign-customer)")
rows = repo.allocations.for_bp(r.bp_id)
left = uc._remainder(r, rows)
earlier = sum((a.amount for a in rows if a.source_kind == "receipt" and a.source_id == payment_id
and a.invoice_id.startswith(f"dep:{request_id}:")), ZERO) # ar names a deposit invoice dep:<request>:<n>
if amount > left and earlier < amount:
raise RuleBroken(f"Allocating {fmt(amount)} to the request; receipt {payment_id} has {fmt(left)} left. Allocate at most the smaller figure")
# validate everything locally BEFORE the side effect: invoicing's settle keys on the payment and never sees the payer, so a receipt
# that allocation would refuse must be refused here - never leave the request settled while payments says no
req = seams.payment_request(request_id, uc._who(caller))
if not req["ok"]:
raise _refusal(req)
if int(req["bp_id"]) != int(r.bp_id):
raise RuleBroken(f"Request {request_id} belongs to customer {req['bp_id']}, the receipt to customer {r.bp_id}. "
"A receipt pays its own customer's request; nothing was settled")
if str(req.get("currency", r.currency)).upper() != r.currency:
raise RuleBroken(f"Request {request_id} is in {req['currency']}, the receipt in {r.currency}. Nothing was settled")
got = seams.settle_payment_request(request_id, payment_id, r.received_on, amount, r.currency, uc._who(caller))
if not got["ok"]:
raise _refusal(got)
deposit, settled = str(got["deposit_invoice_id"]), Decimal(str(got["amount"]))
have = sum((a.amount for a in repo.allocations.pair("receipt", payment_id, deposit)), ZERO)
if have >= settled: # the same payment came again: invoicing replayed, nothing more to allocate
return {"payment_id": payment_id, "request_id": request_id, "deposit_invoice_id": deposit, "replay": True,
"remaining": got.get("remaining"), "status": got.get("status")}
done, _ = uc._allocate_core(repo, caller, source_kind="receipt", source_id=payment_id, source_currency=r.currency, source_bp=r.bp_id,
source_remainder=left, lines=[LineIn(invoice_id=deposit, amount=settled)])
return {"payment_id": payment_id, "request_id": request_id, "deposit_invoice_id": deposit, "invoice_no": got.get("invoice_no"),
"request_remaining": got.get("remaining"), "request_status": got.get("status"), "replay": False, **done}
# ═══ PAY-45: the CIS deduction ═══════════════════════════════════════════════════════════════════════════════
@PAY.use_case(kind="command", route="POST /receipts/{payment_id}/cis-deduction", permission=uc.ALLOC, idempotent=True)
def record_cis_deduction(caller: dict, payment_id: Ref, invoice_id: Code, statement_ref: text(140, min_length=1), tax_month: Day,
gross: Money, deduction: Money, materials: Optional[Money0] = None) -> dict:
"""A contractor customer paid net of a CIS deduction: record the deduction against the invoice with the contractor's payment and
deduction statement reference. Tax is told first (gross labour, materials, deduction; at most 30% of the labour); only when it
accepts is the deduction allocated (source_kind cis_deduction, source_id the statement reference), so the invoice is paid. Tax
refusing leaves the deduction outstanding with tax's reason. The same statement recorded twice adds nothing."""
with uc.repository() as repo, uc._decide():
r = uc._receipt(repo, payment_id)
if r.bp_id is None:
raise RuleBroken("This receipt is not on a customer yet. Name the customer first (assign-customer)")
d = seams.invoice(invoice_id, caller)
if d is None:
raise Refused(404, f"no settleable invoice {invoice_id}")
if d.bp_id != r.bp_id:
raise RuleBroken(f"Invoice {d.number} belongs to customer {d.bp_id}, the receipt to customer {r.bp_id}. A deduction is recorded on the contractor who paid")
if d.currency != r.currency:
raise RuleBroken(f"The receipt is {r.currency}, the invoice {d.currency}. A deduction is in the invoice's currency")
ref, month = statement_ref.strip(), uc._date(tax_month, "tax_month")
if not ref:
raise Refused(422, "statement_ref is required")
prior = repo.allocations.for_source("cis_deduction", ref)
if prior:
if {a.invoice_id for a in prior} != {invoice_id} or {a.bp_id for a in prior} != {r.bp_id}:
raise RuleBroken(f"Statement {ref} was recorded against another invoice or customer. A statement reference names one deduction")
if sum((a.amount for a in prior), ZERO) > ZERO:
return {"payment_id": payment_id, "statement_ref": ref, "invoice_id": invoice_id, "replay": True,
"allocated": f"{sum((a.amount for a in prior), ZERO):.2f}"}
out = stl.outstanding(d.total, repo.allocations.for_invoices([invoice_id]))
if deduction > out:
raise RuleBroken(f"Deduction {fmt(deduction)}; invoice {d.number} has {fmt(out)} outstanding. Record at most the smaller figure")
mat = materials if materials is not None else ZERO
told = seams.record_cis_suffered(r.bp_id, ref, month, invoice_id, payment_id, gross, mat, deduction, uc._who(caller))
if not told["ok"]:
raise Refused(422, f"Tax did not accept the deduction, so {fmt(deduction)} stays outstanding on {d.number}: " +
(" ".join(x for x in (told.get("reason"), told.get("fix")) if x) or "no reason given"))
done, _ = uc._allocate_core(repo, caller, source_kind="cis_deduction", source_id=ref, source_currency=d.currency, source_bp=r.bp_id,
source_remainder=deduction, lines=[LineIn(invoice_id=invoice_id, amount=deduction)], what="CIS deduction")
return {"payment_id": payment_id, "statement_ref": ref, "invoice_id": invoice_id, "tax_replay": told["replay"], "replay": False, **done}
# ═══ PAY-46: the allocation reads ════════════════════════════════════════════════════════════════════════════
def _view(a, received_on: Optional[date]) -> dict:
return {"source_kind": a.source_kind, "source_id": a.source_id, "invoice_id": a.invoice_id, "seq": a.seq, "amount": f"{a.amount:.2f}",
"currency": a.currency, "allocated_on": a.allocated_on.isoformat(), "received_on": received_on.isoformat() if received_on else None,
"reverses_seq": a.reverses_seq}
@PAY.use_case(kind="query", route="GET /cash-basis", permission=uc.READ, aliases={"date_from": "from"})
def cash_basis(caller: dict, to: Day, date_from: Day) -> dict:
"""tax's cash accounting (TAX-36): the receipt allocations whose receipt arrived in [from, to], plus those allocated in the window for
receipts of earlier windows, each with the receipt's received_on and the allocation date; reversing rows included."""
start, end = uc._date(date_from, "from"), uc._date(to, "to")
if start > end:
raise Refused(422, "from is after to")
from sqlalchemy import and_, or_
from brick_payments.infrastructure.tables import pay_allocation, pay_receipt
with uc.repository() as repo:
rows = repo.allocations.with_received(and_(pay_allocation.c.source_kind == "receipt", or_(
and_(pay_receipt.c.received_on >= start, pay_receipt.c.received_on <= end),
and_(pay_allocation.c.allocated_on >= start, pay_allocation.c.allocated_on <= end))))
return {"from": start.isoformat(), "to": end.isoformat(),
"allocations": [{"payment_id": a.source_id, "invoice_id": a.invoice_id, "seq": a.seq, "amount": f"{a.amount:.2f}", "currency": a.currency,
"received_on": rec.isoformat() if rec else None, "allocated_on": a.allocated_on.isoformat(), "source_kind": a.source_kind}
for a, rec in rows]}
@PAY.use_case(kind="query", route="GET /allocations", permission=uc.READ, aliases={"date_from": "from"})
def list_allocations(caller: dict, invoice_ids: text(8000, min_length=1), date_from: Optional[Day] = None) -> dict:
"""Every allocation row of up to 200 invoices (tax's bad-debt repayment and settlement discounts, ar's late interest), from a date on
(allocated_on), reversing rows included: the one allocation read for other modules."""
ids = [i.strip() for i in invoice_ids.split(",") if i.strip()]
if not ids or len(ids) > 200:
raise Refused(422, "invoice_ids is one to 200 comma-separated invoice ids")
start = uc._date(date_from, "from")
from brick_payments.infrastructure.tables import pay_allocation
with uc.repository() as repo:
cond = pay_allocation.c.invoice_id.in_(ids)
if start:
cond = cond & (pay_allocation.c.allocated_on >= start)
return {"allocations": [_view(a, rec) for a, rec in repo.allocations.with_received(cond)]}
# ═══ PAY-47: the receiving account printed on invoices ═══════════════════════════════════════════════════════
@PAY.use_case(kind="query", route="GET /bank-accounts/default", permission=uc.READ)
def default_receiving_account(caller: dict, company_code: Code, currency: Currency) -> dict:
"""The account invoicing prints for a company and currency today: name, account name, sort code, account number in full (for
printing - the caller holds pay:receipt:read), IBAN and BIC. No default answers 404 (receiving_account: Payments > Settings > Bank accounts)."""
with uc.repository() as repo:
today, company, cur = uc._today(repo), company_code.strip().upper(), uc._cur(currency)
live = [x for x in repo.accounts.defaults(company, cur) if x["valid_from"] <= today]
b = repo.config.bank(live[-1]["bank_account_id"]) if live else None
if b is None or (b.get("closed_from") and b["closed_from"] <= today):
raise Refused(404, f"receiving_account: no default receiving account for {company} in {cur}. "
"Fix: Payments > Settings > Bank accounts")
hb = repo.bank.house_bank(b["house_bank_id"]) if b.get("house_bank_id") else None
ib = b.get("iban") or ""
return {"bank_account_id": b["bank_account_id"], "name": b["name"], "account_name": b.get("account_name") or b["name"],
"sort_code": (hb or {}).get("sort_code") or "", "account_number": "" if ib else (b.get("account_ref") or ""),
"iban": ib, "bic": (hb or {}).get("bic") or ""}