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.
| name | type | required | default | meaning |
|---|---|---|---|---|
income | number | string | no | The taxable base | |
table | string | no | one of "es_irpf_2025_state_general", "es_irpf_2025_state_savings", "us_federal_2025_single", "us_federal_2025_married_joint" | |
brackets | array of object | no | ||
allowance | number | string | no | 0 | |
allowance_method | string | no | "deduct_from_base" | one of "deduct_from_base", "tax_on_allowance" |
list_tables | boolean | no |
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