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()