From f7158c8b6f8e0053ecd6c5334405e037eb04f985 Mon Sep 17 00:00:00 2001
From: GeiserX <9169332+GeiserX@users.noreply.github.com>
Date: Sat, 20 Jun 2026 12:55:55 +0200
Subject: [PATCH] feat(report): add FX method-consistency and
deferred-conversion notes
Clarify how DeclaRenta handles foreign-currency (divisa) gains for
foreign-currency stock accounts, after a community thread surfaced
contradictory interpretations of the realize-vs-defer question.
- report.fx_method_consistency (info): when FX disposals exist, explain
the divisa gain is deferred until the currency is converted to euros
(Art. 14.2.e LIRPF; DGT V0152-26) and warn not to mix methods across
years, which can double-count or drop carried basis.
- report.fx_deferred_no_conversion (info): when the user holds
foreign-currency securities (non-EUR STK/FUND/BOND) but made no
FCY->EUR conversion, confirm that 0 in casillas 1633/1637 is correct
and that IBKR's "realized forex gain" uses the US criterion, not the
Spanish one.
- guide_rw.fx_note: rewrite in all 5 locales. The old text wrongly said
FX only appears on manual currency trades, false since auto-convert
(#239) and stock round-trips (#230).
Both notes are info severity (not warnings) and excluded from the
deprecated warnings/messages sync check, matching the existing
report.competitor_reconciliation note.
---
src/generators/report.ts | 29 +++++++++++++++++++++++++++++
src/i18n/locales/ca.ts | 2 +-
src/i18n/locales/en.ts | 2 +-
src/i18n/locales/es.ts | 2 +-
src/i18n/locales/eu.ts | 2 +-
src/i18n/locales/gl.ts | 2 +-
tests/generators/report.test.ts | 10 +++++++++-
7 files changed, 43 insertions(+), 6 deletions(-)
diff --git a/src/generators/report.ts b/src/generators/report.ts
index d67699f..4dafc65 100644
--- a/src/generators/report.ts
+++ b/src/generators/report.ts
@@ -607,6 +607,35 @@ export function generateTaxReport(
message: "Si otra herramienta muestra un importe distinto, puede deberse a que no calcula las ganancias por tipo de cambio (Art. 33.1 LIRPF).",
hint: "Puedes activar el modo monodivisa en tu perfil fiscal para comparar con herramientas como Autodeclaro o Taxdown.",
});
+ // Method-consistency warning: the FX gain on a foreign currency is realized
+ // here only when the currency is converted to euros (defer / carry-basis,
+ // Art. 14.2.e LIRPF; DGT V0152-26). A different admitted method realizes it at
+ // each foreign-currency purchase. The two must NOT be mixed across years.
+ allMessages.push({
+ id: "report.fx_method_consistency",
+ severity: "info",
+ message: "DeclaRenta calcula la ganancia de divisa difiriéndola hasta que conviertes la moneda extranjera a euros (Art. 14.2.e LIRPF; DGT V0152-26). Existe otro método admitido que la realiza en cada compra de valores en divisa.",
+ hint: "No mezcles métodos entre ejercicios: si un año declaraste con otro criterio (o con otra herramienta), la base de divisa arrastrada puede duplicarse u omitirse. Para divisa adquirida antes del periodo de tu informe, incluye los años anteriores en la exportación del bróker.",
+ });
+ } else if (!options?.skipFx && fxDisposals.length === 0) {
+ // Never-converted case: the user holds (and trades with) a foreign currency
+ // but made NO FCY→EUR conversion this year, so there is correctly 0 in
+ // casillas 1633/1637. Only surface this when there are foreign-currency
+ // SECURITIES disposals (STK/FUND/BOND, non-EUR) — i.e. the user genuinely
+ // holds divisa — so it never fires on pure-EUR or crypto-only files. Heads
+ // off the most common "why doesn't my number match IBKR?" question.
+ const FX_ASSET_CATEGORIES = new Set(["STK", "FUND", "BOND"]);
+ const hasForeignCurrencySecurities = disposals.some(
+ (d) => d.currency !== "EUR" && FX_ASSET_CATEGORIES.has(d.assetCategory),
+ );
+ if (hasForeignCurrencySecurities) {
+ allMessages.push({
+ id: "report.fx_deferred_no_conversion",
+ severity: "info",
+ message: "No has convertido divisa a euros este ejercicio, por lo que no hay ganancia por tipo de cambio que declarar (0 en las casillas 1633/1637). La diferencia de cambio mientras mantienes la moneda queda diferida hasta que la conviertas efectivamente a euros (Art. 14.2.e LIRPF; DGT V0152-26).",
+ hint: "Tu bróker (p. ej. IBKR) puede mostrar una «realized forex gain» aunque no hayas pasado a euros: usa el criterio fiscal de EE. UU., no el español. Bajo el criterio de DeclaRenta solo se declara la ganancia de divisa al convertir a euros.",
+ });
+ }
}
// Final boundary guard (audit [HIGH/ERR]): scan every top-level monetary total
diff --git a/src/i18n/locales/ca.ts b/src/i18n/locales/ca.ts
index deff805..d3de9d0 100644
--- a/src/i18n/locales/ca.ts
+++ b/src/i18n/locales/ca.ts
@@ -469,7 +469,7 @@ const ca: TranslationKeys = {
"guide_rw.dt_campo_label": "En quin camp del quadre?",
"guide_rw.dt_campo_hint": "Al quadre de doble imposició, omple DUES files:
• «Altres rendiments nets reduïts obtinguts a l'estranger» (2a fila) → import brut dels dividends estrangers (mateix valor que casella 0029).
• «Impost satisfet a l'estranger» (última fila) → import de la casella 0588 de DeclaRenta.
Si deixes la 2a fila buida, Renta Web mostra l'avís «Ha reflectit l'impost sense fer constar les rendes». Les files 1 i 3 queden a 0.",
"guide_rw.capital_gains_note": "Si tens moltes operacions, pots consolidar-les en una sola línia per tipus d'actiu usant les dates genèriques 01/01 i 31/12. Renta Web accepta imports agregats.",
- "guide_rw.fx_note": "Els guanys per tipus de canvi es declaren apart dels guanys de valors. Només apareixen si has operat amb divises manualment (ex. conversions EUR→USD a IBKR). Si uses mode monodivisa, aquesta secció no aplica.",
+ "guide_rw.fx_note": "Els guanys per tipus de canvi es declaren a part dels guanys de valors. Apareixen quan converteixes divisa a euros —incloses les conversions automàtiques del teu bróker (p. ex. AFx/FXCONV a IBKR)—, no només en les conversions manuals. Mentre mantens la divisa (p. ex. compres i vens accions en USD sense passar a euros), la diferència de canvi queda diferida fins que converteixes efectivament a euros (Art. 14.2.e LIRPF). Si uses mode monodivisa, aquesta secció no aplica.",
"guide_rw.dividends_note": "La retenció estrangera (withholding tax) NO es posa aquí: es dedueix apart a la casella 0588 (doble imposició). En canvi, la retenció espanyola del 19% sobre dividends d'emissors espanyols (ISIN ES…), encara que els tinguis en un bróker estranger, sí és un pagament a compte i va a la casella 0597.",
"guide_rw.interest_note": "Els interessos del broker (remuneració de saldo) es declaren com a rendiments del capital mobiliari. Els interessos de marge pagats NO són deduïbles (Art. 26.1.a LIRPF).",
"guide_rw.double_taxation_note": "La deducció per doble imposició evita pagar dues vegades impostos sobre els mateixos dividends. Es limita al menor entre el pagat a l'origen i la quota espanyola. Si el conveni de doble imposició permet un tipus màxim inferior (ex. 15% EUA), només és deduïble fins a aquest límit.",
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index c70ab36..f8cd38d 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -473,7 +473,7 @@ const en: TranslationKeys = {
"guide_rw.dt_campo_label": "Which field in the dialog?",
"guide_rw.dt_campo_hint": "In the double taxation dialog, fill TWO rows:
• \"Other net reduced income obtained abroad\" (2nd row) → gross dividend amount from abroad (same value as box 0029).
• \"Tax paid abroad\" (last row) → the amount from DeclaRenta's box 0588.
If you leave the 2nd row empty, Renta Web shows a warning about missing income. Rows 1 and 3 stay at 0.",
"guide_rw.capital_gains_note": "If you have many operations, you can consolidate them in one line per asset type using generic dates 01/01 and 31/12. Renta Web accepts aggregated amounts.",
- "guide_rw.fx_note": "FX gains are declared separately from securities gains. They only appear if you manually traded currencies (e.g. EUR→USD conversions in IBKR). If using single-currency mode, this section does not apply.",
+ "guide_rw.fx_note": "FX gains are declared separately from securities gains. They appear when you convert foreign currency to euros — including your broker's automatic conversions (e.g. AFx/FXCONV in IBKR) — not only manual conversions. While you hold the currency (e.g. buying and selling stocks in USD without converting to euros), the exchange difference stays deferred until you actually convert to euros (Art. 14.2.e LIRPF). If using single-currency mode, this section does not apply.",
"guide_rw.dividends_note": "Foreign withholding tax is NOT entered here: it is deducted separately in box 0588 (double taxation). In contrast, the 19% Spanish withholding on dividends from Spanish issuers (ISIN ES…), even if held at a foreign broker, is a prepayment and goes in box 0597.",
"guide_rw.interest_note": "Broker interest (cash remuneration) is declared as investment income. Margin interest paid is NOT deductible (Art. 26.1.a LIRPF).",
"guide_rw.double_taxation_note": "The double taxation deduction prevents paying tax twice on the same dividends. It is limited to the lesser of the amount paid abroad and the Spanish tax due. If the tax treaty allows a lower maximum rate (e.g. 15% USA), only that amount is deductible.",
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 7d4ba2a..f85d51a 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -502,7 +502,7 @@ const es = {
"guide_rw.dt_campo_label": "¿En qué campo del cuadro?",
"guide_rw.dt_campo_hint": "En el cuadro de doble imposición, rellena DOS filas:
• «Otros rendimientos netos reducidos obtenidos en el extranjero» (2ª fila) → importe bruto de los dividendos extranjeros (mismo valor que casilla 0029).
• «Impuesto satisfecho en el extranjero» (última fila) → importe de la casilla 0588 de DeclaRenta.
Si dejas la 2ª fila vacía, Renta Web muestra el aviso «Ha reflejado el impuesto sin hacer constar las rentas». Las filas 1 y 3 quedan a 0.",
"guide_rw.capital_gains_note": "Si tienes muchas operaciones, puedes consolidarlas en una sola línea por tipo de activo usando las fechas genéricas 01/01 y 31/12. Renta Web acepta importes agregados.",
- "guide_rw.fx_note": "Las ganancias por tipo de cambio se declaran aparte de las ganancias de valores. Solo aparecen si has operado con divisas manualmente (ej. conversiones EUR→USD en IBKR). Si usas modo monodivisa, esta sección no aplica.",
+ "guide_rw.fx_note": "Las ganancias por tipo de cambio se declaran aparte de las ganancias de valores. Aparecen cuando conviertes divisa a euros —incluidas las conversiones automáticas de tu bróker (p. ej. AFx/FXCONV en IBKR)—, no solo en las conversiones manuales. Mientras mantienes la divisa (p. ej. compras y vendes acciones en USD sin pasar a euros), la diferencia de cambio queda diferida hasta que conviertes efectivamente a euros (Art. 14.2.e LIRPF). Si usas modo monodivisa, esta sección no aplica.",
"guide_rw.dividends_note": "La retención extranjera (withholding tax) NO se pone aquí: se deduce aparte en la casilla 0588 (doble imposición). En cambio, la retención española del 19% sobre dividendos de emisores españoles (ISIN ES…), aunque los tengas en un bróker extranjero, sí es una retención a cuenta y va en la casilla 0597.",
"guide_rw.interest_note": "Los intereses del broker (remuneración de saldo) se declaran como rendimientos del capital mobiliario. Los intereses de margen pagados NO son deducibles (Art. 26.1.a LIRPF).",
"guide_rw.double_taxation_note": "La deducción por doble imposición evita pagar dos veces impuestos sobre los mismos dividendos. Se limita al menor entre lo pagado en origen y la cuota española. Si el convenio de doble imposición permite un tipo máximo inferior (ej. 15% EE.UU.), solo es deducible hasta ese límite.",
diff --git a/src/i18n/locales/eu.ts b/src/i18n/locales/eu.ts
index d42d355..8ffcdc2 100644
--- a/src/i18n/locales/eu.ts
+++ b/src/i18n/locales/eu.ts
@@ -469,7 +469,7 @@ const eu: TranslationKeys = {
"guide_rw.dt_campo_label": "Zein eremutan koadroan?",
"guide_rw.dt_campo_hint": "Zergapetze bikoitzaren koadroan, bete BI lerro:
• «Atzerrian lortutako beste errendimendu garbi murriztuak» (2. lerroa) → atzerriko dibidenduen zenbateko gordina (0029 gelaxkako balio bera).
• «Atzerrian ordaindutako zerga» (azken lerroa) → DeclaRenta-ren 0588 gelaxkako zenbatekoa.
2. lerroa hutsik uzten baduzu, Renta Web-ek abisu bat erakusten du errentak falta direlako. 1. eta 3. lerroak 0-n geratzen dira.",
"guide_rw.capital_gains_note": "Eragiketa asko badituzu, lerro bakarrean konsolidatu ditzakezu aktibo mota bakoitzeko 01/01 eta 31/12 data generikoak erabiliz. Renta Web-ek zenbateko agregatuak onartzen ditu.",
- "guide_rw.fx_note": "Kanbio-tasagatiko irabaziak balore irabazietatik bereizita aitortzen dira. Dibisekin eskuz operatu baduzu bakarrik agertzen dira (adib. EUR→USD bihurketak IBKR-n). Monodibisa modua erabiltzen baduzu, atal hau ez da aplikagarria.",
+ "guide_rw.fx_note": "Kanbio-tasagatiko irabaziak balore irabazietatik bereizita aitortzen dira. Dibisa euro bihurtzen duzunean agertzen dira —zure brokerraren bihurketa automatikoak barne (adib. AFx/FXCONV IBKR-n)—, eta ez eskuzko bihurketetan soilik. Dibisa daukazun bitartean (adib. USD akzioak erosi eta saldu euro bihurtu gabe), kanbio-diferentzia atzeratuta geratzen da euro benetan bihurtu arte (Art. 14.2.e LIRPF). Monodibisa modua erabiltzen baduzu, atal hau ez da aplikagarria.",
"guide_rw.dividends_note": "Atzerriko atxikipena (withholding tax) EZ da hemen jartzen: 0588 gelaxkan kentzen da bereizita (zergapetze bikoitza). Aldiz, jaulkitzaile espainiarren dibidenduen gaineko %19ko atxikipen espainiarra (ISIN ES…), atzerriko artekari batean baduzu ere, konturako ordainketa da eta 0597 laukira doa.",
"guide_rw.interest_note": "Broker-aren interesak (saldoaren ordainketa) kapital higigarriaren errendimendu gisa aitortzen dira. Ordaindutako marjina interesak EZ dira kengarriak (26.1.a art. LIRPF).",
"guide_rw.double_taxation_note": "Zergapetze bikoitzagatiko kenkariak dibidendo berberengatik bi aldiz zerga ordaintzea ekiditen du. Atzerrian ordaindutakoaren eta Espainiako kuotaren arteko txikienera mugatzen da. Zergapetze bikoitza saihesteko hitzarmenak tasa maximo txikiagoa baimentzen badu (adib. %15 AEB), kopuru horretaraino bakarrik da kengarria.",
diff --git a/src/i18n/locales/gl.ts b/src/i18n/locales/gl.ts
index 0428e37..027e726 100644
--- a/src/i18n/locales/gl.ts
+++ b/src/i18n/locales/gl.ts
@@ -469,7 +469,7 @@ const gl: TranslationKeys = {
"guide_rw.dt_campo_label": "En que campo do cadro?",
"guide_rw.dt_campo_hint": "No cadro de dobre imposición, cubre DÚAS filas:
• «Outros rendementos netos reducidos obtidos no estranxeiro» (2ª fila) → importe bruto dos dividendos estranxeiros (mesmo valor que casilla 0029).
• «Imposto satisfeito no estranxeiro» (última fila) → importe da casilla 0588 de DeclaRenta.
Se deixas a 2ª fila baleira, Renta Web amosa o aviso «Reflectiu o imposto sen facer constar as rendas». As filas 1 e 3 quedan a 0.",
"guide_rw.capital_gains_note": "Se tes moitas operacións, podes consolidalas nunha soa liña por tipo de activo usando as datas xenéricas 01/01 e 31/12. Renta Web acepta importes agregados.",
- "guide_rw.fx_note": "As ganancias por tipo de cambio decláranse aparte das ganancias de valores. Só aparecen se operaches con divisas manualmente (ex. conversións EUR→USD en IBKR). Se usas modo monodivisa, esta sección non aplica.",
+ "guide_rw.fx_note": "As ganancias por tipo de cambio decláranse aparte das ganancias de valores. Aparecen cando convertes divisa a euros —incluídas as conversións automáticas do teu bróker (p. ex. AFx/FXCONV en IBKR)—, non só nas conversións manuais. Mentres manteñas a divisa (p. ex. compras e vendes accións en USD sen pasar a euros), a diferenza de cambio queda diferida ata que convertes efectivamente a euros (Art. 14.2.e LIRPF). Se usas modo monodivisa, esta sección non aplica.",
"guide_rw.dividends_note": "A retención estranxeira (withholding tax) NON se pon aquí: dedúcese aparte na casilla 0588 (dobre imposición). En cambio, a retención española do 19% sobre dividendos de emisores españois (ISIN ES…), aínda que os teñas nun bróker estranxeiro, si é un pagamento a conta e vai na casilla 0597.",
"guide_rw.interest_note": "Os xuros do broker (remuneración de saldo) decláranse como rendementos do capital mobiliario. Os xuros de marxe pagados NON son deducibles (Art. 26.1.a LIRPF).",
"guide_rw.double_taxation_note": "A dedución por dobre imposición evita pagar dúas veces impostos sobre os mesmos dividendos. Limítase ao menor entre o pagado na orixe e a cota española. Se o convenio de dobre imposición permite un tipo máximo inferior (ex. 15% EUA), só é deducible ata ese límite.",
diff --git a/tests/generators/report.test.ts b/tests/generators/report.test.ts
index 5bea577..a321e8a 100644
--- a/tests/generators/report.test.ts
+++ b/tests/generators/report.test.ts
@@ -533,7 +533,15 @@ describe("generateTaxReport", () => {
});
const report = generateTaxReport(statement, rates, 2025);
- const nonHintMessages = report.messages.filter((m) => m.id !== "report.competitor_reconciliation");
+ // Info-only reconciliation/method notes are added to the structured `messages`
+ // array only, never to the deprecated `warnings` string array — so they are
+ // excluded from the backward-compat sync check.
+ const infoOnlyIds = new Set([
+ "report.competitor_reconciliation",
+ "report.fx_method_consistency",
+ "report.fx_deferred_no_conversion",
+ ]);
+ const nonHintMessages = report.messages.filter((m) => !infoOnlyIds.has(m.id));
// eslint-disable-next-line @typescript-eslint/no-deprecated -- verifying backward compat sync
const warningsCount = report.warnings.length;
expect(warningsCount).toBe(nonHintMessages.length);