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