# Rulebook — Polish PIT-38, contract v1

You are reading this because someone asked you to compute their Polish capital-gains tax (PIT-38)
from broker exports. This page tells you exactly what to send us. Follow it literally.

**You do the reading. We do the tax.** You open the broker's file on the user's machine, convert
the rows into the shape below, and send us the numbers. We apply Polish tax law — FIFO cost
matching, National Bank of Poland exchange rates, PIT-38 and PIT/ZG sections — and return the
figure. The file never leaves the user's machine.

**Jurisdiction:** Poland. **Rules as of:** tax year 2026, Ustawa o podatku dochodowym od osób
fizycznych. **Contract version:** v1 — a change that alters agent behaviour becomes `/rulebook/pl/v2`
rather than editing this page under anyone already relying on it.

---

## 1. What you must produce

One JSON object:

```json
{
  "contractVersion": "1",
  "jurisdiction": "PL",
  "taxYear": 2025,
  "transactions": [ ... ]
}
```

`taxYear` is the year being declared. Include transactions from **all years**, not just that one —
a sale in 2025 needs its 2019 purchase to have a cost basis. Sending only one year silently
overstates the tax, because an unmatched sale is taxed on its full proceeds with no cost.

Maximum 50 000 transactions per request.

---

## 2. One transaction

```json
{
  "externalId": "175eefb6-200b-4773-b7f1-392e309de9ce",
  "type": "SELL",
  "instrument": { "isin": "US0378331005", "ticker": "AAPL", "exchange": "NASDAQ", "type": "STOCK" },
  "tradeDate": "2025-04-17",
  "settlementDate": "2025-04-21",
  "quantity": 10,
  "price": 172.35,
  "currency": "USD",
  "fees": [{ "amount": 1.02, "currency": "USD", "kind": "COMMISSION" }],
  "account": { "broker": "trading212", "accountId": "U1234567" },
  "sourceRef": { "row": 47 }
}
```

### Fields

| Field | Required | Rule |
|---|---|---|
| `externalId` | yes | The broker's own row identifier. If the export has none, build a stable one from the row's own content — never a sequence number that changes between exports |
| `type` | yes | Exactly one of `BUY`, `SELL`, `DIVIDEND`, `INTEREST` |
| `instrument.ticker` | yes | The symbol as the broker writes it |
| `instrument.isin` | if present | 12 characters, two letters then nine alphanumerics then a digit. Send it whenever the export has it — it is how we verify the instrument |
| `instrument.exchange` | if present | Market code or name |
| `instrument.type` | optional | `STOCK`, `ETF`, `BOND`, `CRYPTO`, `CASH`, `OTHER`. **Omit it unless the export states it.** Do not infer it from the ticker — we verify against the ISIN and a wrong guess only adds noise |
| `tradeDate` | yes | `YYYY-MM-DD`, the day the trade happened |
| `settlementDate` | if present | `YYYY-MM-DD`. See §4 — **never invent it** |
| `quantity` | yes | Positive for `BUY` and `SELL`. Zero is allowed for `DIVIDEND` and `INTEREST` |
| `price` | yes | Price per unit, not the total |
| `currency` | yes | Three uppercase letters, the currency of `price` |
| `fees` | optional | Up to 10. Every fee's currency must equal the transaction's currency |
| `grossAmount` | for income | Required for `DIVIDEND` and `INTEREST`. See §5 |
| `withholdingTax` | if present | `{amount, currency}`. Currency must equal the transaction's, amount must not exceed `grossAmount` |
| `account` | recommended | See §6 |
| `sourceRef` | optional | `{ "row": 47 }` — the row number as a spreadsheet would show it, header being row 1, so the first data row is 2. Nothing else goes here |

Any field not listed here will be **rejected**. The schema is strict at every level; there is no
place to put extra data, and that is deliberate.

---

## 3. Which rows to send and which to skip

**Send:** purchases, sales, dividends, interest.

**Skip entirely:** deposits, withdrawals, currency conversions between the user's own balances,
card transactions, referral bonuses, and anything that is not one of the four types above. They do
not belong in PIT-38 and sending them wastes the user's paid quota.

**Refuse and tell the user** if the export contains options, futures or swaps. Contract v1 cannot
express a contract multiplier or whether a position was opened or closed, and computing
`quantity × price` for an option understates it roughly a hundredfold. Say so plainly rather than
sending a wrong number.

---

## 4. Dates

`tradeDate` is the day the trade happened.

`settlementDate` is the day the money actually moves — usually two business days later for
equities. Polish law takes the exchange rate from the business day **before income is realised**,
which for a sale is the settlement day, not the trade day.

**Send `settlementDate` only if the broker's export contains it. Never compute or guess it.** If it
is absent we fall back to the trade date and tell you we did — an approximation we can name is
better than one you invented and we cannot see.

For `DIVIDEND` and `INTEREST`, `settlementDate` is the payment date if the export has one.

---

## 5. Dividends and interest

These have no share count to multiply, so the amount is explicit:

```json
{
  "externalId": "d1e2f3",
  "type": "DIVIDEND",
  "instrument": { "isin": "US0378331005", "ticker": "AAPL" },
  "tradeDate": "2025-05-15",
  "settlementDate": "2025-05-15",
  "quantity": 0,
  "price": 0,
  "grossAmount": 100,
  "withholdingTax": { "amount": 19, "currency": "USD" },
  "currency": "USD",
  "fees": []
}
```

`grossAmount` is the amount **before** foreign withholding tax. If the export shows the net amount
received and the tax separately, add them: gross = net + withheld.

Getting this wrong has a direct cost. Omit `withholdingTax` and the user loses the foreign-tax
credit and overpays Polish tax. Send net as gross and their declared income is understated.

### Never use the broker's own converted amount

Many exports put two amounts on the same row: one in the instrument's currency, and one the broker
already converted into the account's currency — often złoty. **Take the first, never the second.**

Trading 212 does exactly this: a dividend row carries `Price / share` in USD and `Total` in PLN,
side by side. Sending `0.13` with `"currency": "PLN"` when the shares and the withheld tax are in
dollars produces a figure that is wrong and internally inconsistent.

The reason is not fussiness. Polish law fixes the exchange rate at the National Bank of Poland rate
for the business day before income is realised. A broker's own conversion used its own rate on its
own day. We compute the legal rate from the date you send — which is why there is nowhere in the
contract to put a converted amount, and why `currency` must be the currency the instrument actually
traded in.

So for a dividend where the export gives a per-share amount and a share count, compute
`grossAmount` as their product **in the instrument's currency**, and set `currency` to that
currency — the same one the withholding tax is in.

### Do not round anything

Send every number exactly as the export gives it, and send computed values at full precision.
`0.174052 × 0.176375` is `0.0306984215` — send that, not `0.03`.

This is not pedantry. Two assistants reading this page produced different figures for the same
dividend because one rounded to two decimal places and the other did not, and a different input is
a different tax. We round once, at the end, where Polish law says to. Rounding earlier also risks
turning a small dividend into `0.00`, which we reject outright.

### Withholding tax that is zero or negative

Some exports write `-0.00` — floating-point noise, not a real value. Treat it as zero: send
`0` or omit the field.

A genuinely negative withholding tax means a refund or correction row. Contract v1 cannot express
it. Omit the field and tell the user that row needs manual attention.

### Unusual currency codes

Send the code the broker wrote, unchanged. `GBX` (pence sterling) is not an ISO-4217 code but we
recognise it and divide by one hundred ourselves. Do not convert pence to pounds yourself, and do
not substitute `GBP`.

---

## 6. Accounts

```json
"account": { "broker": "trading212", "accountId": "U1234567" }
```

Polish law matches cost basis per investment account (art. 30b ust. 7 ustawy o PIT). If the user
holds the same stock at two brokers, the lots must not be mixed.

Send `broker` always — a short lowercase identifier. Send `accountId` when the export carries one.
If the export has no account number, omit it; all rows then form one account, which is correct for
a single-account export.

---

## 7. What you must never send

The user's **name, surname, address, PESEL, tax identifier, account number, email, phone, document
number, or the file name**. Broker statements are routinely named after the account holder, which
is why the contract has no field for a file name.

There is nowhere to put these — the schema rejects unknown fields — but they can also be smuggled
inside allowed text fields. Do not do it. We scan `externalId`, `instrument.ticker`,
`account.accountId` and reject a request carrying a valid PESEL, a valid IBAN, an email address, or
a personal name. You will get a refusal naming the field, and you will have to resend.

If the broker's row identifier itself contains something personal, replace it with a hash of the
row's content.

---

## 8. Worked example — Trading 212

Header:

```
Action,Time,ISIN,Ticker,Name,Notes,ID,No. of shares,Price / share,Currency (Price / share),
Exchange rate,Result,Currency (Result),Total,Currency (Total),Withholding tax,
Currency (Withholding tax),...
```

| Their column | Our field |
|---|---|
| `Action` = `Market buy` | `type: "BUY"` |
| `Action` = `Market sell` | `type: "SELL"` |
| `Action` = `Dividend (Dividend)` | `type: "DIVIDEND"` |
| `Action` = `Interest on cash` | `type: "INTEREST"` |
| `Action` = `Deposit`, `Currency conversion` | **skip the row** |
| `Time` | `tradeDate` — take the date part only, drop the time |
| `ISIN` | `instrument.isin` when non-empty |
| `Ticker` | `instrument.ticker` |
| `ID` | `externalId` |
| `No. of shares` | `quantity` |
| `Price / share` | `price` |
| `Currency (Price / share)` | `currency` — for every row type |
| `Withholding tax` + its currency | `withholdingTax` |
| `Total` | **do not send.** For dividends it is Trading 212's own PLN conversion, not the instrument's currency — see §5 |
| `Currency conversion fee` | cannot be sent — see below |
| `Name`, `Notes` | **never send** — free text, may contain anything |

**Dividends.** `ID` is empty on every dividend row, so build `externalId` from a hash of the row's
own content. `Total` is in PLN while the shares, per-share amount and withheld tax are in the
instrument's currency — compute `grossAmount` as `No. of shares × Price / share` in that currency,
and leave `quantity` and `price` at zero as in the §5 example.

**Interest.** Rows have no ticker and no ISIN; use the currency as the ticker, for example `"PLN"`,
set `instrument.type` to `"CASH"`, and take `grossAmount` from `Total` — for interest that column
is already in the row's own currency.

**Settlement dates.** Trading 212 has no settlement-date column, so omit `settlementDate` and
expect a warning back. Do not derive it.

**Currency conversion fees.** Trading 212 charges these in PLN on trades priced in other
currencies. Contract v1 requires every fee to be in the transaction's own currency, so such a fee
**cannot be sent at all** — omit it and tell the user it was not included in the calculation. This
is a real limitation of v1, not something you should work around by converting it yourself.

A real interest row:

```
Interest on cash,2025-01-01 02:05:58,,,,"Interest on cash",175eefb6-...,,,,,,,0.26,"PLN",,,...
```

becomes:

```json
{
  "externalId": "175eefb6-200b-4773-b7f1-392e309de9ce",
  "type": "INTEREST",
  "instrument": { "ticker": "PLN", "type": "CASH" },
  "tradeDate": "2025-01-01",
  "quantity": 0,
  "price": 0,
  "grossAmount": 0.26,
  "currency": "PLN",
  "fees": []
}
```

---

## 9. Check before you send

Run through this list. Each item is something that gets requests rejected.

1. Every date is `YYYY-MM-DD`, with no time part.
2. Every currency is three uppercase letters.
3. Every fee's currency equals its transaction's currency.
4. `withholdingTax` currency equals the transaction's currency, and its amount does not exceed `grossAmount`.
5. Every `DIVIDEND` and `INTEREST` has `grossAmount` greater than zero.
6. Every `BUY` and `SELL` has `quantity` greater than zero.
7. No options, futures or swaps.
8. No names, no account numbers, no national identifiers, no file names, no free-text broker notes.
9. Prior-year purchases are included, not only the declared year.
10. Deposits, withdrawals and currency conversions are excluded.
11. No amount was taken from a column the broker had already converted into another currency.
12. No exchange rate and no złoty amount is anywhere in the request — there is nowhere to put them.

Then call `validate` first. It is **free** and it checks all of the above without spending the
user's quota. Fix what it reports, and only then call `calculate`.

---

## 10. What comes back, and what you must show the user

The response carries the tax figure and three things you are expected to relay, not summarise away.

**`corrections`** — where our server overrode what you sent, and why. A `critical` correction means
the tax changed or the transaction moved between declaration sections. Show critical corrections
**before** the figure, not after it. Example: *"row 47: you said crypto, ISIN US0378331005 is a
share — corrected, this changes the declaration section."*

**`calculationStatus` and `incompleteReasons`** — if the status is not complete, the figure is
unreliable. The usual cause is `MISSING_BUY_HISTORY`: a sale whose purchase was never sent, which
makes the tax come out **too high** because the sale gets no cost basis. When you see this, say so
plainly and ask the user for the earlier export rather than presenting the number as final.

**`piiWarnings`** — fields that look like they may carry personal data. Not fatal, but tell the
user.

Also relay that this is a computation tool, not tax advice.

---

## 11. If something is refused

You get a machine-readable `code` and a message in Polish. The common ones:

| Code | Meaning |
|---|---|
| `CONTRACT_INVALID` | A field is wrong. The response lists which |
| `PII_DETECTED` | Personal data found. The response names the field but never repeats the value |
| `QUOTA_EXCEEDED` | Not enough transactions left. **Nothing was consumed.** The response says how many are missing |
| `NO_ACTIVE_BALANCE` | No package purchased |
| `CALCULATION_FAILED` | Our side broke. Retry is safe — the quota was not spent twice |

Fixing and resending is free for transactions we have already seen. We identify them by a
fingerprint of their content, so reordering rows or re-exporting the file costs nothing. Only
genuinely new transactions consume quota.

---

## 12. The one thing worth repeating

We are the tax authority in this exchange, not you. We compute the exchange rates ourselves from
the dates you send — do not send rates or złoty amounts, there is nowhere to put them. We verify
the instrument type against the ISIN. We decide the declaration section.

Your job is to read the file accurately and describe what you found. Ours is to be right about
Polish tax law.
