dodil/erp-brick-tax

DODIL ERP brick: tax

src/brick_tax/domain/vat_invoice.py

"""What a VAT invoice must show (HMRC VAT Notice 700 para 16.3.1) and what a business that is not registered must NOT show
(TAX-12). Every item is a gate {gate, met, reason, fix}: one per absent item, the reason in the customer's words citing the
guide, the fix the record that holds the item. The invoice itself is the document's (ar, sales-order): the facts that are
not tax's arrive in `Contents`, and the customer's name and address fall back to the business-partner record.
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Optional

from brick_tax.domain.document_tax import DocumentTax
from brick_tax.domain.errors import Gate

GUIDE = "VAT Notice 700 para 16.3.1"


@dataclass
class Contents:
    """The invoice facts that are not the tax's own."""
    title: str = ""
    invoice_number: str = ""
    issue_date: Optional[date] = None
    supplier_name: str = ""
    supplier_address: str = ""
    supplier_vat_number: str = ""
    customer_name: str = ""
    customer_address: str = ""
    cash_discount_applies: bool = False
    cash_discount_pct: Optional[Decimal] = None
    simplified: bool = False            # a simplified invoice (para 16.6.1): allowed at the threshold or less, with the customer's agreement


def _g(gate: str, met: bool, reason: str, fix: str) -> Gate:
    return Gate(gate, met, reason, fix)


def is_less_detailed(doc: DocumentTax, threshold: int) -> bool:
    """v6 (TAX-50): a purchase whose gross including VAT is at or under simplified_invoice_threshold (GBP; a foreign document converted at
    its rate) is a less detailed invoice (VAT Notice 700 para 16.6.1; VAT Regulations 1995 reg. 16): our name and address are not required."""
    result = doc.computed()
    if doc.direction != "purchase" or result is None or doc.self_billed:
        return False
    gross = result.gross if doc.currency == "GBP" else (result.gross * doc.fx_rate if doc.fx_rate is not None else None)
    return gross is not None and gross <= Decimal(threshold)


def _check_purchase(doc: DocumentTax, c: Contents, threshold: int = 250) -> list:
    """A supplier's bill (TAX-24): the SUPPLIER is the one whose name, address and VAT number must be on it — input VAT is only
    reclaimed on a VAT invoice that shows them — and WE are the customer. The supplier's invoice number and date are theirs."""
    who = c.supplier_name.strip() or "the supplier"
    me = doc.self_billed
    gates = [
        _g("invoice_number", bool(c.invoice_number.strip()),
           (f"the self-billed invoice shows no number from our self-billing series ({GUIDE})" if me
            else f"the bill shows no invoice number from {who} ({GUIDE})"),
           "the next number of our self-billing series" if me else "the supplier's invoice number on the bill"),
        _g("issue_date", c.issue_date is not None,
           f"the self-billed invoice shows no date of issue ({GUIDE})" if me else f"the bill shows no invoice date from {who} ({GUIDE})",
           "the date we issued the self-billed invoice" if me else "the invoice date on the bill"),
        _g("supplier_name", bool(c.supplier_name.strip()), f"the bill shows no supplier name ({GUIDE})", "the supplier's name on the bill"),
        _g("supplier_address", bool(c.supplier_address.strip()), f"the bill shows no address for {who} ({GUIDE})",
           "the supplier's address on the bill"),
        _g("supplier_vat_number", bool(c.supplier_vat_number.strip()),
           f"the bill shows no VAT number for {who}: input VAT cannot be reclaimed on it", "ask the supplier for a VAT invoice"),
        _g("line_description", all(ln.description.strip() for ln in doc.lines), f"every line of the bill describes what was supplied ({GUIDE})",
           "the line's description on the bill"),
        _g("line_rate", all(ln.resolved for ln in doc.lines), "every line of the bill has its VAT code", "Document tax > the line"),
        _g("total_net", doc.complete(), "the bill's total net and VAT are worked out", "Document tax > the lines"),
    ]
    if not is_less_detailed(doc, threshold):
        gates.insert(5, _g("customer_name", bool(c.customer_name.strip()),
                           (f"a VAT invoice over {threshold:.2f} must show your company's name: ask the supplier for a full invoice"
                            if doc.complete() else f"the bill shows no customer name: it must name us ({GUIDE})"),
                           "our company name on the bill"))
    gates.append(_g("contents_complete", all(g.met for g in gates), "the bill shows everything input VAT needs"
                    if all(g.met for g in gates) else "an item input VAT needs is missing from the bill", "the record that holds the item"))
    return gates


def check(doc: DocumentTax, c: Contents, *, threshold: int, partner: Optional[dict]) -> list:
    """Every gate of the content check for this document, met or not."""
    if doc.direction == "purchase":
        return _check_purchase(doc, c, threshold)
    if not doc.registered:
        shows_number = bool((c.supplier_vat_number or "").strip())
        titled = "vat invoice" in (c.title or "").lower()
        return [
            _g("no_vat_number", not shows_number,
               "a business that is not VAT registered does not show a VAT number" if shows_number else "no VAT number is shown",
               "remove the VAT number from the document"),
            _g("not_titled_vat_invoice", not titled,
               'a business that is not VAT registered may not call its document a "VAT invoice"' if titled else "the document is not titled VAT invoice",
               'title the document "Invoice"'),
            _g("contents_complete", not shows_number and not titled, "the document makes no claim to be a VAT invoice"
               if not shows_number and not titled else "the document claims to be a VAT invoice", "the record that holds the item"),
        ]
    partner = partner or {}
    cust_name = c.customer_name or partner.get("legal_name") or ""
    cust_addr = c.customer_address or partner.get("address") or ""
    gates = []
    simple = c.simplified
    gbp_total = doc.computed().gross if doc.computed() else None
    if simple:
        ok = gbp_total is not None and doc.currency == "GBP" and gbp_total <= Decimal(threshold)
        gates.append(_g("simplified_allowed", ok, f"a simplified invoice is allowed at {threshold} GBP or less, with the customer's "
                        "agreement (VAT Notice 700 para 16.6.1)" if not ok else "within the simplified-invoice limit",
                        "issue a full VAT invoice"))
    gates += [
        _g("invoice_number", bool(c.invoice_number.strip()), f"a VAT invoice shows a sequential number ({GUIDE})", "the invoice series on the invoice"),
        _g("tax_point", doc.tax_point is not None, f"a VAT invoice shows the tax point ({GUIDE})", "the tax point on the document"),
        _g("issue_date", c.issue_date is not None, f"a VAT invoice shows the date of issue ({GUIDE})", "the issue date on the invoice"),
        _g("supplier_name", bool(c.supplier_name.strip()), f"a VAT invoice shows our name ({GUIDE})", "the company name on the invoice"),
        _g("supplier_address", bool(c.supplier_address.strip()), f"a VAT invoice shows our address ({GUIDE})", "the company address on the invoice"),
    ]
    number_ok = bool(doc.vat_number) and (not c.supplier_vat_number or c.supplier_vat_number.replace(" ", "").upper() == doc.vat_number)
    gates.append(_g("supplier_vat_number", number_ok,
                    f"a VAT invoice shows our VAT number ({GUIDE})" if not doc.vat_number
                    else f"the invoice shows a VAT number other than the registered {doc.vat_number}",
                    "Tax settings > Registration"))
    if not simple:
        gates += [
            _g("customer_name", bool(cust_name.strip()), f"a VAT invoice shows the customer's name ({GUIDE})", "the customer's name on the business partner"),
            _g("customer_address", bool(cust_addr.strip()), f"a VAT invoice shows the customer's address ({GUIDE})",
               "the customer's address on the business partner"),
        ]
    gates.append(_g("line_description", all(ln.description.strip() for ln in doc.lines),
                    f"every line of a VAT invoice describes what was supplied ({GUIDE})", "the line's description"))
    if not simple:
        gates.append(_g("line_quantity", all(ln.quantity is not None for ln in doc.lines),
                        f"every line shows the quantity ({GUIDE})", "the line's quantity"))
        gates.append(_g("line_unit_price", all(ln.unit_price is not None for ln in doc.lines),
                        f"every line shows the unit price ({GUIDE})", "the line's unit price"))
    gates.append(_g("line_rate", all(ln.resolved for ln in doc.lines), f"every line shows its VAT rate ({GUIDE})", "Document tax > the line"))
    gates.append(_g("total_net", doc.complete(), f"a VAT invoice shows the total net and the VAT ({GUIDE})", "Document tax > the lines"))
    if c.cash_discount_applies:
        gates.append(_g("cash_discount_rate", c.cash_discount_pct is not None,
                        f"an invoice with a cash discount shows its rate ({GUIDE})", "the discount terms on the invoice"))
    if doc.currency != "GBP":
        gates.append(_g("vat_total_gbp", doc.vat_total_gbp is not None,
                        "a foreign-currency invoice still shows its VAT total in sterling (VAT Notice 700 para 16.4)",
                        "enter the rate on the document, or the ledger's rate for the tax point"))
    gates.append(_g("contents_complete", all(g.met for g in gates), "every item VAT Notice 700 para 16.3.1 asks for is on the invoice"
                    if all(g.met for g in gates) else "an item VAT Notice 700 para 16.3.1 asks for is missing", "the record that holds the item"))
    return gates