dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
src/brick_ar/domain/finance.py
"""The arithmetic of v4, pure: statutory interest and compensation (AR-39) and the settlement-discount split (AR-38)."""
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional
from brick_ar.domain.errors import RuleBroken
from brick_ar.domain.money import ZERO, money
MARGIN = Decimal(8) # the Late Payment of Commercial Debts (Interest) Act 1998
def statutory_rate(reference_pct: Decimal, margin_pct: Decimal = MARGIN) -> Decimal:
return Decimal(reference_pct) + Decimal(margin_pct)
def reference_date(first_late_day: date) -> date:
"""The reference rate is the base rate on 30 June (for a debt whose interest starts July-December) or 31 December (January-June)."""
return date(first_late_day.year, 6, 30) if first_late_day.month >= 7 else date(first_late_day.year - 1, 12, 31)
def interest(principal: Decimal, rate_pct: Decimal, days: int) -> Decimal:
"""principal x rate x days / 365, rounded ONCE to the penny (simple interest, daily)."""
if days <= 0:
return ZERO
return (Decimal(principal) * Decimal(rate_pct) / 100 * days / 365).quantize(Decimal("0.01"), ROUND_HALF_UP)
def compensation(debt: Decimal) -> Decimal:
"""The fixed sum: 40.00 under 1,000.00, 70.00 from 1,000.00, 100.00 from 10,000.00."""
d = Decimal(debt)
return Decimal("100.00") if d >= 10000 else Decimal("70.00") if d >= 1000 else Decimal("40.00")
def discount_shares(rows: list, discount: Decimal) -> list:
"""The settlement discount (gross) split per tax code at the invoice's frozen rates, pro rata to each code's gross.
`rows` are (tax_code, net, tax); returns [(tax_code, net_to_credit)] where net_to_credit * (1 + rate) is the code's share."""
gross = sum((n + t for _c, n, t in rows), ZERO)
if gross <= 0:
raise RuleBroken("the invoice has no gross to discount")
out, given = [], ZERO
for i, (code, net, tax) in enumerate(rows):
share = discount - given if i == len(rows) - 1 else money(discount * (net + tax) / gross)
given += share
out.append((code, money(share * net / (net + tax)) if (net + tax) else ZERO, share))
return out
def window_open(received_on: date, until: Optional[date]) -> bool:
return until is not None and received_on <= until