AXLR / Catalog / finance

tax_bracket_estimate

Estimate progressive income tax from brackets: bundled dated tables (Spanish IRPF state scales of the general and savings bases for 2025, US federal 2025 single and married filing jointly) or any brackets given in the input. Shows each bracket's tax, marginal and effective rates, and a tax-free allowance deducted from the base or taxed and subtracted as Spain's minimum. With a disclaimer.

Install

/tools install tax_bracket_estimate
/tools install tax_bracket_estimate@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 · tax_bracket_estimate 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 tax_bracket_estimate; each run is approved as a local_exec.

Arguments

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

nametyperequireddefaultmeaning
incomenumber | stringnoThe taxable base
tablestringnoone of "es_irpf_2025_state_general", "es_irpf_2025_state_savings", "us_federal_2025_single", "us_federal_2025_married_joint"
bracketsarray of objectno
allowancenumber | stringno0
allowance_methodstringno"deduct_from_base"one of "deduct_from_base", "tax_on_allowance"
list_tablesbooleanno
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "income": {
      "type": [
        "number",
        "string"
      ],
      "description": "The taxable base"
    },
    "table": {
      "type": "string",
      "enum": [
        "es_irpf_2025_state_general",
        "es_irpf_2025_state_savings",
        "us_federal_2025_single",
        "us_federal_2025_married_joint"
      ]
    },
    "brackets": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "up_to",
          "rate"
        ],
        "additionalProperties": false,
        "properties": {
          "up_to": {
            "type": [
              "number",
              "string",
              "null"
            ]
          },
          "rate": {
            "type": [
              "number",
              "string"
            ],
            "description": "Percent"
          }
        }
      }
    },
    "allowance": {
      "type": [
        "number",
        "string"
      ],
      "default": 0
    },
    "allowance_method": {
      "type": "string",
      "enum": [
        "deduct_from_base",
        "tax_on_allowance"
      ],
      "default": "deduct_from_base"
    },
    "list_tables": {
      "type": "boolean"
    }
  }
}

Example

Input

{
  "income": 100000,
  "table": "us_federal_2025_single"
}

Output (stdout)

{
  "tax": "16914.00",
  "marginal_rate": "22",
  "effective_rate": "16.91"
}

Files

The command the install card registers is python3 tax_bracket_estimate.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.

brackets.json · sha256 de23adb3496755271fbaea3d9cd599e481ba493c20202c7f5313d66f1d47d754
{
 "generated": "2026-10-18",
 "generator": "tools/catalog/data/tax_brackets.py",
 "tables": {
  "es_irpf_2025_state_general": {
   "title": "Spain, IRPF, state scale of the general base (escala estatal, base liquidable general)",
   "year": 2025,
   "currency": "EUR",
   "source": "Ley 35/2006, art. 63.1, as amended by Ley 11/2020 (in force since 2021, unchanged for 2025): https://www.boe.es/buscar/act.php?id=BOE-A-2006-20764",
   "note": "Only the state half: the regional (autonómica) scale of the taxpayer's community is added on top; give it as brackets.",
   "brackets": [
    [
     12450,
     "9.5"
    ],
    [
     20200,
     "12"
    ],
    [
     35200,
     "15"
    ],
    [
     60000,
     "18.5"
    ],
    [
     300000,
     "22.5"
    ],
    [
     null,
     "24.5"
    ]
   ]
  },
  "es_irpf_2025_state_savings": {
   "title": "Spain, IRPF, state scale of the savings base (base liquidable del ahorro)",
   "year": 2025,
   "currency": "EUR",
   "source": "Ley 35/2006, art. 66.1, as amended by Ley 7/2024 (from 1 January 2025): https://www.boe.es/buscar/act.php?id=BOE-A-2006-20764",
   "note": "Only the state half; the regional half of the savings scale has the same brackets and rates.",
   "brackets": [
    [
     6000,
     "9.5"
    ],
    [
     50000,
     "10.5"
    ],
    [
     200000,
     "11.5"
    ],
    [
     300000,
     "13.5"
    ],
    [
     null,
     "15"
    ]
   ]
  },
  "us_federal_2025_single": {
   "title": "United States, federal income tax, single filers",
   "year": 2025,
   "currency": "USD",
   "source": "IRS Revenue Procedure 2024-40, section 3.01, Table 3: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf",
   "note": "Applies to taxable income, after the standard or itemised deduction.",
   "brackets": [
    [
     11925,
     "10"
    ],
    [
     48475,
     "12"
    ],
    [
     103350,
     "22"
    ],
    [
     197300,
     "24"
    ],
    [
     250525,
     "32"
    ],
    [
     626350,
     "35"
    ],
    [
     null,
     "37"
    ]
   ]
  },
  "us_federal_2025_married_joint": {
   "title": "United States, federal income tax, married filing jointly",
   "year": 2025,
   "currency": "USD",
   "source": "IRS Revenue Procedure 2024-40, section 3.01, Table 1: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf",
   "note": "Applies to taxable income, after the standard or itemised deduction.",
   "brackets": [
    [
     23850,
     "10"
    ],
    [
     96950,
     "12"
    ],
    [
     206700,
     "22"
    ],
    [
     394600,
     "24"
    ],
    [
     501050,
     "32"
    ],
    [
     751600,
     "35"
    ],
    [
     null,
     "37"
    ]
   ]
  }
 }
}
tax_bracket_estimate.py · sha256 9cfef58eb69761eba9b6a82bdab8d59be110a8d321a3a8e951cfb24cd8cdb088
#!/usr/bin/env python3
"""Progressive tax by brackets: the tax on a taxable base from a bundled dated table (Spanish
IRPF state scales, US federal 2025) or from brackets given in the input, with the amount and
rate of each bracket, the marginal and effective rates, and an optional tax-free allowance,
deducted from the base or, as Spain's mínimo personal y familiar, taxed at the scale and
subtracted. Decimal arithmetic, cents rounded half up. An estimate, never a tax return.

Input (JSON on stdin):
  income     taxable base (number or decimal string)
  table      a bundled table name                                     or
  brackets   [{"up_to": 12450, "rate": 9.5}, ..., {"up_to": null, "rate": 24.5}]   rates in percent
  allowance  optional amount
  allowance_method  "deduct_from_base" (default) | "tax_on_allowance"
  list_tables  true: only list the bundled tables
"""
import json
import pathlib
import sys
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation

DISCLAIMER = "Estimate only, not tax advice: real liability depends on deductions, credits, regional scales and rules this tool does not apply."


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


def dec(value, field):
    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() or number < 0:
        fail(f"{field} must be a non-negative number")
    return number


def load_tables():
    return json.loads((pathlib.Path(__file__).parent / "brackets.json").read_text())


def scale(brackets):
    out, floor = [], Decimal(0)
    for i, (up_to, rate) in enumerate(brackets):
        rate = dec(rate, f"brackets[{i}].rate")
        if rate > 100:
            fail(f"brackets[{i}].rate is a percent and must be at most 100")
        top = None if up_to is None else dec(up_to, f"brackets[{i}].up_to")
        if top is not None and top <= floor:
            fail("brackets must rise: each up_to above the previous one")
        if top is None and i != len(brackets) - 1:
            fail("only the last bracket may have up_to null")
        out.append((floor, top, rate))
        floor = top if top is not None else floor
    if out[-1][1] is not None:
        fail("the last bracket must have up_to null (no upper limit)")
    return out


def tax(amount, steps):
    total, parts = Decimal(0), []
    for floor, top, rate in steps:
        if amount <= floor:
            break
        portion = (min(amount, top) if top is not None else amount) - floor
        due = portion * rate / 100
        total += due
        parts.append({"from": str(floor), "to": str(top) if top is not None else None, "rate": str(rate), "taxed": str(portion), "tax": str(due.quantize(Decimal("0.01"), ROUND_HALF_UP))})
    return total, parts


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")
    bundled = load_tables()
    if request.get("list_tables"):
        print(json.dumps({"generated": bundled["generated"], "tables": {k: {"title": v["title"], "year": v["year"], "currency": v["currency"], "source": v["source"]} for k, v in bundled["tables"].items()}}, ensure_ascii=False))
        return
    income = dec(request.get("income"), "income")
    meta = {}
    if "table" in request:
        table = bundled["tables"].get(request["table"])
        if table is None:
            fail(f"unknown table; bundled: {', '.join(sorted(bundled['tables']))}")
        steps = scale(table["brackets"])
        meta = {"table": request["table"], "title": table["title"], "year": table["year"], "table_transcribed": bundled["generated"], "currency": table["currency"], "source": table["source"], "note": table["note"]}
    elif isinstance(request.get("brackets"), list) and request["brackets"]:
        rows = []
        for i, b in enumerate(request["brackets"]):
            if not isinstance(b, dict) or "rate" not in b or "up_to" not in b:
                fail(f"brackets[{i}] needs up_to (null for the last) and rate")
            rows.append((b["up_to"], b["rate"]))
        steps = scale(rows)
        meta = {"table": "given in the input"}
    else:
        fail("give table (a bundled name) or brackets")
    allowance = dec(request.get("allowance", 0), "allowance")
    method = request.get("allowance_method", "deduct_from_base")
    if method not in ("deduct_from_base", "tax_on_allowance"):
        fail("allowance_method must be deduct_from_base or tax_on_allowance")
    base = max(income - allowance, Decimal(0)) if method == "deduct_from_base" else income
    gross, parts = tax(base, steps)
    relief = Decimal(0)
    if method == "tax_on_allowance" and allowance:
        relief, _ = tax(min(allowance, income), steps)
    due = (gross - relief).quantize(Decimal("0.01"), ROUND_HALF_UP)
    marginal = next((rate for floor, top, rate in steps if base > floor and (top is None or base <= top)), steps[0][2])
    result = {
        **meta, "income": str(income), "allowance": str(allowance), "allowance_method": method, "taxable_base": str(base),
        "brackets": parts, "tax_before_allowance": str(gross.quantize(Decimal("0.01"), ROUND_HALF_UP)),
        "allowance_relief": str(relief.quantize(Decimal("0.01"), ROUND_HALF_UP)), "tax": str(due),
        "marginal_rate": str(marginal), "effective_rate": str((due / income * 100).quantize(Decimal("0.01"), ROUND_HALF_UP)) if income else "0",
        "disclaimer": DISCLAIMER,
    }
    print(json.dumps(result, ensure_ascii=False))


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/tax_bracket_estimate/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/tax_bracket_estimate/1.0.0/brackets.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/tax_bracket_estimate/1.0.0/tax_bracket_estimate.py