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}