diff --git a/README.md b/README.md index f64db16a..9acb6249 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/web/docs/SPEC.md b/apps/web/docs/SPEC.md index c0c5d02d..d3f00985 100644 --- a/apps/web/docs/SPEC.md +++ b/apps/web/docs/SPEC.md @@ -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=` 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/`) diff --git a/apps/web/e2e/import.spec.ts b/apps/web/e2e/import.spec.ts index 610ee146..1254113f 100644 --- a/apps/web/e2e/import.spec.ts +++ b/apps/web/e2e/import.spec.ts @@ -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') @@ -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') @@ -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') diff --git a/apps/web/prisma/migrations/20260805120000_stable_import_source_fingerprint/migration.sql b/apps/web/prisma/migrations/20260805120000_stable_import_source_fingerprint/migration.sql new file mode 100644 index 00000000..a21630cb --- /dev/null +++ b/apps/web/prisma/migrations/20260805120000_stable_import_source_fingerprint/migration.sql @@ -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"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index a9dc5ece..886e95f0 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -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") } @@ -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 diff --git a/apps/web/scripts/gen-currency-catalog.mjs b/apps/web/scripts/gen-currency-catalog.mjs index ae784c14..a63980aa 100644 --- a/apps/web/scripts/gen-currency-catalog.mjs +++ b/apps/web/scripts/gen-currency-catalog.mjs @@ -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 @@ -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' @@ -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', @@ -49,7 +49,6 @@ const FEED_CODES = new Set([ 'BAM', 'BBD', 'BDT', - 'BGN', 'BHD', 'BIF', 'BMD', @@ -95,7 +94,6 @@ const FEED_CODES = new Set([ 'GYD', 'HKD', 'HNL', - 'HRK', 'HTG', 'HUF', 'IDR', @@ -114,6 +112,7 @@ const FEED_CODES = new Set([ 'KHR', 'KID', 'KMF', + 'KPW', 'KRW', 'KWD', 'KYD', @@ -165,11 +164,11 @@ const FEED_CODES = new Set([ 'SGD', 'SHP', 'SLE', - 'SLL', 'SOS', 'SRD', 'SSP', 'STN', + 'SVC', 'SYP', 'SZL', 'THB', @@ -201,7 +200,6 @@ const FEED_CODES = new Set([ 'ZAR', 'ZMW', 'ZWG', - 'ZWL', ]) /** @@ -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), })) } diff --git a/apps/web/src/app/api/currencies/route.test.ts b/apps/web/src/app/api/currencies/route.test.ts index 23dbc540..8f28bceb 100644 --- a/apps/web/src/app/api/currencies/route.test.ts +++ b/apps/web/src/app/api/currencies/route.test.ts @@ -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' @@ -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) { @@ -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) }) }) diff --git a/apps/web/src/app/api/currencies/route.ts b/apps/web/src/app/api/currencies/route.ts index 00f6c714..6839f0db 100644 --- a/apps/web/src/app/api/currencies/route.ts +++ b/apps/web/src/app/api/currencies/route.ts @@ -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. diff --git a/apps/web/src/app/api/import/route.ts b/apps/web/src/app/api/import/route.ts index c36f33c1..e2c0ff52 100644 --- a/apps/web/src/app/api/import/route.ts +++ b/apps/web/src/app/api/import/route.ts @@ -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 => { diff --git a/apps/web/src/app/api/rooms/[slug]/expenses/[id]/route.ts b/apps/web/src/app/api/rooms/[slug]/expenses/[id]/route.ts index 6c8b77cd..0f9e46d0 100644 --- a/apps/web/src/app/api/rooms/[slug]/expenses/[id]/route.ts +++ b/apps/web/src/app/api/rooms/[slug]/expenses/[id]/route.ts @@ -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) => { diff --git a/apps/web/src/app/api/rooms/[slug]/expenses/route.ts b/apps/web/src/app/api/rooms/[slug]/expenses/route.ts index db85b2eb..ea191b09 100644 --- a/apps/web/src/app/api/rooms/[slug]/expenses/route.ts +++ b/apps/web/src/app/api/rooms/[slug]/expenses/route.ts @@ -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 diff --git a/apps/web/src/app/api/rooms/[slug]/import/route.ts b/apps/web/src/app/api/rooms/[slug]/import/route.ts index ecf49d5e..d4422c7e 100644 --- a/apps/web/src/app/api/rooms/[slug]/import/route.ts +++ b/apps/web/src/app/api/rooms/[slug]/import/route.ts @@ -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 => { diff --git a/apps/web/src/components/import/ExistingRoomImportFields.test.tsx b/apps/web/src/components/import/ExistingRoomImportFields.test.tsx index 4dda2831..e18e66ff 100644 --- a/apps/web/src/components/import/ExistingRoomImportFields.test.tsx +++ b/apps/web/src/components/import/ExistingRoomImportFields.test.tsx @@ -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( - + ) 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() + renderToStaticMarkup() ).toBe('') }) }) diff --git a/apps/web/src/components/import/ExistingRoomImportScreen.test.tsx b/apps/web/src/components/import/ExistingRoomImportScreen.test.tsx index c54bdf7a..b70d1a01 100644 --- a/apps/web/src/components/import/ExistingRoomImportScreen.test.tsx +++ b/apps/web/src/components/import/ExistingRoomImportScreen.test.tsx @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ liveRoom: vi.fn(), refetch: vi.fn(), retryClick: undefined as (() => void) | undefined, + identity: null as { memberId: string; token: string; name: string } | null, + importTarget: undefined as unknown, })) vi.mock('next/link', () => ({ @@ -24,11 +26,14 @@ vi.mock('@/lib/queries', () => ({ useRoomState: (...args: unknown[]) => mocks.liveRoom(...args), })) vi.mock('@/lib/use-identity', () => ({ - useRoomIdentity: () => ({ identity: null, loaded: true, claim: vi.fn(), forget: vi.fn() }), + useRoomIdentity: () => ({ identity: mocks.identity, loaded: true, claim: vi.fn(), forget: vi.fn() }), })) vi.mock('@/lib/themes', () => ({ themeVars: () => ({}) })) vi.mock('@/components/import/SplitwiseImport', () => ({ - SplitwiseImport: () => createElement('div', { 'data-testid': 'splitwise-import-stub' }), + SplitwiseImport: ({ targetRoom }: { targetRoom: unknown }) => { + mocks.importTarget = targetRoom + return createElement('div', { 'data-testid': 'splitwise-import-stub' }) + }, })) vi.mock('@/components/room/RoomEmblem', () => ({ RoomEmblem: () => null })) vi.mock('@/components/room/RoomStates', () => ({ @@ -76,6 +81,8 @@ describe('ExistingRoomImportScreen room read', () => { mocks.liveRoom.mockReset() mocks.refetch.mockReset() mocks.retryClick = undefined + mocks.identity = null + mocks.importTarget = undefined mocks.snapshot.mockReturnValue(loaded()) mocks.liveRoom.mockReturnValue(loaded()) }) @@ -88,6 +95,18 @@ describe('ExistingRoomImportScreen room read', () => { expect(html).toContain('data-testid="splitwise-import-stub"') }) + it('passes the current member id for a visible Split Pro You mapping suggestion', () => { + mocks.identity = { memberId: 'member-konrad', token: 'token-konrad', name: 'Konrad' } + + renderToStaticMarkup() + + expect(mocks.importTarget).toEqual({ + state, + memberId: 'member-konrad', + memberToken: 'token-konrad', + }) + }) + it('explains a custom target currency before mounting the file importer', () => { mocks.snapshot.mockReturnValue(loaded({ ...state, room: { ...state.room, currency: 'BEER' } })) diff --git a/apps/web/src/components/import/ExistingRoomImportScreen.tsx b/apps/web/src/components/import/ExistingRoomImportScreen.tsx index 85f64614..51cf9e5e 100644 --- a/apps/web/src/components/import/ExistingRoomImportScreen.tsx +++ b/apps/web/src/components/import/ExistingRoomImportScreen.tsx @@ -91,7 +91,9 @@ export function ExistingRoomImportScreen({ slug }: { slug: string }) { ) : (
- +
)} diff --git a/apps/web/src/components/import/SplitwiseImport.tsx b/apps/web/src/components/import/SplitwiseImport.tsx index 4f299055..cef664cf 100644 --- a/apps/web/src/components/import/SplitwiseImport.tsx +++ b/apps/web/src/components/import/SplitwiseImport.tsx @@ -12,7 +12,7 @@ * what we understood. An import that silently guesses wrong is worse than one that refuses. */ -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { motion } from 'motion/react' import { useLocale, useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' @@ -23,15 +23,22 @@ import { Icon } from '@/components/ui/Icon' import { CurrencySelect } from '@/components/room/CurrencySelect' import { LinkMoment } from '@/components/room/LinkMoment' import { track } from '@/lib/analytics' +import { isApiError } from '@/lib/api' import type { ImportedExpenseInput, ImportIntoRoomResult, RoomState, RoomStateWithMember } from '@/lib/api-types' import { cn } from '@/lib/cn' import { useErrorMessage } from '@/lib/error-messages' import { writeIdentity } from '@/lib/identity' import { importedRoomPath } from '@/lib/import-routes' +import { + fingerprintParsedImportFile, + type FingerprintedImportChoice, + type FingerprintedImportFile, +} from '@/lib/import-source-fingerprint' import { formatMoney } from '@/lib/money' import { useCurrencies, useImportIntoRoom, useImportRoom } from '@/lib/queries' import { rememberRoom } from '@/lib/recent-rooms' import { useMotionAllowed } from '@/lib/use-motion' +import { useRateAvailability } from '@/lib/use-rate' import { MAX_FILE_CHARS, SplitwiseParseError, @@ -40,12 +47,7 @@ import { type ParseErrorCode, type SplitwiseImport as ParsedFile, } from '@/lib/splitwise-csv' -import { - parseImportFile, - type ImportChoice, - type ParsedImportFile, - type SkippedImportChoice, -} from '@/lib/splitpro-import' +import { parseImportFile, type ParsedImportFile, type SkippedImportChoice } from '@/lib/splitpro-import' import { useFeedback } from '@/lib/use-settings' import { ExistingRoomImportContext, @@ -69,6 +71,22 @@ export interface ExistingRoomImportTarget { state: RoomState /** Attribution only; holding the slug remains sufficient to write. */ memberToken?: string | null + /** Used only to visibly suggest which imported person is the current visitor. */ + memberId?: string | null +} + +const conversionFailureCurrencies = (error: unknown, candidates: readonly string[]): string[] => { + if (!isApiError(error, 'IMPORT_CURRENCY_CONVERSION_UNSUPPORTED') && !isApiError(error, 'NO_RATE')) { + return [] + } + const details = error.details + if (typeof details !== 'object' || details === null) return [] + const currencies = (details as { currencies?: unknown }).currencies + if (!Array.isArray(currencies)) return [] + const candidateSet = new Set(candidates) + return [...new Set(currencies.filter((code): code is string => typeof code === 'string'))].filter((code) => + candidateSet.has(code) + ) } export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImportTarget } = {}) { @@ -85,12 +103,16 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor const inputRef = useRef(null) const [parsed, setParsed] = useState(null) - const [parsedFile, setParsedFile] = useState(null) + const [parsedFile, setParsedFile] = useState(null) const [choiceIndex, setChoiceIndex] = useState(0) + const [sourceFingerprint, setSourceFingerprint] = useState(null) const [roomName, setRoomName] = useState('') const [currency, setCurrency] = useState('EUR') const [names, setNames] = useState([]) const [memberDrafts, setMemberDrafts] = useState([]) + /** Source code → query data timestamp observed when the write rejected it. + * A later successful probe clears the derived latch without another upload. */ + const [serverRateRejections, setServerRateRejections] = useState>({}) /** * Null until somebody picks, and the submit button is disabled until they do. * @@ -117,6 +139,8 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor switch (code) { case 'NOT_SPLITWISE_CSV': return t('errors.NOT_SPLITWISE_CSV') + case 'MALFORMED_CSV': + return t('errors.MALFORMED_CSV') case 'MALFORMED_JSON': return t('errors.MALFORMED_JSON') case 'SPLITPRO_DIRECT_UNRESOLVED': @@ -172,6 +196,8 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor return t('warnings.SPLITPRO_BALANCES_ONLY') case 'SPLITPRO_PAIR_HISTORY': return t('warnings.SPLITPRO_PAIR_HISTORY') + case 'SPLITPRO_SPLIT_MODE_FLATTENED': + return t('warnings.SPLITPRO_SPLIT_MODE_FLATTENED') case 'SPLITPRO_MISSING_NAMES': return t('warnings.SPLITPRO_MISSING_NAMES', { count: Number(detail) || 0 }) case 'SPLITPRO_BALANCES_SKIPPED': @@ -182,17 +208,25 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor } const applyChoice = useCallback( - (choice: ImportChoice, index: number) => { + (choice: FingerprintedImportChoice, index: number, source: ParsedImportFile['source']) => { setChoiceIndex(index) setParsed(choice.parsed) + setSourceFingerprint(choice.sourceFingerprint) setRoomName(choice.roomName || t('preview.fallbackName')) setCurrency(choice.parsed.suggestedCurrency) setNames(choice.parsed.members) setMemberDrafts( - targetRoom ? initialExistingRoomMemberDrafts(choice.parsed.members, targetRoom.state.members) : [] + targetRoom + ? initialExistingRoomMemberDrafts( + choice.parsed.members, + targetRoom.state.members, + source === 'splitpro' ? targetRoom.memberId : null + ) + : [] ) setMeIndex(null) setError(null) + setServerRateRejections({}) }, [t, targetRoom] ) @@ -218,9 +252,9 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor } try { - const result = parseImportFile(text, file.name) + const result = await fingerprintParsedImportFile(text, parseImportFile(text, file.name)) setParsedFile(result) - applyChoice(result.choices[0], 0) + applyChoice(result.choices[0], 0, result.source) feedback('pop') // Counts only. Not the group's name, not a member's, not an amount. track('import_parsed', { @@ -277,13 +311,52 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor () => (targetRoom ? existingRoomMappingProblem(memberDrafts, targetRoom.state.members) : null), [memberDrafts, targetRoom] ) - const unsupportedCurrencies = useMemo( - () => - parsed && targetRoom - ? unsupportedImportCurrencies(parsed.expenses, targetRoom.state.room.currency, currencies) - : [], - [parsed, targetRoom, currencies] + const targetCurrency = targetRoom?.state.room.currency ?? currency + const catalogUnsupportedCurrencies = useMemo( + () => (parsed ? unsupportedImportCurrencies(parsed.expenses, targetCurrency, currencies) : []), + [parsed, targetCurrency, currencies] + ) + const rateSourceCurrencies = useMemo(() => { + if (!parsed) return [] + const catalogUnsupported = new Set(catalogUnsupportedCurrencies) + return [...new Set(parsed.expenses.map((expense) => expense.currencyCode))].filter( + (source) => source !== targetCurrency && !catalogUnsupported.has(source) + ) + }, [parsed, targetCurrency, catalogUnsupportedCurrencies]) + const rateProbes = useRateAvailability( + rateSourceCurrencies, + targetCurrency, + parsed !== null, + Object.keys(serverRateRejections) + ) + const liveUnavailableCurrencies = rateSourceCurrencies.filter( + (_, index) => rateProbes[index]?.isSuccess && rateProbes[index]?.data === null + ) + const serverUnavailableCurrencies = Object.entries(serverRateRejections).flatMap( + ([code, rejectedDataUpdatedAt]) => { + const probe = rateProbes[rateSourceCurrencies.indexOf(code)] + const recovered = probe?.isSuccess && probe.data !== null && probe.dataUpdatedAt > rejectedDataUpdatedAt + return recovered ? [] : [code] + } ) + const recoveredServerCurrencies = Object.entries(serverRateRejections) + .flatMap(([code, rejectedDataUpdatedAt]) => { + const probe = rateProbes[rateSourceCurrencies.indexOf(code)] + return probe?.isSuccess && probe.data !== null && probe.dataUpdatedAt > rejectedDataUpdatedAt ? [code] : [] + }) + .sort() + .join(',') + useEffect(() => { + if (!recoveredServerCurrencies) return + const recovered = new Set(recoveredServerCurrencies.split(',')) + setServerRateRejections((current) => + Object.fromEntries(Object.entries(current).filter(([code]) => !recovered.has(code))) + ) + }, [recoveredServerCurrencies]) + const checkingRates = rateProbes.some((probe) => probe.isFetching) + const unsupportedCurrencies = [ + ...new Set([...catalogUnsupportedCurrencies, ...liveUnavailableCurrencies, ...serverUnavailableCurrencies]), + ] const memberMappingProblemMessage = (problem: ExistingRoomMappingProblem | null): string | null => { switch (problem) { @@ -302,10 +375,37 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor } } + const rememberConversionFailure = (failure: unknown) => { + const isConversionFailure = + isApiError(failure, 'IMPORT_CURRENCY_CONVERSION_UNSUPPORTED') || isApiError(failure, 'NO_RATE') + if (!isConversionFailure) return + + const unavailable = conversionFailureCurrencies(failure, rateSourceCurrencies) + if (unavailable.length > 0) { + setServerRateRejections((current) => { + const next = { ...current } + for (const code of unavailable) { + const probe = rateProbes[rateSourceCurrencies.indexOf(code)] + next[code] = probe?.dataUpdatedAt ?? 0 + } + return next + }) + } + + // New servers return the exact failed codes. During a rolling deploy an + // older NO_RATE envelope may not, so recheck every candidate but do not + // falsely label all of them unsupported. + const toRecheck = unavailable.length > 0 ? unavailable : rateSourceCurrencies + for (const code of toRecheck) { + void rateProbes[rateSourceCurrencies.indexOf(code)]?.refetch() + } + } + const submit = async () => { - if (!parsed) return + if (!parsed || !sourceFingerprint) return + if (checkingRates || unsupportedCurrencies.length > 0) return if (targetRoom) { - if (memberMappingProblem || unsupportedCurrencies.length > 0) return + if (memberMappingProblem) return } else if (nameProblem || !roomName.trim() || meIndex === null) { return } @@ -314,6 +414,7 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor if (targetRoom) { try { const result = await importIntoRoom.mutateAsync({ + sourceFingerprint, members: importMemberMappings(memberDrafts), expenses: parsed.expenses, }) @@ -326,6 +427,7 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor feedback('pop') setAppended(result) } catch (err) { + rememberConversionFailure(err) setError(errorMessage(err, tExisting('failed'))) feedback('error') track('import_failed', { reason: 'POST_FAILED', target: 'existing' }) @@ -348,6 +450,7 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor try { const state = await importRoom.mutateAsync({ + sourceFingerprint, roomName: roomName.trim(), // Read from the group's own name, same as a hand-made room. It used to be a // hardcoded 🧾, which made every imported room look identical in the list. @@ -369,6 +472,7 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor feedback('pop') setCreated(state) } catch (err) { + rememberConversionFailure(err) setError(errorMessage(err, t('errors.failed'))) feedback('error') track('import_failed', { reason: 'POST_FAILED' }) @@ -378,10 +482,12 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor const startOver = () => { setParsed(null) setParsedFile(null) + setSourceFingerprint(null) setChoiceIndex(0) setMemberDrafts([]) setAppended(null) setError(null) + setServerRateRejections({}) } if (targetRoom && appended) { @@ -565,7 +671,7 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor onChange={(event) => { const index = Number(event.target.value) const choice = parsedFile.choices[index] - if (choice) applyChoice(choice, index) + if (choice) applyChoice(choice, index, parsedFile.source) }} className="h-12 rounded-sm border border-n-1 bg-white px-3 text-base font-bold text-n-1 shadow-[2px_2px_0_#111] outline-none" data-testid="import-group-choice" @@ -669,6 +775,7 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor value={currency} onChange={(code) => { setCurrency(code) + setServerRateRejections({}) feedback('tick') }} currencies={currencies} @@ -681,6 +788,10 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor /> {t('preview.currencyHint')} + )} @@ -765,8 +876,16 @@ export function SplitwiseImport({ targetRoom }: { targetRoom?: ExistingRoomImpor className="justify-center" disabled={ targetRoom - ? !!memberMappingProblem || unsupportedCurrencies.length > 0 || importIntoRoom.isPending - : !roomName.trim() || !!nameProblem || meIndex === null || importRoom.isPending + ? !!memberMappingProblem || + checkingRates || + unsupportedCurrencies.length > 0 || + importIntoRoom.isPending + : !roomName.trim() || + !!nameProblem || + meIndex === null || + checkingRates || + unsupportedCurrencies.length > 0 || + importRoom.isPending } loading={targetRoom ? importIntoRoom.isPending : importRoom.isPending} onClick={submit} diff --git a/apps/web/src/components/import/existing-room-mapping.test.ts b/apps/web/src/components/import/existing-room-mapping.test.ts index eef30d6c..b6c437c4 100644 --- a/apps/web/src/components/import/existing-room-mapping.test.ts +++ b/apps/web/src/components/import/existing-room-mapping.test.ts @@ -20,9 +20,9 @@ describe('existing-room import member mapping', () => { it('checks source-to-room priceability while preserving unrated identity pairs', () => { const expense = (currencyCode: string) => ({ currencyCode }) - expect(unsupportedImportCurrencies([expense('EUR')], 'KPW')).toEqual(['EUR']) - expect(unsupportedImportCurrencies([expense('KPW')], 'KPW')).toEqual([]) - expect(unsupportedImportCurrencies([expense('EUR'), expense('EUR'), expense('USD')], 'KPW')).toEqual([ + expect(unsupportedImportCurrencies([expense('EUR')], 'BGN')).toEqual(['EUR']) + expect(unsupportedImportCurrencies([expense('BGN')], 'BGN')).toEqual([]) + expect(unsupportedImportCurrencies([expense('EUR'), expense('EUR'), expense('USD')], 'BGN')).toEqual([ 'EUR', 'USD', ]) @@ -42,6 +42,31 @@ describe('existing-room import member mapping', () => { ]) }) + it('suggests the importer identity for Split Pro’s explicit You member', () => { + const drafts = initialExistingRoomMemberDrafts( + ['You', 'Natalia'], + [member('konrad', 'Konrad'), member('natalia', 'Natalia')], + 'konrad' + ) + + expect(drafts).toEqual([ + { sourceName: 'You', memberId: 'konrad', newMemberName: 'You' }, + { sourceName: 'Natalia', memberId: 'natalia', newMemberName: 'Natalia' }, + ]) + }) + + it('does not trust an identity that is no longer in the room', () => { + expect(initialExistingRoomMemberDrafts(['You'], [member('konrad', 'Konrad')], 'removed')).toEqual([ + { sourceName: 'You', memberId: null, newMemberName: 'You' }, + ]) + }) + + it('leaves a literal Splitwise member named You unmapped without semantic-self metadata', () => { + expect(initialExistingRoomMemberDrafts(['You'], [member('konrad', 'Konrad')])).toEqual([ + { sourceName: 'You', memberId: null, newMemberName: 'You' }, + ]) + }) + it('does not map two source people to the same room member', () => { const members = [member('ana', 'Ana')] const drafts: ExistingRoomMemberDraft[] = [ diff --git a/apps/web/src/components/import/existing-room-mapping.ts b/apps/web/src/components/import/existing-room-mapping.ts index a6b26d83..5d609b1b 100644 --- a/apps/web/src/components/import/existing-room-mapping.ts +++ b/apps/web/src/components/import/existing-room-mapping.ts @@ -21,7 +21,7 @@ const nameKey = (name: string): string => name.trim().toLowerCase() /** Distinct source currencies that cannot be converted into the fixed target * room currency. `canPrice` checks identity first, so an unrated catalog code - * such as KPW remains valid when both the source row and room use KPW. */ + * such as BGN remains valid when both the source row and room use BGN. */ export function unsupportedImportCurrencies( expenses: readonly Pick[], roomCurrency: string, @@ -39,7 +39,8 @@ export function unsupportedImportCurrencies( */ export function initialExistingRoomMemberDrafts( sourceNames: readonly string[], - members: readonly ApiMember[] + members: readonly ApiMember[], + semanticSelfMemberId?: string | null ): ExistingRoomMemberDraft[] { members = activeMembers(members) const memberByName = new Map() @@ -53,10 +54,15 @@ export function initialExistingRoomMemberDrafts( const used = new Set() return sourceNames.map((sourceName) => { const key = nameKey(sourceName) + const currentMember = + key === 'you' && semanticSelfMemberId + ? members.find((member) => member.id === semanticSelfMemberId) + : undefined const exact = ambiguousNames.has(key) ? undefined : memberByName.get(key) - if (exact && !used.has(exact.id)) { - used.add(exact.id) - return { sourceName, memberId: exact.id, newMemberName: sourceName.trim() } + const suggested = currentMember ?? exact + if (suggested && !used.has(suggested.id)) { + used.add(suggested.id) + return { sourceName, memberId: suggested.id, newMemberName: sourceName.trim() } } return { sourceName, memberId: null, newMemberName: sourceName.trim() } }) diff --git a/apps/web/src/components/marketing/compare-copy.ts b/apps/web/src/components/marketing/compare-copy.ts index c48374fa..6231b9a5 100644 --- a/apps/web/src/components/marketing/compare-copy.ts +++ b/apps/web/src/components/marketing/compare-copy.ts @@ -132,8 +132,8 @@ const es419 = { body: 'El enlace es la sala. Quien lo tenga está adentro, así que déjalo en el chat del grupo y no en un lugar público.', }, { - title: '158 monedas, convertidas', - body: 'Elige en qué cuenta la sala. Carga un gasto en cualquiera de las 158 y Split lo convierte al tipo de cambio del día, y después lo guarda: editar la línea más tarde no la vuelve a cotizar.', + title: '156 monedas, convertidas', + body: 'Elige en qué cuenta la sala. Carga un gasto en cualquiera de las 156 monedas con conversión automática y Split lo convierte al tipo de cambio indicativo del día, y después lo guarda: editar la línea más tarde no la vuelve a cotizar.', }, { title: 'Cuentas que cierran', @@ -300,8 +300,8 @@ const ptBr = { body: 'O link é a sala. Quem tem o link está dentro, então deixe ele no grupo do WhatsApp e não num lugar público.', }, { - title: '158 moedas, convertidas', - body: 'Escolha em que a sala conta. Lance uma despesa em qualquer uma das 158 e o Split converte pela taxa do dia, que ele guarda: editar a linha depois não muda o preço dela.', + title: '156 moedas, convertidas', + body: 'Escolha em que a sala conta. Lance uma despesa em qualquer uma das 156 moedas com conversão automática e o Split converte pela taxa indicativa do dia, que ele guarda: editar a linha depois não muda o preço dela.', }, { title: 'Conta que fecha', diff --git a/apps/web/src/components/marketing/copy.ts b/apps/web/src/components/marketing/copy.ts index 38aa635f..5ca21637 100644 --- a/apps/web/src/components/marketing/copy.ts +++ b/apps/web/src/components/marketing/copy.ts @@ -141,8 +141,8 @@ export const marketingCopy = { body: 'The link is the room. Anyone who has it is in, so keep it in the group chat and not somewhere public.', }, { - title: '158 currencies, converted', - body: 'Pick what the room counts in. Add an expense in any of the 158 and Split converts it at the rate on the day, which it then keeps — editing the line later does not re-price it.', + title: '156 currencies, converted', + body: 'Pick what the room counts in. Add an expense in any of the 156 currencies with automatic conversion and Split converts it at the day’s indicative rate, which it then keeps — editing the line later does not re-price it.', }, { title: 'Maths that reconciles', diff --git a/apps/web/src/components/room/CurrencySelect.test.ts b/apps/web/src/components/room/CurrencySelect.test.ts index a57864d9..1621a0b8 100644 --- a/apps/web/src/components/room/CurrencySelect.test.ts +++ b/apps/web/src/components/room/CurrencySelect.test.ts @@ -28,7 +28,7 @@ const trigger = (top: number, bottom: number, left = 24, width = 136) => ({ /** * A catalog small enough to read, shaped like the real one: a spread of codes that collide on `U`, - * an accented Spanish name, and two codes the rate feed does not carry. + * an accented Spanish name, and two codes the current rate snapshot does not carry. */ const info = (code: string, name: string, hasRate = true): CurrencyInfo => ({ code, @@ -40,12 +40,13 @@ const info = (code: string, name: string, hasRate = true): CurrencyInfo => ({ const CATALOG: CurrencyInfo[] = [ info('ARS', 'Peso argentino'), + info('BGN', 'Bulgarian Lev', false), info('BRL', 'Brazilian Real'), info('CHF', 'Swiss Franc'), info('CUC', 'Cuban Convertible Peso', false), info('EUR', 'Euro'), info('GBP', 'British Pound'), - info('KPW', 'North Korean Won', false), + info('KPW', 'North Korean Won'), info('MXN', 'Mexican Peso'), info('THB', 'Thai Baht'), info('TRY', 'Türk Lirası'), @@ -192,8 +193,9 @@ describe('offerableCurrencies', () => { const offerable = codesOf(offerableCurrencies(CATALOG, 'EUR', 'EUR')) expect(offerable).toContain('THB') + expect(offerable).toContain('KPW') + expect(offerable).not.toContain('BGN') expect(offerable).not.toContain('CUC') - expect(offerable).not.toContain('KPW') }) it('offers one row in a room whose own currency has no rate', () => { diff --git a/apps/web/src/components/room/RoomExport.tsx b/apps/web/src/components/room/RoomExport.tsx index 33814d7a..911dab38 100644 --- a/apps/web/src/components/room/RoomExport.tsx +++ b/apps/web/src/components/room/RoomExport.tsx @@ -42,7 +42,7 @@ export function RoomExport({ state }: { state: RoomState }) { const [open, setOpen] = useState(false) // The browser parsers deliberately discard invented source currencies, so // a custom room has no file it can accept. Catalog targets remain available: - // even an unrated code such as KPW can import rows already denominated in KPW. + // even an unrated code such as BGN can import rows already denominated in BGN. const importsSupported = isCatalogCode(state.room.currency) const headerTitle = importsSupported ? tHeader('importExport') : tHeader('exportOnly') const headerFormats = importsSupported ? tHeader('importExportFormats') : tHeader('exportFormats') diff --git a/apps/web/src/content/_system/localization.es-419.md b/apps/web/src/content/_system/localization.es-419.md index 2a467998..e04c2736 100644 --- a/apps/web/src/content/_system/localization.es-419.md +++ b/apps/web/src/content/_system/localization.es-419.md @@ -107,32 +107,32 @@ Carried from mono `localization.es-419.md` §2: Three-column table, mono's shape (`localization.{locale}.md` §3). **Rows 1–7 are carried from mono verbatim**; the rest are Split terms with no mono equivalent. -| English | es-419 | Avoid | -| --------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- | -| the app | la app | la aplicación, **la plataforma** (banned in every locale, `messaging.md` §14.2) | -| phone | celular | móvil (Spain), teléfono | -| money | **dinero** | pasta, guita, lana — **and `plata`, see below** | -| send money | enviar dinero | transferir fondos, remesar | -| instantly | al instante / instantáneamente | — | -| sign up | registrarse | darse de alta (Spain) | -| computer | computadora | ordenador (Spain) | -| a website | un sitio web / una web | **la app** — Split is not an app and says so (stylebook §10) | -| room | **la sala** | el grupo, la cuenta, el evento | -| link | **el enlace** | el link, la liga (MX-only), el vínculo, la URL | -| the link is the key | **el enlace es la llave** | tu token, tu acceso | -| expense | **el gasto** | el egreso, la erogación (accounting register) | -| add an expense | cargar un gasto / agregar un gasto | ingresar un gasto | -| balance | **el saldo** | el balance (false friend — a balance sheet) | -| all square | **a mano** ("quedan a mano", "estás a mano") | en cero, balanceado, empatados | -| settle up (verb) | **saldar** | liquidar (accounting), pasar la cuenta, arreglar cuentas | -| settled | saldado / saldadas | cerrado, finiquitado | -| who owes who | **quién le debe a quién** | quién debe qué | -| free forever | **gratis para siempre** | gratuito, sin costo, versión gratis | -| 158 converted currencies | 158 monedas convertidas | multimoneda, cualquier moneda (stylebook §6.7) | -| converted at the day's rate | al tipo de cambio del día | tipo de cambio en vivo / en tiempo real (stylebook §6.6) | -| flatmate | roomie / compañero de departamento | **compañero de piso** (Spain), piso | -| flat | departamento | piso (Spain) | -| group chat | el chat del grupo | el grupo de WhatsApp (unscoped) | +| English | es-419 | Avoid | +| --------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- | +| the app | la app | la aplicación, **la plataforma** (banned in every locale, `messaging.md` §14.2) | +| phone | celular | móvil (Spain), teléfono | +| money | **dinero** | pasta, guita, lana — **and `plata`, see below** | +| send money | enviar dinero | transferir fondos, remesar | +| instantly | al instante / instantáneamente | — | +| sign up | registrarse | darse de alta (Spain) | +| computer | computadora | ordenador (Spain) | +| a website | un sitio web / una web | **la app** — Split is not an app and says so (stylebook §10) | +| room | **la sala** | el grupo, la cuenta, el evento | +| link | **el enlace** | el link, la liga (MX-only), el vínculo, la URL | +| the link is the key | **el enlace es la llave** | tu token, tu acceso | +| expense | **el gasto** | el egreso, la erogación (accounting register) | +| add an expense | cargar un gasto / agregar un gasto | ingresar un gasto | +| balance | **el saldo** | el balance (false friend — a balance sheet) | +| all square | **a mano** ("quedan a mano", "estás a mano") | en cero, balanceado, empatados | +| settle up (verb) | **saldar** | liquidar (accounting), pasar la cuenta, arreglar cuentas | +| settled | saldado / saldadas | cerrado, finiquitado | +| who owes who | **quién le debe a quién** | quién debe qué | +| free forever | **gratis para siempre** | gratuito, sin costo, versión gratis | +| automatic conversion for 156 currencies | conversión automática para 156 monedas | multimoneda, cualquier moneda, 150+ (stylebook §6.7) | +| converted at the day's rate | al tipo de cambio del día | tipo de cambio en vivo / en tiempo real (stylebook §6.6) | +| flatmate | roomie / compañero de departamento | **compañero de piso** (Spain), piso | +| flat | departamento | piso (Spain) | +| group chat | el chat del grupo | el grupo de WhatsApp (unscoped) | **`plata` — Split deviates from mono, on purpose.** Mono's `es-419` table permits _"dinero / plata (informal)"_; mono's `es-es` bans it. Split has no `es-es`, so `es-419` is also the page a reader in diff --git a/apps/web/src/content/_system/localization.pt-br.md b/apps/web/src/content/_system/localization.pt-br.md index a11e6647..a71b5aa8 100644 --- a/apps/web/src/content/_system/localization.pt-br.md +++ b/apps/web/src/content/_system/localization.pt-br.md @@ -99,35 +99,35 @@ Carried from mono `localization.pt-br.md` §2: Three-column table, mono's shape (`localization.{locale}.md` §3). **Rows 1–8 are carried from mono verbatim**; the rest are Split terms with no mono equivalent. -| English | pt-br | Avoid | -| --------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| the app | o app / o aplicativo | **a plataforma** (banned in every locale, `messaging.md` §14.2) | -| phone | celular | telemóvel (Portugal) | -| money | dinheiro | grana (too slangy for copy) | -| send money | enviar dinheiro | transferir fundos | -| instantly | instantaneamente / na hora | — | -| sign up | cadastrar-se / criar conta | — | -| try (in a CTA) | **experimente** | tente | -| computer | computador | — | -| a website | um site | **o app** — Split is not an app and says so (stylebook §10) | -| room | **a sala** | o grupo, a conta, o evento | -| link | **o link** | o enlace, a ligação (both Portugal) | -| the link is the key | **o link é a chave** | seu token, seu acesso | -| expense | **a despesa** | o gasto (secondary; keep one word per page) | -| add an expense | **lançar uma despesa** | inserir, cadastrar uma despesa | -| balance | **o saldo** | o balanço (an accounting statement) | -| all square | **quites** ("ficar quites", "tudo quites") | zerado, empatado | -| settle up (verb) | **acertar** | **passar a régua** (banned, stylebook §9.3), quitar (formal) | -| settled | acertado / acertadas | liquidado | -| who owes who | **quem deve a quem** | quem deve o quê | -| free forever | **grátis para sempre** | gratuito, sem custo, versão grátis | -| 158 converted currencies | 158 moedas convertidas | multimoeda, qualquer moeda (stylebook §6.7) | -| converted at the day's rate | pela taxa do dia | taxa ao vivo / em tempo real (stylebook §6.6) | -| the split (the operation) | **o rateio** | a divisão (vague), o acerto | -| each person's share | **a cota** | a parte, a fatia | -| flatmate | colega de apartamento / colega de apê | companheiro de casa | -| group chat | o grupo (do WhatsApp) | o chat | -| email | **e-mail** | email (mono prefers `e-mail` 47:31, and so does the live Split page) | +| English | pt-br | Avoid | +| --------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | +| the app | o app / o aplicativo | **a plataforma** (banned in every locale, `messaging.md` §14.2) | +| phone | celular | telemóvel (Portugal) | +| money | dinheiro | grana (too slangy for copy) | +| send money | enviar dinheiro | transferir fundos | +| instantly | instantaneamente / na hora | — | +| sign up | cadastrar-se / criar conta | — | +| try (in a CTA) | **experimente** | tente | +| computer | computador | — | +| a website | um site | **o app** — Split is not an app and says so (stylebook §10) | +| room | **a sala** | o grupo, a conta, o evento | +| link | **o link** | o enlace, a ligação (both Portugal) | +| the link is the key | **o link é a chave** | seu token, seu acesso | +| expense | **a despesa** | o gasto (secondary; keep one word per page) | +| add an expense | **lançar uma despesa** | inserir, cadastrar uma despesa | +| balance | **o saldo** | o balanço (an accounting statement) | +| all square | **quites** ("ficar quites", "tudo quites") | zerado, empatado | +| settle up (verb) | **acertar** | **passar a régua** (banned, stylebook §9.3), quitar (formal) | +| settled | acertado / acertadas | liquidado | +| who owes who | **quem deve a quem** | quem deve o quê | +| free forever | **grátis para sempre** | gratuito, sem custo, versão grátis | +| automatic conversion for 156 currencies | conversão automática para 156 moedas | multimoeda, qualquer moeda, 150+ (stylebook §6.7) | +| converted at the day's rate | pela taxa do dia | taxa ao vivo / em tempo real (stylebook §6.6) | +| the split (the operation) | **o rateio** | a divisão (vague), o acerto | +| each person's share | **a cota** | a parte, a fatia | +| flatmate | colega de apartamento / colega de apê | companheiro de casa | +| group chat | o grupo (do WhatsApp) | o chat | +| email | **e-mail** | email (mono prefers `e-mail` 47:31, and so does the live Split page) | **`dividir` / `rachar` / `rateio` / `acertar` are strategy, not taste.** Stylebook §9.3 is binding and is not restated here. In one line: `dividir` owns titles, H1s and slugs; `rachar` is body and diff --git a/apps/web/src/content/_system/product-truths.md b/apps/web/src/content/_system/product-truths.md index 77935dbf..daf80292 100644 --- a/apps/web/src/content/_system/product-truths.md +++ b/apps/web/src/content/_system/product-truths.md @@ -1,5 +1,5 @@ --- -last_verified: 2026-07-30 +last_verified: 2026-08-05 --- # Product truths @@ -18,23 +18,27 @@ the next person can check it rather than trust this file. --- -## rated-currencies-158 +## automatic-currency-conversion -**claim:** A room can use any of 162 catalog currencies. Split can automatically convert expenses -across 158 of them. CUC, KPW, SVC and XSU have no feed rate and work only when the expense and room -use the same currency. The indicative conversion rate is frozen onto the expense when it is created. +**claim:** The catalog recognises 162 currency codes, and 156 of them support automatic conversion +to the room's currency at the day's indicative rate. That rate is frozen onto the expense when it +is created. Split reads Peanut's public display-sell snapshot and caches it for 24 hours; during an +outage it may use a last-known-good rate for up to seven days, then falls back to the 12 core static +rates. The 12-rate table is also the only source in dev and test static mode. -**safe:** "162 room currencies" · "158 currencies, converted at the day's rate" · "the rate is -indicative, not your bank's" · "the rate is fixed when the expense is added, so history does not -move" +**safe:** "automatic conversion for 156 currencies" · "converted at the day's indicative rate" · +"the catalog recognises 162 currency codes" · "the rate is indicative, not your bank's" · "the +rate is fixed when the expense is added, so history does not move" -**unsafe:** "any currency converts" · "all 162 currencies convert" · "150+" · "live rate" · -"real-time rate" · anything implying the number moves after the expense is saved +**unsafe:** "twelve currencies" as the production feature · "multi-currency" (says nothing) · "any +currency" · "all currencies" · "150+" · "live rate" · "real-time rate" · anything implying the +number moves after the expense is saved -**source:** `apps/web/src/lib/currency-catalog.ts` (162 catalog entries, 158 with `hasRate`) · -`apps/web/src/lib/currency-rules.ts` (identity or two rated currencies) · `apps/web/src/server/fx.ts` -(live feed → cache → static table) · `apps/web/src/server/expenses.ts` (`fxRate` locked at creation -and reused on edit) +**source:** `apps/web/src/lib/currency-catalog.ts` (162 generated entries, 156 with `hasRate`) · +`apps/web/src/server/fx.ts` (Peanut snapshot → 24h cache → bounded seven-day stale cache → 12-rate +static table; the module says "Rates are indicative — surfaces that show one must say so") · +`apps/web/src/server/money.ts` (`STATIC_USD_PER_UNIT`, the 12 core fallback rates) · +`apps/web/src/server/expenses.ts` (`fxRate` locked at creation and reused on edit) --- diff --git a/apps/web/src/content/_system/stylebook.md b/apps/web/src/content/_system/stylebook.md index 26557455..d6d8bc10 100644 --- a/apps/web/src/content/_system/stylebook.md +++ b/apps/web/src/content/_system/stylebook.md @@ -401,19 +401,19 @@ does not do" and so is "The honest bit": a section title that promises a confess read as one. **§4.4 "Good to know" is flat and neutral, not apologetic.** It is where practical facts land when -they are not comparative: the trust answer (§7.4), 158 currencies converted at the day's rate, up -to twenty people, recording a settle-up needs a connection, Split records a payment rather than -making it. State each one in its own plain sentence. No hedging, no "unfortunately", no sentence that -sums the section up as a shortfall. A page that has nothing but comparative material does not need -the section at all. +they are not comparative: the trust answer (§7.4), automatic conversion for 156 currencies at the +day's indicative rate, up to twenty people, recording a settle-up needs a connection, Split records +a payment rather than making it. State each one in its own plain sentence. No hedging, no +"unfortunately", no sentence that sums the section up as a shortfall. A page that has nothing but +comparative material does not need the section at all. **§4.5 Concession pool** — draw from these; anything new must trace to product truth (§7.3): Split does not check with a bank and cannot, because settling up is a tap that records what two people -already did · 158 currencies, not every currency · up to twenty people · recording a settle-up -needs a connection, on purpose · Split will not chase anybody, so no reminders and no nudges into the -group chat · Split is smaller and does less than {Competitor}, deliberately, and a group that would -have been happy with {Competitor} should use {Competitor}. The accountless facts left this pool on -30 Jul and became mechanics (§4.2). +already did · automatic conversion covers 156 of the 162 currency codes the catalog recognises · up +to twenty people · recording a settle-up needs a connection, on purpose · Split will not chase +anybody, so no reminders and no nudges into the group chat · Split is smaller and does less than +{Competitor}, deliberately, and a group that would have been happy with {Competitor} should use +{Competitor}. The accountless facts left this pool on 30 Jul and became mechanics (§4.2). **§4.6 Concede before the CTA, never after** — the concession earns the CTA; reversing reads as a retraction. **§4.7 Never concede something false to sound humble.** @@ -474,8 +474,8 @@ ingresos` query: a doodle on a page about one partner being in fuel poverty is t | §6.3 | **No engagement gamification.** No streaks, levels, points, leaderboards, rankings, spending contests, progress bars, repeatable rewards, locked-item grids, or shame/loss states. Peanut Split does have a finite catalog of in-room achievements: optional trip keepsakes based on positive coordination, never money or usage pressure. `docs/ACHIEVEMENTS.md` is the source of truth. Marketing copy must not turn that product noun into an acquisition promise. | HN: _"Gamification of stuff you're forced to do is patronizing"_; the keepsake boundary preserves the distinction between noticing a trip and manufacturing a usage loop | | §6.4 | **No marketing adjectives:** seamless, effortless, robust, powerful, world-class, cutting-edge, game-changing, revolutionary, empower, unlock, elevate, supercharge, truly, incredibly. Split also bans **simply** outright, and **just** as a minimiser — sentence-initial imperative "Just …" is legal once per page under §3.18, mid-sentence "just" never is. Achievement UI should name the card or moment directly rather than using "unlock" as generic hype. | mono STE rules + Split's live copy | | §6.5 | **No competitor prices, any locale, including inside a quotation.** Keep the _fact_ of a paid tier without a number | Splitwise publishes none; ES review says "4€ al mes", a rival blog "3-4 euros" — both second-hand. Splid "$3.99" and Kittysplit "€3" violate the pages' own no-rot rule | -| §6.6 | **No live/real-time FX claims.** Ceiling: "converted at the day's rate" | `fx.ts` is live → cache → static fallback; the rate freezes onto each expense at creation | -| §6.7 | **No unbounded or superlative claims:** unlimited, any currency, multi-currency, all currencies, 150+, unguessable, fewest/minimum transfers, optimal, any size group | 158 converted currencies; twenty people; bounded exact netting with a greedy fallback | +| §6.6 | **No live/real-time FX claims.** Ceiling: "converted at the day's indicative rate" | `fx.ts` is live → cache → static fallback; the rate freezes onto each expense at creation | +| §6.7 | **No unbounded or superlative claims:** unlimited, any currency, multi-currency, all currencies, 150+, unguessable, fewest/minimum transfers, optimal, any size group | automatic conversion for 156 currencies; 162 recognised catalog codes; twenty people; bounded exact netting with a greedy fallback | | §6.8 | **No permanence promises.** Never "never lose your data", "safe forever", "a permanent record". The optional email is _access from any device_, never a backup guarantee | device loss is the documented churn driver for accountless apps | | §6.9 | **No slang with a shelf life, any locale.** No gen-alpha lexicon | _"vergonha alheia amanhã"_; the failure has a BR name, _tiozão_. Burned already: _lacrou_, _arrasou_ | | §6.10 | **No "split bills, not friendships"** | taken twice — Splid's hero and PartyTab's | @@ -582,7 +582,7 @@ the reader's behalf is the trust-builder for the migration audience. | Truth | Safe | Unsafe | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| Currencies | "158 currencies, converted at the day's rate"; "indicative, not your bank's rate" | multi-currency, any currency, 150+, live rate, real-time | +| Currencies | "automatic conversion for 156 currencies at the day's indicative rate"; "the catalog recognises 162 currency codes"; "indicative, not your bank's rate" | twelve currencies, multi-currency, any currency, 150+, live rate, real-time | | Group size | "up to twenty people" | unlimited, any size group | | Netting | "two or three transfers instead of twenty"; "a short payment plan" | fewest/minimum transfers, optimal | | Offline | "expenses typed with no signal wait on your phone and go when it comes back"; "recording a settle-up waits for a connection on purpose — a payment written down twice is worse than one written down late" | "works offline" as a category claim; anything implying settle-up works offline | @@ -776,16 +776,16 @@ and invent no others. ## §10 Vocabulary -| Say | Not | -| ------------------------------------------- | ----------------------------------------- | -| room · link · all square | group · invite / invite code · balanced | -| the link is the key / the link is the room | your access token | -| alter ego, cast, recast | avatar, profile | -| free forever, with nothing to upgrade to | free · free tier · freemium | -| reconciles to the cent | 100% accurate | -| Start a split | Get started · Try it free · Sign up | -| a website | the app (Split is not an app and says so) | -| 158 currencies, converted at the day's rate | multi-currency | +| Say | Not | +| ------------------------------------------------------ | ------------------------------------------------------- | +| room · link · all square | group · invite / invite code · balanced | +| the link is the key / the link is the room | your access token | +| alter ego, cast, recast | avatar, profile | +| free forever, with nothing to upgrade to | free · free tier · freemium | +| reconciles to the cent | 100% accurate | +| Start a split | Get started · Try it free · Sign up | +| a website | the app (Split is not an app and says so) | +| 156 currencies, converted at the day's indicative rate | multi-currency · twelve currencies as the product limit | ### §10.1 Naming — "Split", "Peanut Split", "Split by Peanut" diff --git a/apps/web/src/content/alternatives/settle-up-alternative/en.md b/apps/web/src/content/alternatives/settle-up-alternative/en.md index 5a5fca25..46a32559 100644 --- a/apps/web/src/content/alternatives/settle-up-alternative/en.md +++ b/apps/web/src/content/alternatives/settle-up-alternative/en.md @@ -9,7 +9,7 @@ faqs: - question: Is Split free forever? answer: Yes. Split is free forever, with nothing to upgrade to and no ads. Peanut makes it to introduce people to Peanut, which is how Split gets paid for. - question: Can we bring our Settle Up balances across? - answer: By typing them, not by importing them. There is no file to upload and nothing to connect. Put in what each person is up or down today as one line each, and anything already settled in Settle Up stays settled there. + answer: By typing them, not by importing them. Settle Up can export CSV, but Split does not accept that file yet or have a direct connection. Put in what each person is up or down today as one line each, and anything already settled in Settle Up stays settled there. - question: How do I get back into a room? answer: You reopen the link. Nothing to log in to, so the link is the key. Keep it in the group chat, and note that opening a room the first time needs a connection. --- @@ -65,14 +65,14 @@ Friends can **view** the balance. Somebody who never signs up can watch the numb | Cost | Free forever, no paid tier, no ads | Free to use, with parts of it behind a Premium plan | | Chasing people | Nothing. Split will not remind anybody | Reminds group members and nudges them to settle | | Netting debts | A short payment plan, always on | Transfers debts to cut the number of transactions; can be off | -| Currencies | 158 currencies, converted at the day's rate | Every currency, at a downloaded rate you can edit | +| Currencies | 156 currencies, converted at the day's indicative rate | Every currency, at a downloaded rate you can edit | | Offline | New expenses queue on the phone; recording a payment needs a signal | Works with no signal and syncs when it comes back | Settle Up rows are taken from settleup.io (homepage, /tips, /tos, /privacy_policy) and its US App Store listing, read in July 2026. ## What moves across, and what stays -Settle Up data does not import into a room. Settle Up will pull the members and balances of a Splitwise group in for you; there is no equivalent coming the other way, no file to upload and nothing to connect. +Settle Up data does not currently import into a room. Settle Up can export CSV, but Split does not accept that export yet or have a direct Settle Up connection. What you carry over is the position: what each person is up or down today, one line per person, so four people is four lines. The people themselves carry nothing at all, because there is no account for them to make. @@ -118,7 +118,7 @@ That is the trade. Less product, and nothing asked of anyone who opens the link. No. A room is a web link. Whoever opens it types a name and starts adding expenses. No app store, no account, no email, no password. Yes. Split is free forever, with nothing to upgrade to and no ads. Peanut makes it to introduce people to Peanut, which is how Split gets paid for. -By typing them, not by importing them. There is no file to upload and nothing to connect. Put in what each person is up or down today as one line each, and anything already settled in Settle Up stays settled there. +By typing them, not by importing them. Settle Up can export CSV, but Split does not accept that file yet or have a direct connection. Put in what each person is up or down today as one line each, and anything already settled in Settle Up stays settled there. You reopen the link. Nothing to log in to, so the link is the key. Keep it in the group chat, and note that opening a room the first time needs a connection. diff --git a/apps/web/src/content/alternatives/settle-up-alternative/es-419.md b/apps/web/src/content/alternatives/settle-up-alternative/es-419.md index 04d2194f..3c53f2ba 100644 --- a/apps/web/src/content/alternatives/settle-up-alternative/es-419.md +++ b/apps/web/src/content/alternatives/settle-up-alternative/es-419.md @@ -9,7 +9,7 @@ faqs: - question: ¿Split es gratis para siempre? answer: Sí. Split es gratis para siempre, sin nada a lo que subir y sin publicidad. Peanut lo hace para que la gente conozca Peanut, y así es como se paga Split. - question: ¿Podemos traer los saldos de Settle Up? - answer: Escribiéndolos, no importándolos. No hay archivo que subir ni nada que conectar. Carga lo que cada persona tiene a favor o en contra hoy, una línea por persona, y lo que ya quedó saldado en Settle Up sigue saldado ahí. + answer: Escribiéndolos, no importándolos. Settle Up puede exportar CSV, pero Split todavía no acepta ese archivo ni tiene una conexión directa. Carga lo que cada persona tiene a favor o en contra hoy, una línea por persona, y lo que ya quedó saldado en Settle Up sigue saldado ahí. - question: ¿Cómo vuelvo a entrar a una sala? answer: Abres el enlace otra vez. No hay sesión que recuperar, así que el enlace es la llave. Guárdalo en el chat del grupo, y ten en cuenta que abrir una sala por primera vez necesita conexión. --- @@ -71,14 +71,14 @@ Los amigos pueden **ver** el saldo. Alguien que nunca se registra puede mirar c | Precio | Gratis para siempre, sin plan pago, sin publicidad | Gratis de usar, con partes detrás de un plan Premium | | Perseguir a la gente | Nada. Split no le recuerda a nadie | Les recuerda a los miembros y los empuja a saldar | | Reducir deudas | Un plan de pagos corto, siempre activo | Transfiere deudas para bajar el número de transacciones; se puede apagar | -| Monedas | 158 monedas, al tipo de cambio del día | Todas las monedas, a un tipo de cambio descargado que puedes editar | +| Monedas | 156 monedas, al tipo de cambio indicativo del día | Todas las monedas, a un tipo de cambio descargado que puedes editar | | Sin señal | Los gastos nuevos esperan en el celular; registrar un pago pide señal | Funciona sin señal y sincroniza cuando vuelve | Las filas de Settle Up salen de settleup.io (portada, /tips, /tos, /privacy_policy) y de su ficha en la App Store de Estados Unidos, leídas en julio de 2026. ## Qué se muda y qué se queda -Los datos de Settle Up no se importan a una sala. Settle Up sí trae los miembros y los saldos de un grupo de Splitwise; no hay equivalente en la dirección contraria, no hay archivo que subir y no hay nada que conectar. +Los datos de Settle Up todavía no se importan a una sala. Settle Up puede exportar CSV, pero Split todavía no acepta ese archivo ni tiene una conexión directa. Lo que llevas es la posición: lo que cada persona tiene a favor o en contra hoy, una línea por persona, así que cuatro personas son cuatro líneas. Las personas no llevan nada, porque no hay cuenta que tengan que hacerse. @@ -124,7 +124,7 @@ Ese es el intercambio. Menos producto, y nada que pedirle a quien abre el enlace No. Una sala es un enlace web. Quien lo abre escribe un nombre y empieza a cargar gastos: sin tienda de apps, sin cuenta, sin correo, sin contraseña. Sí. Split es gratis para siempre, sin nada a lo que subir y sin publicidad. Peanut lo hace para que la gente conozca Peanut, y así es como se paga Split. -Escribiéndolos, no importándolos. No hay archivo que subir ni nada que conectar. Carga lo que cada persona tiene a favor o en contra hoy, una línea por persona, y lo que ya quedó saldado en Settle Up sigue saldado ahí. +Escribiéndolos, no importándolos. Settle Up puede exportar CSV, pero Split todavía no acepta ese archivo ni tiene una conexión directa. Carga lo que cada persona tiene a favor o en contra hoy, una línea por persona, y lo que ya quedó saldado en Settle Up sigue saldado ahí. Abres el enlace otra vez. No hay sesión que recuperar, así que el enlace es la llave. Guárdalo en el chat del grupo, y ten en cuenta que abrir una sala por primera vez necesita conexión. diff --git a/apps/web/src/content/alternatives/settle-up-alternative/pt-br.md b/apps/web/src/content/alternatives/settle-up-alternative/pt-br.md index 6b68a609..6f4d06b3 100644 --- a/apps/web/src/content/alternatives/settle-up-alternative/pt-br.md +++ b/apps/web/src/content/alternatives/settle-up-alternative/pt-br.md @@ -9,7 +9,7 @@ faqs: - question: O Split é grátis para sempre? answer: É. O Split é grátis para sempre, não existe nada para assinar depois e não tem anúncio. A Peanut faz o Split para apresentar a Peanut às pessoas, e é daí que sai o dinheiro. - question: Dá para trazer os saldos do Settle Up? - answer: Digitando, não importando. Não tem arquivo para subir nem nada para conectar. Ponha quanto cada pessoa está para cima ou para baixo hoje, uma linha para cada uma, e o que já foi acertado no Settle Up continua acertado lá. + answer: Digitando, não importando. O Settle Up pode exportar CSV, mas o Split ainda não aceita esse arquivo nem tem uma conexão direta. Ponha quanto cada pessoa está para cima ou para baixo hoje, uma linha para cada uma, e o que já foi acertado no Settle Up continua acertado lá. - question: Como eu volto para uma sala? answer: Você abre o link de novo. Não tem login, então o link é a chave. Deixe ele no grupo do WhatsApp, e lembre que abrir uma sala pela primeira vez precisa de conexão. --- @@ -71,14 +71,14 @@ Dá para **ver** o saldo. Quem nunca criou conta acompanha os números mexendo; | Preço | Grátis para sempre, sem plano pago, sem anúncios | Grátis de usar, com partes atrás de um plano Premium | | Cobrar as pessoas | Nada. O Split não lembra ninguém | Lembra os membros do grupo e cutuca para acertarem | | Reduzir as dívidas | Um plano de pagamento curto, sempre ligado | Transfere dívidas para cortar transações; dá para desligar | -| Moedas | 158 moedas, pela taxa do dia | Todas as moedas, por uma taxa baixada que você pode editar | +| Moedas | 156 moedas, pela taxa indicativa do dia | Todas as moedas, por uma taxa baixada que você pode editar | | Fora de sinal | Despesas novas ficam na fila no celular; registrar um pagamento precisa de conexão | Funciona sem sinal e sincroniza quando ele volta | As linhas do Settle Up vêm de settleup.io (página inicial, /tips, /tos, /privacy_policy) e da listagem na App Store dos EUA, lidas em julho de 2026. ## O que atravessa e o que fica -Os dados do Settle Up não são importados numa sala. O Settle Up puxa os membros e os saldos de um grupo do Splitwise para dentro dele; no sentido contrário não existe equivalente, não tem arquivo para subir e não tem nada para conectar. +Os dados do Settle Up ainda não são importados numa sala. O Settle Up pode exportar CSV, mas o Split ainda não aceita esse arquivo nem tem uma conexão direta. O que você leva é a posição: quanto cada pessoa está para cima ou para baixo hoje, uma linha por pessoa, então quatro pessoas são quatro linhas. As pessoas em si não levam nada, porque não existe conta para elas criarem. @@ -124,7 +124,7 @@ Não tem banco nem carteira atrás do Split. Duas pessoas acertam do jeito que e Não. Uma sala é um link. Quem abre digita um nome e começa a lançar despesas: sem loja de aplicativos, sem conta, sem e-mail, sem senha. É. O Split é grátis para sempre, não existe nada para assinar depois e não tem anúncio. A Peanut faz o Split para apresentar a Peanut às pessoas, e é daí que sai o dinheiro. -Digitando, não importando. Não tem arquivo para subir nem nada para conectar. Ponha quanto cada pessoa está para cima ou para baixo hoje, uma linha para cada uma, e o que já foi acertado no Settle Up continua acertado lá. +Digitando, não importando. O Settle Up pode exportar CSV, mas o Split ainda não aceita esse arquivo nem tem uma conexão direta. Ponha quanto cada pessoa está para cima ou para baixo hoje, uma linha para cada uma, e o que já foi acertado no Settle Up continua acertado lá. Você abre o link de novo. Não tem login, então o link é a chave. Deixe ele no grupo do WhatsApp, e lembre que abrir uma sala pela primeira vez precisa de conexão. diff --git a/apps/web/src/content/alternatives/splitwise-daily-limit/en.md b/apps/web/src/content/alternatives/splitwise-daily-limit/en.md index ddccdafb..3506e3a6 100644 --- a/apps/web/src/content/alternatives/splitwise-daily-limit/en.md +++ b/apps/web/src/content/alternatives/splitwise-daily-limit/en.md @@ -77,13 +77,13 @@ Their own pages say three things about ads. The Pro page sells "A totally ad-fre ## Where a room is different -| | Peanut Split | Splitwise free | -| ------------ | ------------------------------------------- | --------------------------------------------------- | -| Expenses/day | No cap | A daily cap, lifted by a Pro subscription | -| Ads | None | Pro is sold as an ad-free experience | -| Price | Free forever, no paid tier | Pro price not published; varies by country and date | -| Account | None. A room is a link | An account | -| Currencies | 158 currencies, converted at the day's rate | Currency conversion sits under Pro | +| | Peanut Split | Splitwise free | +| ------------ | ------------------------------------------------------ | --------------------------------------------------- | +| Expenses/day | No cap | A daily cap, lifted by a Pro subscription | +| Ads | None | Pro is sold as an ad-free experience | +| Price | Free forever, no paid tier | Pro price not published; varies by country and date | +| Account | None. A room is a link | An account | +| Currencies | 156 currencies, converted at the day's indicative rate | Currency conversion sits under Pro | Splitwise rows are taken from splitwise.com, kb.splitwise.com and feedback.splitwise.com on 31 July 2026. diff --git a/apps/web/src/content/alternatives/splitwise-daily-limit/es-419.md b/apps/web/src/content/alternatives/splitwise-daily-limit/es-419.md index cd58490b..84b2f9ee 100644 --- a/apps/web/src/content/alternatives/splitwise-daily-limit/es-419.md +++ b/apps/web/src/content/alternatives/splitwise-daily-limit/es-419.md @@ -83,13 +83,13 @@ Sus páginas dicen tres cosas sobre la publicidad. La página de Pro vende "A to ## En qué se diferencia una sala -| | Peanut Split | Splitwise gratis | -| ------------- | -------------------------------------- | -------------------------------------------------- | -| Gastos al día | Sin tope | Un tope diario, que levanta una suscripción Pro | -| Publicidad | Ninguna | Pro se vende como una experiencia sin publicidad | -| Precio | Gratis para siempre, sin plan pago | Precio de Pro sin publicar; varía por país y fecha | -| Cuenta | Ninguna. Una sala es un enlace | Una cuenta | -| Monedas | 158 monedas, al tipo de cambio del día | La conversión de monedas está dentro de Pro | +| | Peanut Split | Splitwise gratis | +| ------------- | ------------------------------------------------- | -------------------------------------------------- | +| Gastos al día | Sin tope | Un tope diario, que levanta una suscripción Pro | +| Publicidad | Ninguna | Pro se vende como una experiencia sin publicidad | +| Precio | Gratis para siempre, sin plan pago | Precio de Pro sin publicar; varía por país y fecha | +| Cuenta | Ninguna. Una sala es un enlace | Una cuenta | +| Monedas | 156 monedas, al tipo de cambio indicativo del día | La conversión de monedas está dentro de Pro | Las filas de Splitwise salen de splitwise.com, kb.splitwise.com y feedback.splitwise.com, leídas el 31 de julio de 2026. diff --git a/apps/web/src/content/alternatives/splitwise-daily-limit/pt-br.md b/apps/web/src/content/alternatives/splitwise-daily-limit/pt-br.md index 3458acc8..b1a56c77 100644 --- a/apps/web/src/content/alternatives/splitwise-daily-limit/pt-br.md +++ b/apps/web/src/content/alternatives/splitwise-daily-limit/pt-br.md @@ -84,13 +84,13 @@ As páginas deles dizem três coisas sobre anúncios. A página do Pro vende "A ## Onde uma sala é diferente -| | Peanut Split | Splitwise grátis | -| ---------------- | ---------------------------------- | ----------------------------------------------------- | -| Despesas por dia | Sem limite | Um limite diário, tirado por uma assinatura Pro | -| Anúncios | Nenhum | O Pro é vendido como experiência sem anúncios | -| Preço | Grátis para sempre, sem plano pago | Preço do Pro não publicado; varia por país e por data | -| Conta | Nenhuma. Uma sala é um link | Uma conta | -| Moedas | 158 moedas, pela taxa do dia | A conversão de moeda fica no Pro | +| | Peanut Split | Splitwise grátis | +| ---------------- | --------------------------------------- | ----------------------------------------------------- | +| Despesas por dia | Sem limite | Um limite diário, tirado por uma assinatura Pro | +| Anúncios | Nenhum | O Pro é vendido como experiência sem anúncios | +| Preço | Grátis para sempre, sem plano pago | Preço do Pro não publicado; varia por país e por data | +| Conta | Nenhuma. Uma sala é um link | Uma conta | +| Moedas | 156 moedas, pela taxa indicativa do dia | A conversão de moeda fica no Pro | As linhas do Splitwise vêm de splitwise.com, kb.splitwise.com e feedback.splitwise.com, lidas em 31 de julho de 2026. diff --git a/apps/web/src/content/alternatives/splitwise-vs-tricount/en.md b/apps/web/src/content/alternatives/splitwise-vs-tricount/en.md index 3f5f6e20..235e50d4 100644 --- a/apps/web/src/content/alternatives/splitwise-vs-tricount/en.md +++ b/apps/web/src/content/alternatives/splitwise-vs-tricount/en.md @@ -169,7 +169,7 @@ Tricount is owned by bunq, a bank, and the card-based automatic tracking runs in | --------------------- | ---------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------- | | Free expenses per day | A daily cap on the free tier, lifted by Pro | no cap; "No ads, no limits" | No cap | | Ads on the free tier | "A totally ad-free experience" is a Pro feature | "100% free" | No ads, and no paid tier | -| Currency conversion | Pro: "Only Pro subscribers can convert" | free: "automatically converts expenses" | 158 currencies, converted at the day's rate | +| Currency conversion | Pro: "Only Pro subscribers can convert" | free: "automatically converts expenses" | 156 currencies, converted at the day's indicative rate | | Netting debts | "Simplify Debts", but not across currencies | "fair, simple, and smart suggestions to settle up" | A short payment plan, across every currency in the room | | Accounts | "everyone in a Splitwise group can log in and see their balance" | marks itself "No Registration" | None. A room is a link | | Where it runs | "Free for iPhone, Android, and web." | download | Any browser, nothing to install | @@ -196,7 +196,7 @@ That group still owes each other money, and the money does not stop mattering be A room is a web link. It asks for neither the login nor the download: you send it, and the people who open it are in. The link is the key, so it belongs in the group chat rather than in one person's browser. -A room counts in one currency and takes expenses in any of 158, converted at the day's rate. The balances net down to a short payment plan. +A room counts in one currency and automatically converts expenses in 156 currencies at the day's indicative rate. The balances net down to a short payment plan. Settling up is where Split is smallest. Two people settle however they settle, and one of them taps to record it. Split does not check with a bank and cannot. It takes your word for it, the way a shared note does. diff --git a/apps/web/src/content/alternatives/splitwise-vs-tricount/es-419.md b/apps/web/src/content/alternatives/splitwise-vs-tricount/es-419.md index 0bbb9a6b..8f950c05 100644 --- a/apps/web/src/content/alternatives/splitwise-vs-tricount/es-419.md +++ b/apps/web/src/content/alternatives/splitwise-vs-tricount/es-419.md @@ -171,7 +171,7 @@ Tricount es de bunq, un banco, y el registro automático por tarjeta funciona so | ---------------------------- | ---------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------ | | Gastos gratis al día | Un tope diario en el plan gratis, que levanta Pro | sin tope; "No ads, no limits" | Sin tope | | Publicidad en el plan gratis | "A totally ad-free experience" es una función Pro | "100% free" | Sin publicidad, y sin plan pago | -| Conversión de monedas | Pro: "Only Pro subscribers can convert" | gratis: "automatically converts expenses" | 158 monedas, al tipo de cambio del día | +| Conversión de monedas | Pro: "Only Pro subscribers can convert" | gratis: "automatically converts expenses" | 156 monedas, al tipo de cambio indicativo del día | | Reducir deudas | "Simplify Debts", pero no entre monedas | Sugerencias para saldar entre las personas | Un plan de pagos corto, con todas las monedas de la sala | | Cuentas | "everyone in a Splitwise group can log in and see their balance" | se marca "No Registration" | Ninguna. Una sala es un enlace | | Dónde funciona | "Free for iPhone, Android, and web." | descarga | Cualquier navegador, nada que instalar | @@ -198,7 +198,7 @@ Ese grupo se sigue debiendo dinero, y el dinero no deja de importar porque ningu Una sala es un enlace web. No pide el registro ni la descarga: lo mandas, y quienes lo abren ya están adentro. El enlace es la llave, así que va en el chat del grupo y no en el navegador de una sola persona. -Una sala cuenta en una moneda y acepta gastos en cualquiera de 158, al tipo de cambio del día. Los saldos se reducen a un plan de pagos corto. +Una sala cuenta en una moneda y convierte automáticamente gastos en 156 monedas al tipo de cambio indicativo del día. Los saldos se reducen a un plan de pagos corto. Saldar es donde Split es más chico. Dos personas saldan como saldan, y una de las dos toca para registrarlo. Split no comprueba con ningún banco, y no puede. Te cree, igual que te cree una nota compartida. diff --git a/apps/web/src/content/alternatives/splitwise-vs-tricount/pt-br.md b/apps/web/src/content/alternatives/splitwise-vs-tricount/pt-br.md index 447aba55..f211e01e 100644 --- a/apps/web/src/content/alternatives/splitwise-vs-tricount/pt-br.md +++ b/apps/web/src/content/alternatives/splitwise-vs-tricount/pt-br.md @@ -173,7 +173,7 @@ O Tricount é da bunq, um banco, e o registro automático por cartão funciona s | ------------------------ | ---------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------- | | Despesas grátis por dia | Um limite diário no plano grátis, tirado pelo Pro | sem limite; "No ads, no limits" | Sem limite | | Anúncios no plano grátis | "A totally ad-free experience" é função do Pro | "100% free" | Sem anúncio e sem plano pago | -| Conversão de moeda | Pro: "Only Pro subscribers can convert" | grátis: "automatically converts expenses" | 158 moedas, pela taxa do dia | +| Conversão de moeda | Pro: "Only Pro subscribers can convert" | grátis: "automatically converts expenses" | 156 moedas, pela taxa indicativa do dia | | Reduzir as dívidas | "Simplify Debts", mas não entre moedas | Sugestões de acerto entre as pessoas | Um plano de pagamento curto, valendo para as moedas da sala | | Contas | "everyone in a Splitwise group can log in and see their balance" | se marca como "No Registration" | Nenhuma. Uma sala é um link | | Onde funciona | "Free for iPhone, Android, and web." | download | Qualquer navegador, sem instalar nada | @@ -200,7 +200,7 @@ Aquele grupo continua devendo dinheiro um ao outro, e o dinheiro não deixa de i Uma sala é um link. Ela não pede nem o login nem o download: você manda, e quem abre está dentro. O link é a chave, então ele mora no grupo do WhatsApp e não no navegador de uma pessoa só. -Uma sala conta numa moeda e aceita despesas em qualquer uma das 158, pela taxa do dia. Os saldos se reduzem a um plano de pagamento curto. +Uma sala conta numa moeda e converte automaticamente despesas em 156 moedas pela taxa indicativa do dia. Os saldos se reduzem a um plano de pagamento curto. Acertar é onde o Split é menor. Duas pessoas acertam do jeito que elas acertam, e uma delas toca para registrar. O Split não confere com banco nenhum e não tem como conferir. Ele acredita na sua palavra, do mesmo jeito que uma nota compartilhada acredita. diff --git a/apps/web/src/content/blog/fronting-a-group-trip/en.md b/apps/web/src/content/blog/fronting-a-group-trip/en.md index 9a36f453..240d17c6 100644 --- a/apps/web/src/content/blog/fronting-a-group-trip/en.md +++ b/apps/web/src/content/blog/fronting-a-group-trip/en.md @@ -6,7 +6,7 @@ type: guide tags: [trips, fronting, getting paid back] claims: - link-is-the-key - - rated-currencies-158 + - automatic-currency-conversion - offline-creates-only - netting-is-bounded-exact - settle-is-a-record @@ -64,7 +64,7 @@ You offered to book the Airbnb. Nobody asked you to run an unsecured lending des So put them in at the table, while the receipt is still on it. It takes longer to describe than to do, and no signal is not a problem: an expense typed with no connection waits on the phone and goes when the connection comes back. -Enter what you actually paid, in the currency you paid it in. Split converts 158 currencies, and an expense in one of them is converted at the day's rate and fixed onto the expense when you add it, so the history does not move under you later. The rate is indicative, not your bank's. +Enter what you actually paid, in the currency you paid it in. Split automatically converts 156 currencies at the day's indicative rate and fixes that rate onto the expense when you add it, so the history does not move under you later. It is an indicative rate, not your bank's. diff --git a/apps/web/src/content/capture/fair-split-calculator/en.md b/apps/web/src/content/capture/fair-split-calculator/en.md index a8ad6149..d3721eb7 100644 --- a/apps/web/src/content/capture/fair-split-calculator/en.md +++ b/apps/web/src/content/capture/fair-split-calculator/en.md @@ -11,7 +11,7 @@ claims: - settle-is-a-record - free-forever - room-size-20 - - rated-currencies-158 + - automatic-currency-conversion cast: [] faqs: - question: Is there a calculator that splits a bill by income? @@ -50,7 +50,7 @@ It is right more often than the search results suggest: when the difference is s Split is free forever, with nothing to upgrade to. Peanut makes it to introduce people to Peanut, which is how Split gets paid for, so there is no paid tier and nothing to move behind one later. -The calculators do the weighting and the room does the ledger. It takes 158 currencies, converted at the day's rate, and holds up to twenty people. Split records a payment rather than making one. It does not check with a bank and cannot. +The calculators do the weighting and the room does the ledger. It automatically converts 156 currencies at the day's indicative rate and holds up to twenty people. Split records a payment rather than making one. It does not check with a bank and cannot. > ` -/** Bank-statement CSV: real columns, real commas, no member columns and no currency column. */ -export const WRONG_CSV = `Transaction Date,Details,Amount,Balance -2026-01-02,COFFEE SHOP,-3.40,1200.10 -2026-01-03,SALARY,2000.00,3200.10 +/** Bank-style CSV whose Date/Cost/Currency and balanced Debit/Credit columns used to be mistaken + * for a Splitwise roster. It lacks the canonical Category metadata column. */ +export const WRONG_CSV = `Date,Description,Cost,Currency,Debit,Credit +2026-01-02,COFFEE SHOP,10.00,EUR,10.00,-10.00 ` /** diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index a0dd2e68..023b0fb0 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -185,7 +185,7 @@ export interface RoomStateWithAddedMember extends RoomState { } export interface ApiError { - error: { code: string; message: string } + error: { code: string; message: string; details?: unknown } } // ─── request bodies ───────────────────────────────────────────────────────── @@ -380,6 +380,10 @@ export interface ImportedExpenseInput { /** POST /api/import — a whole room in one body. */ export interface ImportRoomInput { + /** SHA-256 identity of the local source file and selected raw-file choice. + * Optional only for rolling compatibility with clients predating durable + * source identity; current importers always send it. */ + sourceFingerprint?: string roomName: string emoji?: string | null /** What the room settles in. Expenses in other currencies are converted at import time. */ @@ -398,6 +402,8 @@ export type ImportMemberMapping = /** POST /api/rooms/:slug/import — append one parsed source export atomically. */ export interface ImportIntoRoomInput { + /** Same immutable-source identity as `ImportRoomInput.sourceFingerprint`. */ + sourceFingerprint?: string members: ImportMemberMapping[] expenses: ImportedExpenseInput[] } diff --git a/apps/web/src/lib/api.test.ts b/apps/web/src/lib/api.test.ts index 9d853b1b..9dc36daf 100644 --- a/apps/web/src/lib/api.test.ts +++ b/apps/web/src/lib/api.test.ts @@ -110,6 +110,24 @@ describe('api error paths', () => { expect(failure.message).toBe('Bea is already in this room') }) + it('preserves optional machine-readable error details', async () => { + vi.stubGlobal( + 'fetch', + respondWith(400, { + error: { + code: 'IMPORT_CURRENCY_CONVERSION_UNSUPPORTED', + message: 'PLN cannot be converted', + details: { currencies: ['PLN'], targetCurrency: 'EUR' }, + }, + }) + ) + + const failure = await api.importRoom({} as never).catch((error) => error) + + expect(failure).toBeInstanceOf(ApiRequestError) + expect(failure.details).toEqual({ currencies: ['PLN'], targetCurrency: 'EUR' }) + }) + it('distinguishes a 409 EXPENSE_DELETED from other conflicts', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 357104e7..f31f50ad 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -36,7 +36,8 @@ export class ApiRequestError extends Error { constructor( readonly status: number, readonly code: string, - message: string + message: string, + readonly details?: unknown ) { super(message) this.name = 'ApiRequestError' @@ -143,7 +144,8 @@ async function request(path: string, options: RequestOptions = {}): Promise(path: string, options: RequestOptions = {}): Promise = new Map(CURRENCY_CATALOG.map((c) => [c.code, c])) diff --git a/apps/web/src/lib/currency-hint.ts b/apps/web/src/lib/currency-hint.ts index 5e26656c..160ec452 100644 --- a/apps/web/src/lib/currency-hint.ts +++ b/apps/web/src/lib/currency-hint.ts @@ -17,9 +17,11 @@ * list* rather than one answer: a guess presented as a fact is worse than no guess, and three * one-tap chips cost the user nothing. * - * Everything is table-driven from `ORIGINS` below, which covers only the 12 catalog currencies - * (`src/server/money.ts`). Anything outside it resolves to EUR (the `Europe/` catch-all) or USD - * (the final fallback) rather than to a currency the room could not be created in. + * Everything is table-driven from `ORIGINS` below, which deliberately covers the 12 core + * currencies that remain priceable from the static outage/dev fallback. The full catalog + * recognises 162 codes and the connected rate feed prices 156, but a weak device-locale guess is + * not a reason to suggest all of them. Anything outside this hint table resolves to EUR (the + * `Europe/` catch-all) or USD (the final fallback), both of which are always priceable. * * This module is pure on purpose — no `navigator`, no `Intl`, no React. The device reading lives * in `use-currency-hint.ts`, which keeps this testable with explicit fixtures. @@ -61,8 +63,8 @@ interface CurrencyOrigin { } /** - * One table, three lookups. Adding a 13th currency to the catalog means adding one row here and - * nothing else — the maps below are all derived. + * One table, three lookups. Adding another currency to the hint set means adding one row here and + * nothing else — the maps below are all derived. Catalog and rate-feed coverage are separate. */ const ORIGINS: readonly CurrencyOrigin[] = [ { diff --git a/apps/web/src/lib/import-source-fingerprint.test.ts b/apps/web/src/lib/import-source-fingerprint.test.ts new file mode 100644 index 00000000..da5597ab --- /dev/null +++ b/apps/web/src/lib/import-source-fingerprint.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { fingerprintParsedImportFile } from '@/lib/import-source-fingerprint' +import type { ParsedImportFile } from '@/lib/splitpro-import' + +const parsedFile = (description: string): ParsedImportFile => ({ + source: 'splitwise', + skipped: [], + choices: [ + { + id: 'presentation-only-id', + sourceKey: 'file', + roomName: 'Presentation-only room name', + parsed: { + members: ['You', 'Bea'], + expenses: [ + { + date: '2026-08-05', + description, + category: null, + currencyCode: 'EUR', + costMinor: '1000', + paidBy: 'You', + splitMode: 'EQUAL', + shares: [ + { member: 'You', amountMinor: '500' }, + { member: 'Bea', amountMinor: '500' }, + ], + }, + ], + suggestedCurrency: 'EUR', + currencies: ['EUR'], + totalBalance: null, + warnings: [], + }, + }, + ], +}) + +describe('immutable import source fingerprints', () => { + it('does not change when a parser version projects the same file differently', async () => { + const source = 'the exact same source export bytes after UTF-8 decoding' + const before = await fingerprintParsedImportFile(source, parsedFile('Old parser description')) + const after = await fingerprintParsedImportFile(source, parsedFile('Improved parser description')) + + expect(after.choices[0].sourceFingerprint).toBe(before.choices[0].sourceFingerprint) + expect(after.choices[0].sourceFingerprint).toMatch(/^[a-f0-9]{64}$/) + }) + + it('scopes choices within one file and changes only when the immutable source changes', async () => { + const oneChoice = parsedFile('Dinner') + const twoChoices: ParsedImportFile = { + ...oneChoice, + choices: [oneChoice.choices[0], { ...oneChoice.choices[0], id: 'second label', sourceKey: 'group:1' }], + } + const original = await fingerprintParsedImportFile('source A', twoChoices) + const changed = await fingerprintParsedImportFile('source B', twoChoices) + + expect(original.choices[0].sourceFingerprint).not.toBe(original.choices[1].sourceFingerprint) + expect(changed.choices[0].sourceFingerprint).not.toBe(original.choices[0].sourceFingerprint) + }) +}) diff --git a/apps/web/src/lib/import-source-fingerprint.ts b/apps/web/src/lib/import-source-fingerprint.ts new file mode 100644 index 00000000..1deb603b --- /dev/null +++ b/apps/web/src/lib/import-source-fingerprint.ts @@ -0,0 +1,43 @@ +import type { ImportChoice, ParsedImportFile } from '@/lib/splitpro-import' + +const FINGERPRINT_DOMAIN = 'peanut-split/import-source/v1' + +const sha256 = async (value: Uint8Array): Promise => { + const digest = await globalThis.crypto.subtle.digest('SHA-256', value) + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export interface FingerprintedImportChoice extends ImportChoice { + /** SHA-256 identity of the immutable upload plus this choice's source locator. */ + sourceFingerprint: string +} + +export interface FingerprintedImportFile extends Omit { + choices: FingerprintedImportChoice[] +} + +/** + * Attach durable identities without hashing the parser's projection. + * + * The first digest covers the exact decoded file text. The second scopes it to + * a raw-file choice, because one Split Pro account backup can offer several + * rooms. Parsed names, dates, split labels, and amounts are deliberately absent: + * improving any of those later must not turn a retry of the same export into a + * second ledger write. + */ +export async function fingerprintParsedImportFile( + sourceText: string, + parsed: ParsedImportFile +): Promise { + const encoder = new TextEncoder() + const fileDigest = await sha256(encoder.encode(sourceText)) + const choices = await Promise.all( + parsed.choices.map(async (choice) => ({ + ...choice, + sourceFingerprint: await sha256( + encoder.encode(`${FINGERPRINT_DOMAIN}\0${fileDigest}\0${choice.sourceKey}`) + ), + })) + ) + return { ...parsed, choices } +} diff --git a/apps/web/src/lib/manual-fx-rate.ts b/apps/web/src/lib/manual-fx-rate.ts index 6176a50e..6662e295 100644 --- a/apps/web/src/lib/manual-fx-rate.ts +++ b/apps/web/src/lib/manual-fx-rate.ts @@ -1,14 +1,20 @@ -import { formatAmountInput, formatMinorPlain, isAmountInputAcceptable, parseAmountToMinor } from './money' +import { + formatAmountInput, + formatMinorPlain, + isAmountInputAcceptable, + MAX_SIGNED_MINOR, + parseAmountToMinor, +} from './money' /** Expense.fxRate is Decimal(24,12): twelve digits on either side of the decimal mark. */ export const MANUAL_FX_RATE_DECIMALS = 12 export const MANUAL_FX_RATE_MAX_LENGTH = 25 const MANUAL_FX_RATE_SCALE = 10n ** BigInt(MANUAL_FX_RATE_DECIMALS) const MANUAL_FX_RATE_MAX_SCALED = 999_999_999_999n * MANUAL_FX_RATE_SCALE -export const MAX_SIGNED_MINOR = 9_223_372_036_854_775_807n +export { MAX_SIGNED_MINOR } from './money' const parseManualFxRateScaled = (input: string, locale?: string): bigint | null => { - const scaled = parseAmountToMinor(input, MANUAL_FX_RATE_DECIMALS, locale) + const scaled = parseAmountToMinor(input, MANUAL_FX_RATE_DECIMALS, locale, MANUAL_FX_RATE_MAX_SCALED) if (scaled === null) return null const value = BigInt(scaled) return value > 0n && value <= MANUAL_FX_RATE_MAX_SCALED ? value : null @@ -37,14 +43,19 @@ export function parseManualFxRateInput(input: string, locale?: string): string | /** Can the input hold this value while somebody is still typing it? */ export function isManualFxRateInputAcceptable(input: string, locale?: string): boolean { if (input.length > MANUAL_FX_RATE_MAX_LENGTH) return false - return isAmountInputAcceptable(input, MANUAL_FX_RATE_DECIMALS, locale) + return isAmountInputAcceptable(input, MANUAL_FX_RATE_DECIMALS, locale, MANUAL_FX_RATE_MAX_SCALED) } /** A frozen wire rate -> compact text using the active locale's decimal mark. */ export function formatManualFxRateInput(rate: string, locale: string): string { const canonical = parseManualFxRateInput(rate) if (!canonical) return '' - const scaled = parseAmountToMinor(canonical, MANUAL_FX_RATE_DECIMALS) as string + const scaled = parseAmountToMinor( + canonical, + MANUAL_FX_RATE_DECIMALS, + undefined, + MANUAL_FX_RATE_MAX_SCALED + ) as string const fixed = formatAmountInput(scaled, MANUAL_FX_RATE_DECIMALS, locale) const decimalMark = fixed.includes(',') ? ',' : '.' const decimalAt = fixed.lastIndexOf(decimalMark) diff --git a/apps/web/src/lib/money.test.ts b/apps/web/src/lib/money.test.ts index c862771a..73f457ac 100644 --- a/apps/web/src/lib/money.test.ts +++ b/apps/web/src/lib/money.test.ts @@ -12,9 +12,11 @@ import { formatMoney, formatMoneyParts, isAmountInputAcceptable, + MAX_SIGNED_MINOR, minorToExactNumber, overviewMoneyPresentation, parseAmountToMinor, + parseExportAmountToMinor, } from './money' describe('parseAmountToMinor', () => { @@ -46,6 +48,12 @@ describe('parseAmountToMinor', () => { expect(parseAmountToMinor('abc', 2)).toBeNull() }) + it('shares PostgreSQL BIGINT bounds with API validation without constructing giant BigInts', () => { + expect(parseAmountToMinor(MAX_SIGNED_MINOR.toString(), 0)).toBe(MAX_SIGNED_MINOR.toString()) + expect(parseAmountToMinor((MAX_SIGNED_MINOR + 1n).toString(), 0)).toBeNull() + expect(parseAmountToMinor('9'.repeat(100_000), 0)).toBeNull() + }) + /** * The locale-aware half. A pt-BR keyboard produces "1.234,56" and an en one "1,234.56" for * the same amount — with both separators present the last one is unambiguously the decimal @@ -164,6 +172,40 @@ describe('parseAmountToMinor', () => { }) }) +describe('parseExportAmountToMinor', () => { + it('uses zero-decimal currency metadata to read either grouping convention', () => { + expect(parseExportAmountToMinor('1,234', 0)).toBe('1234') + expect(parseExportAmountToMinor('1.234', 0)).toBe('1234') + expect(parseExportAmountToMinor('1,234,567', 0)).toBe('1234567') + expect(parseExportAmountToMinor('1.234.567', 0)).toBe('1234567') + }) + + it('accepts explicit zero fractions but refuses fractional zero-decimal source money', () => { + expect(parseExportAmountToMinor('1234.00', 0)).toBe('1234') + expect(parseExportAmountToMinor('1,234.00', 0)).toBe('1234') + expect(parseExportAmountToMinor('1234.5', 0)).toBeNull() + expect(parseExportAmountToMinor('12,34', 0)).toBeNull() + }) + + it('retains the established decimal and grouping rules for currencies with fractions', () => { + expect(parseExportAmountToMinor('1,234.56', 2)).toBe('123456') + expect(parseExportAmountToMinor('1.234,56', 2)).toBe('123456') + expect(parseExportAmountToMinor('1,234', 2)).toBe('123400') + expect(parseExportAmountToMinor('1.234', 2)).toBe('123400') + expect(parseExportAmountToMinor('1,234,567', 2)).toBe('123456700') + expect(parseExportAmountToMinor('0,123', 2)).toBeNull() + expect(parseExportAmountToMinor('1,234.567', 2)).toBeNull() + expect(parseExportAmountToMinor('1.234,567', 2)).toBeNull() + }) + + it('refuses the 1000x-ambiguous single-separator shape for three-decimal exports', () => { + expect(parseExportAmountToMinor('1,234', 3)).toBeNull() + expect(parseExportAmountToMinor('1.234', 3)).toBeNull() + expect(parseExportAmountToMinor('0.234', 3)).toBe('234') + expect(parseExportAmountToMinor('1,234,567', 3)).toBe('1234567000') + }) +}) + describe('isAmountInputAcceptable', () => { it('lets an amount be typed one character at a time', () => { for (const partial of ['', '1', '12', '12.', '12.3', '12.34']) { diff --git a/apps/web/src/lib/money.ts b/apps/web/src/lib/money.ts index 516cd6e8..70e30c29 100644 --- a/apps/web/src/lib/money.ts +++ b/apps/web/src/lib/money.ts @@ -12,6 +12,21 @@ import { CATALOG_BY_CODE, CURRENCY_CATALOG } from './currency-catalog' import { formatWithCurrency } from './currency-rules' import type { CurrencyInfo } from './api-types' +/** PostgreSQL BIGINT's positive ceiling. Money is stored as signed minor-unit + * integers, so browser parsing must enforce the same boundary as the API. */ +export const MAX_SIGNED_MINOR = 9_223_372_036_854_775_807n + +/** Refuse an oversized integer before constructing a potentially enormous + * BigInt. Comparing canonical decimal strings is exact and keeps uploaded CSV + * cells on the same bounded path as interactive amounts. */ +const parseBoundedUnsigned = (digits: string, maximum: bigint): bigint | null => { + const canonical = digits.replace(/^0+(?=\d)/, '') || '0' + const maximumText = maximum.toString() + if (canonical.length > maximumText.length || (canonical.length === maximumText.length && canonical > maximumText)) + return null + return BigInt(canonical) +} + /** * The catalog, in the bundle. * @@ -114,7 +129,15 @@ function normaliseDecimalInput(input: string, locale?: string): string | null { * pass a locale and reject extra fraction digits; legacy import callers omit it * and keep deterministic half-up rounding. */ -export function parseAmountToMinor(input: string, decimals: number, locale?: string): string | null { +export function parseAmountToMinor( + input: string, + decimals: number, + locale?: string, + maximum = MAX_SIGNED_MINOR +): string | null { + // Eighteen covers every catalog currency and the 12dp manual-FX input. It + // also prevents a hostile caller from turning padEnd/10** into unbounded work. + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 18) return null const raw = normaliseDecimalInput(input, locale) if (raw === null) return null if (raw.length === 0) return null @@ -132,14 +155,64 @@ export function parseAmountToMinor(input: string, decimals: number, locale?: str if (decimals === 0) { // Round half-up on the first fraction digit. const roundUp = fraction.length > 0 && Number(fraction[0]) >= 5 - return (BigInt(wholePart) + (roundUp ? 1n : 0n)).toString() + const wholeMinor = parseBoundedUnsigned(wholePart, maximum) + if (wholeMinor === null) return null + const rounded = wholeMinor + (roundUp ? 1n : 0n) + return rounded <= maximum ? rounded.toString() : null } const padded = fraction.padEnd(decimals + 1, '0') const kept = padded.slice(0, decimals) const next = Number(padded[decimals] ?? '0') - const minor = BigInt(wholePart) * 10n ** BigInt(decimals) + BigInt(kept === '' ? '0' : kept) - return (next >= 5 ? minor + 1n : minor).toString() + const minor = parseBoundedUnsigned(`${wholePart}${kept}`, maximum) + if (minor === null) return null + const rounded = next >= 5 ? minor + 1n : minor + return rounded <= maximum ? rounded.toString() : null +} + +/** + * Parse a machine-exported amount without silently rounding source precision. + * + * A single separator followed by exactly three digits is inherently ambiguous + * for a 3dp currency: `1,234` can mean either 1.234 KWD or 1,234.000 KWD. An + * export with no locale metadata cannot choose safely, so that shape is + * refused. Repeated canonical groups are unambiguous whole numbers. Mixed + * grouping/decimal conventions remain supported, while a fractional part + * longer than the currency allows is refused. + */ +export function parseExportAmountToMinor(input: string, decimals: number): string | null { + const raw = input.trim().replace(/\s/g, '') + if (!raw.includes('.') && !raw.includes(',')) return parseAmountToMinor(raw, decimals) + + const separatorCount = [...raw].filter((character) => character === '.' || character === ',').length + const groupedWhole = /^[1-9]\d{0,2}(?:,\d{3})+$/.test(raw) || /^[1-9]\d{0,2}(?:\.\d{3})+$/.test(raw) + + if (groupedWhole) { + if (separatorCount > 1 || decimals < 3) return parseAmountToMinor(raw.replace(/[.,]/g, ''), decimals) + return null + } + + const wholeWithZeroFraction = raw.match(/^(\d+)[.,](0+)$/) + if (decimals === 0 && wholeWithZeroFraction) { + return parseAmountToMinor(wholeWithZeroFraction[1], 0) + } + + if (raw.includes('.') && raw.includes(',')) { + const decimalSeparator = raw.lastIndexOf('.') > raw.lastIndexOf(',') ? '.' : ',' + const fraction = raw.slice(raw.lastIndexOf(decimalSeparator) + 1) + if (decimals === 0) { + if (!/^0*$/.test(fraction)) return null + } else if (fraction.length > decimals) { + return null + } + return parseAmountToMinor(raw, decimals) + } + + if (separatorCount > 1) return null + const separator = raw.includes('.') ? '.' : ',' + const fraction = raw.slice(raw.indexOf(separator) + 1) + if (fraction.length > decimals || (decimals === 0 && !/^0*$/.test(fraction))) return null + return parseAmountToMinor(raw, decimals) } /** @@ -171,11 +244,16 @@ const TYPEABLE_COMPLETIONS = ['', '0', '00', '000'] as const * this split". Whitespace alone is not: it is on the way to nothing, and a field * holding it would read as filled while the parser sees no amount in it. */ -export function isAmountInputAcceptable(input: string, decimals: number, locale?: string): boolean { +export function isAmountInputAcceptable( + input: string, + decimals: number, + locale?: string, + maximum = MAX_SIGNED_MINOR +): boolean { if (input.length === 0) return true if (input.trim().length === 0) return false return TYPEABLE_COMPLETIONS.some( - (completion) => parseAmountToMinor(`${input}${completion}`, decimals, locale) !== null + (completion) => parseAmountToMinor(`${input}${completion}`, decimals, locale, maximum) !== null ) } diff --git a/apps/web/src/lib/splitpro-import.test.ts b/apps/web/src/lib/splitpro-import.test.ts index a822afd2..57d905dd 100644 --- a/apps/web/src/lib/splitpro-import.test.ts +++ b/apps/web/src/lib/splitpro-import.test.ts @@ -95,6 +95,7 @@ describe('Split Pro account JSON', () => { expect(parsed.members).toEqual(['You', 'Bruno']) expect(balances(parsed)).toEqual({ You: '-425', Bruno: '425' }) + expect(parsed.expenses[0].date).toBe('1970-01-01') expect(acceptedByServer(parsed)).toBe(true) }) @@ -107,6 +108,7 @@ describe('Split Pro account JSON', () => { describe('Split Pro friend CSV', () => { const parsed = parseImportFile(SPLITPRO_FRIEND_CSV, 'expenses_with_Natalia.csv').choices[0].parsed + const header = SPLITPRO_FRIEND_CSV.split('\n')[0] it('detects the pair export and names both people', () => { expect(parsed.members).toEqual(['You', 'Natalia']) @@ -139,4 +141,83 @@ describe('Split Pro friend CSV', () => { expect(codes).toContain('SPLITPRO_PAIR_HISTORY') expect(codes).toContain('PAYMENT_ROWS') }) + + it('explains when percentage or share weights become fixed final amounts', () => { + const weighted = `${header}\nYou,Dinner,Food,20.00,PERCENTAGE,2026-08-01 12:00:00,EUR,10.00,0,0\n` + const imported = parseImportFile(weighted, 'expenses_with_Natalia.csv').choices[0].parsed + + expect(imported.expenses[0].splitMode).toBe('EXACT') + expect(imported.expenses[0].shares).toEqual([ + { member: 'You', amountMinor: '1000' }, + { member: 'Natalia', amountMinor: '1000' }, + ]) + expect(imported.warnings).toContainEqual({ code: 'SPLITPRO_SPLIT_MODE_FLATTENED' }) + }) + + it('preserves explicit exact editing intent even when the final amounts are 50/50', () => { + const exact = `${header}\nYou,Dinner,Food,20.00,EXACT,2026-08-01 12:00:00,EUR,10.00,0,0\n` + const imported = parseImportFile(exact, 'expenses_with_Natalia.csv').choices[0].parsed + + expect(imported.expenses[0].splitMode).toBe('EXACT') + }) + + it('derives the friend from consistent Paid By cells even when the file was renamed', () => { + const renamed = parseImportFile(SPLITPRO_FRIEND_CSV, 'trip.csv').choices[0].parsed + expect(renamed.members).toEqual(['You', 'Natalia']) + expect(renamed.expenses.map((expense) => expense.paidBy)).toEqual(['You', 'Natalia', 'You']) + }) + + it('cleans a browser download suffix when the rows contain no friend-paid expense', () => { + const onlyYou = `${header}\nYou,Dinner,Food,20.00,EQUAL,2026-08-01 12:00:00,EUR,10.00,0,0\n` + const imported = parseImportFile(onlyYou, 'expenses_with_Natalia Cieśla (5).csv').choices[0].parsed + + expect(imported.members).toEqual(['You', 'Natalia Cieśla']) + }) + + it('drops a payer inconsistent with the inferred pair instead of assigning it to the friend', () => { + const withThirdPayer = [ + header, + 'You,Dinner,Food,20.00,EQUAL,2026-08-01 12:00:00,EUR,10.00,0,0', + 'Natalia,Taxi,Transport,20.00,EQUAL,2026-08-01 12:00:00,EUR,0,10.00,0', + 'Someone Else,Coffee,Food,10.00,EQUAL,2026-08-01 12:00:00,EUR,0,5.00,0', + '', + ].join('\n') + const imported = parseImportFile(withThirdPayer, 'expenses_with_Natalia.csv').choices[0].parsed + + expect(imported.expenses.map((expense) => expense.description)).toEqual(['Dinner', 'Taxi']) + expect(imported.warnings).toContainEqual({ code: 'ROW_NO_PAYER', row: 4, detail: 'Someone Else' }) + }) + + it('preserves the calendar day from SplitPro timestamps independently of timezone', () => { + const atMidnight = `${header}\nYou,Rent,Housing,20.00,EQUAL,2026-08-01 00:00:00,EUR,10.00,0,0\n` + const imported = parseImportFile(atMidnight, 'expenses_with_Natalia.csv').choices[0].parsed + + expect(imported.expenses[0].date).toBe('2026-08-01') + expect(imported.warnings.map((warning) => warning.code)).not.toContain('ROW_BAD_DATE') + }) + + it('uses a deterministic sentinel for an invalid SplitPro date', () => { + const badDate = `${header}\nYou,Rent,Housing,20.00,EQUAL,not a date,EUR,10.00,0,0\n` + const imported = parseImportFile(badDate, 'expenses_with_Natalia.csv').choices[0].parsed + + expect(imported.expenses[0].date).toBe('1970-01-01') + expect(imported.warnings).toContainEqual({ code: 'ROW_BAD_DATE', row: 2 }) + }) + + it('reads grouped zero-decimal amounts without changing their scale', () => { + const jpy = `${header}\nYou,Dinner,Food,"2,000",EQUAL,2026-08-01 12:00:00,JPY,"1,000",0,0\n` + const imported = parseImportFile(jpy, 'expenses_with_Natalia.csv').choices[0].parsed + + expect(imported.expenses[0].costMinor).toBe('2000') + expect(imported.expenses[0].shares).toEqual([ + { member: 'You', amountMinor: '1000' }, + { member: 'Natalia', amountMinor: '1000' }, + ]) + }) + + it('surfaces a typed error for an unterminated quoted field', () => { + expect(() => parseImportFile(`${header}\nYou,"unterminated`, 'expenses_with_Natalia.csv')).toThrowError( + expect.objectContaining>({ code: 'MALFORMED_CSV' }) + ) + }) }) diff --git a/apps/web/src/lib/splitpro-import.ts b/apps/web/src/lib/splitpro-import.ts index c7721875..5386942c 100644 --- a/apps/web/src/lib/splitpro-import.ts +++ b/apps/web/src/lib/splitpro-import.ts @@ -15,7 +15,7 @@ * file never leaves the browser. */ -import { currencyInfo, FALLBACK_CURRENCIES } from '@/lib/money' +import { currencyInfo, FALLBACK_CURRENCIES, MAX_SIGNED_MINOR } from '@/lib/money' import { BROUGHT_FORWARD, MAX_CATEGORY_CHARS, @@ -25,10 +25,11 @@ import { MAX_NAME_CHARS, MAX_PARSED_EXPENSES, MAX_ROWS, + INVALID_DATE_FALLBACK, SplitwiseParseError, capHistory, - isEvenSplit, parseCsvRows, + parseImportDate, parseSignedMinor, parseSplitwiseCsv, roomNameFromFilename, @@ -39,12 +40,14 @@ import { } from '@/lib/splitwise-csv' const SUPPORTED_CURRENCIES = new Set(FALLBACK_CURRENCIES.map((currency) => currency.code)) -const MAX_SIGNED_MINOR = 9_223_372_036_854_775_807n export type ImportSource = 'splitwise' | 'splitpro' export interface ImportChoice { id: string + /** Immutable locator inside the uploaded file. It must depend only on the + * source document's structure, never on names, amounts, or parser output. */ + sourceKey: string roomName: string parsed: SplitwiseImport } @@ -110,22 +113,6 @@ function safeNames(rawNames: string[], warnings: ImportWarning[]): string[] { }) } -const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/ - -function importDate(value: unknown): { date: string; ok: boolean } { - const raw = text(value) - if (ISO_DATE.test(raw)) { - const parsed = new Date(`${raw}T00:00:00.000Z`) - if (!Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === raw) { - return { date: raw, ok: true } - } - } - - const parsed = new Date(raw) - if (raw && !Number.isNaN(parsed.getTime())) return { date: parsed.toISOString().slice(0, 10), ok: true } - return { date: new Date().toISOString().slice(0, 10), ok: false } -} - function finishImport( members: string[], expenses: ParsedExpense[], @@ -188,7 +175,29 @@ function splitProCsvHeader(rows: string[][]): Map | null { function friendNameFromFilename(filename: string): string { const base = filename.replace(/\.csv$/i, '') const match = base.match(/^expenses[_\s-]+with[_\s-]+(.+)$/i) - return (match?.[1] ?? 'Friend').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim() || 'Friend' + return ( + (match?.[1] ?? 'Friend') + // Browsers append this when the same export is downloaded more than once. It is not + // part of the friend's identity and must not create a duplicate room member. + .replace(/\s+\(\d+\)$/i, '') + .replace(/_+/g, ' ') + .replace(/\s+/g, ' ') + .trim() || 'Friend' + ) +} + +/** Prefer the identity repeated in the ledger itself. Only one distinct non-You payer is evidence; + * inconsistent values are left for row validation and fall back to the cleaned download name. */ +function friendNameFromRows(filename: string, rows: string[][], header: Map): string { + const paidBy = header.get('paid by') ?? -1 + const candidates = new Map() + for (const row of rows.slice(1)) { + const raw = (row[paidBy] ?? '').trim() + const key = normalise(raw) + if (!key || key === 'you') continue + if (!candidates.has(key)) candidates.set(key, raw) + } + return candidates.size === 1 ? [...candidates.values()][0] : friendNameFromFilename(filename) } function parseSplitProCsv(filename: string, rows: string[][]): SplitwiseImport { @@ -197,10 +206,13 @@ function parseSplitProCsv(filename: string, rows: string[][]): SplitwiseImport { if (!header) throw new SplitwiseParseError('NOT_SPLITWISE_CSV') const warnings: ImportWarning[] = [{ code: 'SPLITPRO_PAIR_HISTORY' }] - const members = safeNames(['You', friendNameFromFilename(filename)], warnings) + const friendSourceName = friendNameFromRows(filename, rows, header) + const friendSourceKey = normalise(friendSourceName) + const members = safeNames(['You', friendSourceName], warnings) const [you, friend] = members const expenses: ParsedExpense[] = [] let sawSettlement = false + let flattenedSplitMode = false const cell = (row: string[], column: string) => row[header.get(column) ?? -1] ?? '' @@ -228,9 +240,16 @@ function parseSplitProCsv(filename: string, rows: string[][]): SplitwiseImport { continue } - const payer = normalise(cell(row, 'paid by')) === 'you' ? you : friend + const payerKey = normalise(cell(row, 'paid by')) + const payer = payerKey === 'you' ? you : payerKey === friendSourceKey ? friend : null + if (!payer) { + warnings.push({ code: 'ROW_NO_PAYER', row: line, detail: cell(row, 'paid by').trim() || '—' }) + continue + } const receiver = payer === you ? friend : you - const isSettlement = normalise(cell(row, 'split type')) === 'settlement' || settlement! > 0n + const sourceSplitType = normalise(cell(row, 'split type')) + const isSettlement = sourceSplitType === 'settlement' || settlement! > 0n + const flattensWeights = sourceSplitType === 'percentage' || sourceSplitType === 'share' let shares: { member: string; amountMinor: string }[] if (isSettlement) { @@ -257,7 +276,7 @@ function parseSplitProCsv(filename: string, rows: string[][]): SplitwiseImport { const description = clip(rawDescription, MAX_DESCRIPTION_CHARS) if (description !== rawDescription) warnings.push({ code: 'ROW_DESCRIPTION_TRUNCATED', row: line }) - const parsedDate = importDate(cell(row, 'expense date')) + const parsedDate = parseImportDate(cell(row, 'expense date')) if (!parsedDate.ok) warnings.push({ code: 'ROW_BAD_DATE', row: line }) expenses.push({ @@ -267,17 +286,18 @@ function parseSplitProCsv(filename: string, rows: string[][]): SplitwiseImport { currencyCode, costMinor: cost!.toString(), paidBy: payer, - splitMode: isEvenSplit( - cost!, - shares.map((share) => BigInt(share.amountMinor)) - ) - ? 'EQUAL' - : 'EXACT', + // Split Pro exported the user's editing intent. Preserve EQUAL only + // when it says EQUAL; EXACT, weighted modes and settlements must not + // become mutable equal splits merely because today's amounts happen + // to divide evenly. + splitMode: !isSettlement && sourceSplitType === 'equal' ? 'EQUAL' : 'EXACT', shares, }) + if (flattensWeights) flattenedSplitMode = true } if (sawSettlement) warnings.push({ code: 'PAYMENT_ROWS' }) + if (flattenedSplitMode) warnings.push({ code: 'SPLITPRO_SPLIT_MODE_FLATTENED' }) return finishImport(members, expenses, warnings) } @@ -413,7 +433,7 @@ function parseSplitProGroup( continue } const value = amount < 0n ? -amount : amount - const parsedDate = importDate(balance?.updatedAt ?? group.updatedAt) + const parsedDate = parseImportDate(text(balance?.updatedAt ?? group.updatedAt)) expenses.push({ date: parsedDate.date, description: clip(`${BROUGHT_FORWARD} — ${debtor} → ${creditor}`, MAX_DESCRIPTION_CHARS), @@ -434,6 +454,7 @@ function parseSplitProGroup( const roomName = clip(text(group.name) || `SplitPro group ${groupIndex + 1}`, 80) return { id: String(integer(group.id) ?? integer(group.publicId) ?? groupIndex), + sourceKey: `group:${groupIndex}`, roomName, parsed: finishImport(members, expenses, warnings, text(group.defaultCurrency).toUpperCase()), } @@ -466,7 +487,7 @@ function parseDirectBalances(friends: Map): ImportChoice const debtor = amount > 0n ? other : you const value = amount < 0n ? -amount : amount expenses.push({ - date: new Date().toISOString().slice(0, 10), + date: INVALID_DATE_FALLBACK, description: clip(`${BROUGHT_FORWARD} — ${debtor} → ${creditor}`, MAX_DESCRIPTION_CHARS), category: null, currencyCode, @@ -483,6 +504,7 @@ function parseDirectBalances(friends: Map): ImportChoice } return { id: 'direct-balances', + sourceKey: 'direct-balances', roomName: 'SplitPro balances', parsed: finishImport(members, expenses, warnings), } @@ -606,6 +628,7 @@ export function parseImportFile(textValue: string, filename: string): ParsedImpo choices: [ { id: 'splitpro-friend-csv', + sourceKey: 'file', roomName: roomNameFromFilename(filename), parsed: parseSplitProCsv(filename, rows), }, @@ -619,6 +642,7 @@ export function parseImportFile(textValue: string, filename: string): ParsedImpo choices: [ { id: 'splitwise-csv', + sourceKey: 'file', roomName: roomNameFromFilename(filename), parsed: parseSplitwiseCsv(textValue), }, diff --git a/apps/web/src/lib/splitwise-csv.fuzz.test.ts b/apps/web/src/lib/splitwise-csv.fuzz.test.ts index ac39aa46..ec13e9d0 100644 --- a/apps/web/src/lib/splitwise-csv.fuzz.test.ts +++ b/apps/web/src/lib/splitwise-csv.fuzz.test.ts @@ -250,24 +250,31 @@ describe('files nobody meant to write', () => { }) }) -describe('parseCsvRows is total', () => { - it('returns a grid for anything at all, and never throws', () => { +describe('parseCsvRows rejects only structurally truncated CSV', () => { + it('returns a grid or a typed unterminated-quote error for any input', () => { for (let seed = 1; seed <= 400; seed++) { const rng = mulberry32(seed * 31) let text = '' for (let index = between(rng, 0, 200); index > 0; index--) { text += rng() < 0.5 ? pick(rng, HOSTILE) : String.fromCharCode(between(rng, 32, 0x1fff)) } - const rows = parseCsvRows(text) - expect(Array.isArray(rows)).toBe(true) - expect(rows.length).toBeGreaterThan(0) - for (const row of rows) for (const cell of row) expect(typeof cell).toBe('string') + try { + const rows = parseCsvRows(text) + expect(Array.isArray(rows)).toBe(true) + expect(rows.length).toBeGreaterThan(0) + for (const row of rows) for (const cell of row) expect(typeof cell).toBe('string') + } catch (error) { + expect(error).toBeInstanceOf(SplitwiseParseError) + expect((error as SplitwiseParseError).code).toBe('MALFORMED_CSV') + } } }) - it('keeps every cell of a row it can read, however the quotes were mangled', () => { + it('keeps readable quote shapes and refuses an unterminated field', () => { expect(parseCsvRows('a,"b,c",d')).toEqual([['a', 'b,c', 'd']]) - expect(parseCsvRows('a,"unterminated')).toEqual([['a', 'unterminated']]) + expect(() => parseCsvRows('a,"unterminated')).toThrowError( + expect.objectContaining>({ code: 'MALFORMED_CSV' }) + ) expect(parseCsvRows('a,b"c,d')).toEqual([['a', 'b"c', 'd']]) expect(parseCsvRows('\r\n')).toEqual([[''], ['']]) }) diff --git a/apps/web/src/lib/splitwise-csv.test.ts b/apps/web/src/lib/splitwise-csv.test.ts index be27e7a9..681b7b56 100644 --- a/apps/web/src/lib/splitwise-csv.test.ts +++ b/apps/web/src/lib/splitwise-csv.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { convertMinorAtRate, STATIC_USD_PER_UNIT } from '@/server/money' +import { MAX_SIGNED_MINOR } from '@/lib/money' import { exactShares } from '@/server/split' import { importRoomSchema } from '@/server/validation' import { @@ -92,6 +93,12 @@ describe('parseCsvRows — RFC 4180', () => { it('treats a quote that opens mid-field as data', () => { expect(parseCsvRows('a"b",c')).toEqual([['a"b"', 'c']]) }) + + it('rejects an unterminated quote instead of returning a truncated ledger', () => { + expect(() => parseCsvRows('a,"unterminated\nnext,row')).toThrowError( + expect.objectContaining>({ code: 'MALFORMED_CSV' }) + ) + }) }) describe('parseSignedMinor', () => { @@ -119,21 +126,57 @@ describe('parseSignedMinor', () => { expect(parseSignedMinor('1234', 0)).toBe(1234n) }) + it('reads grouped positive and negative amounts in a zero-decimal currency', () => { + expect(parseSignedMinor('1,234', 0)).toBe(1234n) + expect(parseSignedMinor('-1.234', 0)).toBe(-1234n) + expect(parseSignedMinor('1,234,567', 0)).toBe(1_234_567n) + }) + + it('reads a grouped whole amount in a two-decimal currency without changing its scale', () => { + expect(parseSignedMinor('1,234', 2)).toBe(123_400n) + expect(parseSignedMinor('-1.234', 2)).toBe(-123_400n) + }) + it('treats an empty cell as zero', () => { expect(parseSignedMinor('', 2)).toBe(0n) }) it('strips a currency symbol a spreadsheet added', () => { expect(parseSignedMinor('€ 12.34', 2)).toBe(1234n) + expect(parseSignedMinor('EUR 12.34', 2)).toBe(1234n) + expect(parseSignedMinor('12.34 EUR', 2)).toBe(1234n) + expect(parseSignedMinor('€ -12.34', 2)).toBe(-1234n) }) - it('refuses an amount whose separators cannot be read', () => { - expect(parseSignedMinor('1.234.567', 2)).toBeNull() + it('preserves explicit spreadsheet negative notation', () => { + expect(parseSignedMinor('−1,234.56', 2)).toBe(-123456n) + expect(parseSignedMinor('(1,234.56)', 2)).toBe(-123456n) + expect(parseSignedMinor('(€ 1,234.56)', 2)).toBe(-123456n) + }) + + it('refuses notation and junk instead of deleting it into a different amount', () => { + expect(parseSignedMinor('1e3', 2)).toBeNull() + expect(parseSignedMinor('abc12.34', 2)).toBeNull() + expect(parseSignedMinor('12abc34', 2)).toBeNull() + expect(parseSignedMinor('+-12.34', 2)).toBeNull() + expect(parseSignedMinor('(-12.34)', 2)).toBeNull() + }) + + it('reads repeated valid grouping and refuses malformed separators', () => { + expect(parseSignedMinor('1.234.567', 2)).toBe(123_456_700n) + expect(parseSignedMinor('1.23.456', 2)).toBeNull() }) it('refuses a word', () => { expect(parseSignedMinor('n/a', 2)).toBeNull() }) + + it('enforces the storage ceiling before previewing an import', () => { + expect(parseSignedMinor(MAX_SIGNED_MINOR.toString(), 0)).toBe(MAX_SIGNED_MINOR) + expect(parseSignedMinor(`-${MAX_SIGNED_MINOR}`, 0)).toBe(-MAX_SIGNED_MINOR) + expect(parseSignedMinor((MAX_SIGNED_MINOR + 1n).toString(), 0)).toBeNull() + expect(parseSignedMinor('9'.repeat(100_000), 0)).toBeNull() + }) }) describe('allocateProportionally', () => { @@ -243,6 +286,12 @@ describe('parseSplitwiseCsv — localised decimals', () => { { member: 'Bruno', amountMinor: '61728' }, ]) }) + + it('recognises a complete localised Splitwise header', () => { + const spanish = + 'Fecha,Descripción,Categoría,Costo,Moneda,Ana,Bruno\n' + '2026-05-01,Cena,Comida,10.00,EUR,5.00,-5.00\n' + expect(parseSplitwiseCsv(spanish).expenses[0].description).toBe('Cena') + }) }) describe('parseSplitwiseCsv — multi-payer rows', () => { @@ -290,6 +339,21 @@ describe('parseSplitwiseCsv — payments and multi-currency', () => { it('suggests the currency most rows are in', () => { expect(parseSplitwiseCsv(MULTI_CURRENCY).suggestedCurrency).toBe('EUR') }) + + it('preserves a known unrated currency for a same-currency target room', () => { + const kpw = + 'Date,Description,Category,Cost,Currency,Ana,Bruno\n' + + '2026-01-02,Dinner,Food,"2,000",KPW,"1,000","-1,000"\n' + const parsed = parseSplitwiseCsv(kpw) + + expect(parsed.suggestedCurrency).toBe('KPW') + expect(parsed.expenses[0].costMinor).toBe('2000') + expect(parsed.expenses[0].shares).toEqual([ + { member: 'Ana', amountMinor: '1000' }, + { member: 'Bruno', amountMinor: '1000' }, + ]) + expect(codes(parsed)).not.toContain('ROW_UNSUPPORTED_CURRENCY') + }) }) describe('parseSplitwiseCsv — hostile and messy input', () => { @@ -307,7 +371,7 @@ describe('parseSplitwiseCsv — hostile and messy input', () => { expect(result.warnings).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'ROW_UNBALANCED', row: 7 }), - expect.objectContaining({ code: 'ROW_UNSUPPORTED_CURRENCY', row: 8, detail: 'KPW' }), + expect.objectContaining({ code: 'ROW_UNSUPPORTED_CURRENCY', row: 8, detail: 'ZZZ' }), expect.objectContaining({ code: 'ROW_ZERO_COST', row: 9 }), ]) ) @@ -370,11 +434,11 @@ describe('parseSplitwiseCsv — hostile and messy input', () => { ).toBe(true) }) - it('falls back to today when a date will not parse, and says so', () => { + it('falls back to a deterministic sentinel when a date will not parse, and says so', () => { const badDate = 'Date,Description,Category,Cost,Currency,Ana,Bruno\nnot a date,X,Y,10.00,EUR,5.00,-5.00\n' const parsed = parseSplitwiseCsv(badDate) expect(codes(parsed)).toContain('ROW_BAD_DATE') - expect(parsed.expenses[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(parsed.expenses[0].date).toBe('1970-01-01') }) it('rejects impossible ISO calendar dates instead of rolling them forward', () => { @@ -382,7 +446,17 @@ describe('parseSplitwiseCsv — hostile and messy input', () => { const parsed = parseSplitwiseCsv(badDate) expect(codes(parsed)).toContain('ROW_BAD_DATE') - expect(parsed.expenses[0].date).not.toBe('2026-02-31') + expect(parsed.expenses[0].date).toBe('1970-01-01') + }) + + it('preserves the leading calendar day of a timezone-less timestamp', () => { + const timestamp = + 'Date,Description,Category,Cost,Currency,Ana,Bruno\n' + + '2026-08-01 00:00:00,Rent,Housing,10.00,EUR,5.00,-5.00\n' + const parsed = parseSplitwiseCsv(timestamp) + + expect(parsed.expenses[0].date).toBe('2026-08-01') + expect(codes(parsed)).not.toContain('ROW_BAD_DATE') }) it('accepts a real leap day without a date warning', () => { @@ -689,7 +763,6 @@ describe('carrying history a room cannot hold', () => { expect(codes(parsed)).toContain('TRUNCATED_HISTORY') const carried = parsed.expenses.filter((expense) => expense.description.startsWith(BROUGHT_FORWARD)) - expect(carried.length).toBeGreaterThan(0) // At most one row per pair per currency, which is n − 1 at the very worst. expect(carried.length).toBeLessThanOrEqual(MEMBERS.length - 1) // A carried row is a ledger entry, never a division: one share, and it @@ -778,7 +851,7 @@ describe('carrying history a room cannot hold', () => { }) /** - * The ceiling at the worst shape the other caps allow: the biggest roster a + * The ceiling at a large but representable shape: the biggest roster a * room can hold, spending in a dozen currencies, with one person fronting * everything so the pairing cannot fold the residual into fewer than * `n − 1` transfers per currency. @@ -787,10 +860,8 @@ describe('carrying history a room cannot hold', () => { * reserve is bounded by the currencies a FILE uses, and a file with 162 of them would reserve * more than the ceiling — a different case, and not this one. * - * That is the case `reserved` exists for, and the only one where it is spent - * to the last row: 19 × 12 = 228 carried rows plus 272 kept ones is exactly - * 500. Nothing else in the suite would notice `MAX_MEMBERS` growing without the reservation - * following — the import would just start proposing rooms `importRoomSchema` refuses. + * The implementation measures these 228 carried rows rather than assuming + * the worst case, then keeps exactly as much recent history as still fits. */ it('stays inside the ceiling at the worst case the caps allow', () => { const members = Array.from({ length: MAX_MEMBERS }, (_, i) => `P${i}`) @@ -818,6 +889,30 @@ describe('carrying history a room cannot hold', () => { expect(carried).toHaveLength((MAX_MEMBERS - 1) * WORST_CASE_CURRENCIES.length) }) + it('fails honestly when even an exact opening balance cannot fit the room schema', () => { + const members = Array.from({ length: MAX_MEMBERS }, (_, i) => `P${i}`) + const currencies = Array.from({ length: 27 }, (_, i) => `C${String(i).padStart(2, '0')}`) + const shares = members.slice(1).map((member, index) => ({ + member, + amountMinor: String(100 + index), + })) + const costMinor = shares.reduce((total, share) => total + BigInt(share.amountMinor), 0n).toString() + const rows: ParsedExpense[] = Array.from({ length: 540 }, (_, index) => ({ + date: new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10), + description: `Row ${index + 1}`, + category: null, + currencyCode: currencies[index % currencies.length], + costMinor, + paidBy: members[0], + splitMode: 'EXACT', + shares, + })) + + expect(() => capHistory(rows, [])).toThrowError( + expect.objectContaining>({ code: 'TOO_MANY_EXPENSES' }) + ) + }) + it('leaves a file inside the ceiling completely untouched', () => { const parsed = parseSplitwiseCsv(generateLongHistory(120, MEMBERS)) expect(parsed.expenses).toHaveLength(120) diff --git a/apps/web/src/lib/splitwise-csv.ts b/apps/web/src/lib/splitwise-csv.ts index 257c18ac..ef9c0af4 100644 --- a/apps/web/src/lib/splitwise-csv.ts +++ b/apps/web/src/lib/splitwise-csv.ts @@ -20,19 +20,19 @@ * whole format, and everything below is the arithmetic of turning a net back into "who paid" and * "who owes what", which is what Split stores. * - * MONEY. Minor units as BigInt from the first parse to the last. `parseAmountToMinor` (the same - * function the expense drawer types into) does the decimal work, so a comma-decimal export and a - * hand-typed amount go through one code path. + * MONEY. Minor units as BigInt from the first parse to the last. The export parser shares the + * expense drawer's decimal path, with one source-only rule: a separator in a zero-decimal currency + * is grouping, never a fractional unit that may be rounded one thousandfold smaller. */ import { CURRENCY_CATALOG } from '@/lib/currency-catalog' -import { currencyInfo, parseAmountToMinor } from '@/lib/money' +import { currencyInfo, parseExportAmountToMinor } from '@/lib/money' // ─── shape ────────────────────────────────────────────────────────────────── /** One expense, ready to be posted. `costMinor` and every share are in `currencyCode`. */ export interface ParsedExpense { - /** ISO date (YYYY-MM-DD). Splitwise exports one per row; a broken one falls back to today. */ + /** ISO date (YYYY-MM-DD). A broken source date uses a deterministic sentinel and a warning. */ date: string description: string category: string | null @@ -85,6 +85,7 @@ export type WarningCode = | 'TRUNCATED_HISTORY' | 'SPLITPRO_BALANCES_ONLY' | 'SPLITPRO_PAIR_HISTORY' + | 'SPLITPRO_SPLIT_MODE_FLATTENED' | 'SPLITPRO_MISSING_NAMES' | 'SPLITPRO_BALANCES_SKIPPED' | 'SPLITPRO_UNSUPPORTED_CURRENCY' @@ -112,6 +113,7 @@ export interface SplitwiseImport { export type ParseErrorCode = | 'NOT_SPLITWISE_CSV' + | 'MALFORMED_CSV' | 'MALFORMED_JSON' | 'SPLITPRO_DIRECT_UNRESOLVED' | 'NO_MEMBERS' @@ -213,6 +215,10 @@ export function parseCsvRows(input: string): string[][] { } else if (ch !== '\r') field += ch } + // An open quote consumes every later physical row as one field. Returning the readable prefix + // would therefore present a silently truncated ledger as a successful partial import. + if (quoted) throw new SplitwiseParseError('MALFORMED_CSV') + row.push(field) rows.push(row) return rows @@ -252,9 +258,9 @@ const isBlankRow = (row: string[]) => row.every((cell) => cell.trim() === '') /** * Find the header. Splitwise puts it on line 1, but exports that have been through a spreadsheet * pick up title rows and blank lines above it, so the search runs down the file rather than - * insisting on the first line. A row qualifies only if it has Date, Cost AND Currency — any one of - * those words shows up in ordinary data, and a file without a currency column has no readable - * amounts anyway, so demanding all three is both the stronger signal and the honest requirement. + * insisting on the first line. A row qualifies only if it has every canonical metadata column. + * Otherwise ordinary bookkeeping CSVs such as Date/Cost/Currency/Debit/Credit are misread as a + * Splitwise roster whose "members" happen to be Debit and Credit. */ function findHeader(rows: string[][]): Header | null { for (let at = 0; at < rows.length; at++) { @@ -263,7 +269,7 @@ function findHeader(rows: string[][]): Header | null { for (const [key, aliases] of Object.entries(COLUMN_ALIASES)) { columns[key] = cells.findIndex((cell) => aliases.includes(cell)) } - if (columns.date < 0 || columns.cost < 0 || columns.currency < 0) continue + if (Object.values(columns).some((index) => index < 0)) continue const known = new Set(Object.values(columns).filter((index) => index >= 0)) const members = rows[at] @@ -324,35 +330,62 @@ function dedupeMemberNames(names: string[], warnings: ImportWarning[]): string[] /** * A cell → signed minor units, or null if it is not an amount. * - * The sign is peeled off first because `parseAmountToMinor` only accepts non-negative input — it - * is the expense-drawer parser, and a negative amount is not a thing anyone can type into a form. - * Everything after the sign (separators, grouping, the ambiguity rules) is its problem, not this - * file's: one money path, and a comma-decimal CSV behaves exactly like a comma-decimal keystroke. + * The sign is peeled off first because the shared magnitude parser accepts only non-negative input. + * It owns separators, grouping and the zero-decimal export rule; this layer only restores the sign. */ export function parseSignedMinor(cell: string, decimals: number): bigint | null { // An empty member column means "nothing", which is a real zero. Anything else has to read as a // number — `n/a` must NOT come back as 0n, or an unreadable file quietly becomes a balanced one. if (cell.trim() === '') return 0n - // Currency symbols and spacing show up in exports that have been through a spreadsheet. JS's - // `\s` already covers the non-breaking and narrow-no-break spaces those tools like to emit. - const cleaned = cell.replace(/\s/g, '').replace(/[^\d.,+-]/g, '') - if (cleaned === '' || cleaned === '-' || cleaned === '+') return null + // Currency decorations and spacing show up in exports that have been through a spreadsheet. + // Strip only one anchored symbol/code — never arbitrary characters inside the number, because + // doing that turns `1e3` into `13` and a typo into plausible money. + let cleaned = cell + .normalize('NFKC') + .trim() + .replace(/\s/g, '') + .replace(/\u2212/g, '-') + let accountingNegative = false + if (cleaned.includes('(') || cleaned.includes(')')) { + if (!/^\([^()]+\)$/.test(cleaned)) return null + accountingNegative = true + cleaned = cleaned.slice(1, -1) + } + + let sign = '' + if (cleaned.startsWith('+') || cleaned.startsWith('-')) { + sign = cleaned[0] + cleaned = cleaned.slice(1) + } - const negative = cleaned.startsWith('-') - const magnitude = parseAmountToMinor(cleaned.replace(/^[+-]/, ''), decimals) + const leadingDecoration = cleaned.match(/^(?:\p{Sc}|[A-Z]{3})/u)?.[0] + if (leadingDecoration) cleaned = cleaned.slice(leadingDecoration.length) + + // Both `-€12.34` and `€-12.34` are common. Two explicit signs are not. + if (cleaned.startsWith('+') || cleaned.startsWith('-')) { + if (sign) return null + sign = cleaned[0] + cleaned = cleaned.slice(1) + } + + const trailingDecoration = cleaned.match(/(?:\p{Sc}|[A-Z]{3})$/u)?.[0] + if (trailingDecoration) { + if (leadingDecoration) return null + cleaned = cleaned.slice(0, -trailingDecoration.length) + } + + if (accountingNegative && sign) return null + if (cleaned === '') return null + const magnitude = parseExportAmountToMinor(cleaned, decimals) if (magnitude === null) return null const value = BigInt(magnitude) - return negative ? -value : value + return accountingNegative || sign === '-' ? -value : value } -/** - * Rated codes only, not the whole catalog. A row in a currency nothing can price is dropped with - * `ROW_UNSUPPORTED_CURRENCY`, exactly as it has always been — carrying it instead would let one - * unpriceable row fail the whole import with NO_RATE at write time, which is a much worse answer - * than losing the row. - */ -const SUPPORTED_CURRENCIES = new Set(CURRENCY_CATALOG.filter((c) => c.hasRate).map((c) => c.code)) +/** Keep every known catalog currency. Priceability depends on the chosen room currency: an unrated + * code such as BGN is still valid in a BGN room, so dropping it before a target exists loses data. */ +const SUPPORTED_CURRENCIES = new Set(CURRENCY_CATALOG.map((currency) => currency.code)) /** * Spread `total` across `weights` so the parts are whole minor units and sum to `total` exactly. @@ -437,25 +470,22 @@ export function isEvenSplit(costMinor: bigint, shares: readonly bigint[]): boole // ─── rows ─────────────────────────────────────────────────────────────────── -const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/ +const ISO_DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})(?=$|[ T])/ +export const INVALID_DATE_FALLBACK = '1970-01-01' -/** Splitwise writes YYYY-MM-DD. Anything else gets one attempt through `Date` and then gives up - * to today — a wrong date is a cosmetic loss, a dropped expense is a money one. */ -function parseDate(cell: string): { date: string; ok: boolean } { +/** Preserve the source's calendar day without passing a timezone-less timestamp through `Date`. + * Splitwise writes a day and SplitPro prefixes its timestamp with one, so no other shape is needed. */ +export function parseImportDate(cell: string): { date: string; ok: boolean } { const raw = cell.trim() - if (ISO_DATE.test(raw)) { - const parsed = new Date(`${raw}T00:00:00.000Z`) - if (!Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === raw) { - return { date: raw, ok: true } + const candidate = raw.match(ISO_DATE_PREFIX)?.[1] + if (candidate) { + const parsed = new Date(`${candidate}T00:00:00.000Z`) + if (!Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === candidate) { + return { date: candidate, ok: true } } } - const parsed = new Date(raw) - if (raw !== '' && !ISO_DATE.test(raw) && !Number.isNaN(parsed.getTime())) { - return { date: parsed.toISOString().slice(0, 10), ok: true } - } - - return { date: new Date().toISOString().slice(0, 10), ok: false } + return { date: INVALID_DATE_FALLBACK, ok: false } } interface RowContext { @@ -628,16 +658,6 @@ export function capHistory( ): { expenses: ParsedExpense[]; dropped: number } { if (expenses.length <= MAX_EXPENSES) return { expenses: [...expenses], dropped: 0 } - const members = [...new Set(expenses.flatMap((e) => [e.paidBy, ...e.shares.map((s) => s.member)]))] - const currencies = [...new Set(expenses.map((e) => e.currencyCode))] - // The opening balance has to fit inside the ceiling alongside the history it - // is standing in for, so its worst case is reserved before the cut rather - // than discovered after it. The worst case is `MAX_MEMBERS − 1` rows per currency, over the - // currencies THIS FILE uses — not over the catalog, which is 162 codes wide and would reserve - // more than the ceiling. A twelve-currency file reserves 19 × 12 = 228 of the 500. - const reserved = Math.max(0, members.length - 1) * Math.max(1, currencies.length) - const cut = Math.max(1, MAX_EXPENSES - reserved) - // Newest first, with the original file order as the tie-break so two rows on // the same day cannot swap places between runs. const ordered = expenses @@ -646,11 +666,24 @@ export function capHistory( a.expense.date === b.expense.date ? a.index - b.index : a.expense.date < b.expense.date ? 1 : -1 ) - const kept = ordered.slice(0, cut).map((entry) => entry.expense) - const dropped = ordered.slice(cut).map((entry) => entry.expense) - warnings.push({ code: 'TRUNCATED_HISTORY', detail: String(dropped.length) }) + // Reserve the ACTUAL balance-forward rows, not `(members - 1) * currencies`. + // That estimate can exceed the whole 500-row contract and still emit >500 + // rows. Start with the most history we could keep, then lower the cut until + // the real opening balance and retained suffix fit together. The cut only + // moves downward, so this terminates in at most 501 bounded passes. + let keepCount = MAX_EXPENSES + for (;;) { + const kept = ordered.slice(0, keepCount).map((entry) => entry.expense) + const dropped = ordered.slice(keepCount).map((entry) => entry.expense) + const opening = openingBalance(dropped) + if (opening.length + kept.length <= MAX_EXPENSES) { + warnings.push({ code: 'TRUNCATED_HISTORY', detail: String(dropped.length) }) + return { expenses: [...opening, ...kept], dropped: dropped.length } + } - return { expenses: [...openingBalance(dropped), ...kept], dropped: dropped.length } + if (keepCount === 0) throw new SplitwiseParseError('TOO_MANY_EXPENSES') + keepCount = Math.max(0, Math.min(keepCount - 1, MAX_EXPENSES - opening.length)) + } } /** The dropped rows, folded into at most one transfer-shaped expense per pair per @@ -792,7 +825,7 @@ export function parseSplitwiseCsv(text: string): SplitwiseImport { const category = clip(rawCategory, MAX_CATEGORY_CHARS) if (category !== rawCategory) warnings.push({ code: 'ROW_CATEGORY_TRUNCATED', row: line }) - const { date, ok } = parseDate(dateCell) + const { date, ok } = parseImportDate(dateCell) if (!ok) warnings.push({ code: 'ROW_BAD_DATE', row: line }) // Splitwise allows a blank description; Split does not. The category is the next most diff --git a/apps/web/src/lib/splitwise-roundtrip.test.ts b/apps/web/src/lib/splitwise-roundtrip.test.ts index 68997ebc..a46e2245 100644 --- a/apps/web/src/lib/splitwise-roundtrip.test.ts +++ b/apps/web/src/lib/splitwise-roundtrip.test.ts @@ -99,21 +99,29 @@ const netsOf = (row: Row): bigint[] => const csvCell = (value: string): string => (/[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value) +/** A 3dp value such as `29.088` is also a valid grouped whole in an export + * whose locale is unknown. Prefixing the whole part with zero preserves the + * number while making this generated machine fixture unambiguous; the importer + * deliberately rejects the unsafe spelling instead of guessing 1000x. */ +const exportMoney = (minor: bigint, currency: string): string => { + const rendered = formatMinorPlain(minor.toString(), decimalsOf(currency)) + return /^-?[1-9]\d{0,2}[.,]\d{3}$/.test(rendered) ? rendered.replace(/^(-?)/, '$10') : rendered +} + /** The generated ledger, rendered the way a Splitwise group export renders it. */ function toSplitwiseCsv(group: ReturnType, withTotalRow = true): string { const header = ['Date', 'Description', 'Category', 'Cost', 'Currency', ...group.members] const lines = [header.map(csvCell).join(',')] for (const row of group.rows) { - const decimals = decimalsOf(row.currency) lines.push( [ '2026-07-14', csvCell(row.description), 'General', - formatMinorPlain(row.cost.toString(), decimals), + exportMoney(row.cost, row.currency), row.currency, - ...netsOf(row).map((net) => formatMinorPlain(net.toString(), decimals)), + ...netsOf(row).map((net) => exportMoney(net, row.currency)), ].join(',') ) } @@ -130,7 +138,7 @@ function toSplitwiseCsv(group: ReturnType, withTotalRow = tr '', formatMinorPlain('0', decimals), group.rows[0].currency, - ...totals.map((total) => formatMinorPlain(total.toString(), decimals)), + ...totals.map((total) => exportMoney(total, group.rows[0].currency)), ].join(',') ) } diff --git a/apps/web/src/lib/use-rate.ts b/apps/web/src/lib/use-rate.ts index 81fc6e70..ce007ae3 100644 --- a/apps/web/src/lib/use-rate.ts +++ b/apps/web/src/lib/use-rate.ts @@ -14,7 +14,7 @@ * changing the amount is local arithmetic. */ -import { useQuery } from '@tanstack/react-query' +import { useQueries, useQuery, type Query } from '@tanstack/react-query' import { api, type IndicativeRateQuote } from './api' /** An hour. A rate that moves under someone mid-form would make the preview @@ -46,6 +46,33 @@ export function useRate(from: string, to: string, enabled = true) { }) } +/** Probe every distinct source currency before a bulk import is submitted. + * A definitive null response blocks the import; transport errors do not, because + * the write endpoint remains the authority and may still have a cached quote. */ +export function useRateAvailability( + fromCurrencies: readonly string[], + to: string, + enabled = true, + recheckCurrencies: readonly string[] = [] +) { + const recheck = new Set(recheckCurrencies) + return useQueries({ + queries: fromCurrencies.map((from) => ({ + queryKey: ['rate', from, to] as const, + queryFn: ({ signal }: { signal: AbortSignal }) => api.rate(from, to, signal), + select: availableRateQuote, + enabled: enabled && from !== to && from.length > 0 && to.length > 0, + // Missing quotes can recover after an operator repairs the feed, so + // this guard refreshes much sooner than an amount preview. + staleTime: 60 * 1000, + refetchInterval: (query: Query) => + query.state.data?.rate === null || recheck.has(from) ? 60 * 1000 : false, + retry: false, + refetchOnWindowFocus: true, + })), + }) +} + /** * Minor units in `from` → minor units in `to`, at `rate`, half-up. * diff --git a/apps/web/src/server/egress.ts b/apps/web/src/server/egress.ts index 0141106e..3b291fcc 100644 --- a/apps/web/src/server/egress.ts +++ b/apps/web/src/server/egress.ts @@ -32,11 +32,13 @@ export interface EgressResponse { */ const agents = new Map() -export async function egressFetch( - proxyUrl: string | undefined, - url: string, - init: { method: string; headers: Record; body: string; signal?: AbortSignal } -): Promise { +/** + * `init` is a plain RequestInit and the result is a full `Response` so a caller + * that streams the body with a byte ceiling (the FX rate table) can use this + * too, not only the JSON-shaped POSTs. `Response` structurally satisfies + * `EgressResponse`, so existing callers are unaffected. + */ +export async function egressFetch(proxyUrl: string | undefined, url: string, init: RequestInit): Promise { if (!proxyUrl) return fetch(url, init) const { fetch: undiciFetch, ProxyAgent } = await import('undici') let agent = agents.get(proxyUrl) @@ -45,5 +47,5 @@ export async function egressFetch( agents.set(proxyUrl, agent) } // The cast crosses undici's nominal types; the shape above is what we use. - return undiciFetch(url, { ...init, dispatcher: agent } as never) as unknown as EgressResponse + return undiciFetch(url, { ...init, dispatcher: agent } as never) as unknown as Response } diff --git a/apps/web/src/server/expenses.test.ts b/apps/web/src/server/expenses.test.ts index 37ea8a4c..3e77b0f0 100644 --- a/apps/web/src/server/expenses.test.ts +++ b/apps/web/src/server/expenses.test.ts @@ -40,13 +40,17 @@ const roomWithFormer = (currency: string): RoomWithRelations => members: MEMBERS.map((id) => ({ id, removedAt: id === 'bea' || id === 'caro' ? new Date() : null })), }) as unknown as RoomWithRelations -const tableOf = (usdPerUnit: Record): RateTable => ({ - usdPerUnit, - source: 'static', - fetchedAt: null, -}) +const tableOf = (usdPerUnit: Record, base = 'EUR'): RateTable => { + const usdPerBase = usdPerUnit[base] + const basePerUnit = + usdPerBase === undefined + ? { ...usdPerUnit } + : Object.fromEntries(Object.entries(usdPerUnit).map(([quote, usd]) => [quote, usd / usdPerBase])) + return { base, basePerUnit, source: 'static', fetchedAt: null } +} -const STATIC_TABLE = tableOf({ ...STATIC_USD_PER_UNIT }) +const staticTableFor = (base: string) => tableOf({ ...STATIC_USD_PER_UNIT }, base) +const STATIC_TABLE = staticTableFor('EUR') const body = (over: Partial & { paidById?: string } = {}): ExpenseBody & { paidById: string } => { const result = { @@ -413,7 +417,7 @@ describe('a description-only edit does not move the money', () => { room, body({ currency: from, amountMinor: amountMinor.toString() }), undefined, - STATIC_TABLE + staticTableFor(to) ) const edited = await buildExpense( room, @@ -423,7 +427,7 @@ describe('a description-only edit does not move the money', () => { description: 'Dinner (split with Caro)', }), rowOf(created), - STATIC_TABLE + staticTableFor(to) ) if (edited.baseAmountMinor !== created.baseAmountMinor) moved++ expect(edited.fxRate).toBe(created.fxRate) @@ -466,7 +470,7 @@ describe('a description-only edit does not move the money', () => { /** Three decimals into zero decimals, the shape the 162-code catalog introduces and the twelve * never could. Neither code is in the static table, so the rate table is built for the pair. */ it('holds on BHD→JPY, a three-decimal currency into a zero-decimal one', async () => { - const table = tableOf({ BHD: 2.65, JPY: 0.0064 }) + const table = tableOf({ BHD: 2.65, JPY: 0.0064 }, 'JPY') const room = roomIn('JPY') const created = await buildExpense(room, body({ currency: 'BHD', amountMinor: '1000000' }), undefined, table) const edited = await buildExpense( @@ -671,7 +675,7 @@ describe('the stored rate is the rate that priced the expense', () => { roomIn(to), body({ currency: from, amountMinor: amountMinor.toString() }), undefined, - STATIC_TABLE + staticTableFor(to) ) const fromColumn = convertMinorAtRate(amountMinor, from, to, Number(created.fxRate)) if (fromColumn !== created.baseAmountMinor) moved++ @@ -732,7 +736,7 @@ describe('an amount edit reconverts at the locked rate', () => { room, body({ currency: from, amountMinor: '1000' }), undefined, - STATIC_TABLE + staticTableFor(to) ) // First edit changes the amount, so it converts. Second repeats it, so it // carries forward. The two must agree or every re-save walks the balance. @@ -740,13 +744,13 @@ describe('an amount edit reconverts at the locked rate', () => { room, body({ currency: from, amountMinor: amountMinor.toString() }), rowOf(created), - STATIC_TABLE + staticTableFor(to) ) const second = await buildExpense( room, body({ currency: from, amountMinor: amountMinor.toString() }), rowOf(first), - STATIC_TABLE + staticTableFor(to) ) if (first.baseAmountMinor !== second.baseAmountMinor) moved++ } @@ -811,7 +815,7 @@ describe('a row written at the old 1e9 rate scale', () => { description: 'fixed a typo', }), row, - STATIC_TABLE + staticTableFor(to) ) if (edited.baseAmountMinor !== row.baseAmountMinor) rewritten++ // What recomputing would have cost, which is the size of the bug being held shut. @@ -892,7 +896,7 @@ describe('a row written at the old 1e9 rate scale', () => { })), }), row, - STATIC_TABLE + staticTableFor(to) ) expect(write.baseAmountMinor).toBe(row.baseAmountMinor) expect(sumShares(write.shares)).toBe(row.baseAmountMinor) diff --git a/apps/web/src/server/expenses.ts b/apps/web/src/server/expenses.ts index 04d6f63b..7bc7e430 100644 --- a/apps/web/src/server/expenses.ts +++ b/apps/web/src/server/expenses.ts @@ -279,7 +279,9 @@ export async function buildExpense( // and its catalog status means a manual override is forbidden. throw badRequest(`no exchange rate for ${body.currency} → ${room.currency}`, 'NO_RATE') } else { - rate = quantiseRate(requireRate(rateTable ?? (await getRateTable()), body.currency, room.currency)) + rate = quantiseRate( + requireRate(rateTable ?? (await getRateTable(room.currency)), body.currency, room.currency) + ) } } // A rate below 5e-13 rounds to zero in the column, and a zero rate converts the whole expense diff --git a/apps/web/src/server/fx.cache.test.ts b/apps/web/src/server/fx.cache.test.ts index 3371485b..b7c618b8 100644 --- a/apps/web/src/server/fx.cache.test.ts +++ b/apps/web/src/server/fx.cache.test.ts @@ -1,275 +1,482 @@ -/** - * `getRateTable`'s cache branch, against the real `peanut_split_test` database with the upstream - * feed stubbed. - * - * This branch had no test and is the one most likely to be silently wrong at 162 codes: four - * catalog codes are never in the feed, so a freshness test written against the whole catalog is - * false forever — every request re-fetches, and one upstream blip drops all 150 new currencies to - * the twelve-rate static table with nothing logged anywhere. - * - * Each test re-imports the module so `lastFailedFetchAt` and the single-flight promise start - * empty. `@/server/db` caches its client on `globalThis`, so re-importing costs no connection. - */ +/** Base-specific live/cache behavior against the real test database. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { prisma } from '@/server/test/db' import { FX_CORE, type RateTable } from '@/server/fx' -import { STATIC_USD_PER_UNIT } from '@/server/money' +import { isCatalogCode, STATIC_USD_PER_UNIT } from '@/server/money' const freshFx = async () => { vi.resetModules() return await import('@/server/fx') } -/** `rates` on the wire is units per USD; the module stores the inverse. */ -const feed = (rates: Record) => - new Response(JSON.stringify({ result: 'success', rates }), { status: 200 }) +const nowIso = () => new Date().toISOString() -const perUsd = Object.fromEntries(Object.entries(STATIC_USD_PER_UNIT).map(([code, usd]) => [code, 1 / usd])) +const staticDirect = (base: string): Record => { + const usdPerBase = STATIC_USD_PER_UNIT[base] + return Object.fromEntries( + Object.entries(STATIC_USD_PER_UNIT).map(([quote, usdPerQuote]) => [quote, usdPerQuote / usdPerBase]) + ) +} -const seed = async (rows: { quote: string; usdPerUnit: number; fetchedAt?: Date }[]) => { - await prisma.fxRate.deleteMany() +const completeRates = (rates: Record): Record => { + const complete = { ...rates } + for (let index = 0; Object.keys(complete).length < 150; index++) { + const code = `Q${String.fromCharCode(65 + Math.floor(index / 26))}${String.fromCharCode(65 + (index % 26))}` + if (!isCatalogCode(code) && !(code in complete)) complete[code] = 1 + } + return complete +} + +/** Input values are direct base-units per quote; wire values are the inverse. */ +const feedBody = (base: string, directRates: Record) => ({ + base, + basis: 'display_sell', + indicative: true, + generatedAt: nowIso(), + rates: Object.entries(completeRates(directRates)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([code, direct]) => ({ + code, + unitsPerBase: typeof direct === 'number' ? (1 / direct).toString() : direct, + selection: code === base ? 'identity' : 'reference_pair', + baseSource: code === base ? 'identity' : base === 'USD' ? 'identity' : 'reference', + quoteSource: code === base ? 'identity' : code === 'USD' ? 'identity' : 'reference', + effectiveAt: code === base ? null : nowIso(), + })), +}) + +const payload = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }) +const feed = (base: string, rates: Record) => payload(feedBody(base, rates)) + +const seed = async (base: string, rows: { quote: string; basePerUnit: number; fetchedAt?: Date }[]): Promise => { await prisma.fxRate.createMany({ data: rows.map((row) => ({ - base: 'USD', + base, quote: row.quote, - rate: row.usdPerUnit, + rate: row.basePerUnit, fetchedAt: row.fetchedAt ?? new Date(), })), }) } -const coreRows = (fetchedAt?: Date) => - FX_CORE.map((quote) => ({ quote, usdPerUnit: STATIC_USD_PER_UNIT[quote], fetchedAt })) +const coreRows = (base: string, fetchedAt?: Date) => + Object.entries(staticDirect(base)).map(([quote, basePerUnit]) => ({ quote, basePerUnit, fetchedAt })) let fetchSpy: ReturnType beforeEach(async () => { process.env.SPLIT_FX_MODE = '' - fetchSpy = vi.fn(async () => feed(perUsd)) + fetchSpy = vi.fn(async (input: string | URL | Request) => { + const base = new URL(String(input)).searchParams.get('base') ?? 'EUR' + return feed(base, staticDirect(base)) + }) vi.stubGlobal('fetch', fetchSpy) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) await prisma.fxRate.deleteMany() }) afterEach(async () => { vi.unstubAllGlobals() + vi.restoreAllMocks() vi.resetModules() process.env.SPLIT_FX_MODE = 'static' await prisma.fxRate.deleteMany() }) -describe('freshness is judged against FX_CORE, not the catalog', () => { - it('calls a cache with every core code fresh — even though four catalog codes are missing', async () => { - // CUC, KPW, SVC and XSU are in the catalog and will never be in the feed. Judging - // completeness against all 162 makes `fresh` false forever. - await seed(coreRows()) +describe('direct fixed-host transport', () => { + it('uses a bodyless credential-free GET and refuses redirects or implicit caching', async () => { const { getRateTable } = await freshFx() - const table = await getRateTable() - expect(table.source).toBe('cache') + expect((await getRateTable('eur')).source).toBe('live') + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.peanut.me/fx/rates?base=EUR', + expect.objectContaining({ + method: 'GET', + headers: { Accept: 'application/json' }, + signal: expect.any(AbortSignal), + redirect: 'error', + credentials: 'omit', + cache: 'no-store', + }) + ) + const init = fetchSpy.mock.calls[0]?.[1] + expect(init).not.toHaveProperty('body') + expect(init?.headers).not.toHaveProperty('Authorization') + expect(init?.headers).not.toHaveProperty('Cookie') + }) + + it('never puts an invented ticker into an outbound URL', async () => { + const { getRateTable } = await freshFx() + const table = await getRateTable('BEER') + expect(fetchSpy).not.toHaveBeenCalled() + expect(table).toMatchObject({ base: 'BEER', source: 'static', basePerUnit: { BEER: 1 } }) }) +}) - it('refetches when one core code is missing from the cache', async () => { - await seed(coreRows().filter((row) => row.quote !== 'CHF')) +describe('the cache is isolated by destination base', () => { + it('uses a complete fresh cache for the requested base', async () => { + await seed('EUR', coreRows('EUR')) const { getRateTable } = await freshFx() - const table = await getRateTable() - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(table.source).toBe('live') + const table = await getRateTable('EUR') + expect(table).toMatchObject({ base: 'EUR', source: 'cache' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('does not treat a complete USD table as an EUR table', async () => { + await seed('USD', coreRows('USD')) + const { getRateTable } = await freshFx() + + expect((await getRateTable('EUR')).source).toBe('live') + expect(fetchSpy).toHaveBeenCalledWith('https://api.peanut.me/fx/rates?base=EUR', expect.any(Object)) }) - it('is not aged by a non-core row nobody has refreshed in a year, and does not serve it either', async () => { + it('refetches when one required row is missing', async () => { + await seed( + 'EUR', + coreRows('EUR').filter((row) => row.quote !== 'CHF') + ) + const { getRateTable } = await freshFx() + + expect((await getRateTable('EUR')).source).toBe('live') + }) + + it('does not age the table with an old non-core row and does not serve that row', async () => { const ancient = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) - await seed([...coreRows(), { quote: 'INR', usdPerUnit: 0.012, fetchedAt: ancient }]) + await seed('EUR', [...coreRows('EUR'), { quote: 'INR', basePerUnit: 0.011, fetchedAt: ancient }]) const { getRateTable } = await freshFx() - const table = await getRateTable() + const table = await getRateTable('EUR') expect(table.source).toBe('cache') + expect(table.basePerUnit).not.toHaveProperty('INR') expect(fetchSpy).not.toHaveBeenCalled() - // The two halves are separate rules and both matter. The old row does not drag the whole - // table into being refetched on every request — and it is not an answer either. "The only - // rate for INR there is" is not a reason to price money at a year-old number. - expect(table.usdPerUnit).not.toHaveProperty('INR') }) - it('serves a non-core row that is merely old, up to the ceiling', async () => { + it('serves a non-core row only within the seven-day ceiling', async () => { const sixDays = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000) - await seed([...coreRows(), { quote: 'INR', usdPerUnit: 0.012, fetchedAt: sixDays }]) + await seed('EUR', [...coreRows('EUR'), { quote: 'INR', basePerUnit: 0.011, fetchedAt: sixDays }]) const { getRateTable } = await freshFx() - const table = await getRateTable() - expect(table.source).toBe('cache') - expect(table.usdPerUnit.INR).toBeCloseTo(0.012, 12) + expect((await getRateTable('EUR')).basePerUnit.INR).toBeCloseTo(0.011, 12) }) - it('refetches once the core rows are older than the TTL', async () => { + it('refreshes when required rows cross the 24-hour TTL', async () => { const yesterday = new Date(Date.now() - 25 * 60 * 60 * 1000) - await seed(coreRows(yesterday)) + await seed('EUR', coreRows('EUR', yesterday)) const { getRateTable } = await freshFx() - expect((await getRateTable()).source).toBe('live') - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect((await getRateTable('EUR')).source).toBe('live') }) +}) + +describe('base-specific payload validation', () => { + it('uses the selected direct PLN→EUR row without crossing other rows', async () => { + const direct: Record = { ...staticDirect('EUR'), PLN: 0.231481481481 } + // Deliberately unrelated USD row: local crossing would produce a different answer. + direct.USD = 0.5 + fetchSpy.mockResolvedValue(feed('EUR', direct)) + const { getRateTable, rateFrom } = await freshFx() - it('drops a cached row for a code that has left the catalog', async () => { - await seed([...coreRows(), { quote: 'CNH', usdPerUnit: 0.14 }]) + const table = await getRateTable('EUR') + expect(rateFrom(table, 'PLN', 'EUR')).toBeCloseTo(0.231481481481, 12) + expect(rateFrom(table, 'PLN', 'USD')).toBeNull() + }) + + it('accepts provider-pair provenance, including Bridge↔Manteca', async () => { + const body = feedBody('EUR', { ...staticDirect('EUR'), BRL: 0.17 }) + const index = body.rates.findIndex((row) => row.code === 'BRL') + body.rates[index] = { + ...body.rates[index], + selection: 'provider_pair', + baseSource: 'bridge', + quoteSource: 'manteca', + } + fetchSpy.mockResolvedValue(payload(body)) const { getRateTable } = await freshFx() - const table = await getRateTable() - expect(table.source).toBe('cache') - expect(table.usdPerUnit).not.toHaveProperty('CNH') + expect((await getRateTable('EUR')).source).toBe('live') + }) + + it('allows identity provenance only on the actual USD leg of a non-identity pair', async () => { + const valid = feedBody('USD', staticDirect('USD')) + fetchSpy.mockResolvedValueOnce(payload(valid)) + const first = await freshFx() + expect((await first.getRateTable('USD')).source).toBe('live') + + vi.resetModules() + const invalid = feedBody('EUR', staticDirect('EUR')) + const chf = invalid.rates.findIndex((row) => row.code === 'CHF') + invalid.rates[chf] = { ...invalid.rates[chf], baseSource: 'identity' } + fetchSpy.mockResolvedValueOnce(payload(invalid)) + const second = await import('@/server/fx') + expect((await second.getRateTable('EUR')).source).toBe('static') }) -}) -describe('what counts as a usable payload', () => { - it('accepts a payload missing only non-core codes, and leaves them out of the table', async () => { - const partial = { ...perUsd, INR: 88 } - delete (partial as Record).SEK - fetchSpy.mockResolvedValue(feed(partial)) + it.each([ + ['the wrong selected base', (body: ReturnType) => ({ ...body, base: 'GBP' })], + ['the wrong basis', (body: ReturnType) => ({ ...body, basis: 'midmarket' })], + ['a non-indicative flag', (body: ReturnType) => ({ ...body, indicative: false })], + ['a malformed generated timestamp', (body: ReturnType) => ({ ...body, generatedAt: 'today' })], + [ + 'a future generated timestamp', + (body: ReturnType) => ({ + ...body, + generatedAt: new Date(Date.now() + 6 * 60 * 1000).toISOString(), + }), + ], + [ + 'a generated snapshot older than the ingest window', + (body: ReturnType) => ({ + ...body, + generatedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), + }), + ], + ])('rejects %s', async (_label, mutate) => { + fetchSpy.mockResolvedValue(payload(mutate(feedBody('EUR', staticDirect('EUR'))))) const { getRateTable } = await freshFx() - const table = await getRateTable() - expect(table.source).toBe('live') - expect(table.usdPerUnit.INR).toBeCloseTo(1 / 88, 12) - expect(table.usdPerUnit).not.toHaveProperty('SEK') + expect((await getRateTable('EUR')).source).toBe('static') + }) + + it.each([ + ['the removed mixed selection', 'CHF', { selection: 'mixed' }], + ['provider/reference mixing', 'BRL', { selection: 'provider_pair', baseSource: 'bridge' }], + ['identity on a non-base row', 'CHF', { selection: 'identity' }], + ['a non-unit identity value', 'EUR', { unitsPerBase: '1.01' }], + ['an effective timestamp on identity', 'EUR', { effectiveAt: nowIso() }], + ['a missing pair timestamp', 'CHF', { effectiveAt: null }], + ['an old pair timestamp', 'CHF', { effectiveAt: new Date(Date.now() - 31 * 86400_000).toISOString() }], + ['a future pair timestamp', 'CHF', { effectiveAt: new Date(Date.now() + 6 * 60_000).toISOString() }], + ['an unknown leg source', 'CHF', { quoteSource: 'oracle' }], + ])('rejects %s', async (_label, code, replacement) => { + const body = feedBody('EUR', staticDirect('EUR')) + const index = body.rates.findIndex((row) => row.code === code) + body.rates[index] = { ...body.rates[index], ...replacement } + fetchSpy.mockResolvedValue(payload(body)) + const { getRateTable } = await freshFx() + + expect((await getRateTable('EUR')).source).toBe('static') + expect(console.warn).toHaveBeenCalledWith('[fx] EUR rate feed refresh failed (rate feed payload unusable)') + }) + + it.each([ + ['a numeric rather than decimal-string rate', 4.1], + ['zero', '0'], + ['a negative rate', '-4.1'], + ['exponent notation', '4.1e0'], + ['more than eighteen fractional digits', '4.1234567890123456789'], + ['a value above the wire bound', '10000000000000000000'], + ])('rejects %s', async (_label, unitsPerBase) => { + const body = feedBody('EUR', staticDirect('EUR')) + const index = body.rates.findIndex((row) => row.code === 'CHF') + body.rates[index] = { ...body.rates[index], unitsPerBase } + fetchSpy.mockResolvedValue(payload(body)) + const { getRateTable } = await freshFx() + + expect((await getRateTable('EUR')).source).toBe('static') }) - it('rejects a payload missing one core code, whole', async () => { - const truncated = { ...perUsd } - delete (truncated as Record).THB - fetchSpy.mockResolvedValue(feed(truncated)) + it('rejects duplicate and unsorted rows instead of choosing one', async () => { + const duplicate = feedBody('EUR', staticDirect('EUR')) + duplicate.rates.push({ ...duplicate.rates[0] }) + fetchSpy.mockResolvedValueOnce(payload(duplicate)) + const first = await freshFx() + expect((await first.getRateTable('EUR')).source).toBe('static') + + vi.resetModules() + const unsorted = feedBody('EUR', staticDirect('EUR')) + unsorted.rates.reverse() + fetchSpy.mockResolvedValueOnce(payload(unsorted)) + const second = await import('@/server/fx') + expect((await second.getRateTable('EUR')).source).toBe('static') + }) + + it('rejects more than 512 rows before building a table', async () => { + const body = feedBody('EUR', staticDirect('EUR')) + body.rates = Array.from({ length: 513 }, (_, index) => ({ + ...body.rates[0], + code: `${String.fromCharCode(65 + Math.floor(index / 26 / 26))}${String.fromCharCode( + 65 + (Math.floor(index / 26) % 26) + )}${String.fromCharCode(65 + (index % 26))}`, + })) + fetchSpy.mockResolvedValue(payload(body)) const { getRateTable } = await freshFx() - // Nothing cached and the payload refused → the static table, not a half-live one. - expect((await getRateTable()).source).toBe('static') + expect((await getRateTable('EUR')).source).toBe('static') }) - it('drops a code the catalog does not know, so a made-up ticker cannot pick up a real rate', async () => { - // The feed carries CNH, IMP, JEP and five other non-ISO codes. - fetchSpy.mockResolvedValue(feed({ ...perUsd, CNH: 7.1, JEP: 0.79, KID: 1.5 })) + it('rejects a thin response before it can erase broad coverage', async () => { + const body = feedBody('EUR', staticDirect('EUR')) + body.rates = body.rates.slice(0, 149) + fetchSpy.mockResolvedValue(payload(body)) const { getRateTable } = await freshFx() - const table = await getRateTable() + expect((await getRateTable('EUR')).source).toBe('static') + }) + + it('rejects a response missing one core currency', async () => { + const rates = { ...staticDirect('EUR') } + delete rates.THB + fetchSpy.mockResolvedValue(feed('EUR', rates)) + const { getRateTable } = await freshFx() + + expect((await getRateTable('EUR')).source).toBe('static') + expect(console.warn).toHaveBeenCalledWith( + '[fx] EUR rate feed refresh failed (rate feed missing core currencies)' + ) + }) + + it('validates unknown rows before filtering them from Split storage', async () => { + const body = feedBody('EUR', { ...staticDirect('EUR'), QZZ: 1 }) + const index = body.rates.findIndex((row) => row.code === 'QZZ') + body.rates[index] = { ...body.rates[index], selection: 'mixed' } + fetchSpy.mockResolvedValue(payload(body)) + const { getRateTable } = await freshFx() + + expect((await getRateTable('EUR')).source).toBe('static') + }) + + it('omits a catalog cross the DB cannot persist, rather than deriving another route', async () => { + fetchSpy.mockResolvedValue(feed('EUR', { ...staticDirect('EUR'), INR: 1e-13 })) + const { getRateTable, rateFrom } = await freshFx() + + const table = await getRateTable('EUR') expect(table.source).toBe('live') - for (const code of ['CNH', 'JEP', 'KID']) expect(table.usdPerUnit).not.toHaveProperty(code) + expect(rateFrom(table, 'INR', 'EUR')).toBeNull() }) - it('drops a bad value per code rather than failing the payload', async () => { + it('stops reading a chunked response after 256 KiB', async () => { fetchSpy.mockResolvedValue( - feed({ ...perUsd, INR: 0, ZAR: -3, ISK: Number.NaN, PLN: '4.1', NOK: null, SEK: 10 }) + payload({ ...feedBody('EUR', staticDirect('EUR')), padding: 'x'.repeat(256 * 1024) }) ) const { getRateTable } = await freshFx() - const table = await getRateTable() - expect(table.source).toBe('live') - for (const code of ['INR', 'ZAR', 'ISK', 'PLN', 'NOK']) expect(table.usdPerUnit).not.toHaveProperty(code) - expect(table.usdPerUnit.SEK).toBeCloseTo(0.1, 12) + expect((await getRateTable('EUR')).source).toBe('static') + }) + + it('rejects an oversized declared body before reading it', async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify(feedBody('EUR', staticDirect('EUR'))), { + status: 200, + headers: { 'Content-Length': String(256 * 1024 + 1) }, + }) + ) + const { getRateTable } = await freshFx() + + expect((await getRateTable('EUR')).source).toBe('static') }) - it('falls back to a complete cache when the fetch fails, rather than to the static table', async () => { + it('uses a complete last-known table when refresh fails', async () => { const yesterday = new Date(Date.now() - 25 * 60 * 60 * 1000) - await seed([...coreRows(yesterday), { quote: 'INR', usdPerUnit: 0.012, fetchedAt: yesterday }]) + await seed('EUR', [...coreRows('EUR', yesterday), { quote: 'INR', basePerUnit: 0.011, fetchedAt: yesterday }]) fetchSpy.mockRejectedValue(new Error('upstream down')) const { getRateTable } = await freshFx() - const table = await getRateTable() + const table = await getRateTable('EUR') expect(table.source).toBe('cache') - expect(table.usdPerUnit.INR).toBeCloseTo(0.012, 12) + expect(table.basePerUnit.INR).toBeCloseTo(0.011, 12) }) -}) -/** - * The failure this closes: nothing deleted an `FxRate` row, so a code the feed stopped carrying - * kept pricing expenses from whatever it was last worth, while the one request that crossed the - * TTL took the live branch and returned a 400 on the same input. - */ -describe('a code the feed stops carrying', () => { - it('is never priced from a row past the ceiling, however fresh the core is', async () => { + it('does not serve a complete cache after its seven-day ceiling', async () => { const eightDays = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) - await seed([...coreRows(), { quote: 'SEK', usdPerUnit: 0.095, fetchedAt: eightDays }]) - const { getRateTable, rateFrom } = await freshFx() + await seed('EUR', coreRows('EUR', eightDays)) + fetchSpy.mockRejectedValue(new Error('upstream down')) + const { getRateTable } = await freshFx() - const table = await getRateTable() - expect(table.source).toBe('cache') - expect(fetchSpy).not.toHaveBeenCalled() - // Null, not a rate. The room refuses the expense rather than netting it at a week-old number. - expect(rateFrom(table, 'SEK', 'EUR')).toBeNull() + expect((await getRateTable('EUR')).source).toBe('static') }) +}) - it('gives the same answer on both sides of a TTL tick, instead of alternating', async () => { - const ageCore = (ms: number) => - prisma.fxRate.updateMany({ - where: { quote: { in: [...FX_CORE] } }, - data: { fetchedAt: new Date(Date.now() - ms) }, - }) - const HOUR = 60 * 60 * 1000 - // The feed carried SEK yesterday and does not carry it today. - await seed([...coreRows(new Date(Date.now() - 23 * HOUR)), { quote: 'SEK', usdPerUnit: 0.095 }]) - const { getRateTable, rateFrom } = await freshFx() +describe('mirrored writes stay within one base', () => { + it('writes direct rates under the requested base and preserves another base', async () => { + await seed('USD', coreRows('USD')) + fetchSpy.mockResolvedValue(feed('EUR', { ...staticDirect('EUR'), INR: 0.011, KWD: 3.1 })) + const { getRateTable } = await freshFx() + + await getRateTable('EUR') + const eurRows = await prisma.fxRate.findMany({ where: { base: 'EUR' } }) + expect(eurRows).toHaveLength(FX_CORE.length + 2) + expect(Number(eurRows.find((row) => row.quote === 'INR')!.rate)).toBeCloseTo(0.011, 12) + expect(await prisma.fxRate.count({ where: { base: 'USD' } })).toBe(FX_CORE.length) + }) - // Inside the TTL: the cached SEK rate is less than a day old, so it is an honest answer. - const inside = await getRateTable() - expect(inside.source).toBe('cache') - expect(rateFrom(inside, 'SEK', 'EUR')).not.toBeNull() - - // Crossing it: the live payload has no SEK, so the pair has no rate. - await ageCore(25 * HOUR) - const crossing = await getRateTable() - expect(crossing.source).toBe('live') - expect(rateFrom(crossing, 'SEK', 'EUR')).toBeNull() - - // And after it. This is the assertion that matters: the refresh used to leave SEK's row - // in place, so the very next request served the old rate again and the answer alternated - // for as long as the feed stayed silent. - const after = await getRateTable() - expect(after.source).toBe('cache') - expect(rateFrom(after, 'SEK', 'EUR')).toBeNull() - expect(await prisma.fxRate.findFirst({ where: { quote: 'SEK' } })).toBeNull() + it('deletes a dropped quote only from the refreshed base', async () => { + const stale = new Date(Date.now() - 25 * 60 * 60 * 1000) + await seed('EUR', [...coreRows('EUR', stale), { quote: 'SEK', basePerUnit: 0.09, fetchedAt: stale }]) + await seed('GBP', [...coreRows('GBP'), { quote: 'SEK', basePerUnit: 0.08 }]) + const { getRateTable } = await freshFx() + + await getRateTable('EUR') + expect(await prisma.fxRate.findFirst({ where: { base: 'EUR', quote: 'SEK' } })).toBeNull() + expect(await prisma.fxRate.findFirst({ where: { base: 'GBP', quote: 'SEK' } })).not.toBeNull() }) }) -describe('the write', () => { - it('lands every rate in one transaction', async () => { - fetchSpy.mockResolvedValue(feed({ ...perUsd, INR: 88, KWD: 0.306 })) +describe('single-flight and backoff are keyed by base', () => { + it('coalesces concurrent requests for one base', async () => { const { getRateTable } = await freshFx() - await getRateTable() - const rows = await prisma.fxRate.findMany({ where: { base: 'USD' } }) - expect(rows).toHaveLength(FX_CORE.length + 2) - expect(rows.every((row) => row.id.length > 0)).toBe(true) - const inr = rows.find((row) => row.quote === 'INR')! - expect(Number(inr.rate)).toBeCloseTo(1 / 88, 9) + const tables = await Promise.all([ + getRateTable('EUR'), + getRateTable('EUR'), + getRateTable('EUR'), + getRateTable('EUR'), + ]) + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(tables.every((table: RateTable) => table.base === 'EUR' && table.source === 'live')).toBe(true) }) - it('updates the row it already has rather than inserting a second one', async () => { - await seed(coreRows(new Date(Date.now() - 25 * 60 * 60 * 1000))) - fetchSpy.mockResolvedValue(feed({ ...perUsd, EUR: 2 })) + it('does not coalesce different destination bases', async () => { const { getRateTable } = await freshFx() - await getRateTable() - const eur = await prisma.fxRate.findMany({ where: { base: 'USD', quote: 'EUR' } }) - expect(eur).toHaveLength(1) - expect(Number(eur[0].rate)).toBeCloseTo(0.5, 9) + const [eur, gbp] = await Promise.all([getRateTable('EUR'), getRateTable('GBP')]) + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(eur.base).toBe('EUR') + expect(gbp.base).toBe('GBP') }) -}) -describe('single flight', () => { - it('serves concurrent cold requests from one fetch and one write', async () => { + it('does not let one base failure back off another base', async () => { + fetchSpy.mockImplementation(async (input: string | URL | Request) => { + const base = new URL(String(input)).searchParams.get('base')! + if (base === 'EUR') throw new Error('EUR unavailable') + return feed(base, staticDirect(base)) + }) const { getRateTable } = await freshFx() - const tables = await Promise.all([getRateTable(), getRateTable(), getRateTable(), getRateTable()]) - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(tables.every((table: RateTable) => table.source === 'live')).toBe(true) + expect((await getRateTable('EUR')).source).toBe('static') + expect((await getRateTable('GBP')).source).toBe('live') + expect(fetchSpy).toHaveBeenCalledTimes(2) }) - it('does not pin later requests to a refresh that failed', async () => { + it('releases a failed flight so a later cache read can recover', async () => { fetchSpy.mockRejectedValueOnce(new Error('upstream down')) const { getRateTable } = await freshFx() - expect((await getRateTable()).source).toBe('static') - // The backoff, not the guard, is what stops the second call — but the guard must have - // released, or every later request would replay the same settled promise forever. - await seed(coreRows()) - expect((await getRateTable()).source).toBe('cache') + expect((await getRateTable('EUR')).source).toBe('static') + await seed('EUR', coreRows('EUR')) + expect((await getRateTable('EUR')).source).toBe('cache') + }) +}) + +describe('static fallback is materialized per base', () => { + it('prices every pinned quote directly into a pinned base', async () => { + process.env.SPLIT_FX_MODE = 'static' + const { getRateTable, rateFrom } = await freshFx() + const table = await getRateTable('EUR') + + expect(table).toMatchObject({ base: 'EUR', source: 'static' }) + expect(rateFrom(table, 'THB', 'EUR')).toBeCloseTo(0.028 / 1.08, 12) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('gives a non-pinned catalog base identity but invents no cross', async () => { + process.env.SPLIT_FX_MODE = 'static' + const { getRateTable, rateFrom } = await freshFx() + const table = await getRateTable('PLN') + + expect(rateFrom(table, 'PLN', 'PLN')).toBe(1) + expect(rateFrom(table, 'EUR', 'PLN')).toBeNull() }) }) diff --git a/apps/web/src/server/fx.test.ts b/apps/web/src/server/fx.test.ts index 975d7d2c..b87daae5 100644 --- a/apps/web/src/server/fx.test.ts +++ b/apps/web/src/server/fx.test.ts @@ -3,65 +3,72 @@ import { FX_CORE, rateFrom, requireRate, type RateTable } from '@/server/fx' import { ApiError } from '@/server/http' import { convertMinorAtRate, STATIC_USD_PER_UNIT } from '@/server/money' -const table: RateTable = { - usdPerUnit: { ...STATIC_USD_PER_UNIT }, - source: 'static', - fetchedAt: null, +const tableFor = (base: string, basePerUnit?: Record): RateTable => { + const usdPerBase = STATIC_USD_PER_UNIT[base] + const materialized = + basePerUnit ?? + Object.fromEntries( + Object.entries(STATIC_USD_PER_UNIT).map(([quote, usdPerQuote]) => [quote, usdPerQuote / usdPerBase]) + ) + return { base, basePerUnit: materialized, source: 'static', fetchedAt: null } } +const eurTable = tableFor('EUR') + describe('rateFrom', () => { it('is 1 for the same currency', () => { - expect(rateFrom(table, 'EUR', 'EUR')).toBe(1) + expect(rateFrom(eurTable, 'EUR', 'EUR')).toBe(1) + }) + + /** Identity is checked before any lookup, which lets a room settling in a + * made-up ticker hold expenses in that same ticker. */ + it('is 1 for a made-up ticker against itself, with no rate in the table', () => { + expect(rateFrom(eurTable, 'DOGE', 'DOGE')).toBe(1) + expect(rateFrom(tableFor('BEER', {}), 'BEER', 'BEER')).toBe(1) }) - /** Identity is checked before any lookup, which is the whole reason a room settling in a - * made-up ticker can hold expenses at all. */ - it('is 1 for a made-up ticker against itself, with no rate anywhere in the table', () => { - expect(rateFrom(table, 'DOGE', 'DOGE')).toBe(1) - expect(rateFrom({ ...table, usdPerUnit: {} }, 'BEER', 'BEER')).toBe(1) + it('uses the direct row selected for the table base', () => { + expect(rateFrom(eurTable, 'USD', 'EUR')).toBeCloseTo(1 / 1.08, 12) + expect(rateFrom(eurTable, 'THB', 'EUR')).toBeCloseTo(0.028 / 1.08, 12) }) - it('crosses via USD', () => { - expect(rateFrom(table, 'EUR', 'USD')).toBeCloseTo(1.08, 12) - expect(rateFrom(table, 'USD', 'EUR')).toBeCloseTo(1 / 1.08, 12) - expect(rateFrom(table, 'THB', 'EUR')).toBeCloseTo(0.028 / 1.08, 12) + it('refuses to cross one base table into a different destination', () => { + expect(rateFrom(eurTable, 'EUR', 'USD')).toBeNull() + expect(rateFrom(eurTable, 'EUR', 'THB')).toBeNull() }) - it('round-trips an amount back to itself on the static table', () => { + it('round-trips through two independently materialized destination tables', () => { const eur = 12_345n - const thb = convertMinorAtRate(eur, 'EUR', 'THB', rateFrom(table, 'EUR', 'THB')!) - const back = convertMinorAtRate(thb, 'THB', 'EUR', rateFrom(table, 'THB', 'EUR')!) - // Two roundings, so allow the single minor unit they can cost. + const thbTable = tableFor('THB') + const thb = convertMinorAtRate(eur, 'EUR', 'THB', rateFrom(thbTable, 'EUR', 'THB')!) + const back = convertMinorAtRate(thb, 'THB', 'EUR', rateFrom(eurTable, 'THB', 'EUR')!) expect(back - eur >= -1n && back - eur <= 1n).toBe(true) }) - /** - * Null, never 1, and never a throw. A 1 here would net two currencies that have no exchange - * rate against each other at par — the worst thing this module can do, and it would do it - * with nothing on any screen to say so. - */ - it('is null for a pair it cannot price, in both directions', () => { - expect(rateFrom(table, 'DOGE', 'EUR')).toBeNull() - expect(rateFrom(table, 'EUR', 'DOGE')).toBeNull() - expect(rateFrom(table, 'DOGE', 'BEER')).toBeNull() + it('is null for a pair it cannot price', () => { + expect(rateFrom(eurTable, 'DOGE', 'EUR')).toBeNull() + expect(rateFrom(eurTable, 'EUR', 'DOGE')).toBeNull() + expect(rateFrom(tableFor('BEER', {}), 'DOGE', 'BEER')).toBeNull() }) - it('is null for a real catalog code the table carries no rate for', () => { - // KPW is in the catalog and never in the feed. It is not a special case; it is the - // general one, and it gets the same answer a made-up ticker gets. - expect(rateFrom(table, 'KPW', 'EUR')).toBeNull() - expect(rateFrom(table, 'EUR', 'KPW')).toBeNull() + it('is null for a real catalog code the table carries no direct rate for', () => { + expect(rateFrom(eurTable, 'KPW', 'EUR')).toBeNull() }) - it('is null rather than Infinity or NaN when a rate is zero', () => { - const broken: RateTable = { ...table, usdPerUnit: { ...STATIC_USD_PER_UNIT, ZWL: 0 } } + it('is null rather than Infinity or NaN for an invalid direct row', () => { + const broken = tableFor('EUR', { ZWL: 0 }) expect(rateFrom(broken, 'ZWL', 'EUR')).toBeNull() - expect(rateFrom(broken, 'EUR', 'ZWL')).toBeNull() }) - it('prices every ordered pair of the twelve codes that ship a static rate', () => { - for (const from of FX_CORE) { - for (const to of FX_CORE) { + it('refuses a direct rate that cannot fit Decimal(24,12)', () => { + expect(rateFrom(tableFor('TINY', { HUGE: 1e12 }), 'HUGE', 'TINY')).toBeNull() + expect(rateFrom(tableFor('HUGE', { TINY: 1e-13 }), 'TINY', 'HUGE')).toBeNull() + }) + + it('prices every core source into every materialized core destination', () => { + for (const to of FX_CORE) { + const table = tableFor(to) + for (const from of FX_CORE) { const rate = rateFrom(table, from, to) expect(rate).not.toBeNull() expect(rate!).toBeGreaterThan(0) @@ -71,28 +78,26 @@ describe('rateFrom', () => { }) describe('requireRate', () => { - it('returns the rate where rateFrom does', () => { - expect(requireRate(table, 'EUR', 'USD')).toBeCloseTo(1.08, 12) - expect(requireRate(table, 'DOGE', 'DOGE')).toBe(1) + it('returns a direct table rate and identity', () => { + expect(requireRate(eurTable, 'USD', 'EUR')).toBeCloseTo(1 / 1.08, 12) + expect(requireRate(eurTable, 'DOGE', 'DOGE')).toBe(1) }) - /** A 400 with a code the client can translate. A raw `Error` leaves as a 500, which reads as - * "we broke" rather than "that pair has no rate". */ - it('throws a 400 NO_RATE where rateFrom returns null', () => { - expect(() => requireRate(table, 'DOGE', 'EUR')).toThrow(ApiError) + it('throws a 400 NO_RATE when the table cannot answer that destination', () => { + expect(() => requireRate(eurTable, 'DOGE', 'EUR')).toThrow(ApiError) try { - requireRate(table, 'DOGE', 'EUR') + requireRate(eurTable, 'EUR', 'USD') throw new Error('expected a throw') - } catch (err) { - expect(err).toBeInstanceOf(ApiError) - expect((err as ApiError).status).toBe(400) - expect((err as ApiError).code).toBe('NO_RATE') + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + expect((error as ApiError).status).toBe(400) + expect((error as ApiError).code).toBe('NO_RATE') } }) }) describe('FX_CORE', () => { - it('is the twelve legacy codes, and the static table covers exactly them', () => { + it('is the twelve legacy codes covered by the pinned USD source table', () => { expect(FX_CORE).toHaveLength(12) expect([...FX_CORE].sort()).toEqual(Object.keys(STATIC_USD_PER_UNIT).sort()) }) diff --git a/apps/web/src/server/fx.ts b/apps/web/src/server/fx.ts index b097fb42..6fc2d759 100644 --- a/apps/web/src/server/fx.ts +++ b/apps/web/src/server/fx.ts @@ -1,37 +1,54 @@ /** - * Indicative FX. Live rates come from open.er-api.com (free, no key), are cached - * for 24h in the FxRate table, and fall back to the static catalog table when the - * fetch fails. Rates are indicative — surfaces that show one must say so. + * Indicative FX. Peanut resolves each requested room-currency table with the + * same all-provider-or-all-reference policy used by Peanut UI. Split stores + * those direct quote→room rates for 24h and never reconstructs a different pair + * by crossing unrelated rows locally. */ import { prisma } from '@/server/db' +import { egressFetch } from '@/server/egress' import { badRequest } from '@/server/http' import { convertMinorAtRate, isCatalogCode, STATIC_USD_PER_UNIT } from '@/server/money' -const RATE_URL = 'https://open.er-api.com/v6/latest/USD' +// Overridable so staging can be pointed at a staging API. Production egress is +// default-deny, so the call rides the pinned squid proxy exactly like the model +// scan does — without SPLIT_FX_PROXY_URL set, and api.peanut.me on the squid +// CONNECT-443 allowlist, every refresh fails and the table silently degrades to +// the twelve static rates. See the Deployment section of README.md. +const RATE_ENDPOINT = process.env.SPLIT_FX_ENDPOINT ?? 'https://api.peanut.me/fx/rates' const TTL_MS = 24 * 60 * 60 * 1000 -/** - * A cached rate this old never prices money again, whatever else is true. - * - * The TTL says when to TRY a refresh; this says when a row stops being an answer. It is longer - * than the TTL on purpose, because the two guard different things: a short upstream outage must - * not drop 150 currencies to the twelve static rates, and a week-old number must not be quoted as - * a rate. Without a ceiling the age of a row is unbounded — the feed drops a code, nothing - * refreshes it, nothing deletes it, and it keeps converting at whatever it was worth in the past. - */ +/** A cached rate this old never prices money again, even during an outage. */ const MAX_RATE_AGE_MS = 7 * 24 * 60 * 60 * 1000 -const FETCH_TIMEOUT_MS = 4000 -/** Don't re-hit a failing upstream on every request. */ -const FAILURE_BACKOFF_MS = 10 * 60 * 1000 +// The API's cold provider/reference work is bounded at three seconds. Leave +// enough room for DNS, TLS and ordinary network latency around it. +const FETCH_TIMEOUT_MS = 6000 +/** Avoid a request storm without pinning a transient cold-start failure. */ +const FAILURE_BACKOFF_MS = 60 * 1000 +const MIN_RATE_ROWS = 150 +const MAX_RATE_ROWS = 512 +const MAX_RATE_RESPONSE_BYTES = 256 * 1024 +const MAX_RATE_DECIMAL_CHARS = 64 +const MAX_FUTURE_CLOCK_SKEW_MS = 5 * 60 * 1000 +const MAX_EFFECTIVE_AT_AGE_MS = 30 * 24 * 60 * 60 * 1000 +/** The API can describe wider pairs than Split's Decimal(24,12) column. */ +const MIN_WIRE_RATE = 1e-18 +const MAX_WIRE_RATE = 1e18 +const MIN_PERSISTABLE_RATE = 1e-12 +const MAX_PERSISTABLE_RATE = 1e12 +const RATE_DECIMAL = /^(?:0|[1-9]\d*)(?:\.\d{1,18})?$/ +const RATE_CODE = /^[A-Z]{3,4}$/ +const RATE_SELECTIONS = new Set(['identity', 'provider_pair', 'reference_pair']) +const LEG_SOURCES = new Set(['identity', 'bridge', 'manteca', 'reference']) +const PROVIDER_SOURCES = new Set(['bridge', 'manteca']) -/** - * The 12 codes every existing prod room uses. - * - * Freshness is judged against THIS set and never against the whole catalog, and that distinction - * is the whole reason the constant exists. Four catalog codes (CUC, KPW, SVC, XSU) are not in the - * feed and never will be, so "every catalog code is cached" is permanently false — which would - * make the cache never look fresh, re-fetch on every single request, and drop to the 12-rate - * static table the moment upstream blinked. - */ +const persistableRate = (value: number): number | null => { + if (!Number.isFinite(value) || value < MIN_PERSISTABLE_RATE || value >= MAX_PERSISTABLE_RATE) return null + const quantised = Number(value.toFixed(12)) + if (!Number.isFinite(quantised) || quantised < MIN_PERSISTABLE_RATE || quantised >= MAX_PERSISTABLE_RATE) + return null + return quantised +} + +/** The 12 currencies guaranteed by Split's static outage table. */ export const FX_CORE: readonly string[] = [ 'USD', 'EUR', @@ -50,133 +67,293 @@ export const FX_CORE: readonly string[] = [ export type RateSource = 'live' | 'cache' | 'static' export interface RateTable { - /** USD per 1 major unit of the currency. Keys are always a subset of the catalog. */ - usdPerUnit: Record + /** The only destination this table can price. */ + base: string + /** Units of `base` per one major unit of each quote currency. */ + basePerUnit: Record source: RateSource fetchedAt: Date | null } -const STATIC_TABLE: RateTable = { - usdPerUnit: { ...STATIC_USD_PER_UNIT }, - source: 'static', - fetchedAt: null, +const staticTables = new Map() + +/** Materialize the pinned USD fallback in the requested destination currency. + * This is the one deliberate local cross: a fixed, reviewed outage table, not + * independently selected live rows. */ +function staticTableFor(baseInput: string): RateTable { + const base = baseInput.toUpperCase() + // Public rate previews also accept invented 3–4 character tickers for + // same-currency identity. Never retain that attacker-controlled keyspace; + // only the finite generated catalog belongs in the process cache. + const cacheable = isCatalogCode(base) + const existing = cacheable ? staticTables.get(base) : undefined + if (existing) return existing + + const basePerUnit: Record = { [base]: 1 } + const usdPerBase = STATIC_USD_PER_UNIT[base] + if (usdPerBase !== undefined) { + for (const [quote, usdPerQuote] of Object.entries(STATIC_USD_PER_UNIT)) { + basePerUnit[quote] = usdPerQuote / usdPerBase + } + } + const table: RateTable = { base, basePerUnit, source: 'static', fetchedAt: null } + if (cacheable) staticTables.set(base, table) + return table } -let lastFailedFetchAt = 0 +const lastFailedFetchAt = new Map() +const inflight = new Map>() const remoteDisabled = () => process.env.SPLIT_FX_MODE === 'static' -/** open.er-api.com returns "units of X per 1 USD"; we store the inverse. */ -async function fetchUsdPerUnit(): Promise | null> { - if (remoteDisabled()) return null - if (Date.now() - lastFailedFetchAt < FAILURE_BACKOFF_MS) return null +/** Keep diagnostics useful without printing a response body, request URL or an + * unbounded upstream exception. */ +const failureDetail = (error: unknown): string => { + if (!(error instanceof Error)) return 'unknown' + const detail = error.message.startsWith('rate feed ') ? error.message : error.name + return detail.slice(0, 120) +} + +interface LiveRateSnapshot { + basePerUnit: Record + generatedAt: Date +} + +type JsonObject = Record + +const isObject = (value: unknown): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const canonicalInstant = (value: unknown): Date | null => { + if (typeof value !== 'string' || value.length !== 24) return null + const instant = new Date(value) + if (!Number.isFinite(instant.getTime()) || instant.toISOString() !== value) return null + return instant +} + +const unusablePayload = (): never => { + throw new Error('rate feed payload unusable') +} + +/** Read a chunked response with a real byte ceiling; Content-Length is only a + * fast path and is never trusted as the sole bound. */ +async function readLivePayload(response: Response): Promise { + const declared = Number(response.headers.get('content-length') ?? 0) + if (Number.isFinite(declared) && declared > MAX_RATE_RESPONSE_BYTES) return unusablePayload() + if (!response.body) return unusablePayload() + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let text = '' + let seen = 0 try { - const res = await fetch(RATE_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) - if (!res.ok) throw new Error(`rate feed responded ${res.status}`) - const body = (await res.json()) as { result?: string; rates?: Record } - if (body.result !== 'success' || !body.rates) throw new Error('rate feed payload unusable') - const out: Record = {} - for (const [code, perUsd] of Object.entries(body.rates)) { - // Catalog codes only. The feed carries CNH, IMP, JEP and five other non-ISO codes; - // without this, somebody who typed one as a made-up ticker would silently pick up a - // real rate for it — which is the exact surprise this whole change exists to prevent. - if (!isCatalogCode(code)) continue - if (typeof perUsd === 'number' && Number.isFinite(perUsd) && perUsd > 0) out[code] = 1 / perUsd + for (;;) { + const { value, done } = await reader.read() + if (done) break + seen += value.byteLength + if (seen > MAX_RATE_RESPONSE_BYTES) return unusablePayload() + text += decoder.decode(value, { stream: true }) } - // A payload missing one of the twelve is truncated or broken, not merely thin. Codes - // outside the core are simply absent, which is what lets the catalog be wider than the feed. - if (!FX_CORE.every((code) => typeof out[code] === 'number')) - throw new Error('rate feed missing core currencies') - return out + } finally { + await reader.cancel().catch(() => {}) + } + text += decoder.decode() + + try { + return JSON.parse(text) } catch { - lastFailedFetchAt = Date.now() - return null + return unusablePayload() } } -/** - * One refresh at a time per process. - * - * N cold requests would otherwise each run their own fetch and their own 162-row write. This is a - * mitigation and not a fix — the deploy is containers, so it is per-process — and it must clear on - * rejection, or one failed refresh pins every later request to the same rejected promise. - */ -let inflight: Promise | null = null - -/** Cached rates, refreshed at most once per TTL. Never throws — worst case static. */ -export function getRateTable(): Promise { - if (remoteDisabled()) return Promise.resolve(STATIC_TABLE) - if (!inflight) { - inflight = loadRateTable().finally(() => { - inflight = null +type RateSelection = 'identity' | 'provider_pair' | 'reference_pair' +type LegSource = 'identity' | 'bridge' | 'manteca' | 'reference' + +function validLegProvenance(currency: string, source: LegSource, selection: RateSelection): boolean { + if (currency === 'USD') return source === 'identity' + if (selection === 'reference_pair') return source === 'reference' + if (selection === 'provider_pair') return PROVIDER_SOURCES.has(source) + return false +} + +/** Validate the complete base-specific response before selecting catalog rows. + * `unitsPerBase` is quote units per room unit; the DB stores its inverse so a + * source amount can be multiplied directly into the room currency. */ +function parseLiveSnapshot(body: unknown, requestedBase: string): LiveRateSnapshot { + if (!isObject(body)) return unusablePayload() + if ( + body.base !== requestedBase || + body.basis !== 'display_sell' || + body.indicative !== true || + !Array.isArray(body.rates) || + body.rates.length < MIN_RATE_ROWS || + body.rates.length > MAX_RATE_ROWS + ) + return unusablePayload() + + const generatedAt = canonicalInstant(body.generatedAt) + if (!generatedAt) return unusablePayload() + const snapshotAge = Date.now() - generatedAt.getTime() + if (snapshotAge >= TTL_MS || snapshotAge < -MAX_FUTURE_CLOCK_SKEW_MS) return unusablePayload() + + const basePerUnit: Record = {} + let previousCode = '' + const now = Date.now() + for (const value of body.rates) { + if (!isObject(value)) return unusablePayload() + const { code, unitsPerBase, selection, baseSource, quoteSource, effectiveAt } = value + if (typeof code !== 'string' || !RATE_CODE.test(code) || code <= previousCode) return unusablePayload() + previousCode = code + if ( + typeof selection !== 'string' || + !RATE_SELECTIONS.has(selection) || + typeof baseSource !== 'string' || + !LEG_SOURCES.has(baseSource) || + typeof quoteSource !== 'string' || + !LEG_SOURCES.has(quoteSource) + ) + return unusablePayload() + if ( + typeof unitsPerBase !== 'string' || + unitsPerBase.length === 0 || + unitsPerBase.length > MAX_RATE_DECIMAL_CHARS || + !RATE_DECIMAL.test(unitsPerBase) + ) + return unusablePayload() + + const quotePerBase = Number(unitsPerBase) + if (!Number.isFinite(quotePerBase) || quotePerBase < MIN_WIRE_RATE || quotePerBase > MAX_WIRE_RATE) + return unusablePayload() + + const typedSelection = selection as RateSelection + const typedBaseSource = baseSource as LegSource + const typedQuoteSource = quoteSource as LegSource + if (code === requestedBase) { + if ( + quotePerBase !== 1 || + typedSelection !== 'identity' || + typedBaseSource !== 'identity' || + typedQuoteSource !== 'identity' || + effectiveAt !== null + ) + return unusablePayload() + } else { + if ( + typedSelection === 'identity' || + !validLegProvenance(requestedBase, typedBaseSource, typedSelection) || + !validLegProvenance(code, typedQuoteSource, typedSelection) + ) + return unusablePayload() + const effective = canonicalInstant(effectiveAt) + if (!effective) return unusablePayload() + const effectiveAge = now - effective.getTime() + if (effectiveAge > MAX_EFFECTIVE_AT_AGE_MS || effectiveAge < -MAX_FUTURE_CLOCK_SKEW_MS) + return unusablePayload() + } + + // Validate every producer row first. Only then select currencies Split + // recognises, and omit crosses its Decimal column cannot represent. + if (isCatalogCode(code)) { + const directRate = persistableRate(1 / quotePerBase) + if (directRate !== null) basePerUnit[code] = directRate + } + } + + const required = new Set([...FX_CORE, requestedBase]) + if (![...required].every((code) => typeof basePerUnit[code] === 'number')) { + throw new Error('rate feed missing core currencies') + } + return { basePerUnit, generatedAt } +} + +const rateUrl = (base: string): string => { + const url = new URL(RATE_ENDPOINT) + url.searchParams.set('base', base) + return url.toString() +} + +async function fetchBaseRates(base: string): Promise { + if (remoteDisabled() || !isCatalogCode(base)) return null + if (Date.now() - (lastFailedFetchAt.get(base) ?? 0) < FAILURE_BACKOFF_MS) return null + try { + const response = await egressFetch(process.env.SPLIT_FX_PROXY_URL, rateUrl(base), { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + redirect: 'error', + credentials: 'omit', + cache: 'no-store', }) + if (!response.ok) throw new Error(`rate feed responded ${response.status}`) + const snapshot = parseLiveSnapshot(await readLivePayload(response), base) + lastFailedFetchAt.delete(base) + return snapshot + } catch (error) { + lastFailedFetchAt.set(base, Date.now()) + console.warn(`[fx] ${base} rate feed refresh failed (${failureDetail(error)})`) + return null } - return inflight } -async function loadRateTable(): Promise { +/** Cached rates for one destination, refreshed at most once per TTL. */ +export function getRateTable(baseInput: string): Promise { + const base = baseInput.toUpperCase() + const fallback = staticTableFor(base) + if (remoteDisabled() || !isCatalogCode(base)) return Promise.resolve(fallback) + + const existing = inflight.get(base) + if (existing) return existing + const request = loadRateTable(base, fallback).finally(() => { + if (inflight.get(base) === request) inflight.delete(base) + }) + inflight.set(base, request) + return request +} + +async function loadRateTable(base: string, fallback: RateTable): Promise { let rows: { quote: string; rate: unknown; fetchedAt: Date }[] = [] try { - rows = await prisma.fxRate.findMany({ where: { base: 'USD' } }) - } catch { - return STATIC_TABLE + rows = await prisma.fxRate.findMany({ where: { base } }) + } catch (error) { + console.warn(`[fx] ${base} rate cache read failed (${failureDetail(error)})`) + return fallback } const cached: Record = {} - let oldestCore: Date | null = null + const required = new Set([...FX_CORE, base]) + let oldestRequired: Date | null = null const now = Date.now() for (const row of rows) { - // The live branch filters to the catalog; the cache read has to as well, or a code that - // has since left the catalog stays priceable from a row nothing ever deletes. - if (!isCatalogCode(row.quote)) continue - // Per row, not per table. `oldestCore` decides when to refresh and deliberately ignores - // non-core rows; without this, a code the feed stopped carrying would be priced forever - // from the last rate it ever had, because no core row is old enough to trigger anything. - if (now - row.fetchedAt.getTime() >= MAX_RATE_AGE_MS) continue - const rate = Number(row.rate) - if (!Number.isFinite(rate) || rate <= 0) continue + if (!isCatalogCode(row.quote) || now - row.fetchedAt.getTime() >= MAX_RATE_AGE_MS) continue + const rate = persistableRate(Number(row.rate)) + if (rate === null) continue + if (row.quote === base && rate !== 1) continue cached[row.quote] = rate - // Over the core only: a code the feed stops carrying keeps its row forever with an old - // timestamp, and one of those would pin the age of the whole table in the past. - if (FX_CORE.includes(row.quote) && (!oldestCore || row.fetchedAt < oldestCore)) oldestCore = row.fetchedAt + if (required.has(row.quote) && (!oldestRequired || row.fetchedAt < oldestRequired)) { + oldestRequired = row.fetchedAt + } } - const complete = FX_CORE.every((code) => typeof cached[code] === 'number') - const fresh = complete && oldestCore !== null && Date.now() - oldestCore.getTime() < TTL_MS - if (fresh) return { usdPerUnit: cached, source: 'cache', fetchedAt: oldestCore } + const complete = [...required].every((code) => typeof cached[code] === 'number') + const fresh = complete && oldestRequired !== null && now - oldestRequired.getTime() < TTL_MS + if (fresh) return { base, basePerUnit: cached, source: 'cache', fetchedAt: oldestRequired } - const live = await fetchUsdPerUnit() + const live = await fetchBaseRates(base) if (!live) { - if (complete && oldestCore) return { usdPerUnit: cached, source: 'cache', fetchedAt: oldestCore } - return STATIC_TABLE + if (complete && oldestRequired) return { base, basePerUnit: cached, source: 'cache', fetchedAt: oldestRequired } + return fallback } - const fetchedAt = new Date() try { - await writeRates(live, fetchedAt) - } catch { - // Cache write is best-effort; the rates we just fetched are still good. + await writeRates(base, live.basePerUnit, live.generatedAt) + } catch (error) { + console.warn(`[fx] ${base} rate cache write failed (${failureDetail(error)})`) } - return { usdPerUnit: live, source: 'live', fetchedAt } + return { base, basePerUnit: live.basePerUnit, source: 'live', fetchedAt: live.generatedAt } } -/** - * The cache is made to mirror the payload — two statements, one transaction. - * - * 162 upserts inside a transaction is 162 round trips on the request path of whichever unlucky - * request lost the cache race, so the write is one statement over `unnest`. Ids are generated here - * rather than by `gen_random_uuid()` because `FxRate.id` is application-side (`@default(uuid())`) - * and the column has no database default. Rates cross as text and are cast to numeric in the - * database, so no float is parsed twice. - * - * The DELETE is what stops a dropped code being priced forever. Keeping a row the feed no longer - * carries made the same request answer two different ways: inside the TTL the cache branch served - * the old rate, the one request that crossed the TTL took the live branch and returned a 400, and - * the refresh put the cache back the way it was so the next request succeeded again. `fetchUsdPerUnit` - * refuses any payload missing an `FX_CORE` code, so a truncated response cannot reach here and - * empty the table. - */ -async function writeRates(usdPerUnit: Record, fetchedAt: Date): Promise { - const entries = Object.entries(usdPerUnit) +/** Mirror one complete destination snapshot in two statements. */ +async function writeRates(base: string, basePerUnit: Record, fetchedAt: Date): Promise { + const entries = Object.entries(basePerUnit) if (entries.length === 0) return const ids = entries.map(() => crypto.randomUUID()) const quotes = entries.map(([quote]) => quote) @@ -185,34 +362,26 @@ async function writeRates(usdPerUnit: Record, fetchedAt: Date): await prisma.$transaction([ prisma.$executeRaw` INSERT INTO split."FxRate" (id, base, quote, rate, "fetchedAt") - SELECT q.id, 'USD', q.quote, q.rate::numeric, ${fetchedAt} + SELECT q.id, ${base}, q.quote, q.rate::numeric, ${fetchedAt} FROM unnest(${ids}::text[], ${quotes}::text[], ${rates}::text[]) AS q(id, quote, rate) ON CONFLICT (base, quote) DO UPDATE SET rate = EXCLUDED.rate, "fetchedAt" = EXCLUDED."fetchedAt" `, - prisma.$executeRaw`DELETE FROM split."FxRate" WHERE base = 'USD' AND quote <> ALL(${quotes}::text[])`, + prisma.$executeRaw`DELETE FROM split."FxRate" WHERE base = ${base} AND quote <> ALL(${quotes}::text[])`, ]) } /** - * Major units of `to` per 1 major unit of `from`, or **null** when the pair cannot be priced. - * - * Null, never 1. A silent 1:1 between two currencies that have no rate is the one failure this - * whole area exists to make impossible, so the absence of a rate has to be a value the caller is - * forced to handle rather than a plausible-looking number. - * - * `from === to` is 1 by identity, checked before any lookup, so a room settling in a custom - * ticker always accepts its own expenses. + * Units of `to` per one major unit of `from`, or null. A table can answer only + * for its declared destination; crossing it into another base would silently + * reintroduce the policy mismatch the base-specific API prevents. */ export function rateFrom(table: RateTable, from: string, to: string): number | null { if (from === to) return 1 - const f = table.usdPerUnit[from] - const t = table.usdPerUnit[to] - if (!f || !t) return null - return f / t + if (to !== table.base) return null + return persistableRate(table.basePerUnit[from]) } -/** The same lookup on a write path, where "no rate" is a 400 and not an option. */ export function requireRate(table: RateTable, from: string, to: string): number { const rate = rateFrom(table, from, to) if (rate === null) throw badRequest(`no exchange rate for ${from} → ${to}`, 'NO_RATE') @@ -220,13 +389,13 @@ export function requireRate(table: RateTable, from: string, to: string): number } export async function getRate(from: string, to: string): Promise<{ rate: number | null; source: RateSource }> { - const table = await getRateTable() + const table = await getRateTable(to) return { rate: rateFrom(table, from, to), source: table.source } } -/** Convert minor units at the current cached rate. */ +/** Convert minor units at the current direct room-base rate. */ export async function convertMinor(amountMinor: bigint, from: string, to: string): Promise { if (from === to) return amountMinor - const table = await getRateTable() + const table = await getRateTable(to) return convertMinorAtRate(amountMinor, from, to, requireRate(table, from, to)) } diff --git a/apps/web/src/server/http.test.ts b/apps/web/src/server/http.test.ts index 5e2c4935..8f812e5a 100644 --- a/apps/web/src/server/http.test.ts +++ b/apps/web/src/server/http.test.ts @@ -30,6 +30,25 @@ describe('JSON response caching', () => { }) }) +describe('typed error details', () => { + it('preserves bounded machine-readable context without changing ordinary envelopes', async () => { + const response = await respond(() => { + throw badRequest('PLN cannot be converted', 'IMPORT_CURRENCY_CONVERSION_UNSUPPORTED', { + currencies: ['PLN'], + targetCurrency: 'EUR', + }) + }) + + await expect(response.json()).resolves.toEqual({ + error: { + code: 'IMPORT_CURRENCY_CONVERSION_UNSUPPORTED', + message: 'PLN cannot be converted', + details: { currencies: ['PLN'], targetCurrency: 'EUR' }, + }, + }) + }) +}) + describe('readJson', () => { it('parses an ordinary JSON request', async () => { await expect(readJson(request('{"name":"Peanut"}'))).resolves.toEqual({ name: 'Peanut' }) diff --git a/apps/web/src/server/http.ts b/apps/web/src/server/http.ts index 120b3c7e..54e3553e 100644 --- a/apps/web/src/server/http.ts +++ b/apps/web/src/server/http.ts @@ -5,7 +5,8 @@ export class ApiError extends Error { constructor( readonly status: number, readonly code: string, - message: string + message: string, + readonly details?: unknown ) { super(message) } @@ -17,7 +18,8 @@ export class ApiError extends Error { * would describe differently must not share one code. The default is the catch-all for schema * rejections, not a licence to leave a distinct failure unnamed. */ -export const badRequest = (message: string, code = 'VALIDATION_ERROR') => new ApiError(400, code, message) +export const badRequest = (message: string, code = 'VALIDATION_ERROR', details?: unknown) => + new ApiError(400, code, message, details) export const notFound = (message: string, code = 'NOT_FOUND') => new ApiError(404, code, message) export const conflict = (message: string, code = 'CONFLICT') => new ApiError(409, code, message) @@ -40,8 +42,8 @@ export function json(data: unknown, status = 200, headers: Record - json({ error: { code, message } }, status) +export const errorResponse = (code: string, message: string, status: number, details?: unknown) => + json({ error: { code, message, ...(details === undefined ? {} : { details }) } }, status) /** * Any thrown thing → the house envelope. Split out of `respond` because one @@ -50,7 +52,7 @@ export const errorResponse = (code: string, message: string, status: number) => * directly rather than inventing a second error shape. */ export function errorEnvelope(err: unknown): Response { - if (err instanceof ApiError) return errorResponse(err.code, err.message, err.status) + if (err instanceof ApiError) return errorResponse(err.code, err.message, err.status, err.details) if (err instanceof ZodError) { const first = err.issues[0] const path = first?.path.join('.') diff --git a/apps/web/src/server/importRequest.ts b/apps/web/src/server/importRequest.ts index eb4b72e8..c6fe5de3 100644 --- a/apps/web/src/server/importRequest.ts +++ b/apps/web/src/server/importRequest.ts @@ -1,8 +1,11 @@ import { MAX_EXPENSES, MAX_MEMBERS } from '@/lib/splitwise-csv' import { ApiError, badRequest } from '@/server/http' -/** Five hundred expenses across twenty members is roughly 400 KB. */ -export const MAX_IMPORT_BODY_BYTES = 1_000_000 +/** The valid 500-expense × 20-share envelope reaches about 6.4 MB when every + * bounded JavaScript character must be represented as a six-byte JSON escape. + * Keep the byte ceiling above that schema maximum while still refusing + * unexpectedly large bodies before parsing. */ +export const MAX_IMPORT_BODY_BYTES = 7_000_000 /** Aggregate row-amplification ceiling, equal to the full documented product * envelope. It remains explicit so a future dimension-limit change cannot diff --git a/apps/web/src/server/model.test.ts b/apps/web/src/server/model.test.ts index e0b79a57..f035e7f9 100644 --- a/apps/web/src/server/model.test.ts +++ b/apps/web/src/server/model.test.ts @@ -15,6 +15,7 @@ describe('coerceCurrency', () => { expect(coerceCurrency('THB', 'EUR')).toBe('THB') // Wide catalog: this is the point of the change. expect(coerceCurrency('INR', 'EUR')).toBe('INR') + expect(coerceCurrency('KPW', 'EUR')).toBe('KPW') expect(coerceCurrency('KWD', 'JPY')).toBe('KWD') }) @@ -31,8 +32,8 @@ describe('coerceCurrency', () => { /** The D7 rule. A guess the room cannot convert is worse than no guess. */ it('drops a code the room currency has no rate to', () => { - // A real catalog code the feed does not carry. - expect(coerceCurrency('KPW', 'EUR')).toBeNull() + // A real catalog code the current Peanut snapshot does not carry. + expect(coerceCurrency('BGN', 'EUR')).toBeNull() // A real code, in a room that settles in a made-up ticker. expect(coerceCurrency('EUR', 'BEER')).toBeNull() // Two made-up tickers never convert to each other. @@ -42,6 +43,6 @@ describe('coerceCurrency', () => { it('keeps the room currency itself, whatever it is', () => { expect(coerceCurrency('BEER', 'BEER')).toBe('BEER') expect(coerceCurrency('beer', 'BEER')).toBe('BEER') - expect(coerceCurrency('KPW', 'KPW')).toBe('KPW') + expect(coerceCurrency('BGN', 'BGN')).toBe('BGN') }) }) diff --git a/apps/web/src/server/money.test.ts b/apps/web/src/server/money.test.ts index 73bec656..892fc58b 100644 --- a/apps/web/src/server/money.test.ts +++ b/apps/web/src/server/money.test.ts @@ -95,7 +95,14 @@ describe('currency catalog', () => { }) it('says which codes the rate feed carries', () => { - expect(CURRENCY_CATALOG.filter((c) => !c.hasRate).map((c) => c.code)).toEqual(['CUC', 'KPW', 'SVC', 'XSU']) + expect(CURRENCY_CATALOG.filter((c) => !c.hasRate).map((c) => c.code)).toEqual([ + 'BGN', + 'CUC', + 'HRK', + 'SLL', + 'XSU', + 'ZWL', + ]) expect(LEGACY.every((c) => currency(c.code).hasRate)).toBe(true) }) diff --git a/apps/web/src/server/money.ts b/apps/web/src/server/money.ts index e566473c..fcb7d482 100644 --- a/apps/web/src/server/money.ts +++ b/apps/web/src/server/money.ts @@ -30,9 +30,8 @@ export const STATIC_USD_PER_UNIT: Readonly> = { CAD: 0.73, } -/** PostgreSQL BIGINT's positive ceiling. Public money writes are positive, but - * every amount still has to fit the signed column it will be stored in. */ -export const MAX_SIGNED_MINOR = 9_223_372_036_854_775_807n +/** Keep server validation and browser/import parsing on one storage boundary. */ +export { MAX_SIGNED_MINOR } from '@/lib/money' /** How many minor units a code outside the catalog has. Two is part of the invented-currency * contract: parse, format, exact shares, and manual conversion all use it consistently, so diff --git a/apps/web/src/server/receipt.test.ts b/apps/web/src/server/receipt.test.ts index dc5880b5..2ff275ed 100644 --- a/apps/web/src/server/receipt.test.ts +++ b/apps/web/src/server/receipt.test.ts @@ -196,9 +196,10 @@ describe('normalizeReceipt — the optional fields', () => { expect(one({ currency: 'eur' }).currency).toBe('EUR') // The catalog is 162 codes wide now, so a scanned SEK receipt is kept. expect(one({ currency: 'SEK' }).currency).toBe('SEK') - // KPW is real ISO 4217 and the rate feed does not carry it: a room cannot be priced in + expect(one({ currency: 'KPW' }).currency).toBe('KPW') + // BGN is real ISO 4217 and the current Peanut snapshot does not carry it: a room cannot be priced in // it, so a guess here would be worse than the room's own currency. - expect(one({ currency: 'KPW' }).currency).toBeNull() + expect(one({ currency: 'BGN' }).currency).toBeNull() expect(one({ currency: '€' }).currency).toBeNull() expect(one({ currency: 42 }).currency).toBeNull() }) diff --git a/apps/web/src/server/splitwiseImport.ts b/apps/web/src/server/splitwiseImport.ts index fce80784..e226e4cf 100644 --- a/apps/web/src/server/splitwiseImport.ts +++ b/apps/web/src/server/splitwiseImport.ts @@ -53,8 +53,35 @@ const TRANSACTION_TIMEOUT_MS = 30_000 const sourceNameKey = (name: string): string => name.toLowerCase() const compareText = (left: string, right: string): number => (left < right ? -1 : left > right ? 1 : 0) +/** The catalog is a theoretical gate; a loaded table is the runtime gate. Keep + * both import destinations on the same error contract so the preview can make a + * failed quote durable instead of inviting another identical bulk request. */ +function assertImportCurrenciesPriceable( + expenses: readonly { currencyCode: string }[], + targetCurrency: string, + rateTable?: Awaited> +): void { + const unavailable = [...new Set(expenses.map((expense) => expense.currencyCode))].filter( + (sourceCurrency) => + !canPriceCode(sourceCurrency, targetCurrency) || + (rateTable !== undefined && rateFrom(rateTable, sourceCurrency, targetCurrency) === null) + ) + if (unavailable.length > 0) { + throw badRequest( + `import currencies ${unavailable.join(', ')} cannot be converted into ${targetCurrency}`, + 'IMPORT_CURRENCY_CONVERSION_UNSUPPORTED', + { currencies: unavailable, targetCurrency } + ) + } +} + +type ImportFingerprintBody = { + members: readonly (string | { sourceName: string })[] + expenses: readonly ImportRoomBody['expenses'][number][] +} + /** - * A semantic identity for one parsed source export. + * The legacy semantic identity for one parsed source export. * * Target-room choices are intentionally absent: once this source history has * committed to a room, changing its mapping and posting it again must be a @@ -63,7 +90,7 @@ const compareText = (left: string, right: string): number => (left < right ? -1 * order is still the same roster and expense multiset; duplicate identical * rows remain duplicate strings in the sorted array, so multiplicity survives. */ -export function importSourceFingerprint(body: ImportIntoRoomBody | ImportRoomBody): string { +export function importSourceFingerprint(body: ImportFingerprintBody): string { const canonicalExpenses = body.expenses .map((expense) => JSON.stringify({ @@ -96,6 +123,49 @@ export function importSourceFingerprint(body: ImportIntoRoomBody | ImportRoomBod return createHash('sha256').update(canonical).digest('hex') } +/** + * Recover the semantic fingerprint written by the old fresh-room path after + * its UI renamed source people (most visibly Split Pro's `You`). This is a + * compatibility alias only: new batches keep both the original semantic + * projection and the immutable upload fingerprint instead. + */ +function mappedLegacyFingerprint(room: RoomWithRelations, body: ImportIntoRoomBody): string | null { + const targetNameBySource = new Map() + for (const mapping of body.members) { + const targetName = + 'memberId' in mapping + ? room.members.find((member) => member.id === mapping.memberId)?.name + : mapping.newMemberName + if (!targetName) return null + targetNameBySource.set(sourceNameKey(mapping.sourceName), targetName) + } + + const targetName = (sourceName: string): string | null => targetNameBySource.get(sourceNameKey(sourceName)) ?? null + const expenses: ImportRoomBody['expenses'] = [] + for (const expense of body.expenses) { + const paidBy = targetName(expense.paidBy) + if (!paidBy) return null + const shares: (typeof expense)['shares'] = [] + for (const share of expense.shares) { + const member = targetName(share.member) + if (!member) return null + shares.push({ ...share, member }) + } + expenses.push({ ...expense, paidBy, shares }) + } + + return importSourceFingerprint({ + members: body.members.map((mapping) => targetNameBySource.get(sourceNameKey(mapping.sourceName)) as string), + expenses, + }) +} + +function legacyFingerprintCandidates(room: RoomWithRelations, body: ImportIntoRoomBody): string[] { + const direct = importSourceFingerprint(body) + const mapped = mappedLegacyFingerprint(room, body) + return mapped && mapped !== direct ? [direct, mapped] : [direct] +} + export interface ImportIntoRoomOutcome { room: RoomWithRelations batchId: string @@ -215,13 +285,15 @@ export async function importRoom( ): Promise<{ room: RoomWithRelations } & CreatedMember> { // Read phase: the one external lookup the whole import needs, before the transaction opens so // a slow rate feed can never hold a write lock. - const rateTable = await getRateTable() + assertImportCurrenciesPriceable(body.expenses, body.currency) + const rateTable = await getRateTable(body.currency) + assertImportCurrenciesPriceable(body.expenses, body.currency, rateTable) const token = memberToken() const fingerprint = importSourceFingerprint(body) for (let attempt = 0; attempt < SLUG_ATTEMPTS; attempt++) { try { - const slug = await writeRoom(body, rateTable, fingerprint, token, request) + const slug = await writeRoom(body, rateTable, fingerprint, body.sourceFingerprint ?? null, token, request) const room = await loadRoom(slug) const creator = room.members.find((m) => m.token === token) // Unreachable: the creator is validated to be one of the members before we get here. @@ -238,11 +310,12 @@ export async function importRoom( /** * Append a parsed source export to an existing room. * - * Exact semantic replays are successful no-ops. Different fingerprints append - * in full, even when a newer export overlaps an older one; there is no honest + * Replays of one immutable source file choice are successful no-ops, even when + * a newer parser would project that file differently. Different source files + * append in full, even when a newer export overlaps an older one; there is no * stable source-expense id in the supported formats with which to do partial - * overlap detection. The batch fingerprint only protects retries and identical - * concurrent deliveries. + * overlap detection. The legacy semantic fingerprint remains as a rolling- + * compatibility fallback for clients and batches without source identity. */ export async function importIntoRoom( initialRoom: RoomWithRelations, @@ -250,31 +323,47 @@ export async function importIntoRoom( request: Request = new Request('http://localhost'), actorToken: string | null = null ): Promise { - // Splitwise and Split Pro exports contain catalog currencies. A room that - // settles in an invented unit has no automatic conversion target, and the - // import contract intentionally has no manual-rate field. Refuse before the - // FX lookup and transaction so this unsupported pairing cannot stage people - // or touch the ledger. Ordinary manual-rate expense writes remain separate. - if (!isCatalogCode(initialRoom.currency)) { - throw badRequest( - `imports cannot be converted into custom room currency ${initialRoom.currency}`, - 'IMPORT_TARGET_CURRENCY_UNSUPPORTED' - ) + const sourceFingerprint = body.sourceFingerprint ?? null + const legacyFingerprints = legacyFingerprintCandidates(initialRoom, body) + + // Prefer immutable upload identity. The legacy lookup keeps old clients and + // batches working, including fresh-room imports whose UI renamed `You` + // before the old server computed its semantic hash. + const knownBySource = sourceFingerprint + ? await prisma.importBatch.findUnique({ + where: { + roomId_sourceFingerprint: { roomId: initialRoom.id, sourceFingerprint }, + }, + select: { id: true }, + }) + : null + let knownByLegacy: { id: string } | null = null + if (!knownBySource) { + for (const fingerprint of legacyFingerprints) { + knownByLegacy = await prisma.importBatch.findUnique({ + where: { roomId_fingerprint: { roomId: initialRoom.id, fingerprint } }, + select: { id: true }, + }) + if (knownByLegacy) break + } } - const unsupportedCurrencies = [...new Set(body.expenses.map((expense) => expense.currencyCode))].filter( - (sourceCurrency) => !canPriceCode(sourceCurrency, initialRoom.currency) - ) - if (unsupportedCurrencies.length > 0) { - throw badRequest( - `import currencies ${unsupportedCurrencies.join(', ')} cannot be converted into ${initialRoom.currency}`, - 'IMPORT_CURRENCY_CONVERSION_UNSUPPORTED' - ) + const knownBatch = knownBySource ?? knownByLegacy + + if (!knownBatch) { + // Splitwise and Split Pro exports contain catalog currencies. A room + // that settles in an invented unit has no automatic conversion target, + // and the import contract intentionally has no manual-rate field. + if (!isCatalogCode(initialRoom.currency)) { + throw badRequest( + `imports cannot be converted into custom room currency ${initialRoom.currency}`, + 'IMPORT_TARGET_CURRENCY_UNSUPPORTED' + ) + } + assertImportCurrenciesPriceable(body.expenses, initialRoom.currency) } - const fingerprint = importSourceFingerprint(body) - // Identity rows (including KPW → KPW and other catalog currencies without - // a feed rate) need no table. Keeping this decision before the optimistic - // replay lookup means an accepted same-currency import never wakes FX work. + // Identity rows (including BGN → BGN and other catalog currencies without + // a feed rate) need no table. A recognised replay also never wakes FX work. const needsRateTable = body.expenses.some((expense) => expenseNeedsRateTable(initialRoom.currency, expense.currencyCode) ) @@ -282,22 +371,8 @@ export async function importIntoRoom( // A replay must not depend on today's FX table. This optimistic read only // avoids that lookup; the authoritative replay decision still happens // under the room lock below, so a concurrent first delivery is safe. - const knownBatch = await prisma.importBatch.findUnique({ - where: { roomId_fingerprint: { roomId: initialRoom.id, fingerprint } }, - select: { id: true }, - }) - const rateTable = knownBatch || !needsRateTable ? undefined : await getRateTable() - if (rateTable) { - const unavailableNow = [...new Set(body.expenses.map((expense) => expense.currencyCode))].filter( - (sourceCurrency) => rateFrom(rateTable, sourceCurrency, initialRoom.currency) === null - ) - if (unavailableNow.length > 0) { - throw badRequest( - `import currencies ${unavailableNow.join(', ')} cannot be converted into ${initialRoom.currency}`, - 'IMPORT_CURRENCY_CONVERSION_UNSUPPORTED' - ) - } - } + const rateTable = knownBatch || !needsRateTable ? undefined : await getRateTable(initialRoom.currency) + if (rateTable) assertImportCurrenciesPriceable(body.expenses, initialRoom.currency, rateTable) return prisma.$transaction( async (tx) => { @@ -308,10 +383,31 @@ export async function importIntoRoom( // In particular, replaying `{ newMemberName: "Bea" }` after the // original import created Bea is still a clean no-op rather than a // duplicate-name conflict. - const existing = await tx.importBatch.findUnique({ - where: { roomId_fingerprint: { roomId: lockedRoom.id, fingerprint } }, - }) + let existing = sourceFingerprint + ? await tx.importBatch.findUnique({ + where: { + roomId_sourceFingerprint: { roomId: lockedRoom.id, sourceFingerprint }, + }, + }) + : null + if (!existing) { + for (const fingerprint of legacyFingerprints) { + existing = await tx.importBatch.findUnique({ + where: { roomId_fingerprint: { roomId: lockedRoom.id, fingerprint } }, + }) + if (existing) break + } + } if (existing) { + // Opportunistically upgrade a legacy batch. Keeping its + // semantic fingerprint as a separate column means old clients + // still recognise it after this backfill. + if (sourceFingerprint && existing.sourceFingerprint === null) { + existing = await tx.importBatch.update({ + where: { id: existing.id }, + data: { sourceFingerprint }, + }) + } return { room: lockedRoom, batchId: existing.id, @@ -415,7 +511,8 @@ export async function importIntoRoom( data: { id: batchId, roomId: lockedRoom.id, - fingerprint, + fingerprint: legacyFingerprints[0], + sourceFingerprint, importedAt, expenseCount: rows.expenses.length, addedMemberCount: addedMemberIds.length, @@ -487,6 +584,7 @@ async function writeRoom( body: ImportRoomBody, rateTable: Awaited>, fingerprint: string, + sourceFingerprint: string | null, token: string, request: Request ): Promise { @@ -543,6 +641,7 @@ async function writeRoom( id: batchId, roomId: created.id, fingerprint, + sourceFingerprint, importedAt, expenseCount: rows.expenses.length, addedMemberCount: created.members.length, diff --git a/apps/web/src/server/test/api.test.ts b/apps/web/src/server/test/api.test.ts index c367dea8..5fc7bbb4 100644 --- a/apps/web/src/server/test/api.test.ts +++ b/apps/web/src/server/test/api.test.ts @@ -1636,11 +1636,12 @@ describe('fx is locked at creation', () => { * gives a movable table that still never reaches the network. */ const seedRates = async (overrides: Record) => { await prisma.fxRate.deleteMany() + const usdRates = { ...STATIC_USD_PER_UNIT, ...overrides } await prisma.fxRate.createMany({ - data: Object.entries(STATIC_USD_PER_UNIT).map(([code, usdPerUnit]) => ({ - base: 'USD', + data: Object.entries(usdRates).map(([code, usdPerUnit]) => ({ + base: 'EUR', quote: code, - rate: overrides[code] ?? usdPerUnit, + rate: usdPerUnit / usdRates.EUR, fetchedAt: new Date(), })), }) diff --git a/apps/web/src/server/test/import-existing.test.ts b/apps/web/src/server/test/import-existing.test.ts index 80801896..0c63718b 100644 --- a/apps/web/src/server/test/import-existing.test.ts +++ b/apps/web/src/server/test/import-existing.test.ts @@ -3,7 +3,7 @@ * exercise the route against PostgreSQL so retries, locks, provenance and * rollback are proved at the boundary where they matter. */ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { POST as postGlobalImport } from '@/app/api/import/route' import { POST as postRoom } from '@/app/api/rooms/route' import { POST as postMember } from '@/app/api/rooms/[slug]/members/route' @@ -21,6 +21,8 @@ import type { RoomStateWithMember, } from '@/lib/api-types' import { resetEvents, subscribe } from '@/server/events' +import { FX_CORE } from '@/server/fx' +import { STATIC_USD_PER_UNIT } from '@/server/money' import { resetRateLimits } from '@/server/rateLimit' import { prisma, truncateAll } from '@/server/test/db' @@ -101,15 +103,96 @@ const importEvents = (roomId: string) => }) beforeEach(async () => { + process.env.SPLIT_FX_MODE = 'static' await truncateAll() resetRateLimits() resetEvents() }) +afterEach(() => { + process.env.SPLIT_FX_MODE = 'static' +}) + describe('POST /api/rooms/:slug/import', () => { + it('recognises a legacy fresh-room import after Split Pro You was renamed and backfills source identity', async () => { + const sourceFingerprint = '1'.repeat(64) + const original: SplitwiseImport = { + members: ['You', 'Natalia'], + expenses: [ + { + date: '2026-08-05', + description: 'Dinner', + category: 'Dining out', + currencyCode: 'EUR', + costMinor: '1000', + paidBy: 'Natalia', + splitMode: 'EQUAL', + shares: [ + { member: 'You', amountMinor: '500' }, + { member: 'Natalia', amountMinor: '500' }, + ], + }, + ], + suggestedCurrency: 'EUR', + currencies: ['EUR'], + totalBalance: null, + warnings: [], + } + const renamedExpenses = original.expenses.map((expense) => ({ + ...expense, + paidBy: expense.paidBy === 'You' ? 'Konrad' : expense.paidBy, + shares: expense.shares.map((share) => ({ + ...share, + member: share.member === 'You' ? 'Konrad' : share.member, + })), + })) + + // Simulate a batch written before immutable source fingerprints existed: + // the old UI renamed `You` throughout the parsed projection first. + const created = await importNewRoom({ + roomName: 'Renamed Split Pro import', + currency: 'EUR', + creatorName: 'Konrad', + members: ['Konrad', 'Natalia'], + expenses: renamedExpenses, + }) + expect(created.status).toBe(201) + const legacyBatch = await prisma.importBatch.findFirstOrThrow({ where: { roomId: created.body.room.id } }) + expect(legacyBatch.sourceFingerprint).toBeNull() + + const ids = new Map(created.body.members.map((member) => [member.name, member.id])) + const replay = await append( + created.body.room.slug, + { + sourceFingerprint, + members: [ + { sourceName: 'You', memberId: ids.get('Konrad')! }, + { sourceName: 'Natalia', memberId: ids.get('Natalia')! }, + ], + expenses: original.expenses, + }, + created.body.memberToken + ) + + expect(replay.status).toBe(200) + expect(replay.body).toMatchObject({ + batchId: legacyBatch.id, + addedExpenses: 1, + addedMembers: 2, + alreadyImported: true, + }) + expect(replay.body.expenses).toHaveLength(1) + expect(await prisma.importBatch.count({ where: { roomId: created.body.room.id } })).toBe(1) + expect(await prisma.importBatch.findUniqueOrThrow({ where: { id: legacyBatch.id } })).toMatchObject({ + sourceFingerprint, + }) + }) + it('recognises the source that originally created a room as a replay', async () => { const parsed = source() + const sourceFingerprint = '3'.repeat(64) const created = await importNewRoom({ + sourceFingerprint, roomName: 'Originally imported', emoji: '🧦', currency: 'EUR', @@ -120,7 +203,7 @@ describe('POST /api/rooms/:slug/import', () => { expect(created.status).toBe(201) const batch = await prisma.importBatch.findFirstOrThrow({ where: { roomId: created.body.room.id } }) - expect(batch).toMatchObject({ expenseCount: 3, addedMemberCount: 3 }) + expect(batch).toMatchObject({ expenseCount: 3, addedMemberCount: 3, sourceFingerprint }) const importedBefore = await prisma.expense.findMany({ where: { roomId: created.body.room.id }, orderBy: { importRowIndex: 'asc' }, @@ -132,11 +215,14 @@ describe('POST /api/rooms/:slug/import', () => { ]) const ids = new Map(created.body.members.map((member) => [member.name, member.id])) - const body = bodyFor(parsed, [ - { sourceName: 'Ana', memberId: ids.get('Ana')! }, - { sourceName: 'Bruno', memberId: ids.get('Bruno')! }, - { sourceName: 'Carla', memberId: ids.get('Carla')! }, - ]) + const body: ImportIntoRoomInput = { + ...bodyFor(parsed, [ + { sourceName: 'Ana', memberId: ids.get('Ana')! }, + { sourceName: 'Bruno', memberId: ids.get('Bruno')! }, + { sourceName: 'Carla', memberId: ids.get('Carla')! }, + ]), + sourceFingerprint, + } const memberIdsBefore = created.body.members.map((member) => member.id).sort() const expenseIdsBefore = created.body.expenses.map((expense) => expense.id).sort() let pokes = 0 @@ -470,6 +556,52 @@ describe('POST /api/rooms/:slug/import', () => { expect(pokes).toBe(2) }) + it('uses immutable source identity across parser projection changes', async () => { + const { body: target } = await newRoom() + const parsed = source() + const sourceFingerprint = '2'.repeat(64) + const first = await append( + target.room.slug, + { + ...bodyFor(parsed, [ + { sourceName: 'Ana', memberId: target.members[0].id }, + { sourceName: 'Bruno', newMemberName: 'Bruno' }, + { sourceName: 'Carla', newMemberName: 'Carla' }, + ]), + sourceFingerprint, + }, + target.memberToken + ) + expect(first.status).toBe(200) + + const ids = new Map(first.body.members.map((member) => [member.name, member.id])) + const reparsed = parsed.expenses.map((expense) => ({ + ...expense, + // This deliberately changes the legacy semantic hash, as a parser + // normalization or bug fix can, while the local source is unchanged. + description: `${expense.description} (new parser projection)`, + })) + const replay = await append( + target.room.slug, + { + sourceFingerprint, + members: [ + { sourceName: 'Ana', memberId: ids.get('Ana')! }, + { sourceName: 'Bruno', memberId: ids.get('Bruno')! }, + { sourceName: 'Carla', memberId: ids.get('Carla')! }, + ], + expenses: reparsed, + }, + target.memberToken + ) + + expect(replay.status).toBe(200) + expect(replay.body).toMatchObject({ batchId: first.body.batchId, alreadyImported: true }) + expect(replay.body.expenses).toHaveLength(parsed.expenses.length) + expect(await prisma.importBatch.count({ where: { roomId: target.room.id } })).toBe(1) + expect(await prisma.expense.count({ where: { roomId: target.room.id } })).toBe(parsed.expenses.length) + }) + it('rejects invalid reconciliation without writing target members, rows, batches or audit', async () => { const { body: target } = await newRoom() const { body: other } = await newRoom({ name: 'Other room', creatorName: 'Else' }) @@ -568,6 +700,7 @@ describe('POST /api/rooms/:slug/import', () => { expect(result.status).toBe(400) expect(result.body.error.code).toBe('IMPORT_CURRENCY_CONVERSION_UNSUPPORTED') + expect(result.body.error.details).toEqual({ currencies: ['KPW'], targetCurrency: 'EUR' }) expect(await prisma.member.count({ where: { roomId: target.room.id } })).toBe(1) expect(await prisma.expense.count({ where: { roomId: target.room.id } })).toBe(0) expect(await prisma.importBatch.count({ where: { roomId: target.room.id } })).toBe(0) @@ -575,6 +708,62 @@ describe('POST /api/rooms/:slug/import', () => { expect(pokes).toBe(0) }) + it("imports KUNC's 82 PLN toll into its EUR room with the cached Peanut cross-rate", async () => { + const { body: target } = await newRoom({ name: 'KUNC', creatorName: 'You' }) + await prisma.fxRate.createMany({ + data: [ + ...FX_CORE.map((quote) => ({ + base: 'EUR', + quote, + rate: STATIC_USD_PER_UNIT[quote] / STATIC_USD_PER_UNIT.EUR, + fetchedAt: new Date(), + })), + { base: 'EUR', quote: 'PLN', rate: 0.231481481481, fetchedAt: new Date() }, + ], + }) + delete process.env.SPLIT_FX_MODE + + const result = await append( + target.room.slug, + { + members: [ + { sourceName: 'You', memberId: target.members[0].id }, + { sourceName: 'Natalia Cieśla', newMemberName: 'Natalia Cieśla' }, + ], + expenses: [ + { + date: '2026-04-27', + description: 'Toll', + category: 'car', + currencyCode: 'PLN', + costMinor: '8200', + paidBy: 'Natalia Cieśla', + splitMode: 'EQUAL', + shares: [ + { member: 'You', amountMinor: '4100' }, + { member: 'Natalia Cieśla', amountMinor: '4100' }, + ], + }, + ], + }, + target.memberToken + ) + + expect(result.status).toBe(200) + expect(result.body).toMatchObject({ addedExpenses: 1, addedMembers: 1, alreadyImported: false }) + expect(result.body.expenses).toHaveLength(1) + expect(result.body.expenses[0]).toMatchObject({ + description: 'Toll', + amountMinor: '8200', + currency: 'PLN', + baseAmountMinor: '1898', + fxRate: '0.231481481481', + }) + const you = result.body.members.find((member) => member.name === 'You')! + const natalia = result.body.members.find((member) => member.name === 'Natalia Cieśla')! + expect(result.body.balances).toMatchObject({ [you.id]: '-949', [natalia.id]: '949' }) + }) + it('refuses a custom-currency target before adding people, ledger rows or notifications', async () => { const { body: target } = await newRoom({ currency: 'BEER' }) const body = bodyFor(source(), [ @@ -602,7 +791,7 @@ describe('POST /api/rooms/:slug/import', () => { expect(pokes).toBe(0) }) - it('refuses an EUR source into an unrated KPW target before any import side effect', async () => { + it('refuses an EUR source into a KPW target absent from the static FX table before any side effect', async () => { const { body: target } = await newRoom({ currency: 'KPW' }) const body = bodyFor(source(), [ { sourceName: 'Ana', memberId: target.members[0].id }, @@ -629,7 +818,7 @@ describe('POST /api/rooms/:slug/import', () => { expect(pokes).toBe(0) }) - it('imports same-currency KPW rows into an unrated KPW target at identity', async () => { + it('imports same-currency KPW rows into that static-unavailable target at identity', async () => { const { body: target } = await newRoom({ currency: 'KPW' }) const parsed = source() const kpwSource: SplitwiseImport = { diff --git a/apps/web/src/server/test/import.test.ts b/apps/web/src/server/test/import.test.ts index d8497ae3..c7baab86 100644 --- a/apps/web/src/server/test/import.test.ts +++ b/apps/web/src/server/test/import.test.ts @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { prisma, truncateAll } from '@/server/test/db' import { IMPORT_LIMIT, resetRateLimits } from '@/server/rateLimit' +import { MAX_IMPORT_BODY_BYTES } from '@/server/importRequest' import { importRoom } from '@/server/splitwiseImport' import { roomStateBySlug } from '@/server/roomState' import { assertImportCardinality, MAX_IMPORT_SHARE_ROWS, POST as postImport } from '@/app/api/import/route' @@ -361,6 +362,45 @@ describe('what the route refuses', () => { ) }) + it('keeps the body cap above multibyte and JSON-escaped maximum imports for both routes', () => { + for (const fill of ['漢', '\ud800']) { + const members = Array.from( + { length: MAX_MEMBERS }, + (_, index) => `${String(index).padStart(2, '0')}${fill.repeat(78)}` + ) + const expense = { + date: '2026-08-05', + description: fill.repeat(255), + category: fill.repeat(40), + currencyCode: 'EUR', + costMinor: '2000000000000000000', + paidBy: members[0], + shares: members.map((member) => ({ member, amountMinor: '100000000000000000' })), + } + const expenses = Array.from({ length: MAX_EXPENSES }, () => expense) + const payloads = [ + { + roomName: fill.repeat(80), + emoji: '🧾', + currency: 'EUR', + creatorName: members[0], + members, + expenses, + }, + { + members: members.map((sourceName) => ({ sourceName, newMemberName: sourceName })), + expenses, + }, + ] + + for (const payload of payloads) { + const bytes = new TextEncoder().encode(JSON.stringify(payload)).byteLength + expect(bytes).toBeGreaterThan(1_000_000) + expect(bytes).toBeLessThanOrEqual(MAX_IMPORT_BODY_BYTES) + } + } + }) + it('refuses shares that do not add up to the expense', async () => { const file = parsed() file.expenses[0].shares[0].amountMinor = '1' @@ -405,11 +445,13 @@ describe('what the route refuses', () => { expect(status).toBe(400) }) - it('refuses an expense in a currency Split does not carry', async () => { + it('refuses an expense absent from the loaded static FX table', async () => { const file = parsed() file.expenses[0].currencyCode = 'KPW' - const { status } = await post(bodyFor(file)) + const { status, body } = await post(bodyFor(file)) expect(status).toBe(400) + expect(body.error.code).toBe('IMPORT_CURRENCY_CONVERSION_UNSUPPORTED') + expect(body.error.details).toEqual({ currencies: ['KPW'], targetCurrency: 'EUR' }) }) it('refuses a zero-amount expense', async () => { @@ -434,7 +476,10 @@ describe('what the route refuses', () => { it('refuses an oversized body before reading it', async () => { const request = new Request(`${BASE}/api/import`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': '2000000' }, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(MAX_IMPORT_BODY_BYTES + 1), + }, body: JSON.stringify(bodyFor(parsed())), }) const res = await postImport(request) @@ -445,7 +490,7 @@ describe('what the route refuses', () => { /** The bypass the declared-length check could never have caught: no header, * so the old code read the size as 0 and buffered the lot. */ it('refuses an oversized body that declares no length at all', async () => { - const oversized = JSON.stringify(bodyFor(parsed(), { roomName: 'x'.repeat(1_200_000) })) + const oversized = JSON.stringify(bodyFor(parsed(), { roomName: 'x'.repeat(MAX_IMPORT_BODY_BYTES + 200_000) })) const { status, body } = await postChunked(oversized) expect(status).toBe(400) expect(body.error.code).toBe('IMPORT_TOO_LARGE') diff --git a/apps/web/src/server/validation.test.ts b/apps/web/src/server/validation.test.ts index 62a59ea9..7bacbcac 100644 --- a/apps/web/src/server/validation.test.ts +++ b/apps/web/src/server/validation.test.ts @@ -6,6 +6,7 @@ import { expensePatchSchema, expenseSchema, expenseUpdateSchema, + importIntoRoomSchema, importRoomSchema, modelAmountMinor, rateQuerySchema, @@ -298,6 +299,28 @@ describe('structured import dates', () => { }) }) +describe('immutable import source identity', () => { + it('accepts an optional lowercase SHA-256 digest on both import destinations', () => { + const fingerprint = 'a'.repeat(64) + const fresh = imported('100') + const append = { + sourceFingerprint: fingerprint, + members: [{ sourceName: 'Ana', newMemberName: 'Ana' }], + expenses: fresh.expenses, + } + + expect(importRoomSchema.safeParse({ ...fresh, sourceFingerprint: fingerprint }).success).toBe(true) + expect(importIntoRoomSchema.safeParse(append).success).toBe(true) + expect(importRoomSchema.safeParse(fresh).success).toBe(true) + }) + + it('rejects malformed or non-canonical source fingerprints', () => { + for (const sourceFingerprint of ['a'.repeat(63), 'A'.repeat(64), 'g'.repeat(64), 123]) { + expect(importRoomSchema.safeParse({ ...imported('100'), sourceFingerprint }).success).toBe(false) + } + }) +}) + describe('money-write request keys', () => { it('accepts opaque browser keys and rejects short or punctuated values', () => { expect(expenseSchema.safeParse({ ...expense('100'), clientKey: 'expense-request-0001' }).success).toBe(true) diff --git a/apps/web/src/server/validation.ts b/apps/web/src/server/validation.ts index 95601843..1bb7471d 100644 --- a/apps/web/src/server/validation.ts +++ b/apps/web/src/server/validation.ts @@ -501,6 +501,14 @@ const importedExpenseSchema = z.object({ type ImportedExpenseBody = z.infer +/** Browser-computed SHA-256 over the immutable upload and selected source + * choice. Optional keeps rolling deploys compatible with older clients; when + * absent the service falls back to its legacy semantic projection. */ +const importedSourceFingerprint = z + .string() + .regex(/^[a-f0-9]{64}$/, 'must be a lowercase SHA-256 digest') + .optional() + /** Re-establish the source-ledger invariants for both import destinations. The * source names are still the join key when appending; the room-member mapping * is deliberately resolved only after this shape has proved self-consistent. */ @@ -552,6 +560,7 @@ const refineImportedLedger = ( export const importRoomSchema = z .object({ + sourceFingerprint: importedSourceFingerprint, roomName: z.string().trim().min(1, 'is required').max(80), emoji: roomEmblem.nullish(), currency: currencyCode, @@ -573,6 +582,7 @@ const newImportMember = z.object({ sourceName: personName, newMemberName: person export const importIntoRoomSchema = z .object({ + sourceFingerprint: importedSourceFingerprint, members: z .array(z.union([existingImportMember, newImportMember])) .min(1) diff --git a/apps/web/src/tools/mileage-rates.ts b/apps/web/src/tools/mileage-rates.ts index be6700cb..2206e160 100644 --- a/apps/web/src/tools/mileage-rates.ts +++ b/apps/web/src/tools/mileage-rates.ts @@ -18,8 +18,9 @@ import type { ToolDataRow } from './types' * marginal, so applying them the way the UK's or Canada's tiers work gives wrong answers. * - **Ireland**'s civil-service bands depend on how far the car has already gone this calendar * year, and they rise before they fall. A single trip has no rate of its own. - * - **Poland** publishes a per-kilometre maximum in złoty, one of the 158 currencies this site - * converts. The published value is still a useful note rather than a universal default. + * - **Poland** publishes two per-kilometre maxima keyed to engine size. There is no single figure + * to prefill, so the calculator switches to złoty and leaves the rate box empty for the reader + * to choose the applicable maximum. * * **Brazil is a verified negative, not a gap.** The federal instrument (Decreto 3.184/1999, art. 2) * pays a daily maximum with no distance term in it. There is no national per-kilometre rate to @@ -150,7 +151,8 @@ export const MILEAGE_RATES: readonly MileageRate[] = [ label: 'Poland (kilometres)', unit: 'km', rate: null, - note: 'Poland’s published maximum is 1.15 PLN a kilometre for an engine over 900 cm³, and 0.89 PLN below it. The złoty is one of the 158 currencies Split converts.', + currency: 'PLN', + note: 'Poland’s published maximum is 1.15 PLN a kilometre for an engine over 900 cm³, and 0.89 PLN below it. The engine decides which one applies, so the rate box stays empty for you to choose; the calculator keeps it in złoty.', sourceLabel: 'Dziennik Ustaw 2023 poz. 5 (dziennikustaw.gov.pl)', sourceUrl: 'https://dziennikustaw.gov.pl/D2023000000501.pdf', }, diff --git a/apps/web/src/tools/mileage-split-calculator.ts b/apps/web/src/tools/mileage-split-calculator.ts index 09e4b835..c142f510 100644 --- a/apps/web/src/tools/mileage-split-calculator.ts +++ b/apps/web/src/tools/mileage-split-calculator.ts @@ -187,7 +187,7 @@ export const mileageSplitCalculator: Tool = { title: 'Good to know', body: [ 'Split is free forever, with nothing to upgrade to.', - '158 currencies, converted at the day’s rate.', + 'Automatic conversion for 156 currencies at the day’s indicative rate.', 'A room holds up to twenty people.', 'Split records a payment rather than making one. It does not check with a bank and cannot.', ], diff --git a/apps/web/src/tools/rent-split-calculator.ts b/apps/web/src/tools/rent-split-calculator.ts index c3878a56..4677c39e 100644 --- a/apps/web/src/tools/rent-split-calculator.ts +++ b/apps/web/src/tools/rent-split-calculator.ts @@ -111,7 +111,7 @@ export const rentSplitCalculator: Tool = { title: 'Good to know', body: [ 'Split is free forever, with nothing to upgrade to.', - '158 currencies, converted at the day’s rate.', + 'Automatic conversion for 156 currencies at the day’s indicative rate.', 'A room holds up to twenty people.', 'Split records a payment rather than making one. It does not check with a bank and cannot.', ],