Metadata-Version: 2.4
Name: statement2csv
Version: 0.1.0
Summary: bank statement PDF -> CSV
Author-email: Seyoon Park <fenwickyduck@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/fenwickyduck/statement2csv
Keywords: pdf,csv,bank,statement
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pdfplumber<0.12,>=0.11.10
Dynamic: license-file

# statement2csv

CLI tool to convert bank statement PDF to CSV. 

The statement layout is described by TOML config files (see ```hsbc.toml```, ```barclays.toml``` for reference). 
So, adding a new bank means writing a config rather than changing any code. 
The extracted table is checked against the totals printed on page 1 for sanity check. 

## Requirements

Python 3.12 or newer.

## Install

```bash
git clone https://github.com/fenwickyduck/statement2csv
cd statement2csv
pipx install .
mkdir -p ~/.config/statement2csv && cp *.toml ~/.config/statement2csv/
```

Only `statement2csv.py` is packaged into the wheel - 
the default bank configs (hsbc, barclays), if one wishes to use them, have to be copied out of the repo by hand. 

## Usage 

```
usage: statement2csv [-h] [-o OUTPUT_PATH] [--config-dir CONFIG_DIR] [--bank BANK] [--no-verify] pdf_path

extract the transaction table out of a bank statement PDF

positional arguments:
  pdf_path              the statement to parse

options:
  -h, --help            show this help message and exit
  -o OUTPUT_PATH, --output_path OUTPUT_PATH
                        where to write the CSV (default: print to stdout)
  --config-dir CONFIG_DIR
                        directory to look for bank *.toml configs in first (default: the current directory, next to this script, then
                        ~/.config/statement2csv)
  --bank BANK           use the config with this bank name instead of detecting the bank
  --no-verify           write the CSV without checking it against the totals on page 1
```

Relative paths resolve against the directory you run the command from, not
wherever the script is installed. Log messages go to stderr, so piping is safe:

```bash
statement2csv statement.pdf | csvlook
```

### Exit codes

| code | meaning |
| --- | --- |
| 0 | success |
| 1 | could not extract the table (no config matched, missing file, anchor not found) |
| 2 | table extracted, but it does not reconcile with the page 1 totals |

Exit 2 still writes the CSV; the output is there to inspect, it just does not
add up. 

## Configs

Each config is a `*.toml` file. 
Every directory below is searched in order, and
within each one files are read in alphabetical order; the first config whose
`detect` string appears on page 1 wins.

1. `--config-dir`, if given
2. the current directory
3. the directory holding `statement2csv.py`
4. `~/.config/statement2csv/` (or `$XDG_CONFIG_HOME/statement2csv/`)

Use `--bank NAME` to match on `bank.name` instead, which is useful when
auto-detection picks the wrong config or the statement has no detectable marker.

## Writing a config for a new bank

The extractor works by locating text on the page and cropping to it, so a config
is mostly a set of phrases to search for. Copy `barclays.toml` as a starting
point.

```toml
[bank]
name = "barclays"      # what --bank matches on
detect = "BUKBGB22"    # a string that appears on page 1 of this bank's statements
currency = "£"         # stripped before parsing amounts

[region]
page_has_table = "Description"        # if absent from a page, that page is skipped
top_anchor = "Description"            # the table starts at this phrase
bottom_anchor = ["Continued", "Balance carried forward"]   # any one ends the table
left_anchor = "Date"                  # leftmost column header
right_anchor = "Balance £"            # rightmost column header

[[columns]]                           # one block per column, left to right
label = "Date"                        # the column header text, matched on the page
[[columns]]
label = "Description"
[[columns]]
label = "Money out £"
role = "payments"                     # subtracted from the running balance
[[columns]]
label = "Money in £"
role = "receipts"                     # added to the running balance
[[columns]]
label = "Balance £"
role = "balance"                      # compared against the running balance

[verification]                        # phrases on page 1; the amount that
start_balance = "Start balance"       # follows each one is read as the total
money_out = "Money out"
money_in = "Money in"
end_balance = "End balance"
```

Anchors are matched as whole words in reading order, so `"Balance £"` matches the
two words `Balance` and `£` sitting next to each other on the same line.

Configs are validated before any pages are parsed, so a mistake fails
immediately rather than after a long extraction. The `payments`, `receipts` and
`balance` roles are all required. The `[verification]` section is required
unless you pass `--no-verify`.

## Verification

After the table is parsed, the running balance is recomputed from the transaction rows and
compared against the four totals printed on page 1. 

Each row's stated balance is
also checked as it goes, so a mismatch names the row that broke:

```
statement2csv: running balance (1039.89)!= stated balance (1096.00) in [...]
```

Amounts are parsed as `Decimal`, not floating point, so the comparisons are
exact. 

Pass `--no-verify` to skip the whole check.

## Output format

The CSV mirrors the printed table rather than reinterpreting it.

Three caveats:

- A transaction whose description wraps produces one row per printed line.
  The date and amounts appear on the first row only; continuation rows carry the
  rest of the description with every other field empty. Group them yourself if
  you want one record per transaction.
- Each page contributes its own header row, so on a multi-page statement the
  column headers recur partway through the file rather than appearing only once.
- Files written with ```-o``` are UTF-8 with a BOM, so Excel opens the ```£``` signs correctly. Output sent to stdout has no BOM.
