Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,11 @@ architecture overview (PL/EN).

**Reference implementations** (read these first):

- **Stocks** — `pit38/plugins/stock/revolut/` (most complete, handles
BOM, unknown operations, dividends, fees, stock splits)
- **Stocks, CSV input** — `pit38/plugins/stock/revolut/` (most complete,
handles BOM, unknown operations, dividends, fees, stock splits)
- **Stocks, PDF input** — `pit38/plugins/stock/ibi_capital/` (regex-based
PDF text parsing via pdfplumber, synthetic BUY emitted from order
confirmation)
- **Crypto** — `pit38/plugins/crypto/binance/`

**High-level recipe:**
Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ A command-line tool for calculating Polish income tax on **stocks** and **crypto

## Supported Brokers

| Broker | Stocks | Crypto |
|----------|--------|--------|
| Revolut | Yes | Yes |
| E*Trade | Yes | — |
| Binance | — | Yes |
| Manual CSV | Yes | Yes |
| Broker | Stocks | Crypto |
|--------------|-----------------|--------|
| Revolut | Yes | Yes |
| E*Trade | Yes | — |
| IBI Capital | Yes (SELL-side, PDF input) | — |
| Binance | — | Yes |
| Manual CSV | Yes | Yes |

For broker-specific quirks see [`docs/BROKERS.md`](docs/BROKERS.md).

## Quick Start

Expand Down Expand Up @@ -54,6 +57,7 @@ pit38 import revolut-stock -i revolut_export.csv -o transactions.csv
pit38 import revolut-crypto -i revolut_export.csv -o transactions.csv
pit38 import etrade -i etrade_export.csv -o transactions.csv
pit38 import binance -i binance_export.csv -o transactions.csv
pit38 import ibi-capital -i ~/ibi_orders/ -o transactions.csv # PDF input (single file or directory)
```

You can combine multiple files from different brokers:
Expand Down
16 changes: 10 additions & 6 deletions README.pl.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ Narzędzie wiersza poleceń do obliczania polskiego podatku dochodowego od **akc

## Obsługiwani brokerzy

| Broker | Akcje | Krypto |
|----------|--------|--------|
| Revolut | Tak | Tak |
| E*Trade | Tak | — |
| Binance | — | Tak |
| Ręczny CSV | Tak | Tak |
| Broker | Akcje | Krypto |
|--------------|-------------------------------|--------|
| Revolut | Tak | Tak |
| E*Trade | Tak | — |
| IBI Capital | Tak (tylko sprzedaże, input PDF) | — |
| Binance | — | Tak |
| Ręczny CSV | Tak | Tak |

Specyfika poszczególnych brokerów — zob. [`docs/BROKERS.md`](docs/BROKERS.md).

## Szybki start

Expand Down Expand Up @@ -54,6 +57,7 @@ pit38 import revolut-stock -i eksport_revolut.csv -o transakcje.csv
pit38 import revolut-crypto -i eksport_revolut.csv -o transakcje.csv
pit38 import etrade -i eksport_etrade.csv -o transakcje.csv
pit38 import binance -i eksport_binance.csv -o transakcje.csv
pit38 import ibi-capital -i ~/ibi_orders/ -o transakcje.csv # input: PDF (plik lub katalog)
```

Możesz łączyć pliki z różnych brokerów:
Expand Down
93 changes: 93 additions & 0 deletions docs/BROKERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Broker-specific guides

Per-broker notes that don't belong in the README but matter when you're
importing a specific broker's export for the first time. One section per
broker; see the top-level `README.md` for the supported-brokers table.

## IBI Capital

IBI Capital is an Israeli trustee broker that administers equity
compensation (RSU + ESPP) plans for employees of companies listed on
NASDAQ/NYSE. The `pit38 import ibi-capital` command parses IBI's **Sale
Of Stock Activity Statement** PDFs — one PDF per executed sale order —
and emits standardized transactions plus service fees.

### Scope — what this plugin does and doesn't

**Does**:
- Parses every `*.pdf` Sale Of Stock Activity Statement under the paths
you pass via `-i` (files or directories, repeatable).
- Emits one synthetic `BUY` dated at the grant day, one `SELL` dated at
the execution day, and one `SERVICE_FEE` at the execution day when
total fees are non-zero.
- Looks up the company's ticker in a packaged JSON seed
(`pit38/plugins/stock/ibi_capital/companies.json`); `--ticker` overrides.

**Doesn't**:
- Read vesting confirmations, ESPP purchase reports, dividend statements,
or any non-sale IBI document.
- Automatically handle currencies other than USD (all sample PDFs were
USD — if you have a non-USD order, open an issue).

### Usage

Download your order confirmations from the IBI Capital portal into a
folder, then:

```bash
pit38 import ibi-capital -i ~/Downloads/ibi_orders/ -o ibi.csv
pit38 stock -f ibi.csv -y 2025
```

You can mix IBI output with exports from other brokers:

```bash
pit38 stock -f ibi.csv -f revolut.csv -y 2025
```

Use `--ticker MYSYM` to override the company→ticker mapping (useful when
your company isn't in the shipped `companies.json` yet).

### Cost basis — RSU vs ESPP

IBI's order PDFs expose a `Price For Tax` field that the plugin uses as
the per-share cost basis for the synthetic `BUY` transaction:

- **RSU** (typical grant): `Price For Tax: USD 0.00`. The plugin emits
`BUY` with `fiat_value = 0`. This follows the conservative/KIS line of
Polish tax interpretation: for shares received via RSU the employee
did not bear an acquisition cost, and the value at vest was already
taxed as income from employment. The capital gain on sale is therefore
`proceeds − 0 − fees`.
- **ESPP** (employee stock purchase plan): `Price For Tax: USD N.NN`
reflects the discounted purchase price you actually paid. The plugin
uses that as the cost basis — `BUY` `fiat_value = shares × Price For Tax`.

This is an interpretation of tax law, not a universal rule. If your own
advisor takes a different position (e.g. allowing the FMV-at-vest as RSU
cost basis), edit the resulting CSV directly — each `BUY` row's
`fiat_value` column is straightforward to adjust.

### Adding a new company → ticker mapping

If your company name isn't in the seed, you have two options:

1. **Quick fix** — pass `--ticker MYSYM` on the CLI; the plugin uses it
for every PDF in that run.
2. **Permanent fix** — open a PR adding a line to
[`pit38/plugins/stock/ibi_capital/companies.json`](../pit38/plugins/stock/ibi_capital/companies.json).
Keys are the `Company:` value **exactly as it appears in your IBI
PDFs** (lowercase — the loader does case-insensitive lookup, but
keeping the file lowercase keeps diffs clean). Values are current
NASDAQ/NYSE tickers in uppercase.

### Known limitations

- The plugin assumes USD throughout. Non-USD orders would need a
`Currency` enum extension (ILS/EUR support isn't there yet).
- `Wire Fee` from the `Funds Proceed` section isn't surfaced separately.
In the sample PDFs it's always `0.00`; if you hit a non-zero wire fee
that matters for your tax calc, open an issue.
- Anonymized PDF fixtures aren't shipped in the repo (real IBI PDFs
contain PII). Parser unit tests use anonymized text snapshots under
`tests/e2e/fixtures/ibi_order_fake_*.txt`.
44 changes: 44 additions & 0 deletions pit38/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,50 @@ def import_etrade(input_path, output_path, log_level):
click.echo(f"Saved {len(transactions)} transactions to {output_path}")


@import_cmd.command("ibi-capital")
@click.option("-i", "--input", "input_paths", type=click.Path(exists=True),
multiple=True, required=True,
help="IBI order confirmation PDF or directory of PDFs (repeatable)")
@click.option("-o", "--output", "output_path", type=click.Path(), required=True, help="Output standardized CSV")
@click.option("--ticker", default=None,
help="Override ticker (skips companies.json lookup — useful for companies not yet in the seed)")
@click.option("-ll", "--log-level", default="INFO", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]))
def import_ibi_capital(input_paths, output_path, ticker, log_level):
"""Import stock sale confirmations from IBI Capital (Israeli broker, PDF input)."""
setup_logger(log_level)

from pathlib import Path
from pit38.plugins.stock.generic_saver import GenericCsvSaver
from pit38.plugins.stock.ibi_capital.company_ticker import resolve_ticker
from pit38.plugins.stock.ibi_capital.order_parser import parse_order_report
from pit38.plugins.stock.ibi_capital.pdf_reader import extract_text
from pit38.plugins.stock.ibi_capital.record_builder import build_records

pdfs: list[Path] = []
for raw in input_paths:
p = Path(raw)
pdfs.extend(sorted(p.rglob("*.pdf")) if p.is_dir() else [p])

if not pdfs:
click.echo("No PDF files found under the provided --input paths.", err=True)
raise click.Abort()

transactions = []
fees = []
for pdf_path in pdfs:
parsed = parse_order_report(extract_text(pdf_path))
resolved = resolve_ticker(parsed.company, override=ticker)
t, f = build_records(parsed, resolved)
transactions.extend(t)
fees.extend(f)

GenericCsvSaver.save(transactions, fees, output_path)
click.echo(
f"Saved {len(transactions)} transactions and {len(fees)} service fees "
f"from {len(pdfs)} IBI Capital PDF(s) to {output_path}"
)


@import_cmd.command("binance")
@click.option("-i", "--input", "input_path", type=click.Path(exists=True), required=True, help="Binance export CSV")
@click.option("-o", "--output", "output_path", type=click.Path(), required=True, help="Output standardized CSV")
Expand Down
Empty file.
92 changes: 92 additions & 0 deletions pit38/plugins/stock/ibi_capital/companies.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
{
"allot": "ALLT",
"arbe robotics": "ARBE",
"arcturus therapeutics": "ARCT",
"audiocodes": "AUDC",
"b.o.s. better online solutions": "BOSC",
"biolinerx": "BLRX",
"biondvax pharmaceuticals": "BVXV",
"brainstorm cell therapeutics": "BCLI",
"brainsway": "BWAY",
"caesarstone": "CSTE",
"camtek": "CAMT",
"cellebrite": "CLBT",
"ceragon networks": "CRNT",
"ceva": "CEVA",
"check point software technologies": "CHKP",
"collplant biotechnologies": "CLGN",
"compugen": "CGEN",
"cyberark": "CYBR",
"dariohealth": "DRIO",
"elbit systems": "ESLT",
"eltek": "ELTK",
"enlight renewable energy": "ENLT",
"entera bio": "ENTX",
"evogene": "EVGN",
"fiverr": "FVRR",
"foresight autonomous holdings": "FRSX",
"formula systems": "FORTY",
"galmed pharmaceuticals": "GLMD",
"gilat satellite networks": "GILT",
"global-e online": "GLBE",
"hippo holdings": "HIPO",
"icl group": "ICL",
"inmode": "INMD",
"innoviz technologies": "INVZ",
"ituran location and control": "ITRN",
"jfrog": "FROG",
"kaltura": "KLTR",
"kamada": "KMDA",
"kornit digital": "KRNT",
"lemonade": "LMND",
"magic software enterprises": "MGIC",
"mediwound": "MDWD",
"mind cti": "MNDO",
"mobileye": "MBLY",
"monday.com": "MNDY",
"nano dimension": "NNDM",
"nano-x imaging": "NNOX",
"nayax": "NYAX",
"nice": "NICE",
"nova": "NVMI",
"oddity tech": "ODD",
"optibase": "OBAS",
"oramed pharmaceuticals": "ORMP",
"ormat technologies": "ORA",
"otonomo technologies": "OTMO",
"outbrain": "OB",
"pagaya technologies": "PGY",
"partner communications": "PTNR",
"payoneer": "PAYO",
"perion network": "PERI",
"playtika": "PLTK",
"polypid": "PYPD",
"radcom": "RDCM",
"radware": "RDWR",
"rani therapeutics": "RANI",
"redhill biopharma": "RDHL",
"ree automotive": "REE",
"rewalk robotics": "LFWD",
"riskified": "RSKD",
"sapiens international": "SPNS",
"satixfy communications": "SATX",
"scisparc": "SPRC",
"silicom": "SILC",
"sol-gel technologies": "SLGL",
"solaredge technologies": "SEDG",
"stratasys": "SSYS",
"supercom": "SPCB",
"taboola": "TBLA",
"taro pharmaceutical industries": "TARO",
"tat technologies": "TATT",
"teva pharmaceutical industries": "TEVA",
"tigo energy": "TYGO",
"tower semiconductor": "TSEM",
"urogen pharma": "URGN",
"valens semiconductor": "VLN",
"varonis systems": "VRNS",
"verint systems": "VRNT",
"wix.com": "WIX",
"xtl biopharmaceuticals": "XTLB",
"zim integrated shipping services": "ZIM"
}
68 changes: 68 additions & 0 deletions pit38/plugins/stock/ibi_capital/company_ticker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Resolve the ``Company`` field of an IBI order PDF to a stock ticker.

IBI order reports don't include the NASDAQ/NYSE ticker — only a company
name such as ``monday.com``. The mapping to tickers lives in
``companies.json`` shipped alongside this module; seeding and extension
happen via PR. The ``--ticker`` CLI flag overrides the mapping so a user
whose company isn't in the JSON can still import without waiting for a
PR merge.

The JSON is loaded once per process and cached at module level. Lookup
is case-insensitive and trims surrounding whitespace, because we can't
guarantee IBI's formatting stays 100% stable across account types.
"""
from __future__ import annotations

import json
from functools import lru_cache
from importlib.resources import files
from typing import Mapping


class UnknownCompanyError(KeyError):
"""Raised when ``Company`` from a PDF has no ticker mapping and no override."""


_COMPANIES_JSON = "companies.json"
_PR_LINK = (
"https://github.com/pbialon/pit-38/blob/main/"
"pit38/plugins/stock/ibi_capital/companies.json"
)


@lru_cache(maxsize=1)
def _load_mapping() -> Mapping[str, str]:
raw = files(__package__).joinpath(_COMPANIES_JSON).read_text(encoding="utf-8")
data = json.loads(raw)
# Normalize keys to lowercase once up front so lookup stays O(1) and
# doesn't allocate on each call.
return {k.strip().lower(): v for k, v in data.items()}


def resolve_ticker(
company: str,
override: str | None = None,
*,
mapping: Mapping[str, str] | None = None,
) -> str:
"""Return the stock ticker for ``company``.

``override`` wins unconditionally — useful when the packaged JSON
doesn't yet include the user's company, or when the PDF has an
unusual company-name spelling.

``mapping`` is test-only dependency injection; production callers
pass nothing and get the packaged ``companies.json``.
"""
if override:
return override

table = mapping if mapping is not None else _load_mapping()
key = company.strip().lower()
if key not in table:
raise UnknownCompanyError(
f"Company '{company}' has no ticker mapping. "
f"Either pass --ticker <SYMBOL> on the CLI, or open a PR "
f"adding '{company}' to {_PR_LINK}"
)
return table[key]
Loading
Loading