dodil/erp-brick-tax
DODIL ERP brick: tax
src/brick_tax/domain/bad_debt.py
"""Bad debt relief (TAX-34, TAX-35): VAT on a debt written off is reclaimed in box 4 once the debt is six months overdue, and every
later receipt on it is repaid in box 1. The relief is the VAT FRACTION of the unpaid consideration: unpaid x VAT / gross of the
invoice's frozen tax. One claim per invoice, ever."""
from dataclasses import asdict, dataclass, fields
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional
from brick_tax.domain.errors import Gate, GateUnmet, RuleBroken
from brick_tax.domain.vat_return import add_months, money
P2 = Decimal("0.01")
ELIGIBLE, CLAIMED, DISMISSED = "eligible", "claimed", "dismissed"
def fraction_of(amount: Decimal, vat: Decimal, gross: Decimal) -> Decimal:
"""`amount` x VAT / gross, to the penny (exact: the quotient is taken once, from the whole figures)."""
return (amount * vat / gross).quantize(P2, ROUND_HALF_UP) if gross else Decimal(0)
@dataclass
class Claim:
invoice_id: str
lifecycle: str = ELIGIBLE
bp_id: Optional[int] = None
writeoff_id: str = ""
supply_date: Optional[date] = None
due_date: Optional[date] = None
eligible_from: Optional[date] = None
written_off_on: Optional[date] = None
gross: Decimal = Decimal(0)
vat: Decimal = Decimal(0)
unpaid_at_claim: Optional[Decimal] = None
vat_fraction: Optional[Decimal] = None
relief_amount: Optional[Decimal] = None
claimed_in_period_key: str = ""
claimed_by: str = ""
claimed_on: Optional[date] = None
dismissed_reason: str = ""
@classmethod
def from_row(cls, r: dict) -> "Claim":
out = {}
for f in fields(cls):
v = r.get(f.name)
if f.name in ("gross", "vat"):
v = Decimal(str(v or 0))
elif f.name in ("unpaid_at_claim", "vat_fraction", "relief_amount"):
v = Decimal(str(v)) if v is not None else None
elif f.name in ("claimed_in_period_key", "claimed_by", "dismissed_reason", "writeoff_id", "lifecycle", "invoice_id"):
v = v or ""
out[f.name] = v
return cls(**out)
def to_row(self) -> dict:
return asdict(self)
def gates(self, *, today: date, vat_accounted: bool, written_off: bool) -> list:
base = max(d for d in (self.due_date, self.supply_date) if d) if (self.due_date or self.supply_date) else None
six = add_months(base, 6) if base else None
limit = add_months(base, 54) if base else None
return [
Gate("six_months_passed", bool(six) and today >= six,
f"six months from the later of the due date and the supply date is {six}: not before" if not (six and today >= six)
else f"{six} has passed", f"claim on or after {six}"),
Gate("written_off", written_off, "the debt is not written off in the books (no approved write-off from ar)"
if not written_off else "the debt is written off", "write the invoice off in receivables first"),
Gate("vat_accounted", vat_accounted, "the invoice's VAT is not in a submitted return: relief is for VAT already paid to HMRC"
if not vat_accounted else "the invoice's VAT is in a submitted return", "submit the return that holds the invoice"),
Gate("within_time_limit", bool(limit) and today <= limit,
f"relief must be claimed within four years and six months: that ended {limit}" if not (limit and today <= limit)
else f"claimable until {limit}", "the time limit has passed"),
Gate("not_claimed", self.lifecycle != CLAIMED, "relief on this invoice is already claimed: once per invoice"
if self.lifecycle == CLAIMED else "no claim yet", "one claim per invoice"),
]
def claim(self, *, unpaid: Decimal, period_key: str, by: str, today: date, vat_accounted: bool, written_off: bool) -> Decimal:
if self.lifecycle == DISMISSED:
raise RuleBroken(f"relief on {self.invoice_id} was dismissed: {self.dismissed_reason}")
bad = [g for g in self.gates(today=today, vat_accounted=vat_accounted, written_off=written_off) if not g.met]
if bad:
raise GateUnmet(bad)
if unpaid < 0 or unpaid > self.gross:
raise RuleBroken(f"the unpaid amount {money(unpaid)} is not between 0 and the invoice total {money(self.gross)}")
self.lifecycle, self.unpaid_at_claim = CLAIMED, unpaid
self.vat_fraction = (self.vat / self.gross).quantize(Decimal("0.00000001")) if self.gross else Decimal(0)
self.relief_amount = fraction_of(unpaid, self.vat, self.gross)
self.claimed_in_period_key, self.claimed_by, self.claimed_on = period_key, by, today
return self.relief_amount
def dismiss(self, reason: str) -> None:
if self.lifecycle != ELIGIBLE:
raise RuleBroken(f"relief on {self.invoice_id} is {self.lifecycle}: only an eligible one is dismissed")
if not (reason or "").strip():
raise RuleBroken("dismissing relief needs a reason")
self.lifecycle, self.dismissed_reason = DISMISSED, reason.strip()