CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# Working rules — peanutsplit
This repo does **not** follow mono's always-PR workflow. Split is growth-owned, deliberately small, and meant to be shippable (and killable) in an afternoon. `mono/CONTRIBUTING.md` governs `peanut-ui` and `peanut-api-ts`; the rules below govern this repo and win where they differ.
Product rationale, status, milestones and the decision log live in the Notion project (linked from `mono/projects/peanut-split/`). The engineering backlog — what's built, building, queued, gated on infra, and deliberately not built — lives in [`ROADMAP.md`](ROADMAP.md). This file is the working rules only — don't restate either here.
## Readability for all work
- Apply this standard to user-facing copy, docs, comments, identifiers, PR and commit explanations, and assistant responses.
- Lead with the concrete point or action. Use familiar words and complete sentences in prose; keep labels and identifiers concise.
- Cut padding, inflated claims, forced personas, rhetorical questions followed by answers, and invented contrasts.
- Keep the facts, technical precision, necessary detail and natural phrasing in each language.
- Name code for what it represents or does, using existing domain terms consistently.
- Use comments to explain a reason or constraint. Omit comments that merely narrate the code.
- Choose code that is easy to follow. Add abstractions only when they reduce actual complexity.
- Before delivery, do a final editorial reread from the human reader's perspective; pattern checks alone are insufficient. Preserve factual and functional meaning when editing.
## What's in here
```
apps/api Fastify + Prisma + its own Postgres. All money logic: splits,
balances, FX, settlements, and the settle-with-Peanut loop.
apps/web The live product — Next, PWA, per-room link previews. THIS is what
peanutsplit.com serves.
```
`main` is canonical: two apps, one of them live, nothing dead. The original
standalone UI (the settle-up screens wired to `apps/api`'s settle loop) lives on
the **`poc/original-split-ui`** branch — pull the screens from there when porting
them into `apps/web`, don't develop on it.
One seam still open: `apps/web` is outside the pnpm workspace (it brought its own
lockfile) and talks to its own database rather than `apps/api`. Collapsing those
two is the remaining merge.
## Shipping
- **Push straight to `main`.** No PR, no review gate, no waiting. `main` is unprotected on purpose.
- **A push to `main` deploys to production within ~5 minutes.** There is no CI gate in front of it. Run the checks below yourself.
- Open a PR only when you actually want a second opinion, not as ceremony.
- **The steward (the box-resident Claude supervisor) never pushes to `main`.** Konrad and Hugo still may. Every steward change goes: branch → PR → chip's review → the steward's own QA → the steward merges it. No human approval is needed.
- **chip reviews every non-draft PR automatically** (advisory: a comment review, never request-changes). It reads this file and checks against mono `split-content/product/truths.md`. It takes about 5–10 minutes; comment `/chip review` on the PR to ask again.
- **Before merging, the steward reads chip's review** (`gh api repos/peanutprotocol/peanutsplit/pulls/<n>/reviews` and `/pulls/<n>/comments`). For each finding, fix it or reply on the thread saying why not. Never merge with an unanswered blocking or major finding.
- `.github/workflows/chip-pr-review.yml` is the review bridge. It must stay byte-identical to mono `ops/schedulers/pr-review/chip-pr-review.yml`; change it in mono, never here.
- Commit messages explain _why_. No AI co-author lines.
## What still holds
- **Money code needs a test before it ships.** Balances, splits, FX, settlements: if it can produce a wrong number, it has a test. The pure math in `apps/api/src/split/math.ts` is where that logic belongs — testable without a database.
- **The money surface is frozen.** Split settles through an existing Peanut payment-request link. No new money-path endpoints. Split is accountless: recent rooms and member tokens stay on the device, and the room link stays the credential. No email login, passwords, OAuth, profiles, or room ownership. Push notifications are opt-in per device per room. Anything past that is a product decision, not an implementation gap.
- **Do not scaffold deferred ideas.** Future-facing fields, states, routes, or abstractions require an approved ROADMAP item that explicitly authorizes implementation; listing an idea as deferred does not.
- **No identity in analytics either** — no room slug, no member name, no amount. The slug is the room's access control; a name is what someone chose to show their friends. Rooms are grouped by `analyticsKey`, a one-way digest of the room UUID issued by the server — it cannot be turned back into a room, and the slug still never leaves the device. Attach it with `roomProps(slug, …)`, which looks the key up; never put a slug in a property bag.
- Dependencies must be ≥14 days old (`.npmrc` enforces it).
- Run `pnpm typecheck && pnpm test && pnpm format` before pushing. Run `pnpm format` from `apps/web`, never the repo root — the root config can't resolve `prettier-plugin-tailwindcss` and reformats CI yaml as a side effect.
## Local dev
```bash
pnpm bootstrap # NOT plain `pnpm install` — see below
pnpm dev # API :5051 + web :3000 (or dev:api / dev:web for one)
```
`apps/web` is not a workspace member, which has one sharp edge: running
`pnpm install` inside `apps/web` walks _up_ and installs the workspace instead,
leaving `apps/web/node_modules` missing. It needs `--ignore-workspace`, which is
what `pnpm bootstrap` does. (Docker doesn't hit this — the web image's build context
is `apps/web` alone, so there's no parent workspace file to find.) The root
scripts reach it with `--dir apps/web` rather than a filter. If you add an app, wire it into the root
`typecheck`/`test` the same way — a gate that silently covers nothing is worse
than no gate.
**The Playwright webServer ignores `DATABASE_URL`.** It pins its own database — `E2E_DATABASE_URL ?? peanut_split_dev` (see `playwright.config.ts`) — so prefixing `playwright test` with `DATABASE_URL=…` silently does nothing, and an unmigrated `peanut_split_dev` makes every room-creating spec fail with server 500s that read like app bugs. Pass `E2E_DATABASE_URL`, and keep `peanut_split_dev` migrated.
**Prefix every Prisma command with `env -u DATABASE_URL`.** The mono QA harness exports `DATABASE_URL` pointing at the shared `peanut_dev`, and Prisma prefers the process env over `apps/api/.env` — a migration will silently aim at the wrong database. It refuses (P3005) rather than corrupting anything, but the failure is confusing.
## Deploy
Runs on a Hetzner box via Dokploy; see the Deployment section of [`README.md`](README.md) for the topology. Two rules that bite if you don't know them:
- **The containers have no egress.** Nothing can be fetched at runtime. If code needs to reach an external host, it needs a proxy pinned to that host — not an opened network. This is why the Prisma engine is baked into the image rather than downloaded on boot.
- **`NEXT_PUBLIC_*` values are build args**, because Next inlines them into the client bundle at build time. Setting them at runtime silently does nothing; each one needs an `ARG`/`ENV` pair in `apps/web/Dockerfile` and a matching Dokploy build arg.
You don't need server access to ship — push is enough. Ask Hugo for anything infra-side (env vars, domains, database, rollback, logs).
## Before touching the money math
Read [`docs-split-rooms-spike.md`](docs-split-rooms-spike.md) — the original design doc and the 2026-07-06 review — and the "Design choices" section of [`changelog-july-25.md`](changelog-july-25.md), which explains why the settle path is shaped the way it is. Known-open issues:
- ~~`formatMoney` assumes 2 decimals until `/split/currencies` loads~~ — fixed: decimals are a build-time fact via the generated `apps/web/src/lib/currency-catalog.ts` (162 codes, CLDR decimals), `/api/currencies` serves the same table, and the only remaining 2-decimal default is the deliberate custom-ticker placeholder (verified 2026-09-02: no live surface can render a 0- or 3-decimal amount at the wrong scale, even before the catalog query resolves).
- `convertToBaseMinor` (apps/api) still round-trips through `Number` past 2^53 — but apps/api is off-path: no public route, zero traffic (it survives only until the apps/web collapse). `apps/web`'s money path is string/BigInt end-to-end; its one Number crossing (`minorToExactNumber`, NumberFlow display only) refuses anything that fails an exact round-trip.
- ~~FX re-priced on edit~~ and ~~no rate limiting~~ — both fixed 2026-07-28 (`src/server/expenses.ts`, `src/server/rateLimit.ts`).
- **The settle loop cannot complete against real Peanut, and V1 ships without it.** `apps/api/src/peanut/index.ts` assumes a signed `charge:confirmed` webhook. In `peanut-api-ts`, `CHARGE_CONFIRMED` is an enum member nothing emits, charge delivery carries no signature, and charge creation rejects any currency but USD/ARS — so the verified path could not serve a EUR or THB room even if the event existed. **Decided 2026-07-27 (Konrad): the soft launch ships the settle flow as it stands** — `apps/web`'s SettleDrawer opens peanut.me and the room records the payment on the payer's tap, unverified. Nobody is polling anything. If you are here to "finish the receipts", that is a reopened product decision, not a TODO. Polling the public `GET /charges/:chargeId` remains the cheapest route if it is reopened.