# Stripe activity → Wave CSV

## Summary

Paste Stripe's **All Activity** table; get back the ledger Wave imports —
`Date, Description, Amount` — with every fee split onto its own line.
Owner-only (`can_transfer`), at `/stripe-activity`.

`app/services/stripe_activity.py` (parse + split + CSV + gate) ·
`app/routers/stripe_activity.py` (one POST) ·
`frontend/src/features/stripe/StripeActivityPage.tsx`.

## Why

Stripe reports a transaction as `(amount, fees, total)` on one row. Wave wants
the gross and the fee as separate ledger lines, so the split has to happen
somewhere — and doing it by hand for every month is exactly the kind of
transcription work that produces silent bookkeeping errors.

## Behavior

- **The input is one field per LINE, not tab-separated.** A browser copy of
  Stripe's activity table emits the nine columns vertically, so the parser
  reads a flat line stream in fixed groups of nine. Blank lines are dropped;
  the header block is skipped when present, so the same paste works whether or
  not the column titles were grabbed.
- **One Stripe row → one or two ledger lines**, by `Type`:

  | Type | Amount line | Fee line |
  |---|---|---|
  | `Charge` | `Stripe Charge - {description}` | `Credit Card Fee` |
  | `Stripe fee` | `Stripe Fee` | `Stripe Fee` |
  | `Payout` | `Stripe Payout` | (payouts carry no fee) |
  | anything else | the description verbatim, **and reported** | `Stripe Fee` |

- **The description rides through verbatim** — Stripe's own casing and stray
  `#` included (`Stripe Charge - Quarterly Invoice # 2026073`), so a ledger
  line matches what is on screen in Stripe.
- **A fee line is dropped when there is no fee** — Stripe writes an em dash —
  **or when it is zero**. `total` is never emitted: it is the sum of the two
  lines already written, so emitting it would double-count.
- **Signs ride through untouched.** Stripe already writes outflows negative,
  which is what Wave expects; re-deriving them would be a second source of
  truth for a number Stripe got right.
- **The year is derived.** Stripe's table shows `Sep 4`, never the year. The
  list is newest-first, so walking down it the month only ever *rises* when
  the list crossed New Year — that jump is the signal to step back a year.
  The anchor is the current year, dropped by one when the newest row would
  otherwise land more than 31 days in the future (a list pasted in January
  whose top rows are December). Dates come from **Created**, not Available on.

## Constraints & edge cases

- **Newest-first is assumed**, because that is how Stripe sorts the page and
  the year recovery depends on it. A re-sorted paste would produce wrong
  years, so the resolved **date range is shown above the table** — a bad
  inference is visible before the CSV is downloaded, not after it is in Wave.
- **A truncated copy is reported, never silently dropped**: leftover lines
  that do not form a whole nine-field record are counted and surfaced.
- **An unreadable date keeps its row** with an empty Date cell, so it can be
  seen and fixed rather than going missing from the CSV.
- **An unknown `Type`** (a Refund, an Adjustment) keeps Stripe's own
  description and is named in a warning. Guessing a label for money is worse
  than asking.
- Currency symbols are stripped rather than validated — the column is
  single-currency per Stripe account, and rejecting an unexpected prefix would
  fail a whole paste over cosmetics.

## Permissions

Owner-only. The gate is `can_transfer`, checked in `build_export` — in the
SERVICE, so a future second caller cannot reach the transform ungated. The nav
item carries the same capability, so it is hidden from non-owners; a deep-link
renders the owner-only notice and the API refuses independently.

## Not persisted (ADR #030)

There are no batches, no tables, no migration. The transform is a pure
function of the pasted text, so re-pasting reproduces the same CSV byte for
byte. See the ADR for why this differs from the CC expenses tool it is
modelled on.

## Tests

`tests/stripe/test_stripe_activity.py` (43) — money and date parsing, the
optional header, blank lines, the per-type fee split, zero/absent fees,
verbatim descriptions, unknown types, the New Year rollback both ways, the
future-date grace, an impossible day, CSV quoting, and a **reconciliation**
test: `amount + fees == total` on every row of a real 19-transaction paste,
with the emitted lines summing to Stripe's own grand total.
`tests/stripe/test_stripe_router.py` (4) — auth gate, owner gate, roundtrip,
empty paste. The fixture `tests/stripe/fixtures/all_activity_real.txt` is a
real clipboard copy, so the parser is pinned against what it must actually
read.

## Changelog

- **0.26.0 (2026-09-01)** — shipped.
