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