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)]}