Skip to content

Commit 4f97dca

Browse files
committed
feat(tax): add prior losses handling
1 parent 9e6dbf4 commit 4f97dca

24 files changed

Lines changed: 1190 additions & 25 deletions

docs/HOW-IT-WORKS.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -470,13 +470,15 @@ poz29 = max(poz27 − poz26, 0) // Loss (strata)
470470
#### Section D — Tax Calculation
471471

472472
```
473-
poz30 = min(priorYearLoss, poz28) // Prior year loss deduction (capped at gain)
473+
poz30 = applyLossCarryForward(priorLosses, poz28).deductedPln // Prior-year loss deduction
474474
poz31 = roundToFullPln(max(poz28 − poz30, 0)) // Tax base (podstawa obliczenia podatku)
475475
poz33 = poz31 × 0.19 // Tax amount (podatek)
476476
poz34 = 0 // Foreign tax on capital gains (not applicable)
477477
poz35 = roundToFullPln(max(poz33 − poz34, 0)) // Tax due (podatek należny)
478478
```
479479

480+
`applyLossCarryForward` enforces the 5-year window and 50%-per-year cap per loss entry — see Section 9.
481+
480482
#### Sections E & F — Crypto (placeholders, all zeros)
481483

482484
Not yet implemented. Fields poz36–poz45 are set to 0.
@@ -534,19 +536,34 @@ Results are sorted alphabetically by country code.
534536

535537
### 9. Prior Year Losses (Carry-Over)
536538

537-
**Legal basis:** [Art. 9 ust. 3 ustawy o PIT](https://lexlege.pl/ustawa-o-podatku-dochodowym-od-osob-fizycznych/art-9/) — losses from a given income source can be carried forward for up to 5 consecutive tax years, with two alternative methods:
539+
**Implementation:** `src/core/tax/loss-carry-forward.ts`
540+
541+
**Legal basis:** [Art. 9 ust. 3 ustawy o PIT](https://lexlege.pl/ustawa-o-podatku-dochodowym-od-osob-fizycznych/art-9/) — losses from a given income source can be carried forward for up to **5 consecutive tax years** following the year in which they were incurred, with a cap of **50% of the original loss per year** (art. 9 ust. 3 pkt 1).
538542

539-
**Option 1 (gradual):** Deduct up to **50% of the loss per year** across 5 years ([art. 9 ust. 3 pkt 1](https://lexlege.pl/ustawa-o-podatku-dochodowym-od-osob-fizycznych/art-9/)).
543+
> _Option 2_ (one-shot deduction up to 5,000,000 PLN, art. 9 ust. 3 pkt 2) is **not** supported by the calculator. Users who qualify can still enter an equivalent gradual deduction schedule manually.
540544
541-
**Option 2 (one-time):** Deduct the entire loss in a single year, up to **5,000,000 PLN** ([art. 9 ust. 3 pkt 2](https://lexlege.pl/ustawa-o-podatku-dochodowym-od-osob-fizycznych/art-9/)). Any remainder above 5M PLN follows the 50% rule for subsequent years.
545+
**Data model:** Each loss is recorded as a `PriorYearLoss { year, totalLossPln, alreadyDeductedPln }` in the `priorLosses` Dexie table, scoped per session. The `/prior-losses` page provides CRUD for these rows; `alreadyDeductedPln` tracks the residual consumed across prior tax years.
542546

543-
**Implementation detail:** The user manually enters the deduction amount (`priorYearLoss`). The code caps it at the current year's gain:
547+
**Algorithm (`applyLossCarryForward`):**
544548

545549
```
546-
deductible = min(priorYearLoss, capitalGain)
550+
For each priorLoss (oldest year first):
551+
ageInYears = currentYear − loss.year
552+
expired = ageInYears > 5 || ageInYears <= 0
553+
cap = expired ? 0 : min(0.5 × loss.totalLossPln,
554+
loss.totalLossPln − loss.alreadyDeductedPln)
555+
deducted = min(cap, remainingGain)
556+
remainingGain −= deducted
557+
loss.alreadyDeductedPln += deducted // returned in updatedLosses
558+
559+
poz30 = sum of deducted across all priorLosses
547560
```
548561

549-
The 50% limit and 5-year carry-forward tracking are **not enforced by the calculator** — the user is responsible for computing the correct deduction amount. This is by design for flexibility (supports both option 1 and option 2).
562+
**Guarantees:**
563+
564+
- Losses older than 5 years or newer than the tax session are silently skipped (a warning is attached for residuals lost to expiration).
565+
- The 50%-per-year cap is enforced per loss year — a user entering 100% of a loss as "already deducted" previously still has their residual constrained by `0.5 × totalLossPln`.
566+
- The per-year breakdown (`perYear`) is persisted on the `taxSummary.lossDeduction` record and rendered in Section D of the PIT-38 form.
550567

551568
---
552569

@@ -671,7 +688,7 @@ The app supports **three languages:**
671688
- Compute raw (unrounded) tax amounts for dashboard display
672689
6. **PIT-38** (`buildPit38`):
673690
- Map summary values to form fields with proper rounding
674-
- Apply prior-year loss deduction (capped at gain amount)
691+
- Apply prior-year loss deduction via `applyLossCarryForward` (5-year window, 50%-per-year cap, FIFO across loss years)
675692
- Round tax base and tax due to full PLN ([art. 63 § 1](https://lexlege.pl/ordynacja-podatkowa/art-63/))
676693
- Round dividend tax to full groszy up ([art. 63 § 1a](https://lexlege.pl/ordynacja-podatkowa/art-63/))
677694
7. **PIT/ZG** (`buildPitZg`):

messages/en.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,13 +192,28 @@
192192
"nav_docs_more": "More coming soon...",
193193
"nav_faq": "FAQ",
194194
"nav_home": "Home",
195+
"nav_prior_losses": "Prior-year losses",
195196
"nav_rates": "Exchange Rates",
196197
"nav_support": "Support",
197198
"nav_tax_form": "Tax Form",
198199
"noscript": "kloPIT requires JavaScript to run.",
199200
"page_about": "About",
200201
"page_dashboard": "Dashboard",
201202
"page_data": "Data",
203+
"page_prior_losses": "Prior-year losses",
204+
"prior_losses_add": "Add loss year",
205+
"prior_losses_already_deducted": "Already deducted (PLN)",
206+
"prior_losses_already_deducted_help": "Cumulative amount deducted in earlier tax years.",
207+
"prior_losses_cap_explainer": "Each loss can be deducted across the next 5 tax years, with at most 50% of the original loss in any single year (art. 9 ust. 3 updof).",
208+
"prior_losses_empty": "No prior-year losses recorded for this session.",
209+
"prior_losses_intro": "Prior-year capital losses available to offset gains in {year}. Older than 5 years are excluded automatically.",
210+
"prior_losses_no_session": "Select or create a session on the Data page first.",
211+
"prior_losses_remaining": "Remaining (PLN)",
212+
"prior_losses_title": "Prior-year losses (PIT-38 poz. 30)",
213+
"prior_losses_total_loss": "Total loss reported (PLN)",
214+
"prior_losses_warn_expired": "Loss from {year} expired (older than 5 years) — {amount} PLN no longer deductible.",
215+
"prior_losses_warn_fully_deducted": "Loss from {year} is already fully deducted.",
216+
"prior_losses_year": "Loss year",
202217
"page_docs": "Documentation",
203218
"page_docs_desc_faq": "Answers to common questions about PIT-38 filing, tax calculations, and using kloPIT.",
204219
"page_docs_desc_ib": "Step-by-step guide to exporting your activity statement from Interactive Brokers.",
@@ -252,6 +267,12 @@
252267
"tax_c_total_proceeds": "Total income",
253268
"tax_calculating": "Fetching exchange rates and calculating...",
254269
"tax_d_foreign_tax": "Tax paid abroad",
270+
"tax_d_loss_breakdown_cap": "Cap (PLN)",
271+
"tax_d_loss_breakdown_deducted": "Deducted (PLN)",
272+
"tax_d_loss_breakdown_residual": "Remaining after (PLN)",
273+
"tax_d_loss_breakdown_title": "Per-year loss breakdown",
274+
"tax_d_loss_breakdown_year": "Loss year",
275+
"tax_d_loss_warning_expired": "Loss from {year} expired unused ({amount} PLN lost — carry-forward window is 5 years).",
255276
"tax_d_prior_loss": "Losses from previous years",
256277
"tax_d_tax_amount": "Tax",
257278
"tax_d_tax_base": "Tax base (rounded to full PLN)",

messages/pl.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,27 @@
187187
"nav_docs_more": "Więcej wkrótce...",
188188
"nav_faq": "FAQ",
189189
"nav_home": "Start",
190+
"nav_prior_losses": "Straty z lat ubiegłych",
190191
"nav_rates": "Kursy walut",
191192
"nav_support": "Wsparcie",
192193
"nav_tax_form": "Formularz PIT",
193194
"page_about": "O aplikacji",
194195
"page_dashboard": "Panel",
195196
"page_data": "Dane",
197+
"page_prior_losses": "Straty z lat ubiegłych",
198+
"prior_losses_add": "Dodaj rok ze stratą",
199+
"prior_losses_already_deducted": "Już odliczone (PLN)",
200+
"prior_losses_already_deducted_help": "Łączna kwota odliczona w poprzednich latach podatkowych.",
201+
"prior_losses_cap_explainer": "Stratę można odliczać przez 5 kolejnych lat podatkowych, w jednym roku najwyżej 50% jej pierwotnej kwoty (art. 9 ust. 3 updof).",
202+
"prior_losses_empty": "Brak strat z lat ubiegłych dla tej sesji.",
203+
"prior_losses_intro": "Straty kapitałowe z poprzednich lat dostępne do odliczenia od dochodu w {year}. Straty starsze niż 5 lat są pomijane automatycznie.",
204+
"prior_losses_no_session": "Najpierw wybierz lub utwórz sesję na stronie Dane.",
205+
"prior_losses_remaining": "Pozostało (PLN)",
206+
"prior_losses_title": "Straty z lat ubiegłych (PIT-38 poz. 30)",
207+
"prior_losses_total_loss": "Wykazana strata (PLN)",
208+
"prior_losses_warn_expired": "Strata z {year} przedawniona (starsza niż 5 lat) — {amount} PLN nie podlega już odliczeniu.",
209+
"prior_losses_warn_fully_deducted": "Strata z {year} została już w pełni odliczona.",
210+
"prior_losses_year": "Rok poniesienia straty",
196211
"page_docs": "Dokumentacja",
197212
"page_docs_desc_faq": "Odpowiedzi na najczęstsze pytania dotyczące PIT-38, obliczeń podatkowych i korzystania z kloPIT.",
198213
"page_docs_desc_ib": "Instrukcja krok po kroku eksportu wyciągu z Interactive Brokers.",
@@ -246,6 +261,12 @@
246261
"tax_c_total_proceeds": "Razem przychody",
247262
"tax_calculating": "Pobieranie kursów walut i obliczanie...",
248263
"tax_d_foreign_tax": "Podatek zapłacony za granicą",
264+
"tax_d_loss_breakdown_cap": "Limit (PLN)",
265+
"tax_d_loss_breakdown_deducted": "Odliczono (PLN)",
266+
"tax_d_loss_breakdown_residual": "Pozostało po (PLN)",
267+
"tax_d_loss_breakdown_title": "Odliczenie straty per rok",
268+
"tax_d_loss_breakdown_year": "Rok straty",
269+
"tax_d_loss_warning_expired": "Strata z {year} przepadła niewykorzystana ({amount} PLN — okres rozliczenia to 5 lat).",
249270
"tax_d_prior_loss": "Straty z lat ubiegłych",
250271
"tax_d_tax_amount": "Podatek",
251272
"tax_d_tax_base": "Podstawa obliczenia podatku (po zaokrągleniu do pełnych zł)",

messages/uk.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,27 @@
187187
"nav_docs_more": "Більше незабаром...",
188188
"nav_faq": "FAQ",
189189
"nav_home": "Головна",
190+
"nav_prior_losses": "Збитки минулих років",
190191
"nav_rates": "Курси валют",
191192
"nav_support": "Підтримка",
192193
"nav_tax_form": "Форма PIT",
193194
"page_about": "Про додаток",
194195
"page_dashboard": "Панель",
195196
"page_data": "Дані",
197+
"page_prior_losses": "Збитки минулих років",
198+
"prior_losses_add": "Додати рік збитку",
199+
"prior_losses_already_deducted": "Уже відраховано (PLN)",
200+
"prior_losses_already_deducted_help": "Сума, відрахована в попередніх податкових роках.",
201+
"prior_losses_cap_explainer": "Збиток можна відраховувати протягом 5 наступних податкових років, але не більше 50% початкової суми за один рік (ст. 9 п. 3 УПДОФ).",
202+
"prior_losses_empty": "Для цієї сесії немає збитків минулих років.",
203+
"prior_losses_intro": "Капітальні збитки минулих років, доступні для зменшення прибутку у {year}. Збитки старші за 5 років виключаються автоматично.",
204+
"prior_losses_no_session": "Спочатку оберіть або створіть сесію на сторінці Дані.",
205+
"prior_losses_remaining": "Залишилось (PLN)",
206+
"prior_losses_title": "Збитки минулих років (PIT-38 поз. 30)",
207+
"prior_losses_total_loss": "Заявлений збиток (PLN)",
208+
"prior_losses_warn_expired": "Збиток за {year} прострочено (понад 5 років) — {amount} PLN більше не підлягає відрахуванню.",
209+
"prior_losses_warn_fully_deducted": "Збиток за {year} вже повністю відраховано.",
210+
"prior_losses_year": "Рік збитку",
196211
"page_docs": "Документація",
197212
"page_docs_desc_faq": "Відповіді на поширені питання про PIT-38, податкові розрахунки та використання kloPIT.",
198213
"page_docs_desc_ib": "Покрокова інструкція з експорту виписки з Interactive Brokers.",
@@ -246,6 +261,12 @@
246261
"tax_c_total_proceeds": "Всього доходи",
247262
"tax_calculating": "Отримання курсів валют та розрахунок...",
248263
"tax_d_foreign_tax": "Податок сплачений за кордоном",
264+
"tax_d_loss_breakdown_cap": "Ліміт (PLN)",
265+
"tax_d_loss_breakdown_deducted": "Відраховано (PLN)",
266+
"tax_d_loss_breakdown_residual": "Залишилось після (PLN)",
267+
"tax_d_loss_breakdown_title": "Відрахування збитку за роками",
268+
"tax_d_loss_breakdown_year": "Рік збитку",
269+
"tax_d_loss_warning_expired": "Збиток за {year} згорів невикористаним ({amount} PLN — вікно перенесення складає 5 років).",
249270
"tax_d_prior_loss": "Збитки попередніх років",
250271
"tax_d_tax_amount": "Податок",
251272
"tax_d_tax_base": "База оподаткування (округлено до повних злотих)",

src/core/tax/calculator.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ import {
88
type EnrichedWithholdingTax,
99
type Pit38Fields,
1010
type PitZgFields,
11+
type PriorYearLoss,
1112
type TaxPeriod,
1213
type TaxSummary,
1314
type TradeResult,
1415
} from '../types.js';
1516
import { calculateCapitalGains } from './capital-gains.js';
1617
import { calculateDividends } from './dividends.js';
18+
import {
19+
applyLossCarryForward,
20+
type ApplyLossCarryForwardResult,
21+
} from './loss-carry-forward.js';
1722
import { buildPitZg } from './pit-zg.js';
1823
import { buildPit38 } from './pit38.js';
1924
import { getDividendCreditCapRate } from './treaty-rates.js';
@@ -24,7 +29,12 @@ export interface CalculateTaxesArgs {
2429
withholdingTaxes: EnrichedWithholdingTax[];
2530
corporateActions: EnrichedCorporateAction[];
2631
carryInPositions: CarryInPosition[];
27-
priorYearLoss?: number;
32+
/**
33+
* Prior-year capital losses (art. 9 ust. 3 updof). Replaces the legacy
34+
* single-number `priorYearLoss` field — each year carries its own
35+
* residual and 50%-per-year cap.
36+
*/
37+
priorLosses?: PriorYearLoss[];
2838
taxPeriod: TaxPeriod;
2939
symbolCountryMap?: Map<string, string>;
3040
}
@@ -35,6 +45,8 @@ export interface TaxCalculationResult {
3545
summary: TaxSummary;
3646
pit38: Pit38Fields;
3747
pitZg: PitZgFields[];
48+
/** Per-year breakdown of how prior-year losses were applied. */
49+
lossDeduction: ApplyLossCarryForwardResult;
3850
}
3951

4052
/** Orchestrate full tax calculation pipeline */
@@ -45,7 +57,7 @@ export function calculateTaxes(args: CalculateTaxesArgs): TaxCalculationResult {
4557
withholdingTaxes,
4658
corporateActions,
4759
carryInPositions,
48-
priorYearLoss,
60+
priorLosses,
4961
taxPeriod,
5062
} = args;
5163

@@ -72,7 +84,16 @@ export function calculateTaxes(args: CalculateTaxesArgs): TaxCalculationResult {
7284
year: taxPeriod.year,
7385
});
7486

75-
const pit38 = buildPit38({ summary, priorYearLoss });
87+
// Compute the loss-carry-forward breakdown once so the UI and PIT-38
88+
// builder agree on the deduction amount.
89+
const gainPln = Math.max(summary.totalProceedsPln - summary.totalCostPln, 0);
90+
const lossDeduction = applyLossCarryForward({
91+
gainPln,
92+
priorLosses: priorLosses ?? [],
93+
currentYear: taxPeriod.year,
94+
});
95+
96+
const pit38 = buildPit38({ summary, priorLosses });
7697
const pitZg = buildPitZg({
7798
trades: tradeResults,
7899
dividends: dividendResults,
@@ -84,6 +105,7 @@ export function calculateTaxes(args: CalculateTaxesArgs): TaxCalculationResult {
84105
summary,
85106
pit38,
86107
pitZg,
108+
lossDeduction,
87109
};
88110
}
89111

0 commit comments

Comments
 (0)