AXLR / Catalog / administration

invoice_calc

Invoice totals in exact decimal arithmetic: line amounts with per-line and global discounts, tax base and VAT per rate, optional equivalence surcharge, withholding (such as Spanish IRPF) on the base, and the total due, rounded half up per line and per rate.

Install

/tools install invoice_calc
/tools install invoice_calc@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · invoice_calc 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool invoice_calc; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
linesarray of objectyesitems ≥ 1
global_discount_percentnumber | stringno0
withholding_percentnumber | stringno0Withheld on the tax base, e.g. 15 for IRPF
currencystringno"EUR"
decimalsintegerno2≥ 0; ≤ 6
Input schema (JSON)
{
  "type": "object",
  "required": [
    "lines"
  ],
  "additionalProperties": false,
  "properties": {
    "lines": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": [
          "quantity",
          "unit_price",
          "vat_percent"
        ],
        "additionalProperties": false,
        "properties": {
          "description": {
            "type": "string"
          },
          "quantity": {
            "type": [
              "number",
              "string"
            ]
          },
          "unit_price": {
            "type": [
              "number",
              "string"
            ],
            "description": "Before tax; a decimal string keeps exact cents"
          },
          "discount_percent": {
            "type": [
              "number",
              "string"
            ],
            "default": 0
          },
          "vat_percent": {
            "type": [
              "number",
              "string"
            ],
            "description": "e.g. 21, 10, 4, 0"
          },
          "surcharge_percent": {
            "type": [
              "number",
              "string"
            ],
            "default": 0,
            "description": "Recargo de equivalencia, e.g. 5.2 with 21 % VAT"
          }
        }
      }
    },
    "global_discount_percent": {
      "type": [
        "number",
        "string"
      ],
      "default": 0
    },
    "withholding_percent": {
      "type": [
        "number",
        "string"
      ],
      "default": 0,
      "description": "Withheld on the tax base, e.g. 15 for IRPF"
    },
    "currency": {
      "type": "string",
      "default": "EUR"
    },
    "decimals": {
      "type": "integer",
      "minimum": 0,
      "maximum": 6,
      "default": 2
    }
  }
}

Example

Input

{
  "lines": [
    {
      "description": "Consulting",
      "quantity": 10,
      "unit_price": "100.00",
      "vat_percent": 21
    }
  ],
  "withholding_percent": 15
}

Output (stdout)

{
  "base": "1000.00",
  "vat": "210.00",
  "withholding": "150.00",
  "total": "1060.00"
}

Files

The command the install card registers is python3 invoice_calc.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

invoice_calc.py · sha256 edb5c88490622489eba30a86ce90cd7094bde80cef84bfcaa79ff33d7fe6860c
#!/usr/bin/env python3
"""Invoice arithmetic in Decimal: line amounts with discounts, a global discount, tax bases
and VAT per rate, an optional equivalence surcharge per line, withholding on the base, and
the total due. Amounts are rounded half up to the currency's minor unit per line and per
rate, as Spanish and most EU invoicing rules require.

Input (JSON on stdin):
  lines   [{"description", "quantity", "unit_price", "discount_percent"?, "vat_percent",
            "surcharge_percent"?}, ...]  numbers as JSON numbers or decimal strings
  global_discount_percent   optional, applied to every line after its own discount
  withholding_percent       optional, withheld on the total tax base (e.g. IRPF 15)
  currency                  optional label, default "EUR"
  decimals                  optional minor-unit digits, default 2
"""
import json
import sys
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def dec(value, field, default=None):
    if value is None:
        if default is None:
            fail(f"{field} is required")
        return Decimal(default)
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        fail(f"{field} must be a number or a decimal string")
    try:
        number = Decimal(str(value))
    except InvalidOperation:
        fail(f"{field} must be a number or a decimal string")
    if not number.is_finite():
        fail(f"{field} must be finite")
    return number


def percent(value, field):
    number = dec(value, field, "0")
    if number < 0 or number > 100:
        fail(f"{field} must be between 0 and 100")
    return number


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    lines = request.get("lines")
    if not isinstance(lines, list) or not lines:
        fail("lines must be a non-empty list")
    decimals = request.get("decimals", 2)
    if not isinstance(decimals, int) or isinstance(decimals, bool) or not 0 <= decimals <= 6:
        fail("decimals must be an integer from 0 to 6")
    unit = Decimal(1).scaleb(-decimals)
    money = lambda d: d.quantize(unit, rounding=ROUND_HALF_UP)
    show = lambda d: str(money(d))
    global_discount = percent(request.get("global_discount_percent"), "global_discount_percent")
    withholding = percent(request.get("withholding_percent"), "withholding_percent")
    hundred = Decimal(100)

    out_lines, by_rate = [], {}
    gross_total = discount_total = Decimal(0)
    for i, line in enumerate(lines):
        if not isinstance(line, dict):
            fail(f"lines[{i}] must be an object")
        quantity = dec(line.get("quantity"), f"lines[{i}].quantity")
        price = dec(line.get("unit_price"), f"lines[{i}].unit_price")
        discount = percent(line.get("discount_percent"), f"lines[{i}].discount_percent")
        vat = dec(line.get("vat_percent"), f"lines[{i}].vat_percent")
        surcharge = percent(line.get("surcharge_percent"), f"lines[{i}].surcharge_percent")
        if vat < 0 or vat > 100:
            fail(f"lines[{i}].vat_percent must be between 0 and 100")
        gross = money(quantity * price)
        after_line = money(gross * (hundred - discount) / hundred)
        base = money(after_line * (hundred - global_discount) / hundred)
        gross_total += gross
        discount_total += gross - base
        key = (vat, surcharge)
        by_rate[key] = by_rate.get(key, Decimal(0)) + base
        out_lines.append({"description": line.get("description", ""), "quantity": str(quantity), "unit_price": str(price),
                          "gross": show(gross), "discount": show(gross - base), "base": show(base), "vat_percent": str(vat)})

    taxes, base_total, vat_total, surcharge_total = [], Decimal(0), Decimal(0), Decimal(0)
    for (vat, surcharge), base in sorted(by_rate.items(), key=lambda kv: (-kv[0][0], -kv[0][1])):
        vat_amount = money(base * vat / hundred)
        surcharge_amount = money(base * surcharge / hundred)
        entry = {"vat_percent": str(vat), "base": show(base), "vat": show(vat_amount)}
        if surcharge:
            entry.update({"surcharge_percent": str(surcharge), "surcharge": show(surcharge_amount)})
        taxes.append(entry)
        base_total += base
        vat_total += vat_amount
        surcharge_total += surcharge_amount
    withheld = money(base_total * withholding / hundred)
    total = base_total + vat_total + surcharge_total - withheld
    result = {
        "currency": request.get("currency", "EUR"),
        "lines": out_lines,
        "gross": show(gross_total),
        "discounts": show(discount_total),
        "taxes": taxes,
        "base": show(base_total),
        "vat": show(vat_total),
        "surcharge": show(surcharge_total),
        "withholding_percent": str(withholding),
        "withholding": show(withheld),
        "total": show(total),
    }
    print(json.dumps(result))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/invoice_calc/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/invoice_calc/1.0.0/invoice_calc.py