dodil/erp-brick-payments

DODIL ERP brick: payments

src/brick_payments/application/transfers.py

"""Transfers between our own accounts (PAY-25) - and the clearing side of a cash deposit (PAY-26).

Money leaving BARC-01 for BARC-02 shows on two statements, a day or more apart. Each side posts on ITS OWN bank date through the
ledger's transfer clearing purpose (BARC-01 -> clearing on the 14th, clearing -> BARC-02 on the 15th), so clearing is zero once both
have arrived and shows the money in transit between. A debit whose counterparty is one of our accounts is posted to clearing as
soon as it is seen and stays IN TRANSIT (lifecycle matched, kind transfer, no partner yet) until the credit arrives; a credit pairs
with it when the amount and currency are equal, the debit names the credit's account, and the dates are within transfer_window_days.
Amounts in different currencies never pair."""
from decimal import Decimal
from typing import 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.inputs import Code
from brick_payments.domain.errors import RuleBroken
from brick_payments.infrastructure import seams

PAY = uc.PAY
CLEARING = {"purpose": "transfer_clearing"}


def post_side(repo, line: dict, who: str, n: int, *, event: str = "transfer", source: Optional[str] = None) -> str:
    """Post one side of a transfer on the line's own bank date: a debit is clearing / bank, a credit is bank / clearing. The journal is
    derived from (source, event); `n` is the line's claim for this operation, so a later re-match after an undo is a new journal."""
    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 transfer cannot be posted. Set it in the settings")
    amount = Decimal(str(line["amount"]))
    g = f"{abs(amount):.2f}"
    side = seams.account_line(bank["gl_account_id"])
    lines = [{**CLEARING, "debit": g}, {**side, "credit": g}] if amount < 0 else [{**side, "debit": g}, {**CLEARING, "credit": g}]
    posted = seams.post_journal(source or f"{line['line_id']}@{n}", event, line["booked_on"], line["currency"],
                                f"Transfer {line['counterparty_name'] or line['text'] or line['line_id']}"[:200].strip(), lines, who,
                                bank.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"]


def tail(b: dict) -> list:
    """The last four characters of each full number an account of ours has (a statement keeps a counterparty's account only that far)."""
    return [st._mask(v)[-4:] for v in (b.get("account_ref"), b.get("iban")) if v]


def _ours(repo, last4: str) -> list:
    return [b for b in repo.config.banks() if last4 in tail(b)]


def destination(repo, line: dict) -> Optional[dict]:
    """The account of ours a debit is paying, or None when it pays someone else (or the very account it left)."""
    if Decimal(str(line["amount"])) >= 0 or not line["counterparty_account_masked"]:
        return None
    found = [b for b in _ours(repo, line["counterparty_account_masked"][-4:]) if b["bank_account_id"] != line["bank_account_id"]]
    return found[0] if len(found) == 1 else None             # two accounts ending alike are ambiguous: a person decides


def _in_transit(row: dict) -> bool:
    return row["lifecycle"] == "matched" and row["matched_kind"] == "transfer" and not row["matched_ref"] and Decimal(str(row["amount"])) < 0


def _pair_one(repo, caller: dict, line_id: str) -> dict:
    line = repo.bank.line(line_id)
    if line is None or not (line["lifecycle"] == "open" or _in_transit(line)):
        return {}
    dest = destination(repo, line)
    if dest is None:
        return {}
    who, today, window = uc._who(caller), uc._today(repo), repo.settings.current().transfer_window_days
    out = {}
    if line["lifecycle"] == "open":
        n = st._guard(repo, line_id, who, "transfer_out")
        journal = post_side(repo, line, who, n)
        line = {**line, "lifecycle": "matched", "matched_kind": "transfer", "matched_ref": "", "journal_id": journal, "decided_how": "transfer",
                "decided_by": who, "decided_on": today}
        repo.bank.save_line(line)
        out["in_transit"] = 1
    source = next((tail(b) for b in repo.config.banks() if b["bank_account_id"] == line["bank_account_id"]), [])
    amount = abs(Decimal(str(line["amount"])))
    cands = [c for c in repo.bank.lines(bank_account_id=dest["bank_account_id"], lifecycle="open", limit=100000)
             if Decimal(str(c["amount"])) == amount and c["currency"] == line["currency"]
             and abs((c["booked_on"] - line["booked_on"]).days) <= window and (not c["counterparty_account_masked"] or c["counterparty_account_masked"][-4:] in source)]
    if not cands:
        return out
    credit = min(cands, key=lambda c: (abs((c["booked_on"] - line["booked_on"]).days), c["line_id"]))
    st._guard(repo, line_id, who, "transfer_pair")                      # the debit's claim: two runs cannot pair it with two credits
    n2 = st._guard(repo, credit["line_id"], who, "transfer_in")
    journal = post_side(repo, credit, who, n2)
    for s in repo.bank.open_suggestions_of(credit["line_id"]):
        repo.bank.move_suggestion(s["suggestion_id"], status="superseded")
    repo.bank.save_line({**credit, "lifecycle": "matched", "matched_kind": "transfer", "matched_ref": line_id, "journal_id": journal,
                         "decided_how": "transfer", "decided_by": who, "decided_on": today})
    repo.bank.save_line({**line, "matched_ref": credit["line_id"]})
    out["paired"] = 1
    return out


@PAY.use_case(kind="command", route="POST /transfers/reconcile", permission=st.RECONCILE)
def reconcile_transfers(caller: dict, bank_account_id: Optional[Code] = None) -> dict:
    """Match the two sides of every transfer between our own accounts: each debit naming another account of ours posts to the transfer
    clearing account on its own date and waits in transit; a credit on that account with the same amount and currency within
    transfer_window_days pairs with it and posts clearing -> bank on ITS date. Safe to run again: nothing already paired changes."""
    return reconcile_core(caller, bank_account_id)


def reconcile_core(caller: dict, bank_account_id: Optional[str] = None) -> dict:
    with uc.repository() as repo:
        ids = [r["line_id"] for r in repo.bank.lines(bank_account_id=bank_account_id, limit=100000)
               if (r["lifecycle"] == "open" or _in_transit(r)) and destination(repo, r) is not None]
    tally = {"in_transit": 0, "paired": 0, "failed": []}
    for lid in ids:
        try:
            with uc.repository() as repo, uc._decide():
                got = _pair_one(repo, caller, lid)
        except Refused as e:
            tally["failed"].append({"line_id": lid, "reason": str(e)})
            continue
        tally["in_transit"] += got.get("in_transit", 0)
        tally["paired"] += got.get("paired", 0)
    return tally


@PAY.use_case(kind="query", route="GET /transfers/in-transit", permission=uc.READ)
def transfers_in_transit(caller: dict) -> dict:
    """The money between our accounts: debits posted to the clearing account whose credit has not arrived, with where it is going and
    how long it has been out, and the total per currency (what the transfer clearing account shows)."""
    with uc.repository() as repo:
        today = uc._today(repo)
        rows = [r for r in repo.bank.lines(limit=100000) if _in_transit(r)]
        items, totals = [], {}
        for r in rows:
            d = destination(repo, r)
            amt = abs(Decimal(str(r["amount"])))
            totals[r["currency"]] = totals.get(r["currency"], Decimal(0)) + amt
            items.append({"line_id": r["line_id"], "from_bank_account_id": r["bank_account_id"], "to_bank_account_id": d["bank_account_id"] if d else "",
                          "booked_on": r["booked_on"].isoformat(), "amount": f"{amt:.2f}", "currency": r["currency"], "days_in_transit": (today - r["booked_on"]).days,
                          "journal_id": r["journal_id"]})
        return {"in_transit": items, "totals": {c: f"{v:.2f}" for c, v in totals.items()}}