dodil/erp-brick-expenses
DODIL ERP brick: expenses
src/brick_expenses/domain/vat.py
"""The VAT of a receipt (EXP-11, EXP-18): tax answers, expenses keeps its copy and NEVER applies a rate itself.
Plain Python. What is decided here is only what expenses owns:
- a foreign-currency line is converted ONCE, half up, at gl's rate on the receipt date (EXP-05);
- VAT is reclaimed only where tax says it is reclaimable AND the receipt is a valid VAT invoice (tax's content check): a receipt that fails
the check has its VAT made non-reclaimable and its full amount charged to the cost, with the failed gate shown (EXP-11);
- the reclaimable VAT is never more than the VAT on the receipt, and cost + reclaimable VAT is the line's amount to the penny;
- with no tax module, nothing is reclaimed: the VAT is part of the cost.
"""
import hashlib
import json
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional
from brick_expenses.domain.errors import RuleBroken
from brick_expenses.domain.money import ZERO, money
RATE = Decimal("0.000001")
RATE_MAX = Decimal("1e9")
TREATMENT_DEFAULT = "uk_business" # a merchant is not a partner: the treatment names how its receipt is taxed
def rate(raw) -> Decimal:
"""gl's rate (units of the company currency for one of the receipt's), six places; never zero, never absurd."""
try:
d = Decimal(str(raw)).quantize(RATE, rounding=ROUND_HALF_UP)
except Exception:
raise RuleBroken(f"gl answered a rate that is not a number: {raw!r}") from None
if not (Decimal(0) < d < RATE_MAX):
raise RuleBroken(f"gl answered the rate {raw!r}: a rate is more than zero and below a billion")
return d
def convert(amount: Decimal, fx: Decimal) -> Decimal:
"""The receipt amount in the company's currency: rounded ONCE, half up (238.00 x 0.870000 = 207.06)."""
return money(Decimal(amount) * fx)
def supply_of(gl_purpose: str) -> str:
return "goods" if gl_purpose == "office_supplies" else "services"
def digest(parts: dict) -> str:
"""A short fingerprint of what tax was asked, so a returned claim's unchanged line is not asked twice."""
return hashlib.sha256(json.dumps(parts, sort_keys=True, default=str).encode()).hexdigest()[:16]
def compute_body(document_ref: str, *, tax_point, currency: str, treatment: str, description: str, tax_category: str, supply: str, amount: Decimal,
fx: Decimal, company_currency: str) -> dict:
body = {"document_ref": document_ref, "direction": "purchase", "tax_point": tax_point.isoformat(), "currency": currency,
"treatment_code": treatment or TREATMENT_DEFAULT, "prices_include_tax": True,
"lines": [{"line_no": 1, "description": description[:200] or "expense", "category_code": tax_category, "supply": supply,
"amount": f"{amount:.2f}", "quantity": "1", "unit_price": f"{amount:.2f}"}]}
if currency != company_currency:
body["fx_rate"] = f"{fx:.6f}"
return body
def contents_body(contents: dict, issue_date) -> dict:
"""The receipt's particulars as tax's check wants them. The invoice number is required there: a receipt that shows none is checked as it is."""
c = {"invoice_number": contents.get("invoice_number") or "", "issue_date": issue_date.isoformat(), "supplier_name": contents.get("supplier_name") or "",
"supplier_address": contents.get("supplier_address") or {}}
for opt in ("supplier_vat_number", "customer_name"):
if contents.get(opt):
c[opt] = contents[opt]
return {"direction": "purchase", "contents": c}
def settle(answer: Optional[dict], check: Optional[dict], fx: Decimal, typed_vat: Decimal, amount: Decimal) -> dict:
"""The line's VAT in the company's currency from tax's answers: {tax_code, vat, reclaimable, non_reclaimable, gates}.
`answer` is the compute line {tax_code, vat, vat_reclaimable, vat_non_reclaimable} (None: tax is not installed or refused), `check` is tax's
content check ({ready, gates}) or None when none was asked (nothing to reclaim) or none could be (no particulars on the line)."""
if answer is None:
v = min(money(typed_vat), amount)
return {"tax_code": None, "vat": v, "reclaimable": ZERO, "non_reclaimable": v, "gates": []}
vat = min(convert(Decimal(str(answer.get("vat") or 0)), fx), amount)
rec = min(convert(Decimal(str(answer.get("vat_reclaimable") or 0)), fx), vat)
gates = []
if rec > ZERO:
if check is None:
rec = ZERO
gates = [{"gate": "receipt_particulars", "met": False,
"reason": "no supplier name, address and VAT number were given for this receipt, so it cannot be shown to be a VAT invoice",
"fix": "add the receipt's particulars (supplier, address, VAT number, invoice number)"}]
elif not check.get("ready"):
rec = ZERO
gates = [g for g in (check.get("gates") or []) if not g.get("met")] or [{"gate": "not_a_vat_invoice", "met": False, "reason": "tax says the receipt is not a valid VAT invoice", "fix": ""}]
else:
gates = []
return {"tax_code": answer.get("tax_code"), "vat": vat, "reclaimable": rec, "non_reclaimable": money(vat - rec), "gates": gates}