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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,11 @@ Two consequences worth knowing before changing anything:
- **Egress is default-deny, with one pinhole.** The app network has no route
out; every runtime fetch goes through the `split-egress` squid proxy, one
`*_PROXY_URL` env var per consumer (`SPLIT_PUSH_PROXY_URL`,
`SPLIT_SCAN_PROXY_URL`), against a CONNECT-443 allowlist for browser push
gateways and configured model providers. A scan or push that 502s with the
proxy env unset is this, not the provider.
`SPLIT_SCAN_PROXY_URL`, `SPLIT_FX_PROXY_URL`), against a CONNECT-443
allowlist for browser push gateways, configured model providers, and
`api.peanut.me`. A scan or push that 502s with the proxy env unset is this,
not the provider; an FX refresh in that state fails quietly and leaves the
table on the twelve static rates.
- **Every `NEXT_PUBLIC_*` value is a build arg**, because Next inlines them into
the client bundle at build time. Setting one only at runtime silently does
nothing — the bundle already has the old value baked in. They are passed as
Expand Down
33 changes: 29 additions & 4 deletions apps/web/docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,40 @@ model AuthAccount { id String @id @default(uuid()); userId String; provider Stri
reconstruction (passes the original author's e2e 10/10). Port its semantics exactly:

- Minor-unit **strings** on the wire, **BigInt** internally and in DB. A float never touches money.
- Currency decimals respected (JPY/COP = 0). Currency catalog: copy the mock's 12 currencies.
- FX conversion: integer maths at `RATE_SCALE = 1e9`, round half-up (mock's `convertMinor`).
- Currency decimals respected (JPY/COP = 0). The generated catalog recognises 162 currency codes;
156 support automatic conversion in a connected deployment. The mock's 12 core currencies are
the static outage/dev fallback, not the production catalog.
- FX conversion: integer maths at `RATE_SCALE = 1e18`, round half-up. The wider scale is required
for the smallest crosses in the 162-code catalog; the persisted expense rate remains
`Decimal(24,12)` and unsafe zero/overflow crosses are unavailable.
- EQUAL split: base + remainder spread one unit at a time; shares sum to total **exactly**.
- EXACT split: store `enteredAmountMinor` verbatim in expense currency; residue after FX goes on
the largest share; re-opening and re-saving a foreign-currency expense must not drift balances.
- Balances: sum over non-deleted expenses/settlements; suggested transfers via greedy
minimal-transfer (≤ n−1 transfers).
- Live FX: fetch from `https://open.er-api.com/v6/latest/USD` server-side, cache 24h in FxRate,
fall back to the mock's static table when fetch fails. Rates are indicative — label them so.
- Live FX: directly fetch Peanut's public
`https://api.peanut.me/fx/rates?base=<room currency>` display-sell snapshot server-side. Each row
is the backend-selected quote-to-room pair, preserving Peanut UI's all-provider-or-all-reference
choice; Split inverts that row into room-units-per-quote and never crosses independently selected
live rows. The request is bodyless and sends no credential. Cache each base separately for 24h in
FxRate. If refresh fails, cached rows remain usable only while the producer's `generatedAt` is
under seven days old; after that Split materializes the mock's 12-rate static table in the target
base. Rates are indicative — label them so.

## Import compatibility boundary

- Supported and fixture-tested: canonical Splitwise group CSV, Split Pro friend CSV, and Split
Pro account JSON. Unknown CSVs fail closed; they are never guessed into a ledger format.
- Settle Up is **not** currently supported. Its Android app can export CSV, but there is no
versioned schema in this repository and no real export fixture to prove balances. Add an
adapter only with a redacted source file and a round-trip/balance fixture; marketing and UI
must not claim compatibility before that lands.
- A single separator followed by three digits in a 3-decimal currency (`1,234` or `1.234`) is a
1000× ambiguity without locale metadata. The parser rejects that row instead of guessing;
repeated grouping and mixed grouping-plus-decimal forms remain supported.
- Parsed minor-unit amounts are bounded to PostgreSQL signed BIGINT before preview, and history
folding must prove its actual opening-balance rows plus retained history fit the 500-expense
API ceiling.

## API contract (route handlers under `src/app/api/`)

Expand Down
32 changes: 30 additions & 2 deletions apps/web/e2e/import.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ test('import into an existing room appends in place and an exact retry is a no-o
await expect(page.locator('[data-testid="balance-card"][data-member="Bruno"]')).toHaveCount(0)
})

test('an unrated KPW room blocks incompatible EUR history before submit', async ({ page }) => {
test('a KPW room absent from the static e2e FX table blocks incompatible EUR history', async ({ page }) => {
await page.goto('/new')
await page.getByTestId('room-name').fill('KPW import target')
await page.getByTestId('room-currency').selectOption('KPW')
Expand All @@ -226,7 +226,7 @@ test('an unrated KPW room blocks incompatible EUR history before submit', async
await expect(page.getByTestId('import-submit')).toBeDisabled()
})

test('an unrated KPW room accepts same-currency KPW history', async ({ page }) => {
test('that static-unavailable KPW room accepts same-currency KPW history', async ({ page }) => {
await page.goto('/new')
await page.getByTestId('room-name').fill('KPW identity import')
await page.getByTestId('room-currency').selectOption('KPW')
Expand All @@ -246,6 +246,34 @@ test('an unrated KPW room accepts same-currency KPW history', async ({ page }) =
await expect(page.getByTestId('import-submit')).toBeEnabled()
})

test('a live no-rate response blocks a catalog-rated foreign currency before submit', async ({ page }) => {
let probes = 0
// GBP is present in Playwright's static fallback. The row is blocked only if
// this intercepted live answer wins, so the assertion cannot pass merely
// because the test server has remote FX disabled.
await page.route('**/api/rate?from=GBP&to=EUR', async (route) => {
probes++
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ from: 'GBP', to: 'EUR', rate: null, source: 'static', indicative: true }),
})
})
await page.goto('/import')
await page.getByTestId('import-file').setInputFiles({
name: 'expenses_with_Natalia.csv',
mimeType: 'text/csv',
buffer: Buffer.from(SPLITPRO_FRIEND_CSV.replace(',EUR,', ',GBP,'), 'utf8'),
})
await page.locator('[data-testid="import-me"][data-member="You"]').check()

const problem = page.getByTestId('import-currency-unsupported')
await expect(problem).toBeVisible({ timeout: 15_000 })
await expect(problem).toContainText('GBP')
await expect(problem).toContainText('EUR')
await expect(page.getByTestId('import-submit')).toBeDisabled()
expect(probes).toBe(1)
})

test('a file that is not a Splitwise export says so, and writes nothing', async ({ page }) => {
await page.goto('/import')

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Keep the original semantic fingerprint for clients and batches that predate
-- immutable upload identity. New clients additionally send a SHA-256 source
-- fingerprint, which remains stable across parser improvements and UI renames.
ALTER TABLE "split"."ImportBatch"
ADD COLUMN "sourceFingerprint" TEXT;

CREATE UNIQUE INDEX "ImportBatch_roomId_sourceFingerprint_key"
ON "split"."ImportBatch"("roomId", "sourceFingerprint");
24 changes: 13 additions & 11 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -195,22 +195,24 @@ model Expense {
/// write created: the full initial roster for a global import, or only new
/// mappings for an append.
///
/// `fingerprint` describes only the canonical source roster and expense
/// multiset, never the chosen room-member mappings. Its room-scoped unique key
/// turns a repeated/concurrent delivery of the same source into a successful
/// replay while still allowing later, genuinely different exports to append.
/// `fingerprint` is the legacy canonical parsed-ledger identity retained for
/// old clients and batches. `sourceFingerprint` is the stable identity of the
/// immutable local export plus its selected raw-file choice; parser upgrades
/// and UI renames therefore cannot turn a replay into a second ledger write.
model ImportBatch {
id String @id @default(uuid())
roomId String
fingerprint String
importedAt DateTime @db.Timestamp(3)
expenseCount Int
addedMemberCount Int
id String @id @default(uuid())
roomId String
fingerprint String
sourceFingerprint String?
importedAt DateTime @db.Timestamp(3)
expenseCount Int
addedMemberCount Int

room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
expenses Expense[]

@@unique([roomId, fingerprint])
@@unique([roomId, sourceFingerprint])
@@index([roomId, importedAt])
@@schema("split")
}
Expand Down Expand Up @@ -278,7 +280,7 @@ model Settlement {
@@schema("split")
}

/// Cached indicative rates: one row per quote currency against USD.
/// Cached indicative rates: direct base-units per quote, isolated by destination base.
model FxRate {
id String @id @default(uuid())
base String
Expand Down
30 changes: 14 additions & 16 deletions apps/web/scripts/gen-currency-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
/**
* Writes `src/lib/currency-catalog.ts` from the ICU data this Node ships with.
*
* Run it by hand (`pnpm currencies:gen`), never at build or boot. Production has no egress, so
* the catalog has to be a committed artifact — and a table regenerated silently by a Node upgrade
* is a table whose decimals can move under rooms that already hold money.
* Run it by hand (`pnpm currencies:gen`), never at build or boot. The catalog is a committed
* artifact so a table regenerated silently by a Node upgrade cannot move decimals under rooms
* that already hold money.
*
* Re-running on one Node version produces a byte-identical file, so the git diff IS the review.
* Read the diff. The report below names every code added and every decimals change, because both
Expand All @@ -17,7 +17,7 @@
* land on '' and the formatter prints `12.34 AED` for them.
* decimals `Intl.NumberFormat(...).resolvedOptions().maximumFractionDigits` — CLDR, never
* guessed. 33 codes are 0-decimal and 6 are 3-decimal.
* hasRate membership of FEED_CODES below.
* hasRate membership of RATE_CODES below.
*/

import { readFileSync, writeFileSync } from 'node:fs'
Expand All @@ -27,15 +27,15 @@ import { fileURLToPath } from 'node:url'
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'lib', 'currency-catalog.ts')

/**
* The codes open.er-api.com/v6/latest/USD carried on 2026-07-31, verbatim and sorted.
* The currency codes Peanut's `GET /fx/rates?base=USD` snapshot carried on 2026-08-05,
* verbatim and sorted. The backend builds its reference layer from Frankfurter v2
* and overlays Peanut's provider display-sell rates.
*
* Committed rather than fetched for the reason the header gives: no egress at runtime, and a
* boolean that changes without a deploy is a boolean nobody can reason about. Eight of these
* (CLF CNH FOK GGP IMP JEP KID TVD) are not ISO 4217 and are therefore not in the catalog at
* all — `hasRate` only ever describes a code ICU already knows. To refresh: fetch the feed,
* sort the keys of `rates`, paste them here, re-run, and read the diff.
* Committed rather than fetched so supported-code changes are reviewed and deployed deliberately;
* `hasRate` only ever describes a code ICU already knows. To refresh: fetch the Peanut snapshot,
* sort its codes, paste them here, re-run, and read the diff.
*/
const FEED_CODES = new Set([
const RATE_CODES = new Set([
'AED',
'AFN',
'ALL',
Expand All @@ -49,7 +49,6 @@ const FEED_CODES = new Set([
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
Expand Down Expand Up @@ -95,7 +94,6 @@ const FEED_CODES = new Set([
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
Expand All @@ -114,6 +112,7 @@ const FEED_CODES = new Set([
'KHR',
'KID',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
Expand Down Expand Up @@ -165,11 +164,11 @@ const FEED_CODES = new Set([
'SGD',
'SHP',
'SLE',
'SLL',
'SOS',
'SRD',
'SSP',
'STN',
'SVC',
'SYP',
'SZL',
'THB',
Expand Down Expand Up @@ -201,7 +200,6 @@ const FEED_CODES = new Set([
'ZAR',
'ZMW',
'ZWG',
'ZWL',
])

/**
Expand Down Expand Up @@ -256,7 +254,7 @@ function build() {
name: displayNames.of(code) ?? code,
symbol: symbolOf(code),
decimals: decimalsOf(code),
hasRate: FEED_CODES.has(code),
hasRate: RATE_CODES.has(code),
}))
}

Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/app/api/currencies/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
*
* The catalog's `hasRate` describes the live rate feed, and `SPLIT_FX_MODE=static` is the mode
* that has no feed: dev, this suite, and the e2e run all price from the twelve-code static table.
* Advertising the feed's 158 there hands the picker currencies the write path then refuses with a
* Advertising the feed's 156 there hands the picker currencies the write path then refuses with a
* 400 `NO_RATE` — the one failure the whole `hasRate` contract exists to make unreachable. So the
* check below is the contract itself: every code this route says has a rate can be priced by the
* table `getRateTable()` actually returns.
* EUR table `getRateTable('EUR')` actually returns.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { GET } from '@/app/api/currencies/route'
Expand All @@ -27,7 +27,7 @@ describe('GET /api/currencies', () => {
})

it('offers no rate the static table cannot serve', async () => {
const table = await getRateTable()
const table = await getRateTable('EUR')
expect(table.source).toBe('static')
const currencies = await served()
for (const c of currencies) {
Expand All @@ -49,7 +49,8 @@ describe('GET /api/currencies', () => {
delete process.env.SPLIT_FX_MODE
const currencies = await served()
expect(currencies.find((c) => c.code === 'INR')?.hasRate).toBe(true)
// A catalog code the feed never carried stays false in either mode.
expect(currencies.find((c) => c.code === 'KPW')?.hasRate).toBe(false)
expect(currencies.find((c) => c.code === 'KPW')?.hasRate).toBe(true)
// A catalog code absent from the current Peanut snapshot stays false in either mode.
expect(currencies.find((c) => c.code === 'BGN')?.hasRate).toBe(false)
})
})
2 changes: 1 addition & 1 deletion apps/web/src/app/api/currencies/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { publicCurrencies, STATIC_USD_PER_UNIT } from '@/server/money'
*
* One exception, and it is the whole reason this route is not a one-liner. `SPLIT_FX_MODE=static`
* turns the rate feed off — that is dev, the unit suite and the e2e run — and the static table
* prices twelve codes, not 158. Serving the catalog's own `hasRate` there offers the picker
* prices twelve codes, not 156. Serving the catalog's own `hasRate` there offers the picker
* currencies the server then refuses at write time with a 400 `NO_RATE`, which reads as a broken
* app in exactly the environments where the app is being worked on. So static mode advertises what
* static mode can price, and nothing else.
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/app/api/import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ export { MAX_IMPORT_SHARE_ROWS, assertImportCardinality } from '@/server/importR
* Rate-limited as a creation, because that is what it is: one call makes a room, a roster and up
* to five hundred rows that nobody can delete.
*
* NO IDEMPOTENCY KEY, deliberately. Every POST is a new room with a new link — a retried import
* cannot corrupt anything, it can only leave an unshared room nobody opens. The alternative is a
* key the client has to invent and the server has to store, to defend against a duplicate that
* costs a row.
* This route is not globally idempotent: every POST is still a new room with a
* new link. Its source fingerprint is stored only inside that new room's first
* ImportBatch, so uploading the same local export through the room-scoped
* append route later cannot duplicate its ledger.
*/
export const POST = (request: Request) =>
respond(async (): Promise<RoomStateWithMember> => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/rooms/[slug]/expenses/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export const PATCH = (request: Request, ctx: Ctx) =>
initialExpense.currency,
body.manualFxRate
)
? await getRateTable()
? await getRateTable(initial.currency)
: undefined

const result = await prisma.$transaction(async (tx) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/rooms/[slug]/expenses/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const POST = (request: Request, ctx: Ctx) =>
// needs it. Manual custom rates, identity pairs, and validation failures
// must not wake the cache/feed path merely to save an expense.
const rateTable = expenseNeedsRateTable(room.currency, body.currency, undefined, body.manualFxRate)
? await getRateTable()
? await getRateTable(room.currency)
: undefined

let result: ExpenseWriteResult
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/app/api/rooms/[slug]/import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ type Ctx = { params: Promise<{ slug: string }> }
* Append one parsed source export to an existing room.
*
* The browser still parses locally; this endpoint receives only the validated
* ledger projection and explicit source-person mappings. A repeated semantic
* source is returned as an idempotent success by the service and deliberately
* does not poke realtime subscribers a second time.
* ledger projection, immutable-source fingerprint and explicit source-person
* mappings. A repeated local export choice is returned as an idempotent
* success and deliberately does not poke realtime subscribers a second time.
*/
export const POST = (request: Request, ctx: Ctx) =>
respond(async (): Promise<ImportIntoRoomResult> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,20 @@ describe('ExistingRoomImportContext', () => {
})

describe('ExistingRoomImportCurrencyProblem', () => {
it('names an unconvertible EUR source before submission to a KPW room', () => {
it('names an unconvertible EUR source before submission to a BGN room', () => {
const html = renderToStaticMarkup(
<ExistingRoomImportCurrencyProblem sourceCurrencies={['EUR']} roomCurrency="KPW" />
<ExistingRoomImportCurrencyProblem sourceCurrencies={['EUR']} roomCurrency="BGN" />
)

expect(html).toContain('data-testid="import-currency-unsupported"')
expect(html).toContain('role="alert"')
expect(html).toContain('import.existing.currencyUnsupportedTitle')
expect(html).toContain('import.existing.currencyUnsupportedBody:EUR,KPW')
expect(html).toContain('import.existing.currencyUnsupportedBody:EUR,BGN')
})

it('renders nothing when every source currency is priceable', () => {
expect(
renderToStaticMarkup(<ExistingRoomImportCurrencyProblem sourceCurrencies={[]} roomCurrency="KPW" />)
renderToStaticMarkup(<ExistingRoomImportCurrencyProblem sourceCurrencies={[]} roomCurrency="BGN" />)
).toBe('')
})
})
Expand Down
Loading
Loading