dodil/erp-brick-sales-order

DODIL ERP brick: sales order

src/brick_sales_order/domain/billing.py

"""Money that is billed in stages: milestones (SO-46) and the deposits an invoice takes off (SO-47) — pure arithmetic, no rows, no neighbours.

A milestone line is billed BY VALUE: each stage's amount is frozen when it is released, and the stages not yet released share what is left of the
line's net, the last one taking the remainder, so the stages always sum to the line's net exactly. A deposit is deducted per deposit, oldest first,
never beyond what it has left, in its own tax category.
"""
from decimal import Decimal

from brick_sales_order.domain.errors import GateUnmet
from brick_sales_order.domain.readiness import no
from brick_sales_order.domain.values import ZERO, money, num


def milestone_amounts(net: Decimal, stages: list) -> dict:
    """{milestone_no: amount} for a line of `net`: a released stage keeps its frozen amount; the unreleased ones share the rest by their
    percentages (the last takes the remainder, so 30/40/30 of 20,000.00 is 6,000.00 / 8,000.00 / 6,000.00 and a third of 100.00 is
    33.33 / 33.33 / 33.34)."""
    stages = sorted(stages, key=lambda m: m.milestone_no)
    out = {m.milestone_no: m.amount for m in stages if m.released_on}
    rest = [m for m in stages if not m.released_on]
    remaining = net - sum(out.values(), ZERO)
    share = sum((m.pct for m in rest), ZERO)
    left = remaining
    for i, m in enumerate(rest):
        if i == len(rest) - 1 or share == 0:
            amount = left if i == len(rest) - 1 else ZERO
        else:
            amount = money(remaining * m.pct / share)
        out[m.milestone_no] = amount
        left -= amount
    return out


def plan_deductions(mode: str, *, net: Decimal, order_net: Decimal, final: bool, deposits: list, manual: dict) -> list:
    """[(deposit, amount)] this invoice takes off the ISSUED deposits (each {invoice_id, request_no, tax_category, amount, remaining}, oldest first).
    The final invoice takes everything left; proportional takes the deposits' share of the order (deposits / order net x this invoice's net);
    full_first takes as much as it can; manual takes what the biller typed per deposit. Never beyond a deposit's remaining, never below zero
    on the invoice."""
    left_total = sum((d["remaining"] for d in deposits), ZERO)
    if mode == "manual" and not final:
        out = []
        for d in deposits:
            amount = manual.get(d["invoice_id"], ZERO)
            if amount > d["remaining"]:
                raise GateUnmet([no("deposit_remaining", f"deposit {d['invoice_id']} has {num(d['remaining'], 2)} left: {num(amount, 2)} is more",
                                    f"deduct at most {num(d['remaining'], 2)}")])
            if amount > 0:
                out.append((d, amount))
        if sum((a for _, a in out), ZERO) > net:
            raise GateUnmet([no("deposit_remaining", "the deductions are more than this invoice", "deduct no more than the invoice's net")])
        return out
    if final or mode == "manual":
        want = min(left_total, net)
    elif mode == "full_first":
        want = min(left_total, net)
    else:
        total = sum((d["amount"] for d in deposits), ZERO)
        want = min(left_total, net, money(total * net / order_net)) if order_net > 0 else ZERO
    out = []
    for d in deposits:
        take = min(d["remaining"], want)
        if take > 0:
            out.append((d, take))
            want -= take
    return out