fiscal-es 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +91 -68
  3. package/cases/irpf/compensacion/irpf-compensacion-2026-011.yaml +24 -0
  4. package/cases/irpf/compensacion/irpf-compensacion-2026-012.yaml +23 -0
  5. package/dist/errors.d.ts +13 -0
  6. package/dist/errors.js +20 -0
  7. package/dist/index.d.ts +7 -7
  8. package/dist/index.js +4 -4
  9. package/dist/money.d.ts +32 -0
  10. package/dist/money.js +65 -0
  11. package/dist/tables/2026.d.ts +28 -0
  12. package/dist/tables/2026.js +16 -0
  13. package/dist/tables/index.d.ts +6 -0
  14. package/dist/tables/index.js +14 -0
  15. package/dist/tables/tipos.d.ts +21 -0
  16. package/dist/tax/brackets.d.ts +7 -0
  17. package/dist/tax/brackets.js +18 -0
  18. package/dist/{irpf/compensacion.d.ts → tax/offsetting.d.ts} +3 -3
  19. package/dist/{irpf/compensacion.js → tax/offsetting.js} +5 -5
  20. package/dist/tax/savings.d.ts +6 -0
  21. package/dist/tax/savings.js +44 -0
  22. package/dist/tax/tipos.d.ts +23 -0
  23. package/package.json +11 -4
  24. package/dist/dinero.d.ts +0 -31
  25. package/dist/dinero.js +0 -64
  26. package/dist/errores.d.ts +0 -11
  27. package/dist/errores.js +0 -18
  28. package/dist/irpf/ahorro.d.ts +0 -3
  29. package/dist/irpf/ahorro.js +0 -41
  30. package/dist/irpf/escala.d.ts +0 -7
  31. package/dist/irpf/escala.js +0 -18
  32. package/dist/irpf/tipos.d.ts +0 -13
  33. package/dist/tablas/2026.d.ts +0 -28
  34. package/dist/tablas/2026.js +0 -16
  35. package/dist/tablas/index.d.ts +0 -5
  36. package/dist/tablas/index.js +0 -13
  37. package/dist/tablas/tipos.d.ts +0 -16
  38. /package/dist/{irpf → tables}/tipos.js +0 -0
  39. /package/dist/{tablas → tax}/tipos.js +0 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ **Breaking: all public identifiers renamed to English** (ADR-009). The computation is unchanged: every verified
6
+ case in `cases/` gives the same output as in 0.1.0. The YAML keys of the cases do not change.
7
+
8
+ Error messages are now in English.
9
+
10
+ | 0.1.0 | 1.0.0 |
11
+ | ----- | ----- |
12
+ | `Centimos` | `Cents` |
13
+ | `Bp`, `bp`, `parseEuros`, `formatEuros` | unchanged |
14
+ | `CERO` | `ZERO` |
15
+ | `sumar` | `add` |
16
+ | `restar` | `subtract` |
17
+ | `max0` | `clampZero` |
18
+ | `porBp` | `timesBp` |
19
+ | `redondearCentimos` | `roundCents` |
20
+ | `calcularAhorro` | `computeSavingsTax` |
21
+ | `EntradaAhorro` | `SavingsInput` |
22
+ | `EntradaAhorro.ejercicio` | `SavingsInput.taxYear` |
23
+ | `EntradaAhorro.gananciasPatrimoniales` | `SavingsInput.capitalGains` |
24
+ | `EntradaAhorro.perdidasPatrimoniales` | `SavingsInput.capitalLosses` |
25
+ | `EntradaAhorro.rendimientosCapitalMobiliario` | `SavingsInput.investmentIncome` |
26
+ | `ResultadoAhorro` | `SavingsResult` |
27
+ | `ResultadoAhorro.baseLiquidableAhorro` | `SavingsResult.savingsTaxableBase` |
28
+ | `ResultadoAhorro.cuotaEstatal` | `SavingsResult.stateTax` |
29
+ | `ResultadoAhorro.cuotaAutonomica` | `SavingsResult.regionalTax` |
30
+ | `ResultadoAhorro.cuotaAhorro` | `SavingsResult.totalTax` |
31
+ | `getTabla` | `getTaxTable` |
32
+ | `ejerciciosDisponibles` | `availableTaxYears` |
33
+ | `TablaIRPF` | `TaxTable` |
34
+ | `TablaIRPF.ejercicio` | `TaxTable.taxYear` |
35
+ | `TablaIRPF.fuente` / `fuente.verificado` | `TaxTable.source` / `source.verifiedOn` |
36
+ | `TablaIRPF.escalaAhorro` | `TaxTable.brackets` |
37
+ | `TramoAhorro` | `TaxBracket` |
38
+ | `TramoAhorro.hasta` / `estatalBp` / `autonomicoBp` | `TaxBracket.upTo` / `stateBp` / `regionalBp` |
39
+ | `EjercicioSinTabla` (`.ejercicio`, `name`) | `UnknownTaxYear` (`.taxYear`, `name`) |
40
+ | `EntradaInvalida` (`.campo`, `.valor`, `name`) | `InvalidInput` (`.field`, `.value`, `name`) |
41
+
42
+ ## 0.1.0
43
+
44
+ First release.
package/README.md CHANGED
@@ -1,94 +1,117 @@
1
1
  # fiscal-es
2
2
 
3
- Cuota del IRPF sobre la base del ahorro (España, territorio común): integra ganancias, pérdidas y rendimientos
4
- del capital mobiliario de un ejercicio y aplica la escala estatal y la autonómica por separado.
3
+ Spanish personal income tax (IRPF) on the savings base (Spain, common territory): integrates capital gains, capital
4
+ losses and investment income for one tax year and applies the state and regional scales separately.
5
5
 
6
- TypeScript, ESM, sin dependencias de runtime, funciones puras. Importes en céntimos enteros (`Centimos`) y tipos
7
- en puntos básicos enteros (`Bp`); nunca coma flotante.
6
+ TypeScript, ESM, no runtime dependencies, pure functions. Amounts in integer cents (`Cents`) and rates in integer
7
+ basis points (`Bp`); never floating point.
8
8
 
9
- ## Instalación
9
+ ## Installation
10
10
 
11
11
  ```sh
12
12
  npm install fiscal-es
13
13
  ```
14
14
 
15
- Solo ESM (`import`); no hay build CommonJS.
15
+ ESM only (`import`); there is no CommonJS build.
16
16
 
17
- ## Uso
17
+ ## Usage
18
18
 
19
- ### Calcular la cuota del ahorro
19
+ ### Computing the savings tax
20
20
 
21
21
  ```js
22
- import { calcularAhorro, formatEuros, parseEuros } from "fiscal-es";
22
+ import { computeSavingsTax, formatEuros, parseEuros } from "fiscal-es";
23
23
 
24
- const r = calcularAhorro({
25
- ejercicio: 2026,
26
- gananciasPatrimoniales: parseEuros("10000.00"),
27
- perdidasPatrimoniales: parseEuros("3000.00"),
28
- rendimientosCapitalMobiliario: parseEuros("0.00"),
24
+ const r = computeSavingsTax({
25
+ taxYear: 2026,
26
+ capitalGains: parseEuros("10000.00"),
27
+ capitalLosses: parseEuros("3000.00"),
28
+ investmentIncome: parseEuros("0.00"),
29
29
  });
30
- // r = { baseLiquidableAhorro: 700000, cuotaEstatal: 67500, cuotaAutonomica: 67500, cuotaAhorro: 135000 }
31
- formatEuros(r.cuotaAhorro); // "1350.00"
30
+ // r = { savingsTaxableBase: 700000, stateTax: 67500, regionalTax: 67500, totalTax: 135000 }
31
+ formatEuros(r.totalTax); // "1350.00"
32
32
  ```
33
33
 
34
- `parseEuros` solo acepta el formato `"-1234.56"` (punto decimal, dos decimales); cualquier otro lanza
35
- `SyntaxError`. Una entrada sin sentido (importes no enteros, ganancias o pérdidas negativas) lanza
36
- `EntradaInvalida`; un ejercicio sin tabla, `EjercicioSinTabla`.
34
+ `parseEuros` only accepts the format `"-1234.56"` (decimal point, two decimals); anything else throws
35
+ `SyntaxError`. Input that makes no sense (non-integer amounts, negative gains or losses) throws `InvalidInput`;
36
+ a tax year without a table throws `UnknownTaxYear`.
37
37
 
38
- ### Ejercicios disponibles
38
+ ### Available tax years
39
39
 
40
40
  ```js
41
- import { ejerciciosDisponibles, getTabla } from "fiscal-es";
41
+ import { availableTaxYears, getTaxTable } from "fiscal-es";
42
42
 
43
- for (const ejercicio of ejerciciosDisponibles()) {
44
- console.log(ejercicio, getTabla(ejercicio).fuente.verificado); // 2026 "2026-10-25"
43
+ for (const taxYear of availableTaxYears()) {
44
+ console.log(taxYear, getTaxTable(taxYear).source.verifiedOn); // 2026 "2026-10-25"
45
45
  }
46
46
  ```
47
47
 
48
- ## Rango y errores
49
-
50
- El cálculo es exacto para importes de hasta ≈ 9 000 millones de euros por operación: con tipos de hasta
51
- 10 000 bp, el producto intermedio céntimos × puntos básicos cabe en `Number.MAX_SAFE_INTEGER`. Por encima, una
52
- operación cuyo resultado o producto intermedio salga del rango entero seguro lanza `RangeError`.
53
-
54
- - `SyntaxError`: `parseEuros` recibe un formato distinto de `"-1234.56"` (ver [Uso](#uso)).
55
- - `EntradaInvalida`: `calcularAhorro` recibe un importe que no es un entero de céntimos (`NaN`, fracción de
56
- céntimo), ganancias o pérdidas negativas, o un ejercicio no entero.
57
- - `EjercicioSinTabla`: `calcularAhorro` recibe un ejercicio sin tabla.
58
- - `RangeError`: `parseEuros`, `sumar`, `restar`, `porBp` o `redondearCentimos` reciben o producen un valor fuera
59
- del rango entero seguro.
60
-
61
- ## Ejercicios soportados
62
-
63
- | Ejercicio | Escala verificada |
64
- | --------- | ----------------- |
65
- | 2026 | 2026-10-25 |
66
-
67
- ## `cases/`: la spec ejecutable
68
-
69
- Cada fichero YAML de `cases/` es un caso con su entrada, la salida esperada al céntimo, la referencia normativa
70
- y la fecha en que una persona lo verificó. La suite del paquete los ejecuta todos contra el motor; un cambio en
71
- el motor que altere la salida de un caso verificado es un cambio de versión mayor. El formato está en
72
- `cases/SCHEMA.md`. `cases/_propuestos/` contiene casos aún no verificados y no forma parte de la spec.
73
-
74
- ## Límites conocidos
75
-
76
- - Sin compensación cruzada: un saldo negativo de ganancias y pérdidas no compensa rendimientos del capital
77
- mobiliario positivos, ni al revés (art. 49.1 LIRPF). El saldo negativo simplemente no tributa.
78
- - Sin arrastre de saldos negativos a los cuatro ejercicios siguientes (art. 49.2 LIRPF).
79
- - Sin regímenes forales (Navarra, País Vasco).
80
- - Solo la base del ahorro: no calcula la base general, mínimos personales ni deducciones.
81
-
82
- ## Desarrollo en el monorepo
83
-
84
- `exports` tiene una condición propia, `fiscal-es-src`, que apunta a `src/` para que la app del monorepo use el
85
- motor sin construirlo; los consumidores externos no la activan y reciben `dist/`.
86
-
87
- ## Aviso
88
-
89
- Herramienta de cálculo. No es asesoramiento fiscal ni financiero. Comprueba tu caso con la normativa vigente o
90
- con un profesional.
91
-
92
- ## Licencia
48
+ ## Range and errors
49
+
50
+ The computation is exact for amounts up to ≈ 9 000 million euros per operation: with rates up to 10 000 bp, the
51
+ intermediate product cents × basis points fits in `Number.MAX_SAFE_INTEGER`. Above that, any operation whose result
52
+ or intermediate product leaves the safe integer range throws `RangeError`.
53
+
54
+ - `SyntaxError`: `parseEuros` receives a format other than `"-1234.56"` (see [Usage](#usage)).
55
+ - `InvalidInput`: `computeSavingsTax` receives an amount that is not an integer number of cents (`NaN`, a fraction
56
+ of a cent), negative gains or losses, or a non-integer tax year.
57
+ - `UnknownTaxYear`: `computeSavingsTax` receives a tax year without a table.
58
+ - `RangeError`: `parseEuros`, `add`, `subtract`, `timesBp` or `roundCents` receive or produce a value outside the
59
+ safe integer range.
60
+
61
+ ## Supported tax years
62
+
63
+ | Tax year | Scale verified on |
64
+ | -------- | ----------------- |
65
+ | 2026 | 2026-10-25 |
66
+
67
+ ## `cases/`: the executable spec
68
+
69
+ Each YAML file in `cases/` is a case with its input, the expected output to the cent, the legal reference and the
70
+ date on which a person verified it. The package's test suite runs all of them against the engine; a change to the
71
+ engine that alters the output of a verified case is a major version change. The format is described in
72
+ `cases/SCHEMA.md`. The cases are written in Spanish, with Spanish keys (`ganancias_patrimoniales`, `cuota_ahorro`…),
73
+ because they are checked against the Spanish tax return manual; [Terminology](#terminology) maps them to the API.
74
+
75
+ ## Terminology
76
+
77
+ | Identifier | Spanish term | LIRPF |
78
+ | ---------- | ------------ | ----- |
79
+ | `computeSavingsTax` | cuota del ahorro | arts. 49, 50.2, 66.1 y 76 |
80
+ | `SavingsInput` / `SavingsResult` | entrada / resultado del ahorro | |
81
+ | `taxYear` | ejercicio | |
82
+ | `capitalGains` (`ganancias_patrimoniales`) | ganancias patrimoniales | art. 33 |
83
+ | `capitalLosses` (`perdidas_patrimoniales`) | pérdidas patrimoniales | art. 33 |
84
+ | `investmentIncome` (`rendimientos_capital_mobiliario`) | rendimientos del capital mobiliario | art. 25 |
85
+ | `savingsTaxableBase` (`base_liquidable_ahorro`) | base liquidable del ahorro | arts. 49 y 50.2 |
86
+ | `stateTax` (`cuota_estatal`) | cuota estatal | art. 66.1 |
87
+ | `regionalTax` (`cuota_autonomica`) | cuota autonómica | art. 76 |
88
+ | `totalTax` (`cuota_ahorro`) | cuota del ahorro (estatal + autonómica) | |
89
+ | `TaxTable` / `getTaxTable` / `availableTaxYears` | tabla normativa del ejercicio / ejercicios disponibles | |
90
+ | `TaxBracket` (`upTo`, `stateBp`, `regionalBp`) | tramo de la escala del ahorro (hasta, tipo estatal, tipo autonómico) | arts. 66.1 y 76 |
91
+ | `UnknownTaxYear` | ejercicio sin tabla | |
92
+ | `InvalidInput` | entrada inválida | |
93
+ | `Cents` / `Bp` | céntimos / puntos básicos | |
94
+ | `add` / `subtract` / `clampZero` / `ZERO` | sumar / restar / acotar a cero / cero | |
95
+ | `timesBp` / `roundCents` | céntimos × puntos básicos / redondeo al céntimo | |
96
+
97
+ ## Known limitations
98
+
99
+ - No cross-offsetting: a negative balance of gains and losses does not offset positive investment income, nor the
100
+ other way round (art. 49.1 LIRPF). The negative balance simply is not taxed.
101
+ - No carry-forward of negative balances to the following four tax years (art. 49.2 LIRPF).
102
+ - No foral regimes (Navarra, País Vasco).
103
+ - Savings base only: does not compute the general base, personal allowances or deductions.
104
+
105
+ ## Development in the monorepo
106
+
107
+ `exports` has a custom condition, `fiscal-es-src`, pointing to `src/` so the monorepo app can use the engine without
108
+ building it; external consumers do not enable it and get `dist/`.
109
+
110
+ ## Disclaimer
111
+
112
+ A calculation tool. Not tax or financial advice. Check your situation against current legislation or with a
113
+ professional.
114
+
115
+ ## License
93
116
 
94
117
  MIT
@@ -0,0 +1,24 @@
1
+ id: irpf-compensacion-2026-011
2
+ title: Dos saldos con céntimos que suman exactamente el umbral del primer tramo (6.000,00)
3
+ ejercicio: 2026
4
+ estado: verde
5
+ normativa:
6
+ - ref: "Ley 35/2006 IRPF, art. 49 (integración y compensación en la base imponible del ahorro)"
7
+ - ref: "Ley 35/2006 IRPF, art. 66.1 (gravamen estatal base del ahorro)"
8
+ - ref: "Ley 35/2006 IRPF, art. 76 (gravamen autonómico base del ahorro)"
9
+ verificado: "2026-10-26"
10
+ input:
11
+ ganancias_patrimoniales: "4000.50"
12
+ perdidas_patrimoniales: "1000.50"
13
+ rendimientos_capital_mobiliario: "3000.00"
14
+ expected:
15
+ base_liquidable_ahorro: "6000.00"
16
+ cuota_estatal: "570.00"
17
+ cuota_autonomica: "570.00"
18
+ cuota_ahorro: "1140.00"
19
+ notas: >
20
+ Saldo G − P = 400.050 − 100.050 = 300.000 c; RCM 300.000 c; base 600.000 c = 6.000,00, exactamente en el
21
+ límite inclusivo del primer tramo. 600.000 c × 950 bp = 570.000.000 → 57.000 c = 570,00 por cuota; total 1.140,00.
22
+ Los casos de umbral existentes (006, 018, 015, 019) llegan al límite con un solo saldo; este llega sumando
23
+ dos saldos y con céntimos en ganancias y pérdidas, y detecta un `<` en lugar de `<=` en el corte del tramo
24
+ cuando la base viene de la integración.
@@ -0,0 +1,23 @@
1
+ id: irpf-compensacion-2026-012
2
+ title: Pérdidas con céntimos que dejan la base un céntimo por encima del segundo umbral (50.000,01)
3
+ ejercicio: 2026
4
+ estado: verde
5
+ normativa:
6
+ - ref: "Ley 35/2006 IRPF, art. 49 (integración y compensación en la base imponible del ahorro)"
7
+ - ref: "Ley 35/2006 IRPF, art. 66.1 (gravamen estatal base del ahorro)"
8
+ - ref: "Ley 35/2006 IRPF, art. 76 (gravamen autonómico base del ahorro)"
9
+ verificado: "2026-10-26"
10
+ input:
11
+ ganancias_patrimoniales: "50500.00"
12
+ perdidas_patrimoniales: "499.99"
13
+ rendimientos_capital_mobiliario: "0.00"
14
+ expected:
15
+ base_liquidable_ahorro: "50000.01"
16
+ cuota_estatal: "5190.00"
17
+ cuota_autonomica: "5190.00"
18
+ cuota_ahorro: "10380.00"
19
+ notas: >
20
+ Saldo G − P = 5.050.000 − 49.999 = 5.000.001 c = 50.000,01. Por cuota: 600.000 × 950 = 570.000.000;
21
+ 4.400.000 × 1.050 = 4.620.000.000; 1 × 1.150 = 1.150; acumulado 5.190.001.150 c·bp → 519.000,115 c →
22
+ 519.000 c = 5.190,00. Total 10.380,00. Mismo resultado que irpf-ahorro-2026-011, pero la base sale de restar
23
+ pérdidas con céntimos: detecta errores de signo o de redondeo en la compensación antes de la escala.
@@ -0,0 +1,13 @@
1
+ /** No tax table registered for the requested tax year ("ejercicio sin tabla"). */
2
+ export declare class UnknownTaxYear extends Error {
3
+ readonly taxYear: number;
4
+ readonly name = "UnknownTaxYear";
5
+ constructor(taxYear: number);
6
+ }
7
+ /** Invalid input ("entrada inválida"): non-integer amounts, negative gains or losses, non-integer tax year. */
8
+ export declare class InvalidInput extends Error {
9
+ readonly field: string;
10
+ readonly value: number;
11
+ readonly name = "InvalidInput";
12
+ constructor(field: string, value: number, reason: string);
13
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,20 @@
1
+ /** No tax table registered for the requested tax year ("ejercicio sin tabla"). */
2
+ export class UnknownTaxYear extends Error {
3
+ taxYear;
4
+ name = "UnknownTaxYear";
5
+ constructor(taxYear) {
6
+ super(`No tax table for tax year ${String(taxYear)}`);
7
+ this.taxYear = taxYear;
8
+ }
9
+ }
10
+ /** Invalid input ("entrada inválida"): non-integer amounts, negative gains or losses, non-integer tax year. */
11
+ export class InvalidInput extends Error {
12
+ field;
13
+ value;
14
+ name = "InvalidInput";
15
+ constructor(field, value, reason) {
16
+ super(`Invalid input: ${field} = ${String(value)} (${reason})`);
17
+ this.field = field;
18
+ this.value = value;
19
+ }
20
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- export type { Bp, Centimos } from "./dinero.js";
2
- export { bp, CERO, formatEuros, max0, parseEuros, porBp, redondearCentimos, restar, sumar } from "./dinero.js";
3
- export { EjercicioSinTabla, EntradaInvalida } from "./errores.js";
4
- export { calcularAhorro } from "./irpf/ahorro.js";
5
- export type { EntradaAhorro, ResultadoAhorro } from "./irpf/tipos.js";
6
- export type { TablaIRPF, TramoAhorro } from "./tablas/index.js";
7
- export { ejerciciosDisponibles, getTabla } from "./tablas/index.js";
1
+ export type { Bp, Cents } from "./money.js";
2
+ export { add, bp, clampZero, formatEuros, parseEuros, roundCents, subtract, timesBp, ZERO } from "./money.js";
3
+ export { InvalidInput, UnknownTaxYear } from "./errors.js";
4
+ export { computeSavingsTax } from "./tax/savings.js";
5
+ export type { SavingsInput, SavingsResult } from "./tax/tipos.js";
6
+ export type { TaxBracket, TaxTable } from "./tables/index.js";
7
+ export { availableTaxYears, getTaxTable } from "./tables/index.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { bp, CERO, formatEuros, max0, parseEuros, porBp, redondearCentimos, restar, sumar } from "./dinero.js";
2
- export { EjercicioSinTabla, EntradaInvalida } from "./errores.js";
3
- export { calcularAhorro } from "./irpf/ahorro.js";
4
- export { ejerciciosDisponibles, getTabla } from "./tablas/index.js";
1
+ export { add, bp, clampZero, formatEuros, parseEuros, roundCents, subtract, timesBp, ZERO } from "./money.js";
2
+ export { InvalidInput, UnknownTaxYear } from "./errors.js";
3
+ export { computeSavingsTax } from "./tax/savings.js";
4
+ export { availableTaxYears, getTaxTable } from "./tables/index.js";
@@ -0,0 +1,32 @@
1
+ declare const centsBrand: unique symbol;
2
+ declare const bpBrand: unique symbol;
3
+ /** Amount in euro cents ("importe en céntimos"). Safe integer. */
4
+ export type Cents = number & {
5
+ readonly [centsBrand]: true;
6
+ };
7
+ /** Rate in basis points ("tipo en puntos básicos"): 12.34 % = 1234. Safe integer. */
8
+ export type Bp = number & {
9
+ readonly [bpBrand]: true;
10
+ };
11
+ /** Builds a `Bp` ("puntos básicos") from an integer. */
12
+ export declare function bp(n: number): Bp;
13
+ /** Converts `"-1234.56"` euros to cents ("importe en céntimos"). Accepts only `^-?\d+\.\d{2}$`; any other format throws. */
14
+ export declare function parseEuros(s: string): Cents;
15
+ /** Cents to euros ("importe en euros"), exact inverse of `parseEuros`: `123456` → `"1234.56"`. */
16
+ export declare function formatEuros(c: Cents): string;
17
+ /** Zero cents ("cero céntimos"), to initialise accumulators without literals in the logic. */
18
+ export declare const ZERO: Cents;
19
+ /** Adds cents ("sumar"). Throws if the result leaves the safe integer range (ADR-002). */
20
+ export declare function add(a: Cents, b: Cents): Cents;
21
+ /** Subtracts cents ("restar"). Throws if the result leaves the safe integer range (ADR-002). */
22
+ export declare function subtract(a: Cents, b: Cents): Cents;
23
+ /** `max(c, 0)`: clamps a negative amount to zero ("saldo acotado a cero"). */
24
+ export declare function clampZero(c: Cents): Cents;
25
+ /** Integer product cents × bp ("céntimos por puntos básicos"), to accumulate before `roundCents`. Throws outside the safe range. */
26
+ export declare function timesBp(c: Cents, t: Bp): number;
27
+ /**
28
+ * Cents·bp → cents ("redondeo al céntimo"): divides by 10 000 and rounds to the cent, half away from zero.
29
+ * The engine's only division point (ADR-002).
30
+ */
31
+ export declare function roundCents(centsTimesBp: number): Cents;
32
+ export {};
package/dist/money.js ADDED
@@ -0,0 +1,65 @@
1
+ // Dinero y tipos como enteros (ADR-002). Nunca float para importes.
2
+ const EUROS_FORMAT = /^(-?)(\d+)\.(\d{2})$/;
3
+ const BP_PER_UNIT = 10_000;
4
+ function requireSafeInteger(n, what) {
5
+ if (!Number.isSafeInteger(n)) {
6
+ throw new RangeError(`${what} must be a safe integer; got ${String(n)}`);
7
+ }
8
+ }
9
+ /** Builds a `Bp` ("puntos básicos") from an integer. */
10
+ export function bp(n) {
11
+ requireSafeInteger(n, "Bp");
12
+ return n;
13
+ }
14
+ /** Converts `"-1234.56"` euros to cents ("importe en céntimos"). Accepts only `^-?\d+\.\d{2}$`; any other format throws. */
15
+ export function parseEuros(s) {
16
+ const m = EUROS_FORMAT.exec(s);
17
+ if (m === null) {
18
+ throw new SyntaxError(`Invalid amount format: "${s}" (expected e.g. "8000.00")`);
19
+ }
20
+ const [, sign, euros, cents] = m;
21
+ const abs = Number(`${euros ?? ""}${cents ?? ""}`);
22
+ requireSafeInteger(abs, "Amount");
23
+ return (sign === "-" && abs !== 0 ? -abs : abs);
24
+ }
25
+ /** Cents to euros ("importe en euros"), exact inverse of `parseEuros`: `123456` → `"1234.56"`. */
26
+ export function formatEuros(c) {
27
+ requireSafeInteger(c, "Cents");
28
+ const digits = String(Math.abs(c)).padStart(3, "0");
29
+ return `${c < 0 ? "-" : ""}${digits.slice(0, -2)}.${digits.slice(-2)}`;
30
+ }
31
+ /** Zero cents ("cero céntimos"), to initialise accumulators without literals in the logic. */
32
+ export const ZERO = 0;
33
+ /** Adds cents ("sumar"). Throws if the result leaves the safe integer range (ADR-002). */
34
+ export function add(a, b) {
35
+ const sum = a + b;
36
+ requireSafeInteger(sum, "Sum of cents");
37
+ return sum;
38
+ }
39
+ /** Subtracts cents ("restar"). Throws if the result leaves the safe integer range (ADR-002). */
40
+ export function subtract(a, b) {
41
+ const difference = a - b;
42
+ requireSafeInteger(difference, "Difference of cents");
43
+ return difference;
44
+ }
45
+ /** `max(c, 0)`: clamps a negative amount to zero ("saldo acotado a cero"). */
46
+ export function clampZero(c) {
47
+ return (c > 0 ? c : 0);
48
+ }
49
+ /** Integer product cents × bp ("céntimos por puntos básicos"), to accumulate before `roundCents`. Throws outside the safe range. */
50
+ export function timesBp(c, t) {
51
+ const product = c * t;
52
+ requireSafeInteger(product, "Cents·bp");
53
+ return product;
54
+ }
55
+ /**
56
+ * Cents·bp → cents ("redondeo al céntimo"): divides by 10 000 and rounds to the cent, half away from zero.
57
+ * The engine's only division point (ADR-002).
58
+ */
59
+ export function roundCents(centsTimesBp) {
60
+ requireSafeInteger(centsTimesBp, "Cents·bp");
61
+ const abs = Math.abs(centsTimesBp);
62
+ const remainder = abs % BP_PER_UNIT;
63
+ const quotient = (abs - remainder) / BP_PER_UNIT + (remainder * 2 >= BP_PER_UNIT ? 1 : 0);
64
+ return (centsTimesBp < 0 && quotient !== 0 ? -quotient : quotient);
65
+ }
@@ -0,0 +1,28 @@
1
+ export declare const taxTable2026: {
2
+ readonly taxYear: 2026;
3
+ readonly source: {
4
+ readonly ref: "Ley 35/2006 IRPF, art. 66.1 (escala estatal) y art. 76 (escala autonómica) de la base del ahorro";
5
+ readonly verifiedOn: "2026-10-25";
6
+ };
7
+ readonly brackets: readonly [{
8
+ readonly upTo: import("../money.js").Cents;
9
+ readonly stateBp: import("../money.js").Bp;
10
+ readonly regionalBp: import("../money.js").Bp;
11
+ }, {
12
+ readonly upTo: import("../money.js").Cents;
13
+ readonly stateBp: import("../money.js").Bp;
14
+ readonly regionalBp: import("../money.js").Bp;
15
+ }, {
16
+ readonly upTo: import("../money.js").Cents;
17
+ readonly stateBp: import("../money.js").Bp;
18
+ readonly regionalBp: import("../money.js").Bp;
19
+ }, {
20
+ readonly upTo: import("../money.js").Cents;
21
+ readonly stateBp: import("../money.js").Bp;
22
+ readonly regionalBp: import("../money.js").Bp;
23
+ }, {
24
+ readonly upTo: null;
25
+ readonly stateBp: import("../money.js").Bp;
26
+ readonly regionalBp: import("../money.js").Bp;
27
+ }];
28
+ };
@@ -0,0 +1,16 @@
1
+ import { bp, parseEuros } from "../money.js";
2
+ // Escala verificada contra el manual de Renta; fecha en `source.verifiedOn`.
3
+ export const taxTable2026 = {
4
+ taxYear: 2026,
5
+ source: {
6
+ ref: "Ley 35/2006 IRPF, art. 66.1 (escala estatal) y art. 76 (escala autonómica) de la base del ahorro",
7
+ verifiedOn: "2026-10-25",
8
+ },
9
+ brackets: [
10
+ { upTo: parseEuros("6000.00"), stateBp: bp(950), regionalBp: bp(950) },
11
+ { upTo: parseEuros("50000.00"), stateBp: bp(1050), regionalBp: bp(1050) },
12
+ { upTo: parseEuros("200000.00"), stateBp: bp(1150), regionalBp: bp(1150) },
13
+ { upTo: parseEuros("300000.00"), stateBp: bp(1350), regionalBp: bp(1350) },
14
+ { upTo: null, stateBp: bp(1500), regionalBp: bp(1500) },
15
+ ],
16
+ };
@@ -0,0 +1,6 @@
1
+ import type { TaxTable } from "./tipos.js";
2
+ export type { TaxBracket, TaxTable } from "./tipos.js";
3
+ /** Normative table for a tax year ("tabla normativa del ejercicio", ADR-003). Throws `UnknownTaxYear`. */
4
+ export declare function getTaxTable(taxYear: number): TaxTable;
5
+ /** Tax years with a registered table ("ejercicios disponibles"), ascending. */
6
+ export declare function availableTaxYears(): readonly number[];
@@ -0,0 +1,14 @@
1
+ import { UnknownTaxYear } from "../errors.js";
2
+ import { taxTable2026 } from "./2026.js";
3
+ const TABLES = new Map([[taxTable2026.taxYear, taxTable2026]]);
4
+ /** Normative table for a tax year ("tabla normativa del ejercicio", ADR-003). Throws `UnknownTaxYear`. */
5
+ export function getTaxTable(taxYear) {
6
+ const table = TABLES.get(taxYear);
7
+ if (table === undefined)
8
+ throw new UnknownTaxYear(taxYear);
9
+ return table;
10
+ }
11
+ /** Tax years with a registered table ("ejercicios disponibles"), ascending. */
12
+ export function availableTaxYears() {
13
+ return [...TABLES.keys()].sort((a, b) => a - b);
14
+ }
@@ -0,0 +1,21 @@
1
+ import type { Bp, Cents } from "../money.js";
2
+ /** One bracket of the savings scale ("tramo de la escala del ahorro"). */
3
+ export type TaxBracket = {
4
+ /** Upper limit of the bracket, inclusive ("hasta"). `null` = no limit. */
5
+ readonly upTo: Cents | null;
6
+ /** State rate ("tipo estatal", art. 66.1 LIRPF). */
7
+ readonly stateBp: Bp;
8
+ /** Regional rate ("tipo autonómico", art. 76 LIRPF). */
9
+ readonly regionalBp: Bp;
10
+ };
11
+ /** Normative IRPF table for one tax year ("tabla normativa del ejercicio"). */
12
+ export type TaxTable = {
13
+ readonly taxYear: number;
14
+ readonly source: {
15
+ readonly ref: string;
16
+ /** ISO date of human verification against the source; `null` if not verified. */
17
+ readonly verifiedOn: string | null;
18
+ };
19
+ /** Savings scale ("escala del ahorro"). */
20
+ readonly brackets: ReadonlyArray<TaxBracket>;
21
+ };
@@ -0,0 +1,7 @@
1
+ import type { Cents } from "../money.js";
2
+ import type { TaxBracket } from "../tables/index.js";
3
+ /**
4
+ * Cuota de aplicar una escala por tramos a `base`. Acumula céntimos·bp en enteros a lo largo de toda la
5
+ * escala y redondea una sola vez al final, nunca por tramo (ADR-002). `upTo` es inclusivo.
6
+ */
7
+ export declare function applyBrackets(base: Cents, brackets: ReadonlyArray<TaxBracket>, scale: "state" | "regional"): Cents;
@@ -0,0 +1,18 @@
1
+ import { clampZero, roundCents, subtract, timesBp, ZERO } from "../money.js";
2
+ /**
3
+ * Cuota de aplicar una escala por tramos a `base`. Acumula céntimos·bp en enteros a lo largo de toda la
4
+ * escala y redondea una sola vez al final, nunca por tramo (ADR-002). `upTo` es inclusivo.
5
+ */
6
+ export function applyBrackets(base, brackets, scale) {
7
+ let from = ZERO;
8
+ let accumulated = ZERO;
9
+ for (const bracket of brackets) {
10
+ const ceiling = bracket.upTo === null || base < bracket.upTo ? base : bracket.upTo;
11
+ const slice = clampZero(subtract(ceiling, from));
12
+ accumulated += timesBp(slice, scale === "state" ? bracket.stateBp : bracket.regionalBp);
13
+ if (bracket.upTo === null || base <= bracket.upTo)
14
+ break;
15
+ from = bracket.upTo;
16
+ }
17
+ return roundCents(accumulated);
18
+ }
@@ -1,5 +1,5 @@
1
- import type { Centimos } from "../dinero.js";
2
- import type { EntradaAhorro } from "./tipos.js";
1
+ import type { Cents } from "../money.js";
2
+ import type { SavingsInput } from "./tipos.js";
3
3
  /**
4
4
  * Base liquidable del ahorro = saldo de ganancias − pérdidas + saldo de rendimientos del capital mobiliario,
5
5
  * cada saldo acotado a cero por separado.
@@ -7,4 +7,4 @@ import type { EntradaAhorro } from "./tipos.js";
7
7
  * v1 (ADR-005): un saldo negativo no compensa al otro (compensación cruzada con límite del art. 49.1 LIRPF)
8
8
  * ni se arrastra a ejercicios siguientes (art. 49.2). Simplemente no tributa.
9
9
  */
10
- export declare function integrarBaseAhorro(e: EntradaAhorro): Centimos;
10
+ export declare function integrateSavingsBase(e: SavingsInput): Cents;
@@ -1,4 +1,4 @@
1
- import { max0, restar, sumar } from "../dinero.js";
1
+ import { add, clampZero, subtract } from "../money.js";
2
2
  /**
3
3
  * Base liquidable del ahorro = saldo de ganancias − pérdidas + saldo de rendimientos del capital mobiliario,
4
4
  * cada saldo acotado a cero por separado.
@@ -6,8 +6,8 @@ import { max0, restar, sumar } from "../dinero.js";
6
6
  * v1 (ADR-005): un saldo negativo no compensa al otro (compensación cruzada con límite del art. 49.1 LIRPF)
7
7
  * ni se arrastra a ejercicios siguientes (art. 49.2). Simplemente no tributa.
8
8
  */
9
- export function integrarBaseAhorro(e) {
10
- const saldoGananciasPerdidas = max0(restar(e.gananciasPatrimoniales, e.perdidasPatrimoniales));
11
- const saldoRendimientos = max0(e.rendimientosCapitalMobiliario);
12
- return sumar(saldoGananciasPerdidas, saldoRendimientos);
9
+ export function integrateSavingsBase(e) {
10
+ const gainsLossesBalance = clampZero(subtract(e.capitalGains, e.capitalLosses));
11
+ const incomeBalance = clampZero(e.investmentIncome);
12
+ return add(gainsLossesBalance, incomeBalance);
13
13
  }
@@ -0,0 +1,6 @@
1
+ import type { SavingsInput, SavingsResult } from "./tipos.js";
2
+ /**
3
+ * Savings tax ("cuota del ahorro", arts. 49, 50.2, 66.1 y 76 LIRPF).
4
+ * Cuota de la base del ahorro: estatal y autonómica por separado, cada una redondeada; la total es su suma.
5
+ */
6
+ export declare function computeSavingsTax(e: SavingsInput): SavingsResult;
@@ -0,0 +1,44 @@
1
+ import { add } from "../money.js";
2
+ import { InvalidInput } from "../errors.js";
3
+ import { getTaxTable } from "../tables/index.js";
4
+ import { integrateSavingsBase } from "./offsetting.js";
5
+ import { applyBrackets } from "./brackets.js";
6
+ /** Rechaza entradas sin sentido. Los rendimientos del capital mobiliario sí pueden ser negativos. */
7
+ function validate(e) {
8
+ if (!Number.isInteger(e.taxYear)) {
9
+ throw new InvalidInput("taxYear", e.taxYear, "must be an integer year");
10
+ }
11
+ const amounts = {
12
+ capitalGains: e.capitalGains,
13
+ capitalLosses: e.capitalLosses,
14
+ investmentIncome: e.investmentIncome,
15
+ };
16
+ for (const [field, value] of Object.entries(amounts)) {
17
+ if (!Number.isSafeInteger(value)) {
18
+ throw new InvalidInput(field, value, "must be a safe integer of cents");
19
+ }
20
+ }
21
+ if (e.capitalGains < 0) {
22
+ throw new InvalidInput("capitalGains", e.capitalGains, "must be ≥ 0");
23
+ }
24
+ if (e.capitalLosses < 0) {
25
+ throw new InvalidInput("capitalLosses", e.capitalLosses, "must be ≥ 0");
26
+ }
27
+ }
28
+ /**
29
+ * Savings tax ("cuota del ahorro", arts. 49, 50.2, 66.1 y 76 LIRPF).
30
+ * Cuota de la base del ahorro: estatal y autonómica por separado, cada una redondeada; la total es su suma.
31
+ */
32
+ export function computeSavingsTax(e) {
33
+ validate(e);
34
+ const table = getTaxTable(e.taxYear);
35
+ const savingsTaxableBase = integrateSavingsBase(e);
36
+ const stateTax = applyBrackets(savingsTaxableBase, table.brackets, "state");
37
+ const regionalTax = applyBrackets(savingsTaxableBase, table.brackets, "regional");
38
+ return {
39
+ savingsTaxableBase,
40
+ stateTax,
41
+ regionalTax,
42
+ totalTax: add(stateTax, regionalTax),
43
+ };
44
+ }
@@ -0,0 +1,23 @@
1
+ import type { Cents } from "../money.js";
2
+ /** Savings input ("rentas de la base del ahorro" de un ejercicio, art. 46 LIRPF). */
3
+ export type SavingsInput = {
4
+ /** Tax year ("ejercicio"). */
5
+ taxYear: number;
6
+ /** Capital gains ("ganancias patrimoniales", art. 33 LIRPF). */
7
+ capitalGains: Cents;
8
+ /** Capital losses ("pérdidas patrimoniales", art. 33 LIRPF). */
9
+ capitalLosses: Cents;
10
+ /** Investment income ("rendimientos del capital mobiliario", art. 25 LIRPF). May be negative. */
11
+ investmentIncome: Cents;
12
+ };
13
+ /** Savings result ("base liquidable y cuotas del ahorro"). */
14
+ export type SavingsResult = {
15
+ /** Savings taxable base ("base liquidable del ahorro", arts. 49 y 50.2 LIRPF). */
16
+ savingsTaxableBase: Cents;
17
+ /** State tax ("cuota estatal", art. 66.1 LIRPF). */
18
+ stateTax: Cents;
19
+ /** Regional tax ("cuota autonómica", art. 76 LIRPF). */
20
+ regionalTax: Cents;
21
+ /** Total savings tax ("cuota del ahorro"): `stateTax + regionalTax`. */
22
+ totalTax: Cents;
23
+ };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "fiscal-es",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
+ "description": "Spanish personal income tax (IRPF) on the savings base: exact integer-cent engine with executable YAML test cases",
4
5
  "type": "module",
5
6
  "license": "MIT",
6
7
  "repository": {
@@ -12,7 +13,12 @@
12
13
  "irpf",
13
14
  "fiscalidad",
14
15
  "españa",
15
- "ahorro"
16
+ "ahorro",
17
+ "spain",
18
+ "tax",
19
+ "income-tax",
20
+ "savings",
21
+ "capital-gains"
16
22
  ],
17
23
  "exports": {
18
24
  ".": {
@@ -27,14 +33,15 @@
27
33
  "cases",
28
34
  "!cases/_propuestos",
29
35
  "LICENSE",
30
- "README.md"
36
+ "README.md",
37
+ "CHANGELOG.md"
31
38
  ],
32
39
  "sideEffects": false,
33
40
  "engines": {
34
41
  "node": ">=20"
35
42
  },
36
43
  "scripts": {
37
- "build": "tsc -p tsconfig.build.json",
44
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
38
45
  "test": "vitest run",
39
46
  "typecheck": "tsc && tsc -p tests"
40
47
  },
package/dist/dinero.d.ts DELETED
@@ -1,31 +0,0 @@
1
- declare const marcaCentimos: unique symbol;
2
- declare const marcaBp: unique symbol;
3
- /** Importe en céntimos de euro. Entero seguro. */
4
- export type Centimos = number & {
5
- readonly [marcaCentimos]: true;
6
- };
7
- /** Tipo en puntos básicos: 12,34 % = 1234. Entero seguro. */
8
- export type Bp = number & {
9
- readonly [marcaBp]: true;
10
- };
11
- /** Construye un `Bp` a partir de un entero. */
12
- export declare function bp(n: number): Bp;
13
- /** Convierte `"-1234.56"` en céntimos. Acepta solo `^-?\d+\.\d{2}$`; cualquier otro formato lanza. */
14
- export declare function parseEuros(s: string): Centimos;
15
- /** Inversa exacta de `parseEuros`: `123456` → `"1234.56"`. */
16
- export declare function formatEuros(c: Centimos): string;
17
- /** Cero céntimos, para inicializar acumuladores sin literales en la lógica. */
18
- export declare const CERO: Centimos;
19
- /** Lanza si el resultado sale del rango entero seguro (ADR-002). */
20
- export declare function sumar(a: Centimos, b: Centimos): Centimos;
21
- /** Lanza si el resultado sale del rango entero seguro (ADR-002). */
22
- export declare function restar(a: Centimos, b: Centimos): Centimos;
23
- export declare function max0(c: Centimos): Centimos;
24
- /** Producto entero céntimos × bp, para acumular antes de `redondearCentimos`. Lanza fuera del rango seguro. */
25
- export declare function porBp(c: Centimos, t: Bp): number;
26
- /**
27
- * Céntimos·bp → céntimos: divide entre 10 000 y redondea al céntimo, mitad alejándose de cero.
28
- * Único punto de división del motor (ADR-002).
29
- */
30
- export declare function redondearCentimos(centimosPorBp: number): Centimos;
31
- export {};
package/dist/dinero.js DELETED
@@ -1,64 +0,0 @@
1
- // Dinero y tipos como enteros (ADR-002). Nunca float para importes.
2
- const FORMATO_EUROS = /^(-?)(\d+)\.(\d{2})$/;
3
- const BP_POR_UNIDAD = 10_000;
4
- function exigirEntero(n, que) {
5
- if (!Number.isSafeInteger(n)) {
6
- throw new RangeError(`${que} debe ser un entero seguro; recibido ${String(n)}`);
7
- }
8
- }
9
- /** Construye un `Bp` a partir de un entero. */
10
- export function bp(n) {
11
- exigirEntero(n, "Bp");
12
- return n;
13
- }
14
- /** Convierte `"-1234.56"` en céntimos. Acepta solo `^-?\d+\.\d{2}$`; cualquier otro formato lanza. */
15
- export function parseEuros(s) {
16
- const m = FORMATO_EUROS.exec(s);
17
- if (m === null) {
18
- throw new SyntaxError(`Importe con formato inválido: "${s}" (se espera p. ej. "8000.00")`);
19
- }
20
- const [, signo, euros, cent] = m;
21
- const abs = Number(`${euros ?? ""}${cent ?? ""}`);
22
- exigirEntero(abs, "Importe");
23
- return (signo === "-" && abs !== 0 ? -abs : abs);
24
- }
25
- /** Inversa exacta de `parseEuros`: `123456` → `"1234.56"`. */
26
- export function formatEuros(c) {
27
- exigirEntero(c, "Centimos");
28
- const digitos = String(Math.abs(c)).padStart(3, "0");
29
- return `${c < 0 ? "-" : ""}${digitos.slice(0, -2)}.${digitos.slice(-2)}`;
30
- }
31
- /** Cero céntimos, para inicializar acumuladores sin literales en la lógica. */
32
- export const CERO = 0;
33
- /** Lanza si el resultado sale del rango entero seguro (ADR-002). */
34
- export function sumar(a, b) {
35
- const suma = a + b;
36
- exigirEntero(suma, "Suma de céntimos");
37
- return suma;
38
- }
39
- /** Lanza si el resultado sale del rango entero seguro (ADR-002). */
40
- export function restar(a, b) {
41
- const resta = a - b;
42
- exigirEntero(resta, "Resta de céntimos");
43
- return resta;
44
- }
45
- export function max0(c) {
46
- return (c > 0 ? c : 0);
47
- }
48
- /** Producto entero céntimos × bp, para acumular antes de `redondearCentimos`. Lanza fuera del rango seguro. */
49
- export function porBp(c, t) {
50
- const producto = c * t;
51
- exigirEntero(producto, "Céntimos·bp");
52
- return producto;
53
- }
54
- /**
55
- * Céntimos·bp → céntimos: divide entre 10 000 y redondea al céntimo, mitad alejándose de cero.
56
- * Único punto de división del motor (ADR-002).
57
- */
58
- export function redondearCentimos(centimosPorBp) {
59
- exigirEntero(centimosPorBp, "Céntimos·bp");
60
- const abs = Math.abs(centimosPorBp);
61
- const resto = abs % BP_POR_UNIDAD;
62
- const cociente = (abs - resto) / BP_POR_UNIDAD + (resto * 2 >= BP_POR_UNIDAD ? 1 : 0);
63
- return (centimosPorBp < 0 && cociente !== 0 ? -cociente : cociente);
64
- }
package/dist/errores.d.ts DELETED
@@ -1,11 +0,0 @@
1
- export declare class EjercicioSinTabla extends Error {
2
- readonly ejercicio: number;
3
- readonly name = "EjercicioSinTabla";
4
- constructor(ejercicio: number);
5
- }
6
- export declare class EntradaInvalida extends Error {
7
- readonly campo: string;
8
- readonly valor: number;
9
- readonly name = "EntradaInvalida";
10
- constructor(campo: string, valor: number, motivo: string);
11
- }
package/dist/errores.js DELETED
@@ -1,18 +0,0 @@
1
- export class EjercicioSinTabla extends Error {
2
- ejercicio;
3
- name = "EjercicioSinTabla";
4
- constructor(ejercicio) {
5
- super(`No hay tabla normativa para el ejercicio ${String(ejercicio)}`);
6
- this.ejercicio = ejercicio;
7
- }
8
- }
9
- export class EntradaInvalida extends Error {
10
- campo;
11
- valor;
12
- name = "EntradaInvalida";
13
- constructor(campo, valor, motivo) {
14
- super(`Entrada inválida: ${campo} = ${String(valor)} (${motivo})`);
15
- this.campo = campo;
16
- this.valor = valor;
17
- }
18
- }
@@ -1,3 +0,0 @@
1
- import type { EntradaAhorro, ResultadoAhorro } from "./tipos.js";
2
- /** Cuota de la base del ahorro: estatal y autonómica por separado, cada una redondeada; la total es su suma. */
3
- export declare function calcularAhorro(e: EntradaAhorro): ResultadoAhorro;
@@ -1,41 +0,0 @@
1
- import { sumar } from "../dinero.js";
2
- import { EntradaInvalida } from "../errores.js";
3
- import { getTabla } from "../tablas/index.js";
4
- import { integrarBaseAhorro } from "./compensacion.js";
5
- import { aplicarEscala } from "./escala.js";
6
- /** Rechaza entradas sin sentido. Los rendimientos del capital mobiliario sí pueden ser negativos. */
7
- function validar(e) {
8
- if (!Number.isInteger(e.ejercicio)) {
9
- throw new EntradaInvalida("ejercicio", e.ejercicio, "debe ser un año entero");
10
- }
11
- const importes = {
12
- gananciasPatrimoniales: e.gananciasPatrimoniales,
13
- perdidasPatrimoniales: e.perdidasPatrimoniales,
14
- rendimientosCapitalMobiliario: e.rendimientosCapitalMobiliario,
15
- };
16
- for (const [campo, valor] of Object.entries(importes)) {
17
- if (!Number.isSafeInteger(valor)) {
18
- throw new EntradaInvalida(campo, valor, "debe ser un entero seguro de céntimos");
19
- }
20
- }
21
- if (e.gananciasPatrimoniales < 0) {
22
- throw new EntradaInvalida("gananciasPatrimoniales", e.gananciasPatrimoniales, "no puede ser negativo");
23
- }
24
- if (e.perdidasPatrimoniales < 0) {
25
- throw new EntradaInvalida("perdidasPatrimoniales", e.perdidasPatrimoniales, "no puede ser negativo");
26
- }
27
- }
28
- /** Cuota de la base del ahorro: estatal y autonómica por separado, cada una redondeada; la total es su suma. */
29
- export function calcularAhorro(e) {
30
- validar(e);
31
- const tabla = getTabla(e.ejercicio);
32
- const baseLiquidableAhorro = integrarBaseAhorro(e);
33
- const cuotaEstatal = aplicarEscala(baseLiquidableAhorro, tabla.escalaAhorro, "estatal");
34
- const cuotaAutonomica = aplicarEscala(baseLiquidableAhorro, tabla.escalaAhorro, "autonomico");
35
- return {
36
- baseLiquidableAhorro,
37
- cuotaEstatal,
38
- cuotaAutonomica,
39
- cuotaAhorro: sumar(cuotaEstatal, cuotaAutonomica),
40
- };
41
- }
@@ -1,7 +0,0 @@
1
- import type { Centimos } from "../dinero.js";
2
- import type { TramoAhorro } from "../tablas/index.js";
3
- /**
4
- * Cuota de aplicar una escala por tramos a `base`. Acumula céntimos·bp en enteros a lo largo de toda la
5
- * escala y redondea una sola vez al final, nunca por tramo (ADR-002). `hasta` es inclusivo.
6
- */
7
- export declare function aplicarEscala(base: Centimos, tramos: ReadonlyArray<TramoAhorro>, tipo: "estatal" | "autonomico"): Centimos;
@@ -1,18 +0,0 @@
1
- import { CERO, max0, porBp, redondearCentimos, restar } from "../dinero.js";
2
- /**
3
- * Cuota de aplicar una escala por tramos a `base`. Acumula céntimos·bp en enteros a lo largo de toda la
4
- * escala y redondea una sola vez al final, nunca por tramo (ADR-002). `hasta` es inclusivo.
5
- */
6
- export function aplicarEscala(base, tramos, tipo) {
7
- let desde = CERO;
8
- let acumulado = CERO;
9
- for (const tramo of tramos) {
10
- const techo = tramo.hasta === null || base < tramo.hasta ? base : tramo.hasta;
11
- const porcion = max0(restar(techo, desde));
12
- acumulado += porBp(porcion, tipo === "estatal" ? tramo.estatalBp : tramo.autonomicoBp);
13
- if (tramo.hasta === null || base <= tramo.hasta)
14
- break;
15
- desde = tramo.hasta;
16
- }
17
- return redondearCentimos(acumulado);
18
- }
@@ -1,13 +0,0 @@
1
- import type { Centimos } from "../dinero.js";
2
- export type EntradaAhorro = {
3
- ejercicio: number;
4
- gananciasPatrimoniales: Centimos;
5
- perdidasPatrimoniales: Centimos;
6
- rendimientosCapitalMobiliario: Centimos;
7
- };
8
- export type ResultadoAhorro = {
9
- baseLiquidableAhorro: Centimos;
10
- cuotaEstatal: Centimos;
11
- cuotaAutonomica: Centimos;
12
- cuotaAhorro: Centimos;
13
- };
@@ -1,28 +0,0 @@
1
- export declare const tabla2026: {
2
- readonly ejercicio: 2026;
3
- readonly fuente: {
4
- readonly ref: "Ley 35/2006 IRPF, art. 66.1 (escala estatal) y art. 76 (escala autonómica) de la base del ahorro";
5
- readonly verificado: "2026-10-25";
6
- };
7
- readonly escalaAhorro: readonly [{
8
- readonly hasta: import("../dinero.js").Centimos;
9
- readonly estatalBp: import("../dinero.js").Bp;
10
- readonly autonomicoBp: import("../dinero.js").Bp;
11
- }, {
12
- readonly hasta: import("../dinero.js").Centimos;
13
- readonly estatalBp: import("../dinero.js").Bp;
14
- readonly autonomicoBp: import("../dinero.js").Bp;
15
- }, {
16
- readonly hasta: import("../dinero.js").Centimos;
17
- readonly estatalBp: import("../dinero.js").Bp;
18
- readonly autonomicoBp: import("../dinero.js").Bp;
19
- }, {
20
- readonly hasta: import("../dinero.js").Centimos;
21
- readonly estatalBp: import("../dinero.js").Bp;
22
- readonly autonomicoBp: import("../dinero.js").Bp;
23
- }, {
24
- readonly hasta: null;
25
- readonly estatalBp: import("../dinero.js").Bp;
26
- readonly autonomicoBp: import("../dinero.js").Bp;
27
- }];
28
- };
@@ -1,16 +0,0 @@
1
- import { bp, parseEuros } from "../dinero.js";
2
- // Escala verificada contra el manual de Renta; fecha en `fuente.verificado`.
3
- export const tabla2026 = {
4
- ejercicio: 2026,
5
- fuente: {
6
- ref: "Ley 35/2006 IRPF, art. 66.1 (escala estatal) y art. 76 (escala autonómica) de la base del ahorro",
7
- verificado: "2026-10-25",
8
- },
9
- escalaAhorro: [
10
- { hasta: parseEuros("6000.00"), estatalBp: bp(950), autonomicoBp: bp(950) },
11
- { hasta: parseEuros("50000.00"), estatalBp: bp(1050), autonomicoBp: bp(1050) },
12
- { hasta: parseEuros("200000.00"), estatalBp: bp(1150), autonomicoBp: bp(1150) },
13
- { hasta: parseEuros("300000.00"), estatalBp: bp(1350), autonomicoBp: bp(1350) },
14
- { hasta: null, estatalBp: bp(1500), autonomicoBp: bp(1500) },
15
- ],
16
- };
@@ -1,5 +0,0 @@
1
- import type { TablaIRPF } from "./tipos.js";
2
- export type { TablaIRPF, TramoAhorro } from "./tipos.js";
3
- export declare function getTabla(ejercicio: number): TablaIRPF;
4
- /** Ejercicios con tabla normativa registrada, en orden ascendente. */
5
- export declare function ejerciciosDisponibles(): readonly number[];
@@ -1,13 +0,0 @@
1
- import { EjercicioSinTabla } from "../errores.js";
2
- import { tabla2026 } from "./2026.js";
3
- const TABLAS = new Map([[tabla2026.ejercicio, tabla2026]]);
4
- export function getTabla(ejercicio) {
5
- const tabla = TABLAS.get(ejercicio);
6
- if (tabla === undefined)
7
- throw new EjercicioSinTabla(ejercicio);
8
- return tabla;
9
- }
10
- /** Ejercicios con tabla normativa registrada, en orden ascendente. */
11
- export function ejerciciosDisponibles() {
12
- return [...TABLAS.keys()].sort((a, b) => a - b);
13
- }
@@ -1,16 +0,0 @@
1
- import type { Bp, Centimos } from "../dinero.js";
2
- export type TramoAhorro = {
3
- /** Límite superior del tramo (incluido). `null` = sin límite. */
4
- readonly hasta: Centimos | null;
5
- readonly estatalBp: Bp;
6
- readonly autonomicoBp: Bp;
7
- };
8
- export type TablaIRPF = {
9
- readonly ejercicio: number;
10
- readonly fuente: {
11
- readonly ref: string;
12
- /** Fecha ISO de verificación humana contra la fuente; `null` si no se ha verificado. */
13
- readonly verificado: string | null;
14
- };
15
- readonly escalaAhorro: ReadonlyArray<TramoAhorro>;
16
- };
File without changes
File without changes