b6eb8dd3

Add a Wise importer

## What this is

A new `centjes-import-wise` package, modelled on the Neon and Revolut importers but with two things they do not need:

- **Conversions across statements.** Wise balances are per currency and a conversion is written into the statement of *both* balances. The importer reads several statements at once, matches the two legs by Wise id, and writes one transaction with a currency on each side, which is what `banks/wise.cent` already looks like by hand:

  ```
  2025-01-15
    | Converted 9559.05 EUR to 8983.40 CHF
    * assets:wise           -9559.05 EUR @ 898340 / 955905 CHF
    * assets:wise           +8983.40 CHF
    * assets:wise             -41.10 EUR
    * expenses:banking:wise   +41.10 EUR
    + assert assets:wise = +0.00 EUR
    + assert assets:wise = +8983.40 CHF
  ```

  The rate written is the exact ratio of the two amounts, not the `Exchange Rate` column: that one is rounded to five decimals, and 9559.05 EUR at 0.93977 is three centimes short of what actually arrived, so a transaction written with it would not balance.

- **Two commands.** `fetch` downloads a statement CSV per balance; `import` reads CSVs from disk, whether downloaded or exported by hand. The network and signing code is entirely out of the import path, so what decides what the ledger says is a pure function over data a test can hold.

```
centjes-import-wise fetch --statement-directory statements/
centjes-import-wise import statements/*.csv -o wise-import.cent
```

Default output is `wise-import.cent`, not `wise.cent`, so a stray run in `banks/` cannot eat the hand-maintained file.

## Things worth knowing before you merge

**The CSV header is from Wise's documented column set, not from one of your exports.** I could not find a Wise CSV anywhere on disk, and the `fetch` path needs credentials I do not have. The parser is strict about the columns that say what a transaction *is* (id, date, amount, currency, description, running balance) and tolerant about the rest: `Payment Reference`, `Merchant`, `Exchange From`/`To` and `Total fees` may be absent from the header entirely. **Please run it against a real export before trusting it** — if the header has drifted, it fails loudly rather than booking anything wrong.

**Wise may not serve statements over the API for a Swiss profile.** Their docs say both "this endpoint is SCA protected, SCA applies outside US/AU/NZ/SG/CA/MY" and, elsewhere, "retrieving balance statements via API is not supported except for" those same countries. I could not resolve which is true without an account to try. The SCA flow is implemented (403 → sign the `x-2fa-approval` one-time token with a registered RSA key → retry with `X-Signature`), and a 403 or 404 tells you to export by hand instead. If fetching turns out not to work for CH, the manual path is unaffected and the fetch command can be dropped.

**The amount convention is an assumption with a check behind it.** I read `Amount` as the whole balance movement with `Total fees` included in it, so what was transacted is `Amount + Total fees`. If that is backwards, the postings will not add up to the running balance Wise states and `centjes check` will say so on the first run — which is why every row gets its balance asserted.

## Two bugs my own tests caught, worth a look

- **Ordering.** Sorting a day's events by Wise id put a conversion *before* the payment that funded it, which made every assertion after it wrong. A statement lists its own balance in the order things happened, so events are now topologically sorted to respect every statement at once, with day and id only deciding between events no statement orders. `Pair.orderEvents` is the part to read.
- **Fees.** The movement case emitted both a gross assets posting and a negative fee posting, counting the fee twice. A movement now gets one assets posting and only the expense side of the fee; a conversion gets both sides, because there the assets side is split into the converted part and the fee part.

## What I would like you to check

- `Declarations.movementPostings` and `conversionPostings`: the sign conventions, and whether splitting the conversion's assets side into two postings (converted + fee) reads right to you, since your hand-written file writes a transfer's fee as a single assets posting.
- `test_resources/declarations/statements.cent`: the golden output, against how you would write those five transactions yourself.
- Whether `expenses:unknown:wise` is the right default for an outgoing transfer. Wise cannot tell a transfer from an expense, so a transfer to Neon lands in expenses and you re-point it; `assets:self-transfer:wise-neon` is what you actually write.
- The error messages in `Pair.renderPairError` — those are what you will read when a statement is missing.

## Things to try locally

```sh
# The pure path, end to end, with no Wise account needed:
cabal run centjes-import-wise -- --ledger <your ledger> import \
  centjes-import-wise/test_resources/statements/{EUR,CHF}.csv -o /tmp/wise.cent

# Then point centjes at it.  I did this and it says Valid, with an opening
# balance of 1356.00 EUR to start the EUR statement from.
```

Also worth trying: pass only `EUR.csv` and see the unpaired-conversion error; pass `EUR.csv` twice and see the duplicate-row error.

## Testing

50 tests in a new `centjes-import-wise-test` suite: statement parsing, conversion pairing and ordering, the declarations (golden `.cent`, postings summing to zero, asserted balances matching the running totals, a re-parse round trip), the statement URL and file names, and `settingsLintSpec` plus the documentation golden. `nix flake check` passes.

Total

27m43s

Created Started Finished

Time to Start Worker time Duration Time to finish Idle
Config 0s 0s 0s 0s 0s
Eval 0s 11s 11s 12s 0s
Build 12s 0s 27m31s 27m44s 27m31s
Suite 0s 12s 27m43s 27m44s 27m31s

Timeline

Config Eval Build Compressed gap
0s10s