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 ""}