From 0887f251cece4b29a4be60fcdc8b68e1a6bb1393 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 19:44:40 +0200 Subject: [PATCH 01/14] fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506) Since 4.3.0 the guard resolves a name to every address it has, and a single blocked address refused the whole name. LAN DNS servers commonly publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on such a name failed every login with "Requests to link-local / cloud-metadata addresses are not allowed". Immich, Synology and the other strict-guard integrations on the same kind of name failed the same way. Both guards now leave a blocked address out of the list the socket is pinned to and connect over the rest, and a name that resolves to nothing else is still refused. A private address among the remaining ones still needs ALLOW_INTERNAL_NETWORK under the strict guard, and ALLOW_LINK_LOCAL_IPS works as before. Leaving addresses out is only safe if the blocked set is complete, so the strict guard now blocks everything the admin lane does. The AWS IMDSv6 (fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened to any URL a user can type. Both guards also read an IPv4-mapped or IPv4-compatible address as the IPv4 it carries: Node prints an AAAA record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and ::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a link-local or metadata address now, and the strict guard never connects to loopback either. --- server/src/utils/ssrfGuard.ts | 119 +++--- .../integration/oidc-local-provider.test.ts | 269 +++++++++++++ .../tests/unit/utils/ssrf-dual-stack.test.ts | 131 ++++++- server/tests/unit/utils/ssrfGuard.test.ts | 354 +++++++++++++++++- wiki/Internal-Network-Access.md | 13 +- wiki/Troubleshooting.md | 2 + 6 files changed, 830 insertions(+), 58 deletions(-) create mode 100644 server/tests/integration/oidc-local-provider.test.ts diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index 2e11bd06c..7e257fceb 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -14,7 +14,11 @@ export interface SsrfResult { allowed: boolean; /** The first of `resolvedIps`, kept for the callers that name one address. */ resolvedIp?: string; - /** Every address the name resolves to, each one checked, IPv4 first. */ + /** + * The addresses a connection to the name may use, IPv4 first: every address + * it resolves to, less the ones the guard never connects to. On a refusal, + * every address it resolved to. + */ resolvedIps?: string[]; isPrivate: boolean; error?: string; @@ -27,11 +31,11 @@ export interface SsrfResult { * the resolver lists first, on Node 22 in the resolver's own order, and a * dual-stack name whose AAAA comes first then pinned the connection to an IPv6 * that many hosts cannot reach on port 443 while the A record would have - * answered at once. The whole list goes to the socket, where Node tries the - * addresses in turn (autoSelectFamily, the default since Node 20), so an - * unreachable family costs a quarter of a second instead of the caller's whole - * timeout. IPv4 leads because that is the family that is reachable from the - * containers and LXCs TREK usually runs in. + * answered at once. Every address the guard allows goes to the socket, where + * Node tries the addresses in turn (autoSelectFamily, the default since Node + * 20), so an unreachable family costs a quarter of a second instead of the + * caller's whole timeout. IPv4 leads because that is the family that is + * reachable from the containers and LXCs TREK usually runs in. */ async function resolveAll(hostname: string): Promise<{ address: string; family: number }[]> { const result = await dns.lookup(hostname, { all: true }); @@ -49,16 +53,23 @@ async function resolveAll(hostname: string): Promise<{ address: string; family: } /** - * The IPv4 an IPv4-mapped address (`::ffff:a.b.c.d`) stands for, or null. + * The IPv4 an IPv4-mapped (`::ffff:a.b.c.d`) or IPv4-compatible (`::a.b.c.d`) + * address stands for, or null. * * Taken from the hextets rather than from the text, because `::ffff:127.0.0.1` * and `::ffff:7f00:1` are one address in two spellings and `dns.lookup` picks * which one comes back. The prefix regexes this replaces covered the dotted - * spelling of two ranges; every other mapped address walked past them. + * spelling of two ranges; every other mapped address walked past them. The + * compatible form is deprecated but still parsed, and Node prints an AAAA + * record of `::a9fe:a9fe` as `::169.254.169.254`. `::` and `::1` are left to + * the IPv6 checks, being the unspecified and loopback addresses themselves. */ -function mappedIpv4(hextets: number[]): string | null { +function carriedIpv4(hextets: number[]): string | null { const g = hextets; - if (g[0] || g[1] || g[2] || g[3] || g[4] || g[5] !== 0xffff) return null; + if (g[0] || g[1] || g[2] || g[3] || g[4]) return null; + const mapped = g[5] === 0xffff; + const compatible = g[5] === 0 && (g[6] !== 0 || g[7] > 1); + if (!mapped && !compatible) return null; return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`; } @@ -72,9 +83,12 @@ function isAlwaysBlocked(ip: string): boolean { if (addr.startsWith('127.') || addr === '::1') return true; // Unspecified if (addr.startsWith('0.')) return true; - // Link-local / cloud metadata, apart from an address listed in ALLOW_LINK_LOCAL_IPS, - // which isPrivateNetwork then treats as the internal network it is. - if (addr.startsWith('169.254.') && !ALLOWED_LINK_LOCAL.has(addr)) return true; + // Link-local and cloud metadata: everything the admin lane refuses, apart from + // an address listed in ALLOW_LINK_LOCAL_IPS, which isPrivateNetwork then treats + // as the internal network it is. The AWS IMDSv6 and Alibaba addresses sit in + // ULA and CGNAT space, so without this ALLOW_INTERNAL_NETWORK would open them + // to a URL any user can type. + if (isLinkLocal(addr)) return true; const hextets = expandIpv6(addr); if (hextets) { @@ -83,11 +97,13 @@ function isAlwaysBlocked(ip: string): boolean { // (`::`, `::0`, `0:0:0:0:0:0:0:0`) that only the expanded hextets settle it. // The `0.` check above never saw any of them: they begin with a colon. if (hextets.every(h => h === 0)) return true; + // Loopback in any spelling, not only the `::1` a resolver prints. + if (hextets.slice(0, 7).every(h => h === 0) && hextets[7] === 1) return true; // fe80::/10 spans fe80: through febf:, not just the four characters 'fe80'. if ((hextets[0] & 0xffc0) === 0xfe80) return true; - // A mapped address inherits the verdict of the IPv4 it carries. - const mapped = mappedIpv4(hextets); - if (mapped) return isAlwaysBlocked(mapped); + // A mapped or compatible address inherits the verdict of the IPv4 it carries. + const carried = carriedIpv4(hextets); + if (carried) return isAlwaysBlocked(carried); } // IPv6 transition addresses (NAT64/6to4/Teredo) embedding a hard-blocked IPv4. const embedded = embeddedTransitionIpv4(addr); @@ -111,12 +127,12 @@ function isPrivateNetwork(ip: string): boolean { if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true; // IPv6 ULA (fc00::/7) if (/^f[cd]/i.test(addr)) return true; - // A mapped address inherits the verdict of the IPv4 it carries. Deciding on the - // hextets rather than on three `::ffff:`-prefix regexes also covers the hex - // spelling and the CGNAT range those regexes never listed. + // A mapped or compatible address inherits the verdict of the IPv4 it carries. + // Deciding on the hextets rather than on three `::ffff:`-prefix regexes also + // covers the hex spelling and the CGNAT range those regexes never listed. const hextets = expandIpv6(addr); - const mapped = hextets && mappedIpv4(hextets); - if (mapped) return isPrivateNetwork(mapped); + const carried = hextets && carriedIpv4(hextets); + if (carried) return isPrivateNetwork(carried); // IPv6 transition addresses (NAT64/6to4/Teredo) embedding a private IPv4. const embedded = embeddedTransitionIpv4(addr); if (embedded) return isPrivateNetwork(embedded); @@ -143,10 +159,7 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean const hostname = url.hostname.toLowerCase(); - // Resolve the name to every address it has. Each one is judged on its own, - // and a single blocked address blocks the name: the socket may end up on any - // of them, so a list that mixes a public and a private address is a private - // target with a public alibi. + // Resolve the name to every address it has and judge each one on its own. let resolvedIps: string[]; try { resolvedIps = (await resolveAll(hostname)).map((entry) => entry.address); @@ -154,20 +167,27 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown'; return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` }; } - const resolvedIp = resolvedIps[0]; - const blocked = resolvedIps.find((ip) => isAlwaysBlocked(ip)); - if (blocked) { + // An address the guard never connects to is left out of the list the socket + // gets, rather than refusing the name. A LAN DNS server commonly hands out a + // host's fe80:: address next to its IPv4, and refusing the name for it locked + // out every such host (#2506). Only a name with nothing else left is refused. + const usableIps = resolvedIps.filter((ip) => !isAlwaysBlocked(ip)); + if (usableIps.length === 0) { return { allowed: false, isPrivate: true, - resolvedIp: blocked, + resolvedIp: resolvedIps[0], resolvedIps, error: 'Requests to loopback and link-local addresses are not allowed', }; } + const resolvedIp = usableIps[0]; - const privateIp = resolvedIps.find((ip) => isPrivateNetwork(ip)); + // A private address among the rest decides for the whole name: the socket + // may end up on any address it is handed, so a list that mixes a public and a + // private address is a private target with a public alibi. + const privateIp = usableIps.find((ip) => isPrivateNetwork(ip)); if (privateIp || isInternalHostname(hostname)) { if (!ALLOW_INTERNAL_NETWORK || bypassInternalIpAllowed) { return { @@ -179,10 +199,10 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.', }; } - return { allowed: true, isPrivate: true, resolvedIp: privateIp ?? resolvedIp, resolvedIps }; + return { allowed: true, isPrivate: true, resolvedIp: privateIp ?? resolvedIp, resolvedIps: usableIps }; } - return { allowed: true, isPrivate: false, resolvedIp, resolvedIps }; + return { allowed: true, isPrivate: false, resolvedIp, resolvedIps: usableIps }; } /** Link-local / cloud-metadata addresses — never a legitimate model host. */ @@ -191,17 +211,23 @@ function isLinkLocal(ip: string): boolean { // IPv4 link-local — AWS, GCP, Azure and OpenStack all serve credentials from 169.254.169.254. // An address listed in ALLOW_LINK_LOCAL_IPS is the exception; those never include it. if (addr.startsWith('169.254.')) return !ALLOWED_LINK_LOCAL.has(addr); - // IPv4-mapped (::ffff:169.254.x) and IPv4-compatible (::a9fe:xxxx = ::169.254.x) spellings. - if (/^::ffff:169\.254\./.test(addr) || /^::(ffff:)?a9fe:/.test(addr)) return true; // IPv6 link-local fe80::/10 — the whole range (fe80: … febf:), not just the fe80: prefix. if (/^fe[89ab][0-9a-f]:/.test(addr)) return true; - // AWS IMDSv6 sits in a fixed ULA slot; block it specifically while leaving the rest of - // fc00::/7 reachable (a self-hosted model server may legitimately sit on a ULA address). - if (addr === 'fd00:ec2::254' || addr.startsWith('fd00:ec2:')) return true; + // AWS IMDSv6 sits in a fixed ULA slot (fd00:ec2::/32); block it specifically while + // leaving the rest of fc00::/7 reachable (a self-hosted model server may + // legitimately sit on a ULA address). Read off the hextets, so every spelling counts. + const hextets = expandIpv6(addr); + if (hextets && hextets[0] === 0xfd00 && hextets[1] === 0x0ec2) return true; // Alibaba Cloud ECS metadata (100.100.100.200, and .100) lives in CGNAT space // (100.64.0.0/10), which safeFetchLlm otherwise allows for a LAN model server — // so block the specific metadata IPs directly rather than the whole range. if (addr === '100.100.100.200' || addr === '100.100.100.100') return true; + // An IPv4-mapped or IPv4-compatible address, dotted or hex, is the IPv4 it + // carries. So 169.254.169.254 cannot come back as ::ffff:a9fe:a9fe, or as the + // ::169.254.169.254 Node prints for a compatible AAAA record, and an address + // listed in ALLOW_LINK_LOCAL_IPS keeps its exception in every spelling. + const carried = hextets && carriedIpv4(hextets); + if (carried) return isLinkLocal(carried); // NAT64/6to4/Teredo embedding one of the above link-local/metadata IPs — extract // the embedded IPv4 and re-check. A transition address to a public or LAN IPv4 // stays allowed (safeFetchLlm deliberately permits private/loopback targets). @@ -257,11 +283,15 @@ export async function safeFetchAdminConfigured( const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown'; throw new SsrfBlockedError(`Could not resolve hostname (${code})`); } - if (resolvedIps.some((ip) => isLinkLocal(ip))) { + // As in checkSsrf: a link-local or metadata address is left out of the list + // the socket gets, and only a name with nothing else left is refused. An + // fe80:: record beside an IdP's LAN address refused every OIDC login (#2506). + const usableIps = resolvedIps.filter((ip) => !isLinkLocal(ip)); + if (usableIps.length === 0) { throw new SsrfBlockedError('Requests to link-local / cloud-metadata addresses are not allowed'); } - const dispatcher = createPinnedDispatcher(resolvedIps, true, responseTimeoutMs); + const dispatcher = createPinnedDispatcher(usableIps, true, responseTimeoutMs); const response = await fetch(currentUrl, { ...hopInit, redirect: 'manual', dispatcher } as any); // Only a 3xx WITH a Location header is a redirect we follow; anything else @@ -414,8 +444,8 @@ export interface SafeFetchFollowOptions extends SafeFetchOptions { keepCredentialsOnRedirect?: boolean; /** * When true, private/internal IPs that ALLOW_INTERNAL_NETWORK would normally - * permit are still blocked (matches `checkSsrf(url, true)`). Loopback and - * link-local are always blocked regardless. Defaults to false. + * permit are still blocked (matches `checkSsrf(url, true)`). Loopback, + * link-local and cloud metadata are always blocked regardless. Defaults to false. */ bypassInternalIpAllowed?: boolean; } @@ -495,9 +525,10 @@ export async function safeFetchFollow( * addresses. This prevents DNS rebinding (TOCTOU) by ensuring the outbound * connection goes to an address we checked, not a re-resolved one. * - * Given the whole checked list, in the order `resolveAll` put it, the socket - * gets every address at once: Node asks for `all: true` and walks the list - * itself, moving on after its attempt timeout when one family does not answer. + * Given the vetted list, in the order `resolveAll` put it, the socket gets + * every address on it at once and never one the guard left out: Node asks for + * `all: true` and walks the list itself, moving on after its attempt timeout + * when one family does not answer. * A caller that still names one address gets exactly the old behaviour. */ export function createPinnedDispatcher( diff --git a/server/tests/integration/oidc-local-provider.test.ts b/server/tests/integration/oidc-local-provider.test.ts new file mode 100644 index 000000000..82b7bb91e --- /dev/null +++ b/server/tests/integration/oidc-local-provider.test.ts @@ -0,0 +1,269 @@ +/** + * The whole OIDC login against a real provider on this machine (#2506). + * + * oidc.test.ts stubs the four provider calls to test the flow around them. This + * file stubs nothing but the resolver: the provider is a small HTTP server on + * loopback that answers discovery, authorize, token, JWKS (with a real RS256 + * signature) and userinfo, so every request the login makes goes through the + * SSRF guard to a socket. + * + * Its name resolves the way a LAN DNS server answers for a self-hosted IdP: an + * fe80:: record next to the reachable address. Since 4.3.0 that one record + * refused the name, and every login ended in `{"error":"OIDC login failed"}`. + * Loopback stands in for the LAN address, being the one address a test can + * reach; the admin lane OIDC rides on treats the two alike. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; +import request from 'supertest'; +import http from 'node:http'; +import crypto from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; + +const { testDb, dbMock, answers } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock, answers: new Map() }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, + SESSION_DURATION_REMEMBER: '30d', + SESSION_DURATION_REMEMBER_MS: 2592000000, + SESSION_DURATION_REMEMBER_SECONDS: 2592000, + DEFAULT_LANGUAGE: 'en', +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); +// The provider's names are answered here; every other name goes to the real resolver. +vi.mock('dns/promises', async (importOriginal) => { + const real = await importOriginal(); + const lookup = ((hostname: string, options?: { all?: boolean }) => { + const answer = answers.get(hostname); + if (!answer) return real.lookup(hostname, options as never); + return Promise.resolve(options?.all ? answer : answer[0]); + }) as typeof real.lookup; + return { ...real, default: { ...real, lookup }, lookup }; +}); + +import { buildApp } from '../../src/bootstrap'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import { createUser } from '../helpers/factories'; + +const CLIENT_ID = 'trek-local'; +const CLIENT_SECRET = 'local-secret'; +const EMAIL = 'lan-admin@home.test'; + +/** Read one cookie's value out of a response's Set-Cookie header, as a browser would. */ +function readCookie(res: request.Response, name: string): string | undefined { + const raw = res.headers['set-cookie']; + const all: string[] = Array.isArray(raw) ? raw : raw ? [raw as unknown as string] : []; + const value = all.filter((c) => c.startsWith(`${name}=`)).pop()?.split(';')[0].slice(name.length + 1); + return value ? decodeURIComponent(value) : undefined; +} + +// ── The provider ──────────────────────────────────────────────────────────── + +const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'k1', alg: 'RS256', use: 'sig' }; +const codes = new Set(); +/** Every request the provider saw: method, path and the address the client dialled. */ +const seen: { method: string; path: string; local: string }[] = []; +let provider: http.Server; +let port = 0; + +const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); +function signIdToken(issuer: string): string { + const now = Math.floor(Date.now() / 1000); + const signingInput = `${b64({ alg: 'RS256', kid: 'k1', typ: 'JWT' })}.${b64({ + iss: issuer, sub: 'lan-sub-1', aud: CLIENT_ID, iat: now, exp: now + 300, email: EMAIL, email_verified: true, + })}`; + return `${signingInput}.${crypto.sign('RSA-SHA256', Buffer.from(signingInput), privateKey).toString('base64url')}`; +} + +function answerJson(res: http.ServerResponse, status: number, body: unknown) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function handle(req: http.IncomingMessage, res: http.ServerResponse) { + // The Host header carries the name TREK asked for, so one server plays every issuer. + const issuer = `http://${req.headers.host}`; + const url = new URL(req.url ?? '/', issuer); + seen.push({ method: req.method ?? '', path: url.pathname, local: req.socket.localAddress ?? '' }); + if (url.pathname === '/.well-known/openid-configuration') { + return answerJson(res, 200, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + userinfo_endpoint: `${issuer}/userinfo`, + jwks_uri: `${issuer}/jwks`, + }); + } + if (url.pathname === '/authorize') { + const code = crypto.randomBytes(12).toString('hex'); + codes.add(code); + const back = new URL(url.searchParams.get('redirect_uri') ?? ''); + back.searchParams.set('code', code); + back.searchParams.set('state', url.searchParams.get('state') ?? ''); + res.writeHead(302, { location: back.toString() }); + return res.end(); + } + if (url.pathname === '/token' && req.method === 'POST') { + let raw = ''; + req.on('data', (chunk) => (raw += chunk)); + req.on('end', () => { + const form = new URLSearchParams(raw); + const code = form.get('code') ?? ''; + if (!codes.delete(code) || form.get('client_secret') !== CLIENT_SECRET || !form.get('code_verifier')) { + return answerJson(res, 400, { error: 'invalid_grant' }); + } + answerJson(res, 200, { access_token: 'lan-access', token_type: 'Bearer', id_token: signIdToken(issuer) }); + }); + return; + } + if (url.pathname === '/userinfo') { + if (req.headers.authorization !== 'Bearer lan-access') return answerJson(res, 401, { error: 'invalid_token' }); + return answerJson(res, 200, { sub: 'lan-sub-1', email: EMAIL, email_verified: true, name: 'LAN Admin' }); + } + if (url.pathname === '/jwks') return answerJson(res, 200, { keys: [jwk] }); + answerJson(res, 404, { error: 'not_found' }); +} + +// ── The app ───────────────────────────────────────────────────────────────── + +let nestApp: INestApplication; +let app: Application; + +beforeAll(async () => { + provider = http.createServer(handle); + await new Promise((resolve) => provider.listen(0, '127.0.0.1', resolve)); + port = (provider.address() as AddressInfo).port; + createTables(testDb); + runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); +}); + +beforeEach(() => { + resetTestDb(testDb); + resetRateLimits(nestApp); + seen.length = 0; + answers.clear(); + process.env.OIDC_CLIENT_ID = CLIENT_ID; + process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET; + process.env.APP_URL = 'http://localhost:3001'; +}); + +afterEach(() => { + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; + delete process.env.APP_URL; + vi.restoreAllMocks(); +}); + +afterAll(async () => { + await nestApp.close(); + await new Promise((resolve) => provider.close(resolve)); + testDb.close(); +}); + +/** Point TREK at the provider under `host`, answering the name with `answer`. */ +function useIssuer(host: string, answer: { address: string; family: number }[]): string { + answers.set(host, answer); + const issuer = `http://${host}:${port}`; + process.env.OIDC_ISSUER = issuer; + return issuer; +} + +describe('OIDC login against a provider whose name has an fe80:: record (#2506)', () => { + it('OIDC-LAN-001: signs in end to end, and every provider request lands on the vetted address', async () => { + const { user } = createUser(testDb, { email: EMAIL }); + // Link-local first, the order Windows and some LAN resolvers hand it out in. + const issuer = useIssuer('idp.home.test', [ + { address: 'fe80::1', family: 6 }, + { address: 'fe80::f9f1:353:73:14a0', family: 6 }, + { address: '127.0.0.1', family: 4 }, + ]); + + const login = await request(app).get('/api/auth/oidc/login'); + expect(login.status).toBe(302); + const authorize = new URL(login.headers.location!); + expect(authorize.origin).toBe(issuer); + const state = readCookie(login, 'trek_oidc_state')!; + expect(authorize.searchParams.get('state')).toBe(state); + + // The browser's leg: the provider sends it back to /callback with a code. + const back = await fetch(`http://127.0.0.1:${port}${authorize.pathname}${authorize.search}`, { redirect: 'manual' }); + const callbackUrl = new URL(back.headers.get('location')!); + const cb = await request(app) + .get(`${callbackUrl.pathname}${callbackUrl.search}`) + .set('Cookie', `trek_oidc_state=${state}`); + expect(cb.status).toBe(302); + const landing = new URL(cb.headers.location!, 'http://localhost'); + expect(landing.searchParams.get('oidc_error')).toBeNull(); + const oidcCode = landing.searchParams.get('oidc_code')!; + expect(oidcCode).toBeTruthy(); + + const exchange = await request(app) + .get(`/api/auth/oidc/exchange?code=${oidcCode}`) + .set('Cookie', `trek_oidc_exchange=${readCookie(cb, 'trek_oidc_exchange')}`); + expect(exchange.status).toBe(200); + const session = readCookie(exchange, 'trek_session'); + expect(session).toBeTruthy(); + + const me = await request(app).get('/api/auth/me').set('Cookie', `trek_session=${session}`); + expect(me.status).toBe(200); + expect(me.body.user).toMatchObject({ id: user.id, email: EMAIL }); + + // Discovery, token, JWKS and userinfo all went out, each over loopback. + const server = seen.filter((r) => r.path !== '/authorize'); + expect(server.map((r) => `${r.method} ${r.path}`)).toEqual([ + 'GET /.well-known/openid-configuration', + 'POST /token', + 'GET /jwks', + 'GET /userinfo', + ]); + expect(new Set(server.map((r) => r.local))).toEqual(new Set(['127.0.0.1'])); + }); + + it('OIDC-LAN-002: a name with nothing but link-local and metadata records is still refused', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + useIssuer('metadata.home.test', [ + { address: 'fe80::1', family: 6 }, + { address: '169.254.169.254', family: 4 }, + { address: 'fd00:ec2::254', family: 6 }, + ]); + + const login = await request(app).get('/api/auth/oidc/login'); + + expect(login.status).toBe(500); + expect(login.body).toEqual({ error: 'OIDC login failed' }); + expect(errors).toHaveBeenCalledWith( + '[OIDC] Login error:', + 'Requests to link-local / cloud-metadata addresses are not allowed', + ); + expect(seen).toEqual([]); + }); +}); diff --git a/server/tests/unit/utils/ssrf-dual-stack.test.ts b/server/tests/unit/utils/ssrf-dual-stack.test.ts index 36122c1b2..3cfb0708f 100644 --- a/server/tests/unit/utils/ssrf-dual-stack.test.ts +++ b/server/tests/unit/utils/ssrf-dual-stack.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'; import http from 'node:http'; import net from 'node:net'; +import os from 'node:os'; import type { AddressInfo } from 'node:net'; // Only the resolver is stubbed. undici and net are the real thing, because the @@ -11,10 +12,19 @@ vi.mock('dns/promises', () => ({ })); import dns from 'dns/promises'; -import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard'; +import { safeFetchAdminConfigured, SsrfBlockedError } from '../../../src/utils/ssrfGuard'; const mockLookup = vi.mocked(dns.lookup); +// Every address a socket dials, read off the socket itself rather than the +// resolver: Node emits `connectionAttempt` once for each address it tries. +const attempted: string[] = []; +const realConnect = net.Socket.prototype.connect; +function trackAttempts(this: net.Socket, ...args: unknown[]) { + this.on('connectionAttempt', (ip: string) => attempted.push(ip)); + return (realConnect as (...a: unknown[]) => net.Socket).apply(this, args); +} + // RFC 6666 discard prefix and TEST-NET-1: routed nowhere on any sane machine, // so a connect attempt either fails at once or sits until the family timeout. const DEAD_V6 = '100::1'; @@ -32,6 +42,7 @@ const listen = (server: http.Server, host: string) => }); beforeAll(async () => { + net.Socket.prototype.connect = trackAttempts as typeof realConnect; v4 = http.createServer((_req, res) => res.end('v4')); v4Port = await listen(v4, '127.0.0.1'); const six = http.createServer((_req, res) => res.end('v6')); @@ -44,12 +55,14 @@ beforeAll(async () => { }); afterAll(async () => { + net.Socket.prototype.connect = realConnect; await new Promise((r) => v4.close(r)); if (v6) await new Promise((r) => v6!.close(r)); }); afterEach(() => { mockLookup.mockReset(); + attempted.length = 0; }); describe('a dual-stack name where one family does not answer', () => { @@ -97,3 +110,119 @@ describe('a dual-stack name where one family does not answer', () => { expect(mockLookup).toHaveBeenCalledTimes(1); }); }); + +describe('a name with link-local records next to a reachable address (#2506)', () => { + it('SEC-DUAL-104: an fe80:: record beside the IdP address no longer refuses the name', async () => { + // Loopback stands in for the LAN address, being the one address reachable + // here; the admin lane treats the two alike. + mockLookup.mockResolvedValue([ + { address: 'fe80::1', family: 6 }, + { address: '127.0.0.1', family: 4 }, + ] as never); + + const res = await safeFetchAdminConfigured(`http://idp.example:${v4Port}/`, { signal: AbortSignal.timeout(8000) }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe('v4'); + expect(attempted).toEqual(['127.0.0.1']); + }); + + it('SEC-DUAL-105: the socket never dials a link-local or metadata address, not even one listed first', async () => { + mockLookup.mockResolvedValue([ + { address: '169.254.169.254', family: 4 }, + { address: 'fe80::1', family: 6 }, + { address: '::ffff:169.254.169.254', family: 6 }, + { address: '::169.254.169.254', family: 6 }, + { address: 'fd00:ec2::254', family: 6 }, + { address: '127.0.0.1', family: 4 }, + ] as never); + + const res = await safeFetchAdminConfigured(`http://idp.example:${v4Port}/`, { signal: AbortSignal.timeout(8000) }); + + expect(res.status).toBe(200); + expect(attempted).toEqual(['127.0.0.1']); + }); + + it('SEC-DUAL-106: with nothing else to offer the name is refused and no socket is opened', async () => { + mockLookup.mockResolvedValue([ + { address: 'fe80::1', family: 6 }, + { address: '169.254.169.254', family: 4 }, + ] as never); + + await expect( + safeFetchAdminConfigured(`http://idp.example:${v4Port}/`, { signal: AbortSignal.timeout(8000) }), + ).rejects.toThrow(SsrfBlockedError); + expect(attempted).toEqual([]); + }); +}); + +describe('the strict guard, ALLOW_INTERNAL_NETWORK on, with metadata records in the answer (#2506)', () => { + // The strict guard refuses loopback, so the reachable address here is one of + // this machine's own interface addresses, with a server listening on it. + const lanIp = Object.values(os.networkInterfaces()) + .flat() + .find((nic) => nic && nic.family === 'IPv4' && !nic.internal && !nic.address.startsWith('169.254.'))?.address; + let lan: http.Server | null = null; + let lanPort = 0; + + beforeAll(async () => { + if (!lanIp) return; + const server = http.createServer((_req, res) => res.end('lan')); + try { + lanPort = await listen(server, lanIp); + lan = server; + } catch { + server.close(); + } + }); + + afterAll(async () => { + if (lan) await new Promise((r) => lan!.close(r)); + }); + + afterEach(() => vi.unstubAllEnvs()); + + // The flag is read when the guard loads, so each case loads a fresh copy. + async function strictGuard() { + vi.stubEnv('ALLOW_INTERNAL_NETWORK', 'true'); + vi.resetModules(); + const guard = await import('../../../src/utils/ssrfGuard'); + const lookup = vi.mocked((await import('dns/promises')).default.lookup); + return { guard, lookup }; + } + + it('SEC-DUAL-107: a metadata record next to an fe80:: one is refused and no socket is opened', async () => { + for (const answer of [ + [{ address: 'fe80::1', family: 6 }, { address: 'fd00:ec2::254', family: 6 }], + [{ address: 'fe80::1', family: 6 }, { address: '100.100.100.200', family: 4 }], + [{ address: '::169.254.169.254', family: 6 }], + ]) { + const { guard, lookup } = await strictGuard(); + lookup.mockResolvedValue(answer as never); + + await expect( + guard.safeFetch(`http://photos.example:${v4Port}/`, { signal: AbortSignal.timeout(3000) }), + ).rejects.toThrow(guard.SsrfBlockedError); + } + expect(attempted).toEqual([]); + }); + + it('SEC-DUAL-108: the socket dials the LAN address and never the metadata ones listed ahead of it', async (ctx) => { + if (!lan || !lanIp) return ctx.skip(); + const { guard, lookup } = await strictGuard(); + lookup.mockResolvedValue([ + { address: 'fd00:ec2::254', family: 6 }, + { address: '100.100.100.200', family: 4 }, + { address: '100.100.100.100', family: 4 }, + { address: '::169.254.169.254', family: 6 }, + { address: 'fe80::1', family: 6 }, + { address: lanIp, family: 4 }, + ] as never); + + const res = await guard.safeFetch(`http://nas.example:${lanPort}/`, { signal: AbortSignal.timeout(8000) }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe('lan'); + expect(attempted).toEqual([lanIp]); + }); +}); diff --git a/server/tests/unit/utils/ssrfGuard.test.ts b/server/tests/unit/utils/ssrfGuard.test.ts index 95a1ed15b..9c108a46b 100644 --- a/server/tests/unit/utils/ssrfGuard.test.ts +++ b/server/tests/unit/utils/ssrfGuard.test.ts @@ -29,6 +29,14 @@ function mockIp(ip: string) { mockLookup.mockResolvedValue({ address: ip, family: ip.includes(':') ? 6 : 4 }); } +/** What the last pinned dispatcher hands a socket that asks for every address. */ +function pinnedList(): unknown { + const lookup = agentCapture.options.connect.lookup as (h: string, o: object, cb: (...a: unknown[]) => void) => void; + const seen: unknown[] = []; + lookup('any.example', { all: true }, (...args: unknown[]) => seen.push(...args)); + return seen[1]; +} + describe('checkSsrf', () => { afterEach(() => { vi.unstubAllEnvs(); @@ -227,6 +235,31 @@ describe('checkSsrf', () => { expect(result.allowed).toBe(true); expect(result.isPrivate).toBe(false); }); + + // An IPv4-compatible address (::a.b.c.d) is the IPv4 it carries as well. Node + // prints an AAAA record of ::a9fe:a9fe as ::169.254.169.254, so that is the + // spelling that reaches the guard, and it used to pass as a public address. + it.each([ + ['compatible metadata, the spelling Node prints', '::169.254.169.254'], + ['compatible metadata, hex', '::a9fe:a9fe'], + ['compatible Alibaba metadata', '::100.100.100.200'], + ['compatible loopback', '::127.0.0.1'], + ['compatible loopback, hex', '::7f00:1'], + ['loopback written out', '0:0:0:0:0:0:0:1'], + ])('SEC-013: always blocks %s (%s)', async (_label, ip) => { + mockIp(ip); + const result = await checkSsrf('http://attacker.example'); + expect(result).toMatchObject({ allowed: false, error: 'Requests to loopback and link-local addresses are not allowed' }); + }); + + it('SEC-013: treats a compatible RFC-1918 address as private and a compatible public one as public', async () => { + mockIp('::10.0.0.1'); + expect(await checkSsrf('http://attacker.example')).toMatchObject({ + allowed: false, isPrivate: true, error: expect.stringContaining('ALLOW_INTERNAL_NETWORK'), + }); + mockIp('::8.8.8.8'); + expect(await checkSsrf('http://cdn.example')).toMatchObject({ allowed: true, isPrivate: false }); + }); }); // SEC-014 — fe80::/10 is ten bits, not the four characters 'fe80' @@ -596,6 +629,11 @@ describe('safeFetchLlm', () => { await expect(safeFetchLlm('http://host.example/chat')).rejects.toThrow(SsrfBlockedError); mockIp('::a9fe:a9fe'); await expect(safeFetchLlm('http://host.example/chat')).rejects.toThrow(SsrfBlockedError); + // What Node actually hands back for an AAAA record of ::a9fe:a9fe. + mockIp('::169.254.169.254'); + await expect(safeFetchLlm('http://host.example/chat')).rejects.toThrow(SsrfBlockedError); + mockIp('::100.100.100.200'); + await expect(safeFetchLlm('http://host.example/chat')).rejects.toThrow(SsrfBlockedError); }); it('blocks the AWS IMDSv6 ULA endpoint but allows other ULA (a LAN model server)', async () => { @@ -690,6 +728,22 @@ describe('safeFetchLlm', () => { await expect(safeFetchLlm('http://model.example/chat')).rejects.toThrow(SsrfBlockedError); }); + it('blocks the Alibaba metadata IPs in their IPv4-mapped spellings, and only those', async () => { + const okFetch = vi.fn().mockResolvedValue({ ok: true } as Response); + vi.stubGlobal('fetch', okFetch); + for (const ip of ['::ffff:100.100.100.200', '::ffff:6464:64c8', '::ffff:6464:6464']) { + mockIp(ip); + await expect(safeFetchLlm('http://model.example/chat')).rejects.toThrow(SsrfBlockedError); + } + expect(okFetch).not.toHaveBeenCalled(); + // A mapped LAN or CGNAT model server is still a model server. + for (const ip of ['::ffff:192.168.1.50', '::ffff:100.100.100.201']) { + mockIp(ip); + await safeFetchLlm('http://model.example/chat'); + } + expect(okFetch).toHaveBeenCalledTimes(2); + }); + it.each([ ['NAT64 → metadata', '64:ff9b::a9fe:a9fe'], ['6to4 → metadata', '2002:a9fe:a9fe::'], @@ -752,6 +806,14 @@ describe('ALLOW_LINK_LOCAL_IPS (#2400)', () => { await guard.safeFetchAdminConfigured('https://keycloak.example.com/realms/trek/.well-known/openid-configuration'); expect(okFetch).toHaveBeenCalledTimes(1); + + // The IPv4-mapped spellings of the listed address are the same address, as + // they already were under the strict guard. + for (const ip of ['::ffff:169.254.1.2', '::ffff:a9fe:102']) { + resolve(ip); + await guard.safeFetchAdminConfigured('https://keycloak.example.com/'); + } + expect(okFetch).toHaveBeenCalledTimes(3); }); it('refuses the gateway as before while nothing is listed, whatever ALLOW_INTERNAL_NETWORK says', async () => { @@ -805,6 +867,7 @@ describe('dual-stack names: every address is checked and every address is pinned vi.unstubAllEnvs(); vi.unstubAllGlobals(); mockLookup.mockReset(); + agentCapture.options = null; }); const resolveAll = (entries: { address: string; family: number }[]) => @@ -831,13 +894,15 @@ describe('dual-stack names: every address is checked and every address is pinned expect(result.resolvedIp).toBe('10.0.0.5'); }); - it('SEC-DUAL-003: a loopback address hidden behind a public one is still loopback', async () => { + it('SEC-DUAL-003: a loopback address behind a public one is never handed to the socket', async () => { resolveAll([{ address: '203.0.113.10', family: 4 }, { address: '::1', family: 6 }]); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 200, ok: true, headers: { get: () => null } })); const result = await checkSsrf('https://rebind.example'); + await safeFetchFollow('https://rebind.example/'); - expect(result.allowed).toBe(false); - expect(result.error).toContain('loopback'); + expect(result).toMatchObject({ allowed: true, isPrivate: false, resolvedIp: '203.0.113.10', resolvedIps: ['203.0.113.10'] }); + expect(pinnedList()).toEqual([{ address: '203.0.113.10', family: 4 }]); }); it('SEC-DUAL-004: the pinned dispatcher hands the socket the whole checked list, IPv4 first', async () => { @@ -860,13 +925,15 @@ describe('dual-stack names: every address is checked and every address is pinned expect(single).toEqual([null, '203.0.113.10', 4]); }); - it('SEC-DUAL-005: the admin lane blocks a name with a metadata address anywhere in its answer', async () => { - resolveAll([{ address: '203.0.113.10', family: 4 }, { address: '169.254.169.254', family: 4 }]); - const fetchSpy = vi.fn(); + it('SEC-DUAL-005: the admin lane never hands a metadata address in the answer to the socket', async () => { + resolveAll([{ address: '169.254.169.254', family: 4 }, { address: '203.0.113.10', family: 4 }]); + const fetchSpy = vi.fn().mockResolvedValue({ status: 200, ok: true, headers: { get: () => null } }); vi.stubGlobal('fetch', fetchSpy); - await expect(safeFetchLlm('https://models.example/v1')).rejects.toThrow(SsrfBlockedError); - expect(fetchSpy).not.toHaveBeenCalled(); + await safeFetchLlm('https://models.example/v1'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(pinnedList()).toEqual([{ address: '203.0.113.10', family: 4 }]); }); it('SEC-DUAL-006: a redirect hop resolves and pins its own full list again', async () => { @@ -908,3 +975,274 @@ describe('dual-stack names: every address is checked and every address is pinned expect(result.error).toBe('Could not resolve hostname (ENOTFOUND)'); }); }); + +describe('a name with a link-local record next to a usable one (#2506)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + agentCapture.options = null; + }); + + // The flags are read when the module loads, so each case loads a fresh copy of + // the guard under its own environment and resolver answer. + async function guardWith(env: Record, answer: { address: string; family: number }[]) { + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value); + vi.resetModules(); + const guard = await import('../../../src/utils/ssrfGuard'); + const lookup = vi.mocked((await import('dns/promises')).default.lookup); + lookup.mockResolvedValue(answer as never); + const fetchSpy = vi.fn().mockResolvedValue({ status: 200, ok: true, headers: new Headers() }); + vi.stubGlobal('fetch', fetchSpy); + return { guard, lookup, fetchSpy }; + } + + const v4 = (address: string) => ({ address, family: 4 }); + const v6 = (address: string) => ({ address, family: 6 }); + const redirectTo = (location: string) => ({ + status: 302, + ok: false, + headers: new Headers({ location }), + body: { cancel: () => Promise.resolve() }, + }); + + // The second reporter's DNS: the IdP's LAN address with an fe80:: AAAA beside it. + const REPORTED = [v4('10.0.40.239'), v6('fe80::1')]; + // How Windows answers for its own name: the link-local addresses come first. + const LINK_LOCAL_FIRST = [v6('fe80::b67e:9b1c:8625:40a7'), v6('fe80::f9f1:353:73:14a0'), v4('192.168.178.36')]; + // Every spelling of link-local and cloud metadata both lanes refuse. The + // IPv4-compatible ones come as ::169.254.169.254 from a real resolver, which + // prints ::/96 in dotted form; the hex spelling is what an IP literal keeps. + const METADATA_AND_LINK_LOCAL = [ + v4('169.254.169.254'), v4('169.254.0.1'), v4('100.100.100.200'), v4('100.100.100.100'), + v6('fe80::1'), v6('fe80::1%eth0'), v6('febf::1'), v6('fd00:ec2::254'), v6('fd00:0ec2::254'), + v6('::ffff:169.254.169.254'), v6('::ffff:a9fe:a9fe'), v6('::a9fe:a9fe'), v6('::169.254.169.254'), + v6('::ffff:100.100.100.200'), v6('::ffff:6464:6464'), v6('::100.100.100.200'), + v6('64:ff9b::a9fe:a9fe'), v6('2002:a9fe:a9fe::'), v6('2001::5601:5601'), v6('64:ff9b::6464:64c8'), + ]; + + describe('the admin lane (OIDC, plugin OAuth, model endpoints, own routing engine)', () => { + it('SEC-2506-001: OIDC discovery reaches the IdP over its LAN address and never the fe80:: one', async () => { + const { guard, fetchSpy } = await guardWith({}, REPORTED); + + await guard.safeFetchAdminConfigured('https://auth.home.example/.well-known/openid-configuration'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(pinnedList()).toEqual([v4('10.0.40.239')]); + // A socket that asks for one address gets the same vetted one. + const single: unknown[] = []; + agentCapture.options.connect.lookup('auth.home.example', {}, (...args: unknown[]) => single.push(...args)); + expect(single).toEqual([null, '10.0.40.239', 4]); + }); + + it('SEC-2506-002: link-local records ahead of the IPv4 change nothing', async () => { + const { guard, fetchSpy } = await guardWith({}, LINK_LOCAL_FIRST); + + await guard.safeFetchAdminConfigured('http://maurice-pc:3191/.well-known/openid-configuration'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(pinnedList()).toEqual([v4('192.168.178.36')]); + }); + + it('SEC-2506-003: no link-local or metadata spelling reaches the socket, whatever ALLOW_INTERNAL_NETWORK says', async () => { + for (const flag of ['true', 'false']) { + const { guard } = await guardWith( + { ALLOW_INTERNAL_NETWORK: flag }, + [...METADATA_AND_LINK_LOCAL, v4('10.0.40.239'), v6('fd12:3456::1')], + ); + + await guard.safeFetchAdminConfigured('https://auth.home.example/'); + + expect(pinnedList()).toEqual([v4('10.0.40.239'), v6('fd12:3456::1')]); + } + }); + + it('SEC-2506-004: a name with nothing but link-local and metadata records is still refused', async () => { + const { guard, fetchSpy } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, METADATA_AND_LINK_LOCAL); + + await expect(guard.safeFetchAdminConfigured('https://auth.home.example/')).rejects.toThrow( + 'Requests to link-local / cloud-metadata addresses are not allowed', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('SEC-2506-005: an address listed in ALLOW_LINK_LOCAL_IPS stays usable, the rest of 169.254 does not', async () => { + const { guard } = await guardWith( + { ALLOW_LINK_LOCAL_IPS: '169.254.1.2' }, + [v4('169.254.169.254'), v4('169.254.1.2'), v4('169.254.1.3'), v6('fe80::1')], + ); + + await guard.safeFetchAdminConfigured('https://keycloak.example.com/'); + + expect(pinnedList()).toEqual([v4('169.254.1.2')]); + }); + + it('SEC-2506-006: every redirect hop leaves out its own link-local records and pins what is left', async () => { + const { guard, lookup, fetchSpy } = await guardWith({}, REPORTED); + lookup.mockResolvedValueOnce(REPORTED as never).mockResolvedValueOnce(LINK_LOCAL_FIRST as never); + const pins: unknown[] = []; + fetchSpy.mockImplementation(async () => { + pins.push(pinnedList()); + return pins.length === 1 + ? redirectTo('https://second.home.example/') + : { status: 200, ok: true, headers: new Headers() }; + }); + + const res = await guard.safeFetchAdminConfigured('https://auth.home.example/'); + + expect(res.status).toBe(200); + expect(pins).toEqual([[v4('10.0.40.239')], [v4('192.168.178.36')]]); + }); + + it('SEC-2506-007: a redirect to a name with only link-local records is refused before its fetch', async () => { + const { guard, lookup, fetchSpy } = await guardWith({}, REPORTED); + lookup.mockResolvedValueOnce(REPORTED as never).mockResolvedValue([v4('169.254.169.254'), v6('fe80::1')] as never); + fetchSpy.mockResolvedValueOnce(redirectTo('http://metadata.example/latest/meta-data/')); + + await expect(guard.safeFetchAdminConfigured('https://auth.home.example/')).rejects.toThrow(guard.SsrfBlockedError); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('the strict guard (Immich, Synology, AirTrail, Dawarich, document stores, webhooks)', () => { + it('SEC-2506-010: a LAN name with an fe80:: record works once ALLOW_INTERNAL_NETWORK is on', async () => { + const { guard, fetchSpy } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, REPORTED); + + expect(await guard.checkSsrf('https://immich.home.example')).toMatchObject({ + allowed: true, isPrivate: true, resolvedIp: '10.0.40.239', resolvedIps: ['10.0.40.239'], + }); + await guard.safeFetch('https://immich.home.example/api/users/me'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(pinnedList()).toEqual([v4('10.0.40.239')]); + }); + + it('SEC-2506-011: with the flag off the refusal names ALLOW_INTERNAL_NETWORK, not link-local', async () => { + const { guard } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'false' }, REPORTED); + + expect(await guard.checkSsrf('https://immich.home.example')).toMatchObject({ + allowed: false, isPrivate: true, resolvedIp: '10.0.40.239', error: expect.stringContaining('ALLOW_INTERNAL_NETWORK'), + }); + }); + + it('SEC-2506-012: loopback, link-local, metadata and the unspecified address never reach the socket', async () => { + const { guard } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'false' }, [ + v4('127.0.0.1'), v6('::1'), v6('::'), v4('0.0.0.0'), v4('169.254.169.254'), v6('fe80::1'), v6('febf::1'), + v6('::ffff:127.0.0.1'), v6('::ffff:7f00:1'), v6('::ffff:169.254.169.254'), v6('::ffff:a9fe:a9fe'), + v6('64:ff9b::a9fe:a9fe'), v4('203.0.113.10'), v6('2001:db8::10'), + ]); + + expect(await guard.checkSsrf('https://photos.example')).toMatchObject({ + allowed: true, isPrivate: false, resolvedIp: '203.0.113.10', resolvedIps: ['203.0.113.10', '2001:db8::10'], + }); + await guard.safeFetchFollow('https://photos.example/'); + + expect(pinnedList()).toEqual([v4('203.0.113.10'), v6('2001:db8::10')]); + }); + + it('SEC-2506-013: a name whose every record is always blocked is refused as before', async () => { + const { guard, fetchSpy } = await guardWith( + { ALLOW_INTERNAL_NETWORK: 'true' }, + [v6('fe80::1'), v4('169.254.169.254'), v4('127.0.0.1'), v6('::1')], + ); + + expect(await guard.checkSsrf('https://photos.example')).toMatchObject({ + allowed: false, + isPrivate: true, + resolvedIp: '169.254.169.254', + error: 'Requests to loopback and link-local addresses are not allowed', + }); + await expect(guard.safeFetchFollow('https://photos.example/')).rejects.toThrow(guard.SsrfBlockedError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('SEC-2506-014: a caller that refuses the internal network still refuses the LAN address behind it', async () => { + const { guard, fetchSpy } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, REPORTED); + + expect(await guard.checkSsrf('https://x.home.example', true)).toMatchObject({ + allowed: false, isPrivate: true, error: expect.stringContaining('ALLOW_INTERNAL_NETWORK'), + }); + await expect(guard.safeFetchFollow('https://x.home.example/', undefined, { bypassInternalIpAllowed: true })) + .rejects.toThrow(guard.SsrfBlockedError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('SEC-2506-015: a private address beside a public one still decides for the whole name', async () => { + const { guard } = await guardWith( + { ALLOW_INTERNAL_NETWORK: 'false' }, + [v6('fe80::1'), v4('203.0.113.10'), v4('10.0.0.5')], + ); + + expect(await guard.checkSsrf('https://split.example')).toMatchObject({ + allowed: false, isPrivate: true, resolvedIp: '10.0.0.5', error: expect.stringContaining('ALLOW_INTERNAL_NETWORK'), + }); + }); + + it('SEC-2506-016: an address listed in ALLOW_LINK_LOCAL_IPS is internal next to an fe80:: record too', async () => { + const { guard } = await guardWith( + { ALLOW_LINK_LOCAL_IPS: '169.254.1.2', ALLOW_INTERNAL_NETWORK: 'true' }, + [v6('fe80::1'), v4('169.254.169.254'), v4('169.254.1.2')], + ); + + expect(await guard.checkSsrf('https://immich.example')).toMatchObject({ + allowed: true, isPrivate: true, resolvedIps: ['169.254.1.2'], + }); + await guard.safeFetch('https://immich.example/'); + expect(pinnedList()).toEqual([v4('169.254.1.2')]); + }); + + // ALLOW_INTERNAL_NETWORK opens ULA and CGNAT space, and the AWS IMDSv6 and + // Alibaba metadata addresses sit in exactly those ranges. A URL a user typed + // must not reach what an admin-configured endpoint cannot. + it('SEC-2506-017: no link-local or metadata spelling reaches the socket, whatever ALLOW_INTERNAL_NETWORK says', async () => { + const lan = await guardWith( + { ALLOW_INTERNAL_NETWORK: 'true' }, + [...METADATA_AND_LINK_LOCAL, v4('10.0.40.239'), v6('fd12:3456::1')], + ); + expect(await lan.guard.checkSsrf('https://immich.home.example')).toMatchObject({ + allowed: true, isPrivate: true, resolvedIp: '10.0.40.239', resolvedIps: ['10.0.40.239', 'fd12:3456::1'], + }); + await lan.guard.safeFetchFollow('https://immich.home.example/'); + expect(pinnedList()).toEqual([v4('10.0.40.239'), v6('fd12:3456::1')]); + + const pub = await guardWith( + { ALLOW_INTERNAL_NETWORK: 'false' }, + [...METADATA_AND_LINK_LOCAL, v4('203.0.113.10'), v6('2001:db8::10')], + ); + expect(await pub.guard.checkSsrf('https://photos.example')).toMatchObject({ + allowed: true, isPrivate: false, resolvedIps: ['203.0.113.10', '2001:db8::10'], + }); + await pub.guard.safeFetchFollow('https://photos.example/'); + expect(pinnedList()).toEqual([v4('203.0.113.10'), v6('2001:db8::10')]); + }); + + it.each([ + ['AWS IMDSv6', 'fd00:ec2::254'], + ['Alibaba metadata', '100.100.100.200'], + ['Alibaba DNS', '100.100.100.100'], + ['Alibaba metadata, IPv4-mapped', '::ffff:100.100.100.200'], + ['metadata, IPv4-compatible', '::169.254.169.254'], + ])('SEC-2506-018: %s (%s) is refused beside an fe80:: record even with ALLOW_INTERNAL_NETWORK on', async (_label, ip) => { + const record = ip.includes(':') ? v6(ip) : v4(ip); + for (const answer of [[v6('fe80::1'), record], [record]]) { + const { guard, fetchSpy } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, answer); + + expect(await guard.checkSsrf('https://photos.example')).toMatchObject({ + allowed: false, error: 'Requests to loopback and link-local addresses are not allowed', + }); + await expect(guard.safeFetchFollow('https://photos.example/')).rejects.toThrow(guard.SsrfBlockedError); + await expect(guard.safeFetchAdminConfigured('https://photos.example/')).rejects.toThrow(guard.SsrfBlockedError); + expect(fetchSpy).not.toHaveBeenCalled(); + } + }); + + it('SEC-2506-019: a metadata record beside a LAN address is left out and the LAN address pinned', async () => { + const { guard } = await guardWith({ ALLOW_INTERNAL_NETWORK: 'true' }, [v6('fd00:ec2::254'), v4('10.0.0.5')]); + + expect(await guard.checkSsrf('https://nas.home.example')).toMatchObject({ + allowed: true, isPrivate: true, resolvedIp: '10.0.0.5', resolvedIps: ['10.0.0.5'], + }); + await guard.safeFetch('https://nas.home.example/'); + expect(pinnedList()).toEqual([v4('10.0.0.5')]); + }); + }); +}); diff --git a/wiki/Internal-Network-Access.md b/wiki/Internal-Network-Access.md index ebb6832ac..abf6a0d97 100644 --- a/wiki/Internal-Network-Access.md +++ b/wiki/Internal-Network-Access.md @@ -19,11 +19,12 @@ Under the strict guard, these ranges are blocked whatever `ALLOW_INTERNAL_NETWOR | Range | Description | |---|---| | `127.0.0.0/8`, `::1` | Loopback | -| `0.0.0.0/8` | Unspecified | +| `0.0.0.0/8`, `::` | Unspecified | | `169.254.0.0/16`, `fe80::/10` | Link-local / cloud metadata endpoints | -| `::ffff:127.x.x.x`, `::ffff:169.254.x.x` | IPv4-mapped loopback and link-local | +| `fd00:ec2::/32`, `100.100.100.200`, `100.100.100.100` | AWS (IMDSv6) and Alibaba Cloud metadata. They sit inside the ULA and CGNAT ranges below and stay blocked when those open up | +| IPv4-mapped, IPv4-compatible and NAT64/6to4/Teredo forms of the above | e.g. `::ffff:127.0.0.1`, `::169.254.169.254`, `64:ff9b::a9fe:a9fe` | -The IPv6 link-local rule covers the whole `fe80::/10` prefix (`fe80:` to `febf:`), under the relaxed guard as well. +The IPv6 link-local rule covers the whole `fe80::/10` prefix (`fe80:` to `febf:`). The link-local and metadata rows hold under the relaxed guard as well. The one way past this table is `ALLOW_LINK_LOCAL_IPS`, for a single IPv4 link-local address, see [below](#a-link-local-address-you-need). @@ -41,7 +42,7 @@ The one way past this table is `ALLOW_LINK_LOCAL_IPS`, for a single IPv4 link-lo The hostname `localhost` is matched at the hostname stage too, but it normally resolves to a loopback address (`127.0.0.1` or `::1`), which the always-blocked loopback rule catches first, so under the strict guard it is blocked no matter how `ALLOW_INTERNAL_NETWORK` is set. On a host that maps `localhost` somewhere else, the hostname rule still applies and it stays blocked unless `ALLOW_INTERNAL_NETWORK=true`. The relaxed guard allows `localhost` outright, which is what makes a local Ollama the supported default for AI Parsing. -`*.local` and `*.internal` hostnames are permitted when `ALLOW_INTERNAL_NETWORK=true`: the guard still resolves them to an IP and enforces all IP-level rules, so any such hostname that resolves to a loopback or link-local address remains blocked regardless. +`*.local` and `*.internal` hostnames are permitted when `ALLOW_INTERNAL_NETWORK=true`: the guard still resolves them to an IP and enforces all IP-level rules, so TREK never connects to a loopback or link-local address such a hostname resolves to, and a hostname with no other address remains blocked regardless. ## When to enable @@ -71,7 +72,9 @@ Several addresses are separated by commas. The list is read at startup, so resta ## DNS rebinding protection -Even with `ALLOW_INTERNAL_NETWORK=true`, TREK pins the DNS resolution to prevent rebinding attacks. When the guard checks a URL, it resolves the hostname once and records the IP. The outbound connection is then made directly to that IP using a pinned dispatcher (via undici), so the hostname cannot re-resolve to a different address between the check and the actual request. +Even with `ALLOW_INTERNAL_NETWORK=true`, TREK pins the DNS resolution to prevent rebinding attacks. When the guard checks a URL, it resolves the hostname once and records the addresses it may use. The outbound connection is then made directly to those addresses using a pinned dispatcher (via undici), so the hostname cannot re-resolve to a different address between the check and the actual request. + +A hostname can resolve to several addresses, and a LAN DNS server often hands out a host's IPv6 link-local address (`fe80::`) next to its IPv4 one. TREK never connects to an address the guard blocks: it leaves that address out and connects over the ones that remain. Only a hostname whose addresses are all blocked is refused. Under the strict guard, a private address among the rest still needs `ALLOW_INTERNAL_NETWORK=true`. ## Audit log diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index aeedbd7b5..d7458672e 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -269,6 +269,8 @@ Set `OIDC_ISSUER` to that exact string. **Cause:** Not the SSRF guard, despite what it looks like. All four OIDC calls (discovery, token, userinfo, JWKS) go through the admin-configured fetch path, which deliberately **allows** loopback and private/LAN targets: a Keycloak or Authentik on `192.168.x` or `10.x` is a supported setup and needs no extra variable. `ALLOW_INTERNAL_NETWORK` belongs to the guard on *user*-supplied URLs and changes nothing about OIDC. The only addresses that path refuses are link-local and cloud-metadata ones (`169.254.0.0/16`, `fe80::/10`), which fail with `Requests to link-local / cloud-metadata addresses are not allowed`. A provider behind the host gateway of a rootless Podman container resolves to `169.254.1.2` and fails exactly like that; list that address in `ALLOW_LINK_LOCAL_IPS`, see [Internal-Network-Access](Internal-Network-Access#a-link-local-address-you-need). +Versions 4.3.0 to 4.3.2 also failed like that when the provider's hostname had an IPv6 link-local (`fe80::`) record next to its LAN address, which many LAN DNS servers hand out. From 4.3.3 on, TREK leaves such an address out and connects over the LAN address, so only a hostname that resolves to nothing but link-local or metadata addresses still fails. + **Fix:** Look for the reasons an internal provider actually fails. A failed discovery fetch answers `500 { "error": "OIDC login failed" }` and logs the real message as `[OIDC] Login error: …`, so start there: ```bash From 26ab4a58b1b2a0cc96a5781f4103334efb353a6a Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 22:57:17 +0200 Subject: [PATCH 02/14] fix(db): keep SQLite temp files in memory so trips can be deleted (#2518) Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container runs with a read-only root filesystem and no tmpfs on /tmp. The cascade behind DELETE FROM trips keeps a statement journal, SQLite moves it to a temp file once it passes 64 KiB, and it looks for a directory for that file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working directory. None of them is writable there. Installs on the shipped compose file have a tmpfs on /tmp and never hit it. Every connection the server opens now comes from openDatabase() in src/db/connection.ts, which sets temp_store = MEMORY: the main database (also when it is reopened after a restore), each plugin's own database and the uploaded backup checked before a restore. reset-admin.js and the key rotation script cannot import src/ in the image, so they set the pragma themselves. A test pins that nothing else in src/ constructs a handle, and one deletes a full trip on the real schema and watches for temp files. --- server/reset-admin.js | 4 + server/scripts/migrate-encryption.ts | 4 + server/src/db/connection.ts | 27 +++ server/src/db/database.ts | 5 +- server/src/nest/backup/backup.impl.ts | 5 +- .../nest/plugins/host/plugin-data.service.ts | 4 +- server/tests/unit/db/connection.test.ts | 207 ++++++++++++++++++ server/tests/unit/nest/backup.impl.test.ts | 11 +- 8 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 server/src/db/connection.ts create mode 100644 server/tests/unit/db/connection.test.ts diff --git a/server/reset-admin.js b/server/reset-admin.js index da41ecca6..cf0b3835a 100644 --- a/server/reset-admin.js +++ b/server/reset-admin.js @@ -50,6 +50,10 @@ const db = new Database(dbPath); // journal mode below waits for a busy server instead of failing on the spot. // Five seconds, same as src/db/database.ts. db.exec('PRAGMA busy_timeout = 5000'); +// Temp files in memory, like every connection src/db/connection.ts opens: a +// container with a read-only root and no tmpfs on /tmp has nowhere to put them +// (#2518). +db.exec('PRAGMA temp_store = MEMORY'); db.exec(`PRAGMA journal_mode = ${journalMode}`); db.exec(`PRAGMA synchronous = ${synchronous}`); diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts index 5ce11fe7b..36324ae25 100644 --- a/server/scripts/migrate-encryption.ts +++ b/server/scripts/migrate-encryption.ts @@ -184,6 +184,10 @@ async function main() { const db = new Database(dbPath); db.pragma('busy_timeout = 5000'); + // Temp files in memory, like every connection src/db/connection.ts opens: a + // container with a read-only root and no tmpfs on /tmp has nowhere to put + // them, and the rotation below runs as one long transaction (#2518). + db.pragma('temp_store = MEMORY'); // The rollback copy has to be a consistent snapshot, not a plain file copy: // this script is documented as running against a live server, so in WAL mode diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts new file mode 100644 index 000000000..b2923add2 --- /dev/null +++ b/server/src/db/connection.ts @@ -0,0 +1,27 @@ +import Database from 'better-sqlite3'; + +/** + * Opens a SQLite connection. Every handle the server opens comes from here (the + * main database, each plugin's own database, a backup being checked before a + * restore), so what has to hold for all of them is set in one place. + * + * Temporary storage stays in memory. Statement journals, sort runs and transient + * indices move to a temp file once they outgrow a small in-memory buffer, and + * SQLite looks for a directory to hold that file in SQLITE_TMPDIR, TMPDIR, + * /var/tmp, /usr/tmp, /tmp and the working directory. A container with a + * read-only root filesystem and no tmpfs on /tmp has none of them writable, so + * the first statement that needs a temp file fails with SQLITE_IOERR_GETTEMPPATH. + * Deleting a trip is where that showed: the cascade keeps a statement journal + * larger than the 64 KiB SQLite holds before it spills to disk (#2518). + * + * The data directory is the only place TREK can count on being able to write, + * and SQLite has no supported per-connection way to put temp files there, so + * they stay in RAM. The shipped compose file already had them there, its /tmp + * being a tmpfs. VACUUM INTO, the only VACUUM TREK runs, writes straight into + * its target file and builds no temporary database. + */ +export function openDatabase(filename: string, options?: Database.Options): Database.Database { + const db = new Database(filename, options); + db.pragma('temp_store = MEMORY'); + return db; +} diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 6270d5ad4..dbab97d67 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -1,7 +1,8 @@ -import Database from 'better-sqlite3'; +import type Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import { readEnv } from '../app-config'; +import { openDatabase } from './connection'; import { applyDurabilityPragmas } from './durability'; import { createTables } from './schema'; import { runMigrations } from './migrations'; @@ -39,7 +40,7 @@ function initDb(): void { _db = null; } - _db = new Database(dbPath); + _db = openDatabase(dbPath); // Ahead of the journal switch now: changing journal_mode needs an exclusive // lock, which a sibling process (reset-admin, the rotation script) may hold. _db.exec('PRAGMA busy_timeout = 5000'); diff --git a/server/src/nest/backup/backup.impl.ts b/server/src/nest/backup/backup.impl.ts index 8d623af4a..509be1695 100644 --- a/server/src/nest/backup/backup.impl.ts +++ b/server/src/nest/backup/backup.impl.ts @@ -4,7 +4,8 @@ import path from 'path'; import { pipeline } from 'node:stream/promises'; import { readEnv } from '../../app-config'; import fs from 'fs'; -import Database from 'better-sqlite3'; +import type Database from 'better-sqlite3'; +import { openDatabase } from '../../db/connection'; import { db, closeDb, reinitialize } from '../../db/database'; import { VALID_INTERVALS } from './auto-backup.settings'; import { invalidatePermissionsCache } from '../permissions/permissions-cache'; @@ -436,7 +437,7 @@ export async function restoreFromZip(storage: StorageService, zipPath: string): let uploadedDb: InstanceType | null = null; try { - uploadedDb = new Database(extractedDb, { readonly: true }); + uploadedDb = openDatabase(extractedDb, { readonly: true }); const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string }; if (integrityResult.integrity_check !== 'ok') { diff --git a/server/src/nest/plugins/host/plugin-data.service.ts b/server/src/nest/plugins/host/plugin-data.service.ts index ae798e466..a31c6c159 100644 --- a/server/src/nest/plugins/host/plugin-data.service.ts +++ b/server/src/nest/plugins/host/plugin-data.service.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import Database from 'better-sqlite3'; import type { Database as Db } from 'better-sqlite3'; +import { openDatabase } from '../../../db/connection'; import { pluginDataDir, pluginDbFile, pluginsDataRoot } from '../paths'; /** @@ -63,7 +63,7 @@ export class PluginDataDb { constructor(pluginId: string) { this.pluginId = pluginId; fs.mkdirSync(pluginDataDir(pluginId), { recursive: true }); - this.db = new Database(pluginDbFile(pluginId)); + this.db = openDatabase(pluginDbFile(pluginId)); this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); openDbs.add(this); diff --git a/server/tests/unit/db/connection.test.ts b/server/tests/unit/db/connection.test.ts new file mode 100644 index 000000000..3c81b93b5 --- /dev/null +++ b/server/tests/unit/db/connection.test.ts @@ -0,0 +1,207 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { openDatabase } from '../../../src/db/connection'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { TripsService } from '../../../src/nest/trips/trips.service'; +import { + createDayAssignment, + createDayNote, + createPackingItem, + createPlace, + createTrip, + createUser, +} from '../../helpers/factories'; + +// #2518: a container with a read-only root filesystem and no tmpfs on /tmp +// gives SQLite nowhere to put a temp file, so deleting a trip failed with +// SQLITE_IOERR_GETTEMPPATH. Every connection TREK opens keeps its temp storage +// in memory now, and these tests pin that down. + +const SERVER_ROOT = path.resolve(__dirname, '../../..'); +const MEMORY = 2; // PRAGMA temp_store: 0 default, 1 file, 2 memory + +let tmpDir: string; +let schemaFile: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-connection-')); + // Real schema, built once and copied per test: the delete below has to + // cascade through the same tables and foreign keys a live install has. + schemaFile = path.join(tmpDir, 'schema.db'); + const db = openDatabase(schemaFile); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + createTables(db); + runMigrations(db); + db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + db.close(); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function freshCopy(name: string): string { + const file = path.join(tmpDir, `${name}.db`); + fs.copyFileSync(schemaFile, file); + return file; +} + +describe('openDatabase', () => { + it('keeps temp storage in memory on a read-write handle', () => { + const db = openDatabase(freshCopy('rw')); + try { + expect(db.pragma('temp_store', { simple: true })).toBe(MEMORY); + } finally { + db.close(); + } + }); + + it('keeps temp storage in memory on a read-only handle', () => { + const db = openDatabase(freshCopy('ro'), { readonly: true }); + try { + expect(db.readonly).toBe(true); + expect(db.pragma('temp_store', { simple: true })).toBe(MEMORY); + } finally { + db.close(); + } + }); + + it('is what the server opens its main database with', async () => { + const { db } = await import('../../../src/db/database'); + expect(db.pragma('temp_store', { simple: true })).toBe(MEMORY); + }); +}); + +// SQLite names every temp file etilqs_. Linux unlinks the file the +// moment it is opened, so it only shows among the process's open descriptors; +// Windows keeps it in the temp directory until it is closed. macOS offers +// neither view, which is why the test below does not run there. +const canSeeTempFiles = process.platform === 'linux' || process.platform === 'win32'; + +function sqliteTempFiles(): string[] { + if (process.platform === 'linux') { + return fs + .readdirSync('/proc/self/fd') + .flatMap((fd) => { + try { + return [fs.readlinkSync(`/proc/self/fd/${fd}`)]; + } catch { + return []; + } + }) + .filter((target) => target.includes('etilqs_')); + } + return fs.readdirSync(os.tmpdir()).filter((name) => name.startsWith('etilqs_')); +} + +/** + * Builds a trip the size of a real one on `db`, deletes it through + * TripsService.remove and reports which SQLite temp files were open while the + * delete ran. A trigger calls back into the test after the cascade, still + * inside the statement, which is when a spilled statement journal is open. + */ +function deleteTripAndWatchTempFiles(db: Database.Database): { + tempFiles: string[]; + tripLeft: number; + placesLeft: number; +} { + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + + const { user } = createUser(db); + const trip = createTrip(db, user.id, { + title: 'Niederlande Hoofddorp', + start_date: '2026-06-01', + end_date: '2026-06-14', + }); + const days = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; + for (let i = 0; i < 25; i++) { + const place = createPlace(db, trip.id, { name: `Ort ${i}`, description: 'Grachten und Tulpen. '.repeat(10) }); + createDayAssignment(db, days[i % days.length].id, place.id); + } + days.forEach((day) => createDayNote(db, day.id, trip.id)); + for (let i = 0; i < 20; i++) createPackingItem(db, trip.id, { name: `Teil ${i}` }); + + const before = new Set(sqliteTempFiles()); + const seen = new Set(); + db.function('trek_watch_temp_files', () => { + for (const file of sqliteTempFiles()) if (!before.has(file)) seen.add(file); + return null; + }); + db.exec('CREATE TRIGGER trek_watch_trip_delete AFTER DELETE ON trips BEGIN SELECT trek_watch_temp_files(); END'); + + const none = undefined as never; + const trips = new TripsService(new DatabaseService(db), none, none, none, none, none, none, none, none, none); + trips.remove(trip.id, user.id, 'user'); + + return { + tempFiles: [...seen], + tripLeft: (db.prepare('SELECT COUNT(*) AS n FROM trips WHERE id = ?').get(trip.id) as { n: number }).n, + placesLeft: (db.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id) as { n: number }).n, + }; +} + +describe.runIf(canSeeTempFiles)('deleting a trip with content (#2518)', () => { + it('needs a temp file on a connection left at the SQLite default', () => { + // The control: proves the watcher sees temp files, and that this delete is + // one SQLite cannot finish without one where no temp directory is writable. + const db = new Database(freshCopy('default')); + try { + const result = deleteTripAndWatchTempFiles(db); + expect(result.tempFiles.length).toBeGreaterThan(0); + expect(result.tripLeft).toBe(0); + } finally { + db.close(); + } + }); + + it('never touches a temp file on a connection TREK opens', () => { + const db = openDatabase(freshCopy('trek')); + try { + const result = deleteTripAndWatchTempFiles(db); + expect(result.tempFiles).toEqual([]); + expect(result.tripLeft).toBe(0); + expect(result.placesLeft).toBe(0); + } finally { + db.close(); + } + }); +}); + +describe('every SQLite connection goes through openDatabase', () => { + function sourceFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.name.endsWith('.ts') ? [full] : []; + }); + } + + it('leaves no other place in src/ constructing a better-sqlite3 handle', () => { + const offenders = sourceFiles(path.join(SERVER_ROOT, 'src')).filter((file) => { + if (file === path.join(SERVER_ROOT, 'src', 'db', 'connection.ts')) return false; + const source = fs.readFileSync(file, 'utf8'); + // A value import (not `import type`) under whatever local name, then `new` on it. + const imported = /import\s+(?!type\b)(\w+)[^;]*?from\s+['"]better-sqlite3['"]/.exec(source)?.[1]; + const required = /(\w+)\s*=\s*require\(\s*['"]better-sqlite3['"]\s*\)/.exec(source)?.[1]; + return [imported, required].some((name) => name && new RegExp(`new\\s+${name}\\s*\\(`).test(source)); + }); + expect(offenders.map((file) => path.relative(SERVER_ROOT, file))).toEqual([]); + }); + + it('is mirrored by the standalone scripts that cannot import it', () => { + // reset-admin.js and the key rotation run in the container without src/, + // so they set the pragma themselves. + for (const script of ['reset-admin.js', 'scripts/migrate-encryption.ts']) { + const source = fs.readFileSync(path.join(SERVER_ROOT, script), 'utf8'); + expect(source, script).toMatch(/PRAGMA temp_store = MEMORY|pragma\('temp_store = MEMORY'\)/); + } + }); +}); diff --git a/server/tests/unit/nest/backup.impl.test.ts b/server/tests/unit/nest/backup.impl.test.ts index ee9e182c0..5bbd528b3 100644 --- a/server/tests/unit/nest/backup.impl.test.ts +++ b/server/tests/unit/nest/backup.impl.test.ts @@ -856,6 +856,7 @@ describe('BACKUP-061 restoreFromZip extraction', () => { .mockReturnValueOnce({ all: vi.fn().mockReturnValue([{ name: 'users' }, { name: 'trips' }, { name: 'trip_members' }, { name: 'places' }, { name: 'days' }]), }), + pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { @@ -1059,6 +1060,7 @@ describe('BACKUP-042 restoreFromZip — integrity check fails', () => { get: vi.fn().mockReturnValue({ integrity_check: 'corruption' }), all: vi.fn(), }), + pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { @@ -1095,6 +1097,7 @@ describe('BACKUP-043 restoreFromZip — missing required table', () => { .mockReturnValueOnce({ all: vi.fn().mockReturnValue([{ name: 'users' }, { name: 'trips' }]), }), + pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { @@ -1156,6 +1159,7 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => { { name: 'days' }, ]), }), + pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { @@ -1166,7 +1170,7 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => { it('BACKUP-045a — returns { success: true } on full success', async () => { setupSuccessfulExtraction(); - setupAllTablesPresent(); + const uploaded = setupAllTablesPresent(); fsMock.existsSync.mockImplementation((p: string) => { if (String(p).endsWith('travel.db')) return true; @@ -1180,6 +1184,10 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => { const result = await restoreFromZip(stubStorage(), '/data/tmp/upload.zip'); expect(result).toEqual({ success: true }); + // The uploaded database is checked through the shared opener, read-only and + // with its temp files in memory like every other handle (#2518). + expect(DatabaseMock).toHaveBeenCalledWith(expect.stringContaining('travel.db'), { readonly: true }); + expect(uploaded.pragma).toHaveBeenCalledWith('temp_store = MEMORY'); }); it('BACKUP-045b — closeDb is called before file copy operations', async () => { @@ -1325,6 +1333,7 @@ describe('BACKUP-046 restoreFromZip — uploads rehydration through storage', () { name: 'days' }, ]), }), + pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { From bea3af4b45b97c61fca5cebf9c64a5d6c12b4a80 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 22:50:59 +0000 Subject: [PATCH 03/14] fix(pwa): recover from a dead chunk and a broken precache after an update (#2524) After an update, a tab or a device whose service worker still serves the previous build asks for chunks the update removed. The server answered those requests with index.html and a 200, so the import failed with a MIME error, and everything caching in between was free to keep the HTML as the chunk. A precache install that met a missing file stored it, and Workbox reuses hashed entries by URL, so the device showed "A new version is available" on every load, whatever the reload or the cleared browser cache, and carried the broken entry into every later release that keeps the file's name. The SPA fallback now answers a GET for a build file that is not on disk with a plain 404 (no-store) instead of index.html. A precache install refuses it, so the worker whose cache is complete stays in charge until the new build is. index.html is sent relative to the public dir, which also stops an install living under a dot directory from answering every deep link with a 404. On the client, the reload after a dead chunk (the automatic one and the button on the update screen) no longer goes straight back to the worker that served the broken shell. When that same worker is still in charge and the server answers, its precache and registration are dropped first, so the reload reaches the build the server runs now. Offline, and in offline mode, the shell is kept. Every recovery reload, and the reload of the version handover, asks for the page under a one-off query parameter, so a proxy or CDN that keeps index.html against its headers cannot hand back the stale shell either. A chunk the entry imports statically fails before any of that code runs. shell-guard.js, a small classic script loaded ahead of the entry, watches for exactly that failure and runs the same recovery, once per session. A stylesheet, font or icon that holds a page raises no error at all, so once the app has started and the browser is idle, it looks through the precache for entries that hold a page under a build file's name, fetches each from the server past every cache and puts it back in place. A repaired stylesheet is loaded again, and so are the app's stylesheets when a font was repaired, since the browser does not try a face that failed again in the same document. Only the headers are read, a precache found sound is not looked at again until its entries change, nothing is fetched while nothing is broken, and nothing happens offline, in offline mode or without storage. An answer that is not the file (an auth wall, its login page, a server error) leaves the entry for the next launch. A page the recovery loaded from the network already runs the version the server reports, so the version handover now records it instead of reloading it a second time once the new worker takes over. A page loaded past an older worker that is still in charge, as a hard reload does, still asks for the new worker and records the version only once that has taken over, so the next tab or launch cannot get the old shell without a handover. --- client/index.html | 3 + client/public/shell-guard.js | 154 ++++++ .../components/shared/ErrorBoundary.test.tsx | 48 +- .../src/components/shared/ErrorBoundary.tsx | 16 +- client/src/main.tsx | 4 + client/src/utils/chunkReload.test.ts | 180 ++++++- client/src/utils/chunkReload.ts | 64 ++- client/src/utils/globalErrorHandlers.test.ts | 3 +- client/src/utils/serviceWorkerShell.test.ts | 481 ++++++++++++++++++ client/src/utils/serviceWorkerShell.ts | 242 +++++++++ client/src/utils/versionHandover.test.ts | 164 +++++- client/src/utils/versionHandover.ts | 68 ++- client/tests/unit/utils/shellGuard.test.ts | 236 +++++++++ server/src/nest/platform/platform.routes.ts | 44 +- .../src/nest/platform/spa-fallback.filter.ts | 9 +- server/tests/e2e/spa-fallback.e2e.test.ts | 112 ++++ .../unit/nest/platform.controller.test.ts | 93 +++- 17 files changed, 1867 insertions(+), 54 deletions(-) create mode 100644 client/public/shell-guard.js create mode 100644 client/src/utils/serviceWorkerShell.test.ts create mode 100644 client/src/utils/serviceWorkerShell.ts create mode 100644 client/tests/unit/utils/shellGuard.test.ts create mode 100644 server/tests/e2e/spa-fallback.e2e.test.ts diff --git a/client/index.html b/client/index.html index d31606d03..bc8f8325c 100644 --- a/client/index.html +++ b/client/index.html @@ -8,6 +8,9 @@ + + diff --git a/client/public/shell-guard.js b/client/public/shell-guard.js new file mode 100644 index 000000000..4c2232f0a --- /dev/null +++ b/client/public/shell-guard.js @@ -0,0 +1,154 @@ +/* + * Boot guard for the app shell (#2524). + * + * Loaded as an external classic script in (NOT a module), like + * theme-boot.js, so it runs before the entry module and complies with the + * production CSP (script-src 'self'). + * + * The entry module and every chunk it imports statically have to load before + * any of the app's own code runs. When one of them does not (a service worker + * precache that keeps index.html under a chunk's name, a proxy that hands out a + * stale file), the module script fails as a whole: nothing renders, and no error + * screen or reload button of the app is there to help. A reload changes nothing + * either, since the same precache answers it. This notices exactly that failure + * and, while the server answers, loads the page again from the server under a + * URL no cache has seen. When a service worker served the page, the app shell + * it keeps (the Workbox precache and the worker itself) is thrown away first, + * since that is what answered with the broken file. Once per session, so a build + * that is broken on the server cannot put the page in a reload loop. Offline + * nothing happens: the precache is then the only way the app can start at all. + * + * It also strips the query parameter such a reload carries, before the app + * reads the URL. Keep the parameter and the cache filter in sync with + * src/utils/chunkReload.ts and src/utils/serviceWorkerShell.ts. + * + * It must never throw. + */ +(function () { + var PARAM = 'trek-reload'; + var MARKER = 'trek:shell-reload'; + var HEALTH_TIMEOUT_MS = 3000; + + try { + var here = new URL(window.location.href); + if (here.searchParams.has(PARAM)) { + here.searchParams.delete(PARAM); + window.history.replaceState(window.history.state, '', here.pathname + here.search + here.hash); + } + } catch (e) { + /* the parameter stays in the address bar, which is harmless */ + } + + // The built entry lives under /assets/. The dev server's /src/main.tsx does + // not, so a syntax error while developing is left to Vite's own overlay. + function isEntry(el) { + return !!el && el.tagName === 'SCRIPT' && el.type === 'module' && /\/assets\//.test(el.src || ''); + } + + function alreadyTried() { + try { + return !!sessionStorage.getItem(MARKER); + } catch (e) { + // Without the marker there is no loop protection, so do nothing. + return true; + } + } + + function markTried() { + try { + sessionStorage.setItem(MARKER, String(Date.now())); + return true; + } catch (e) { + return false; + } + } + + // Same test as probeNow() in src/sync/connectivity.ts: TREK's own health JSON, + // not an edge proxy's login page and not a request that never came back. + function serverAnswers() { + var ctrl = typeof AbortController === 'function' ? new AbortController() : null; + var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, HEALTH_TIMEOUT_MS) : null; + return fetch('/api/health', { + credentials: 'include', + cache: 'no-store', + redirect: 'manual', + signal: ctrl ? ctrl.signal : undefined, + }).then(function (res) { + var type = res.headers.get('content-type') || ''; + return res.ok && type.indexOf('application/json') !== -1; + }, function () { + return false; + }).then(function (ok) { + if (timer) clearTimeout(timer); + return ok; + }); + } + + function servedByWorker() { + try { + return !!(navigator.serviceWorker && navigator.serviceWorker.controller); + } catch (e) { + return false; + } + } + + function dropAppShell() { + var jobs = []; + if (typeof caches !== 'undefined') { + jobs.push(caches.keys().then(function (names) { + return Promise.all(names.filter(function (name) { + return name.indexOf('-precache-') !== -1; + }).map(function (name) { + return caches.delete(name); + })); + })); + } + if (navigator.serviceWorker) { + jobs.push(navigator.serviceWorker.getRegistration().then(function (reg) { + return reg ? reg.unregister() : false; + })); + } + return Promise.all(jobs); + } + + function reloadFresh() { + var url = new URL(window.location.href); + url.searchParams.set(PARAM, Date.now().toString(36)); + window.location.replace(url.href); + } + + function recover() { + if (alreadyTried()) return; + // Read now: a page the worker did not serve failed on the network, and its + // precache has nothing to do with it. + var fromWorker = servedByWorker(); + serverAnswers().then(function (ok) { + if (!ok || alreadyTried() || !markTried()) return; + var dropped = fromWorker ? dropAppShell() : Promise.resolve(); + return dropped.then(null, function () { + /* reload regardless, the server has the current build */ + }).then(reloadFresh); + }).then(null, function () { + /* never block boot */ + }); + } + + try { + // Error and load events of a script do not bubble, so both are caught on + // their way down. On the document, since a load event never reaches window. + document.addEventListener('error', function (event) { + if (isEntry(event.target)) recover(); + }, true); + // The shell started, so a later update in this session may recover too. + document.addEventListener('load', function (event) { + if (!isEntry(event.target)) return; + try { + sessionStorage.removeItem(MARKER); + } catch (e) { + /* nothing to clear */ + } + }, true); + } catch (e) { + /* never block boot */ + } +})(); diff --git a/client/src/components/shared/ErrorBoundary.test.tsx b/client/src/components/shared/ErrorBoundary.test.tsx index 62d18bf47..be5e7b7ed 100644 --- a/client/src/components/shared/ErrorBoundary.test.tsx +++ b/client/src/components/shared/ErrorBoundary.test.tsx @@ -1,6 +1,14 @@ import React from 'react'; import { render, screen, fireEvent } from '../../../tests/helpers/render'; import ErrorBoundary, { RootErrorFallback } from './ErrorBoundary'; +import * as chunkReload from '../../utils/chunkReload'; + +// Only the Reload button's way out is replaced; the boundary's own automatic reload +// (reloadOnceForChunk) keeps running the real code. +vi.mock('../../utils/chunkReload', async (importOriginal) => ({ + ...(await importOriginal()), + reloadOntoCurrentBuild: vi.fn(), +})); // React logs every caught error itself; without this each of these tests prints a // full component stack and buries the real output. @@ -146,9 +154,10 @@ describe('ErrorBoundary', () => { }); it('FE-COMP-ERRBOUND-011: a chunk failure reloads once, then stops', () => { + // The recovery reload is a location.replace onto a fresh URL (chunkReload). const reload = vi.fn(); Object.defineProperty(window, 'location', { - value: { ...window.location, reload }, + value: { ...window.location, replace: reload }, writable: true, }); @@ -214,3 +223,40 @@ describe('RootErrorFallback', () => { expect(screen.getByText('A new version is available')).toBeInTheDocument(); }); }); + +describe('Reload button', () => { + let reload: ReturnType; + beforeEach(() => { + reload = vi.fn(); + Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true }); + vi.mocked(chunkReload.reloadOntoCurrentBuild).mockClear(); + }); + + // A plain reload is answered by the service worker with the shell that just + // failed, which is how the update screen outlived every reload (#2524). + it('FE-COMP-ERRBOUND-017: after a dead chunk it reloads onto the current build', () => { + render( {}} isChunkError />); + fireEvent.click(screen.getByText('Reload page')); + expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); + }); + + it('FE-COMP-ERRBOUND-018: the route fallback for a dead chunk does the same', () => { + sessionStorage.setItem('trek:chunk-reload', '1'); + render( + + + , + ); + fireEvent.click(screen.getByText('Reload page')); + expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); + }); + + it('FE-COMP-ERRBOUND-019: any other failure keeps the plain reload', () => { + render( {}} isChunkError={false} />); + fireEvent.click(screen.getByText('Reload page')); + expect(reload).toHaveBeenCalledTimes(1); + expect(chunkReload.reloadOntoCurrentBuild).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/shared/ErrorBoundary.tsx b/client/src/components/shared/ErrorBoundary.tsx index de75be4e0..78452e614 100644 --- a/client/src/components/shared/ErrorBoundary.tsx +++ b/client/src/components/shared/ErrorBoundary.tsx @@ -1,7 +1,7 @@ import React from 'react' import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react' import { useTranslation } from '../../i18n' -import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload' +import { isChunkLoadError, reloadOnceForChunk, reloadOntoCurrentBuild } from '../../utils/chunkReload' /** * The app had none of these, so a single throw during render unmounted the whole @@ -95,6 +95,16 @@ export default class ErrorBoundary extends React.Component window.location.reload()} + onClick={() => reloadPage(isChunkError)} className={[ 'inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-caption font-medium', mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content', @@ -201,7 +211,7 @@ export function RootErrorFallback({ error, isChunkError }: FallbackState) { {message && {message}} + )} + + + ) +} diff --git a/client/src/pages/sharedTrip/sharedTripModel.test.ts b/client/src/pages/sharedTrip/sharedTripModel.test.ts index 7858e44f6..f71078c67 100644 --- a/client/src/pages/sharedTrip/sharedTripModel.test.ts +++ b/client/src/pages/sharedTrip/sharedTripModel.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' -import { formatDurationMinutes, isHttpUrl, linkHost } from './sharedTripModel' +import { AxiosError, AxiosHeaders } from 'axios' +import { formatDurationMinutes, isHttpUrl, isSharedTripPayload, linkHost, sharedTripLoadError } from './sharedTripModel' describe('sharedTripModel (#2320)', () => { it('accepts http and https and nothing else', () => { @@ -35,3 +36,65 @@ describe('sharedTripModel (#2320)', () => { expect(linkHost('nonsense')).toBe('nonsense') }) }) + +describe('sharedTripLoadError (#2505)', () => { + // What the share endpoint sends for an unknown, expired or revoked token. + const ENDPOINT_404 = { error: 'Invalid or expired link' } + + const answered = (status: number, data: unknown = ENDPOINT_404) => + new AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, { + status, + statusText: '', + data, + headers: {}, + config: { headers: new AxiosHeaders() }, + }) + + it('calls only the endpoint 404 an expired link', () => { + expect(sharedTripLoadError(answered(404))).toBe('expired') + }) + + it('treats every other answer as a failed load', () => { + for (const status of [400, 401, 403, 408, 429, 500, 502, 503, 504]) { + expect(sharedTripLoadError(answered(status))).toBe('unavailable') + } + }) + + it('does not take a 404 without the TREK error body for an expired link', () => { + // Traefik with the container stopped or still starting, nginx, an empty + // answer: a 404 from something in front of TREK says nothing about the token. + expect(sharedTripLoadError(answered(404, '404 page not found\n'))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, '

404 Not Found

'))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, ''))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, null))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, {}))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, [{ error: 'x' }]))).toBe('unavailable') + expect(sharedTripLoadError(answered(404, { error: 404 }))).toBe('unavailable') + }) + + it('treats a request without any answer as a failed load', () => { + expect(sharedTripLoadError(new AxiosError('Network Error', AxiosError.ERR_NETWORK))).toBe('unavailable') + expect(sharedTripLoadError(new AxiosError('timeout of 8000ms exceeded', AxiosError.ECONNABORTED))).toBe('unavailable') + expect(sharedTripLoadError(new TypeError('Failed to fetch'))).toBe('unavailable') + expect(sharedTripLoadError(undefined)).toBe('unavailable') + expect(sharedTripLoadError(null)).toBe('unavailable') + }) +}) + +describe('isSharedTripPayload (#2505)', () => { + it('accepts what the share endpoint sends', () => { + expect(isSharedTripPayload({ trip: { id: 1, title: 'Lisbon' }, days: [], permissions: {} })).toBe(true) + }) + + it('rejects a 200 that is not the share payload', () => { + // An auth wall or a captive portal answering the API call with its own page. + expect(isSharedTripPayload('Sign in')).toBe(false) + expect(isSharedTripPayload('')).toBe(false) + expect(isSharedTripPayload(null)).toBe(false) + expect(isSharedTripPayload(undefined)).toBe(false) + expect(isSharedTripPayload([])).toBe(false) + expect(isSharedTripPayload({})).toBe(false) + expect(isSharedTripPayload({ trip: null })).toBe(false) + expect(isSharedTripPayload({ trip: 'Lisbon' })).toBe(false) + }) +}) diff --git a/client/src/pages/sharedTrip/sharedTripModel.ts b/client/src/pages/sharedTrip/sharedTripModel.ts index 4eff44662..b6ae81a26 100644 --- a/client/src/pages/sharedTrip/sharedTripModel.ts +++ b/client/src/pages/sharedTrip/sharedTripModel.ts @@ -46,3 +46,39 @@ export function linkHost(url: string): string { return url } } + +/** + * Why the share payload did not arrive (#2505). + * + * The share endpoint answers 404 with its JSON `{ error }` body for a token + * that is unknown, expired or revoked, and that is the only answer that says + * anything about the link. A 5xx while the server restarts, a rate limit, a + * timeout or no response at all because the viewer is offline says the request + * failed, not the link, so the page offers a retry instead of sending the + * viewer back to the owner for a new link that would not have been needed. + * + * A 404 without that body is not TREK talking: a reverse proxy with no + * upstream answers one on its own (Traefik while the container is stopped or + * still starting, nginx with a missing location), so it counts as a failed + * load too. + */ +export type SharedTripLoadError = 'expired' | 'unavailable' + +export function sharedTripLoadError(err: unknown): SharedTripLoadError { + const response = (err as { response?: { status?: number; data?: unknown } } | null | undefined)?.response + if (response?.status !== 404) return 'unavailable' + return isPlainObject(response.data) && typeof response.data.error === 'string' ? 'expired' : 'unavailable' +} + +/** + * Whether a 200 carried the share payload. An auth wall or a captive portal + * can answer the API call with its own HTML page, which axios hands over as a + * string; the page would crash on it, so the hook treats it as a failed load. + */ +export function isSharedTripPayload(payload: unknown): boolean { + return isPlainObject(payload) && isPlainObject(payload.trip) +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/client/src/pages/sharedTrip/useSharedTrip.ts b/client/src/pages/sharedTrip/useSharedTrip.ts index 49470123d..9eb76cab2 100644 --- a/client/src/pages/sharedTrip/useSharedTrip.ts +++ b/client/src/pages/sharedTrip/useSharedTrip.ts @@ -2,28 +2,61 @@ import { useState, useEffect } from 'react' import { useParams } from 'react-router' import { shareApi } from '../../api/client' import { useExchangeRates } from '../../hooks/useExchangeRates' +import { isSharedTripPayload, sharedTripLoadError, type SharedTripLoadError } from './sharedTripModel' /** * Shared-trip (public) data hook — owns the token lookup, the read-only share - * fetch and the view state (selected day, active tab, language picker). - * SharedTripPage is a pure wiring container; the post-load derivations + * fetch with its retry, and the view state (selected day, active tab, language + * picker). SharedTripPage is a pure wiring container; the post-load derivations * (sortedDays, map places, …) stay in the page next to the JSX that uses them. - * Behaviour is identical to the previous in-component logic. */ export function useSharedTrip() { const { token } = useParams<{ token: string }>() // The shared payload is an open-ended snapshot (trip, days, assignments, …), // matched 1:1 from the public share endpoint — kept loosely typed as before. const [data, setData] = useState(null) - const [error, setError] = useState(false) + // 'expired' only for the endpoint's own 404; any other failure, a proxy's + // 404 or a 200 that is not the payload included, is 'unavailable' and keeps + // the viewer on a retry (#2505). + const [error, setError] = useState(null) + const [attempt, setAttempt] = useState(0) + const [retrying, setRetrying] = useState(false) const [selectedDay, setSelectedDay] = useState(null) const [activeTab, setActiveTab] = useState('plan') const [showLangPicker, setShowLangPicker] = useState(false) useEffect(() => { if (!token) return - shareApi.getSharedTrip(token).then(setData).catch(() => setError(true)) - }, [token]) + let cancelled = false + shareApi + .getSharedTrip(token) + .then((payload) => { + if (cancelled) return + if (!isSharedTripPayload(payload)) { + setError('unavailable') + return + } + setData(payload) + setError(null) + }) + .catch((err: unknown) => { + if (!cancelled) setError(sharedTripLoadError(err)) + }) + .finally(() => { + if (!cancelled) setRetrying(false) + }) + return () => { + cancelled = true + } + }, [token, attempt]) + + // The failed screen stays up while the next attempt runs, so a request that + // fails again straight away does not flash the spinner. + const retry = () => { + if (retrying) return + setRetrying(true) + setAttempt((n) => n + 1) + } // The server now withholds the whole itinerary when the owner disabled the map // (share_map=false), so the Plan tab has nothing to show — land on the first @@ -46,5 +79,18 @@ export function useSharedTrip() { const base = String(data?.baseCurrency || data?.trip?.currency || 'EUR').toUpperCase() const { convert } = useExchangeRates(base) - return { data, error, base, convert, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker } + return { + data, + error, + retry, + retrying, + base, + convert, + selectedDay, + setSelectedDay, + activeTab, + setActiveTab, + showLangPicker, + setShowLangPicker, + } } diff --git a/client/tests/helpers/msw/handlers/shared.ts b/client/tests/helpers/msw/handlers/shared.ts index 891f6ebbf..13ed0e279 100644 --- a/client/tests/helpers/msw/handlers/shared.ts +++ b/client/tests/helpers/msw/handlers/shared.ts @@ -5,8 +5,9 @@ export const sharedHandlers = [ http.get('/api/shared/:token', ({ params }) => { const { token } = params; + // What the share endpoint answers for an unknown, expired or revoked token. if (token === 'invalid-token' || token === 'expired-token') { - return new HttpResponse(null, { status: 404 }); + return HttpResponse.json({ error: 'Invalid or expired link' }, { status: 404 }); } const trip = { ...buildTrip({ start_date: '2026-07-01', end_date: '2026-07-05' }), title: 'Shared Paris Trip' }; diff --git a/shared/src/i18n/ar/shared.ts b/shared/src/i18n/ar/shared.ts index 14e97cf9b..f246b203d 100644 --- a/shared/src/i18n/ar/shared.ts +++ b/shared/src/i18n/ar/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'الرابط منتهي أو غير صالح', 'shared.expiredHint': 'رابط الرحلة المشترك لم يعد نشطًا.', + 'shared.loadFailed': 'تعذّر تحميل هذه الرحلة', + 'shared.loadFailedHint': 'هذا لا يعني أن الرابط قد انتهت صلاحيته. تحقّق من اتصالك وحاول مرة أخرى بعد قليل.', + 'shared.retry': 'إعادة المحاولة', 'shared.readOnly': 'عرض للقراءة فقط', 'shared.tabPlan': 'الخطة', 'shared.tabBookings': 'الحجوزات', diff --git a/shared/src/i18n/br/shared.ts b/shared/src/i18n/br/shared.ts index 603e9a5eb..563c3e2a6 100644 --- a/shared/src/i18n/br/shared.ts +++ b/shared/src/i18n/br/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link expirado ou inválido', 'shared.expiredHint': 'Este link de viagem compartilhado não está mais ativo.', + 'shared.loadFailed': 'Não foi possível carregar esta viagem', + 'shared.loadFailedHint': + 'Isso não significa que o link expirou. Verifique sua conexão e tente novamente em instantes.', + 'shared.retry': 'Tentar novamente', 'shared.readOnly': 'Visualização somente leitura', 'shared.tabPlan': 'Plano', 'shared.tabBookings': 'Reservas', diff --git a/shared/src/i18n/ca/shared.ts b/shared/src/i18n/ca/shared.ts index d0310a047..44010f57b 100644 --- a/shared/src/i18n/ca/shared.ts +++ b/shared/src/i18n/ca/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Enllaç caducat o invàlid', 'shared.expiredHint': 'Aquest enllaç de viatge compartit ja no està actiu.', + 'shared.loadFailed': "No s'ha pogut carregar aquest viatge", + 'shared.loadFailedHint': + "Això no vol dir que l'enllaç hagi caducat. Comprova la connexió i torna-ho a provar d'aquí a una estona.", + 'shared.retry': 'Torna-ho a provar', 'shared.readOnly': 'Vista de només lectura', 'shared.tabPlan': 'Pla', 'shared.tabBookings': 'Reserves', diff --git a/shared/src/i18n/cs/shared.ts b/shared/src/i18n/cs/shared.ts index 644c49e94..b4c425e7d 100644 --- a/shared/src/i18n/cs/shared.ts +++ b/shared/src/i18n/cs/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Odkaz vypršel nebo je neplatný', 'shared.expiredHint': 'Tento sdílený odkaz na cestu již není aktivní.', + 'shared.loadFailed': 'Cestu se nepodařilo načíst', + 'shared.loadFailedHint': 'Neznamená to, že odkaz vypršel. Zkontrolujte připojení a za chvíli to zkuste znovu.', + 'shared.retry': 'Zkusit znovu', 'shared.readOnly': 'Sdílené zobrazení – pouze pro čtení', 'shared.tabPlan': 'Plán', 'shared.tabBookings': 'Rezervace', diff --git a/shared/src/i18n/de/shared.ts b/shared/src/i18n/de/shared.ts index f1289f90e..02839504c 100644 --- a/shared/src/i18n/de/shared.ts +++ b/shared/src/i18n/de/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link abgelaufen oder ungültig', 'shared.expiredHint': 'Dieser geteilte Reise-Link ist nicht mehr aktiv.', + 'shared.loadFailed': 'Die Reise konnte nicht geladen werden', + 'shared.loadFailedHint': + 'Das heißt nicht, dass der Link abgelaufen ist. Prüfe deine Verbindung und versuche es gleich noch einmal.', + 'shared.retry': 'Erneut versuchen', 'shared.readOnly': 'Nur-Lesen Ansicht', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Buchungen', diff --git a/shared/src/i18n/en/shared.ts b/shared/src/i18n/en/shared.ts index f784b1181..5f568858e 100644 --- a/shared/src/i18n/en/shared.ts +++ b/shared/src/i18n/en/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link expired or invalid', 'shared.expiredHint': 'This shared trip link is no longer active.', + 'shared.loadFailed': 'This trip could not be loaded', + 'shared.loadFailedHint': 'That does not mean the link has expired. Check your connection and try again in a moment.', + 'shared.retry': 'Try again', 'shared.readOnly': 'Read-only shared view', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Bookings', diff --git a/shared/src/i18n/es/shared.ts b/shared/src/i18n/es/shared.ts index cb8fb0d6a..0a5980d04 100644 --- a/shared/src/i18n/es/shared.ts +++ b/shared/src/i18n/es/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Enlace expirado o inválido', 'shared.expiredHint': 'Este enlace de viaje compartido ya no está activo.', + 'shared.loadFailed': 'No se pudo cargar este viaje', + 'shared.loadFailedHint': + 'Eso no significa que el enlace haya caducado. Comprueba tu conexión y vuelve a intentarlo en un momento.', + 'shared.retry': 'Reintentar', 'shared.readOnly': 'Vista de solo lectura', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Reservas', diff --git a/shared/src/i18n/fr/shared.ts b/shared/src/i18n/fr/shared.ts index 58bf39e02..5617203e6 100644 --- a/shared/src/i18n/fr/shared.ts +++ b/shared/src/i18n/fr/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Lien expiré ou invalide', 'shared.expiredHint': "Ce lien de partage n'est plus actif.", + 'shared.loadFailed': 'Impossible de charger ce voyage', + 'shared.loadFailedHint': + 'Cela ne veut pas dire que le lien a expiré. Vérifiez votre connexion et réessayez dans un instant.', + 'shared.retry': 'Réessayer', 'shared.readOnly': 'Vue en lecture seule', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Réservations', diff --git a/shared/src/i18n/gr/shared.ts b/shared/src/i18n/gr/shared.ts index cf5c0fe00..5fd1b1a04 100644 --- a/shared/src/i18n/gr/shared.ts +++ b/shared/src/i18n/gr/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Ο σύνδεσμος έληξε ή δεν είναι έγκυρος', 'shared.expiredHint': 'Αυτός ο κοινόχρηστος σύνδεσμος ταξιδιού δεν είναι πλέον ενεργός.', + 'shared.loadFailed': 'Δεν ήταν δυνατή η φόρτωση αυτού του ταξιδιού', + 'shared.loadFailedHint': + 'Αυτό δεν σημαίνει ότι ο σύνδεσμος έχει λήξει. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά σε λίγο.', + 'shared.retry': 'Δοκιμάστε ξανά', 'shared.readOnly': 'Κοινόχρηστη προβολή μόνο για ανάγνωση', 'shared.tabPlan': 'Σχέδιο', 'shared.tabBookings': 'Κρατήσεις', diff --git a/shared/src/i18n/hu/shared.ts b/shared/src/i18n/hu/shared.ts index d9e30cc4d..a5ffaa131 100644 --- a/shared/src/i18n/hu/shared.ts +++ b/shared/src/i18n/hu/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link lejárt vagy érvénytelen', 'shared.expiredHint': 'Ez a megosztott utazási link már nem aktív.', + 'shared.loadFailed': 'Az utazást nem sikerült betölteni', + 'shared.loadFailedHint': + 'Ez nem jelenti azt, hogy a link lejárt. Ellenőrizd a kapcsolatot, és próbáld újra egy kicsit később.', + 'shared.retry': 'Újrapróbálás', 'shared.readOnly': 'Csak olvasható megosztott nézet', 'shared.tabPlan': 'Terv', 'shared.tabBookings': 'Foglalások', diff --git a/shared/src/i18n/id/shared.ts b/shared/src/i18n/id/shared.ts index f635e7976..164c26392 100644 --- a/shared/src/i18n/id/shared.ts +++ b/shared/src/i18n/id/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Tautan kedaluwarsa atau tidak valid', 'shared.expiredHint': 'Tautan perjalanan bersama ini tidak lagi aktif.', + 'shared.loadFailed': 'Perjalanan ini tidak dapat dimuat', + 'shared.loadFailedHint': + 'Ini tidak berarti tautannya sudah kedaluwarsa. Periksa koneksi Anda dan coba lagi sebentar lagi.', + 'shared.retry': 'Coba lagi', 'shared.readOnly': 'Tampilan bersama — hanya baca', 'shared.tabPlan': 'Rencana', 'shared.tabBookings': 'Pemesanan', diff --git a/shared/src/i18n/it/shared.ts b/shared/src/i18n/it/shared.ts index ba0fd4a93..bf52af3a9 100644 --- a/shared/src/i18n/it/shared.ts +++ b/shared/src/i18n/it/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link scaduto o non valido', 'shared.expiredHint': 'Questo link di viaggio condiviso non è più attivo.', + 'shared.loadFailed': 'Impossibile caricare questo viaggio', + 'shared.loadFailedHint': 'Non significa che il link sia scaduto. Controlla la connessione e riprova tra un momento.', + 'shared.retry': 'Riprova', 'shared.readOnly': 'Vista in sola lettura', 'shared.tabPlan': 'Programma', 'shared.tabBookings': 'Prenotazioni', diff --git a/shared/src/i18n/ja/shared.ts b/shared/src/i18n/ja/shared.ts index 9eee1b6b8..152f2a58c 100644 --- a/shared/src/i18n/ja/shared.ts +++ b/shared/src/i18n/ja/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'リンクが無効', 'shared.expiredHint': 'この共有リンクは無効です。', + 'shared.loadFailed': 'この旅行を読み込めませんでした', + 'shared.loadFailedHint': + 'リンクの有効期限が切れたわけではありません。接続を確認して、しばらくしてからもう一度お試しください。', + 'shared.retry': '再試行', 'shared.readOnly': '読み取り専用', 'shared.tabPlan': 'プラン', 'shared.tabBookings': '予約', diff --git a/shared/src/i18n/ko/shared.ts b/shared/src/i18n/ko/shared.ts index 105dd11e3..db2f61f94 100644 --- a/shared/src/i18n/ko/shared.ts +++ b/shared/src/i18n/ko/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': '링크가 만료되었거나 유효하지 않습니다', 'shared.expiredHint': '이 공유 여행 링크는 더 이상 유효하지 않습니다.', + 'shared.loadFailed': '이 여행을 불러올 수 없습니다', + 'shared.loadFailedHint': '링크가 만료되었다는 뜻은 아닙니다. 연결 상태를 확인한 뒤 잠시 후 다시 시도해 주세요.', + 'shared.retry': '다시 시도', 'shared.readOnly': '읽기 전용 공유 보기', 'shared.tabPlan': '계획', 'shared.tabBookings': '예약', diff --git a/shared/src/i18n/nl/shared.ts b/shared/src/i18n/nl/shared.ts index 325b1ae80..25898db7c 100644 --- a/shared/src/i18n/nl/shared.ts +++ b/shared/src/i18n/nl/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link verlopen of ongeldig', 'shared.expiredHint': 'Deze gedeelde reislink is niet meer actief.', + 'shared.loadFailed': 'Deze reis kon niet worden geladen', + 'shared.loadFailedHint': + 'Dat betekent niet dat de link is verlopen. Controleer je verbinding en probeer het zo nog eens.', + 'shared.retry': 'Opnieuw proberen', 'shared.readOnly': 'Alleen-lezen weergave', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Boekingen', diff --git a/shared/src/i18n/pl/shared.ts b/shared/src/i18n/pl/shared.ts index 30402fdc3..999a349ff 100644 --- a/shared/src/i18n/pl/shared.ts +++ b/shared/src/i18n/pl/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Link wygasł lub jest nieprawidłowy', 'shared.expiredHint': 'Ten link do podróży jest już nieaktywny.', + 'shared.loadFailed': 'Nie udało się wczytać tej podróży', + 'shared.loadFailedHint': 'Nie oznacza to, że link wygasł. Sprawdź połączenie i spróbuj ponownie za chwilę.', + 'shared.retry': 'Spróbuj ponownie', 'shared.readOnly': 'Widok udostępniony tylko do odczytu', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Rezerwacje', diff --git a/shared/src/i18n/ru/shared.ts b/shared/src/i18n/ru/shared.ts index 727b58f97..9e4709de4 100644 --- a/shared/src/i18n/ru/shared.ts +++ b/shared/src/i18n/ru/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Ссылка устарела или недействительна', 'shared.expiredHint': 'Эта ссылка на поездку больше не активна.', + 'shared.loadFailed': 'Не удалось загрузить поездку', + 'shared.loadFailedHint': + 'Это не значит, что срок действия ссылки истёк. Проверьте подключение и повторите попытку чуть позже.', + 'shared.retry': 'Повторить', 'shared.readOnly': 'Режим только для чтения', 'shared.tabPlan': 'План', 'shared.tabBookings': 'Бронирования', diff --git a/shared/src/i18n/sv/shared.ts b/shared/src/i18n/sv/shared.ts index 07b4a94ec..6e199fde4 100644 --- a/shared/src/i18n/sv/shared.ts +++ b/shared/src/i18n/sv/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Länken har gått ut eller är ogiltig', 'shared.expiredHint': 'Den här länken till den delade resan är inte längre aktiv.', + 'shared.loadFailed': 'Resan kunde inte laddas', + 'shared.loadFailedHint': + 'Det betyder inte att länken har gått ut. Kontrollera din anslutning och försök igen om en liten stund.', + 'shared.retry': 'Försök igen', 'shared.readOnly': 'Delad vy i skrivskyddat läge', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Bokningar', diff --git a/shared/src/i18n/tr/shared.ts b/shared/src/i18n/tr/shared.ts index a69bdcbc6..7da7bd8a6 100644 --- a/shared/src/i18n/tr/shared.ts +++ b/shared/src/i18n/tr/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Bağlantının süresi dolmuş veya geçersiz', 'shared.expiredHint': 'Bu paylaşılan gezi bağlantısı artık etkin değil.', + 'shared.loadFailed': 'Bu gezi yüklenemedi', + 'shared.loadFailedHint': + 'Bu, paylaşım bağlantısının süresinin dolduğu anlamına gelmez. İnternet bağlantınızı kontrol edip biraz sonra tekrar deneyin.', + 'shared.retry': 'Yeniden dene', 'shared.readOnly': 'Salt okunur paylaşılan görünüm', 'shared.tabPlan': 'Planı', 'shared.tabBookings': 'Rezervasyonlar', diff --git a/shared/src/i18n/uk/shared.ts b/shared/src/i18n/uk/shared.ts index 53ca18229..a679a6807 100644 --- a/shared/src/i18n/uk/shared.ts +++ b/shared/src/i18n/uk/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Посилання прострочено або недійсне', 'shared.expiredHint': 'Це посилання на поїздку більше не активне.', + 'shared.loadFailed': 'Не вдалося завантажити поїздку', + 'shared.loadFailedHint': + 'Це не означає, що термін дії посилання минув. Перевірте з’єднання та спробуйте ще раз трохи згодом.', + 'shared.retry': 'Спробувати ще раз', 'shared.readOnly': 'Тільки для читання', 'shared.tabPlan': 'План', 'shared.tabBookings': 'Бронювання', diff --git a/shared/src/i18n/vi/shared.ts b/shared/src/i18n/vi/shared.ts index 505d0ec92..95ecf8b5a 100644 --- a/shared/src/i18n/vi/shared.ts +++ b/shared/src/i18n/vi/shared.ts @@ -3,6 +3,10 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': 'Liên kết đã hết hạn hoặc không hợp lệ', 'shared.expiredHint': 'Liên kết chuyến đi được chia sẻ này không còn hoạt động.', + 'shared.loadFailed': 'Không thể tải chuyến đi này', + 'shared.loadFailedHint': + 'Điều này không có nghĩa là liên kết đã hết hạn. Hãy kiểm tra kết nối và thử lại sau ít phút.', + 'shared.retry': 'Thử lại', 'shared.readOnly': 'Chế độ xem chia sẻ chỉ đọc', 'shared.tabPlan': 'Kế hoạch', 'shared.tabBookings': 'Đặt chỗ', diff --git a/shared/src/i18n/zh-TW/shared.ts b/shared/src/i18n/zh-TW/shared.ts index 59f01e27c..8ece885d0 100644 --- a/shared/src/i18n/zh-TW/shared.ts +++ b/shared/src/i18n/zh-TW/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': '連結已過期或無效', 'shared.expiredHint': '此共享旅行連結已失效。', + 'shared.loadFailed': '無法載入此旅行', + 'shared.loadFailedHint': '這並不代表連結已過期。請檢查網路連線,稍後再試。', + 'shared.retry': '重試', 'shared.readOnly': '只讀共享檢視', 'shared.tabPlan': '計劃', 'shared.tabBookings': '預訂', diff --git a/shared/src/i18n/zh/shared.ts b/shared/src/i18n/zh/shared.ts index 149da482c..663aed7f2 100644 --- a/shared/src/i18n/zh/shared.ts +++ b/shared/src/i18n/zh/shared.ts @@ -3,6 +3,9 @@ import type { TranslationStrings } from '../types'; const shared: TranslationStrings = { 'shared.expired': '链接已过期或无效', 'shared.expiredHint': '此共享旅行链接已失效。', + 'shared.loadFailed': '无法加载此旅行', + 'shared.loadFailedHint': '这并不代表链接已过期。请检查网络连接,稍后再试。', + 'shared.retry': '重试', 'shared.readOnly': '只读共享视图', 'shared.tabPlan': '计划', 'shared.tabBookings': '预订', From 30a3f664b1953f91b897df7f57b15f01caf18e7b Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 20:05:56 +0200 Subject: [PATCH 05/14] fix(map): open a place from its pin after the map was dragged (#2504) Since drag-to-day (#891) a place pin swallows mousedown so the map holds still while a drag starts. Leaflet only clears its "the map was panned" flag on a mousedown it sees, so after one drag of the map it took every later click on a pin for the end of that pan and dropped it. Pins stayed dead until something else on the map was pressed, which is why zooming out and clicking a cluster brought them back. Trip planner and collections map were both affected; the GL renderers never were. Pressing a draggable pin now clears that flag itself by switching the map's pan handler off and on again, which is what the map would have done had it seen the press. The click keeps Leaflet's normal route, so it still closes an open popup on the map and still reaches listeners on the page, such as an open context menu. The new test file drives a real Leaflet map, since the mocked react-leaflet has no pan state to go stale. --- .../components/Map/MapView.leaflet.test.tsx | 246 ++++++++++++++++++ client/src/components/Map/MapView.tsx | 29 ++- client/src/components/Map/markerDrag.ts | 2 + 3 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Map/MapView.leaflet.test.tsx diff --git a/client/src/components/Map/MapView.leaflet.test.tsx b/client/src/components/Map/MapView.leaflet.test.tsx new file mode 100644 index 000000000..f76fe298d --- /dev/null +++ b/client/src/components/Map/MapView.leaflet.test.tsx @@ -0,0 +1,246 @@ +/** + * Clicking a place pin after the map has been dragged (#2504). + * + * Nothing Leaflet-related is mocked here on purpose. The bug lived in the space between + * our drag wiring and Leaflet's own event routing: the pins swallow mousedown so a drag + * onto a day can start without the map sliding away, and Leaflet only forgets that the + * map was panned when it sees the next mousedown. A mocked react-leaflet, like the one + * MapView.test.tsx uses, has no such memory and kept passing throughout. + * + * The click itself has to keep taking Leaflet's route and the page's: it is what closes + * an open popup on the map, and an open context menu beside it. + */ +import React, { useState } from 'react' +import { flushSync } from 'react-dom' +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' +import { render, waitFor, act } from '../../../tests/helpers/render' +import { resetAllStores } from '../../../tests/helpers/store' +import { buildPlace } from '../../../tests/helpers/factories' + +vi.mock('../../hooks/useGeolocation', () => ({ + useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }), +})) + +vi.mock('../../services/photoService', () => ({ + getCached: vi.fn(() => null), + isLoading: vi.fn(() => false), + fetchPhoto: vi.fn(), + onThumbReady: vi.fn(() => () => {}), + getAllThumbs: vi.fn(() => ({})), +})) + +import L from 'leaflet' +import { MapView } from './MapView' +import { ContextMenu } from '../shared/ContextMenu' + +// Every map this file builds, newest last, for the tests that reach past the component. +const maps: L.Map[] = [] +L.Map.addInitHook(function (this: L.Map) { maps.push(this) }) + +// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL. +const RASTER = 'https://tiles.example.test/{z}/{x}/{y}.png' + +// Leaflet sizes the map from the container, which jsdom lays out at 0 x 0. The cluster +// group only draws pins inside the visible bounds, so the map needs a real size. +const sized = ['clientWidth', 'clientHeight'] as const +const originals = new Map() +beforeAll(() => { + // The pans here are synthetic and instant. With inertia, Leaflet would carry them on in + // animation frames that can land after a test has already unmounted its map. + L.Map.mergeOptions({ inertia: false }) + for (const key of sized) { + originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key)) + Object.defineProperty(HTMLElement.prototype, key, { configurable: true, get: () => (key === 'clientWidth' ? 1000 : 700) }) + } +}) +afterAll(() => { + L.Map.mergeOptions({ inertia: true }) + for (const key of sized) { + const original = originals.get(key) + if (original) Object.defineProperty(HTMLElement.prototype, key, original) + else delete (HTMLElement.prototype as unknown as Record)[key] + } +}) + +afterEach(() => { + resetAllStores() +}) + +/** Leaflet only starts a pan for the primary button, which it reads off `which`. */ +function mouse(type: string, x: number, y: number): MouseEvent { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: y }) + Object.defineProperty(event, 'which', { value: 1 }) + return event +} + +/** Drag the map by its background, the way a user pans it. */ +function panMap(container: Element) { + act(() => { + container.dispatchEvent(mouse('mousedown', 300, 300)) + container.dispatchEvent(mouse('mousemove', 340, 320)) + container.dispatchEvent(mouse('mousemove', 380, 340)) + container.dispatchEvent(mouse('mouseup', 380, 340)) + }) +} + +/** A plain click on an element: press, release, click, all on the same spot. */ +function clickOn(el: Element) { + act(() => { + el.dispatchEvent(mouse('mousedown', 500, 350)) + el.dispatchEvent(mouse('mouseup', 500, 350)) + el.dispatchEvent(mouse('click', 500, 350)) + }) +} + +// Two stops a few hundred metres apart in Nara: separate pins at this zoom, not a cluster. +const places = [ + { ...buildPlace({ id: 1, name: 'Todai-ji', lat: 34.689, lng: 135.8398 }), category_name: null, category_color: null, category_icon: null }, + { ...buildPlace({ id: 2, name: 'Kasuga Taisha', lat: 34.6812, lng: 135.8484 }), category_name: null, category_color: null, category_icon: null }, +] + +async function renderMap() { + const onMarkerClick = vi.fn() + const onMapClick = vi.fn() + const view = render( + , + ) + const container = view.container.querySelector('.leaflet-container') as HTMLElement + // The cluster group takes its markers in on a timer (chunkedLoading). + await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2)) + const pins = [...container.querySelectorAll('.leaflet-marker-icon[draggable="true"]')] as HTMLElement[] + return { container, pins, onMarkerClick, onMapClick } +} + +describe('MapView on a real Leaflet map', () => { + it('FE-MAPCLICK-001: a pin opens its place on a map nobody has dragged yet', async () => { + const { pins, onMarkerClick, onMapClick } = await renderMap() + clickOn(pins[0]) + expect(onMarkerClick).toHaveBeenCalledTimes(1) + // Still the marker's click, not one on the empty map behind it. + expect(onMapClick).not.toHaveBeenCalled() + }) + + it('FE-MAPCLICK-002: a pin still opens its place after the map was dragged (#2504)', async () => { + const { container, pins, onMarkerClick, onMapClick } = await renderMap() + panMap(container) + clickOn(pins[0]) + expect(onMarkerClick).toHaveBeenCalledTimes(1) + // And again: the report was that every click after the pan went nowhere. + clickOn(pins[1]) + expect(onMarkerClick).toHaveBeenCalledTimes(2) + expect(onMarkerClick.mock.calls.map(call => call[0]).sort()).toEqual([1, 2]) + expect(onMapClick).not.toHaveBeenCalled() + }) + + it('FE-MAPCLICK-003: the click that ends a pan still counts for nothing', async () => { + const { container, onMarkerClick, onMapClick } = await renderMap() + panMap(container) + // The browser aims the click that closes a drag at the element both ends share, + // which is the map rather than the pin the pointer happened to stop on. Leaflet + // swallows it, and must go on doing so: it is not a click on the empty map either. + act(() => { container.dispatchEvent(mouse('click', 380, 340)) }) + expect(onMarkerClick).not.toHaveBeenCalled() + expect(onMapClick).not.toHaveBeenCalled() + }) + + it("FE-MAPCLICK-004: a pin on a touch screen keeps Leaflet's own click and no drag wiring", async () => { + const original = Object.getOwnPropertyDescriptor(window.navigator, 'maxTouchPoints') + Object.defineProperty(window.navigator, 'maxTouchPoints', { configurable: true, value: 5 }) + try { + const onMarkerClick = vi.fn() + const view = render() + const container = view.container.querySelector('.leaflet-container') as HTMLElement + await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon').length).toBe(2)) + const pin = container.querySelector('.leaflet-marker-icon') as HTMLElement + expect(pin.getAttribute('draggable')).toBeNull() + clickOn(pin) + expect(onMarkerClick).toHaveBeenCalledTimes(1) + } finally { + if (original) Object.defineProperty(window.navigator, 'maxTouchPoints', original) + else delete (window.navigator as unknown as Record).maxTouchPoints + } + }) + + it('FE-MAPCLICK-005: the place a pin opens stays open when the pin redraws mid-click', async () => { + // The selection a pin makes redraws its icon, which replaces the element the click + // landed on. Leaflet has to have found the pin before that happens: reading the target + // afterwards, it finds it cut out of the map and takes the click for one on the empty + // background, which would close the place again straight away. + const onMapClick = vi.fn() + function Planner() { + const [selected, setSelected] = useState(null) + return ( + <> + {selected ?? ''} + flushSync(() => setSelected(id))} + onMapClick={() => { onMapClick(); setSelected(null) }} + /> + + ) + } + const view = render() + const container = view.container.querySelector('.leaflet-container') as HTMLElement + await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2)) + const pin = container.querySelector('.leaflet-marker-icon[draggable="true"]') as HTMLElement + // The pointer lands on the photo inside the pin, which the redraw replaces. + clickOn(pin.firstElementChild as HTMLElement) + expect(onMapClick).not.toHaveBeenCalled() + expect(view.getByTestId('selected').textContent).not.toBe('') + }) + + it('FE-MAPCLICK-006: a pin click after a pan still closes an open popup on the map', async () => { + const { container, pins, onMarkerClick } = await renderMap() + const map = maps[maps.length - 1] + panMap(container) + // A hazard or plugin popup, as the planner opens them. Leaflet closes those on the + // "preclick" it sends ahead of every click it routes, a pin's included. + const popup = L.popup({ autoPan: false }).setLatLng([34.685, 135.844]).setContent('Road closed') + act(() => { popup.openOn(map) }) + expect(map.hasLayer(popup)).toBe(true) + clickOn(pins[0]) + expect(onMarkerClick).toHaveBeenCalledTimes(1) + expect(map.hasLayer(popup)).toBe(false) + }) + + it('FE-MAPCLICK-007: a pin click after a pan still reaches the page and closes an open context menu', async () => { + const onClose = vi.fn() + const onMarkerClick = vi.fn() + const view = render( + <> + {/* The menu the places list and the day plan open on a right click. It closes on + any click that reaches the document. */} + + + , + ) + const container = view.container.querySelector('.leaflet-container') as HTMLElement + await waitFor(() => expect(container.querySelectorAll('.leaflet-marker-icon[draggable="true"]').length).toBe(2)) + const pin = container.querySelector('.leaflet-marker-icon[draggable="true"]') as HTMLElement + panMap(container) + clickOn(pin) + expect(onMarkerClick).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-MAPCLICK-008: pressing a pin after a pan still holds the map still for a drag onto a day', async () => { + const { container, pins } = await renderMap() + const map = maps[maps.length - 1] + panMap(container) + const before = map.getCenter() + // Press on the pin and move off it, as the start of a drag towards the day plan. + act(() => { + pins[0].dispatchEvent(mouse('mousedown', 500, 350)) + document.dispatchEvent(mouse('mousemove', 560, 390)) + document.dispatchEvent(mouse('mousemove', 620, 430)) + document.dispatchEvent(mouse('mouseup', 620, 430)) + }) + expect(map.getCenter()).toEqual(before) + // The pan handler is back on, and the background still pans the map. + expect(map.dragging.enabled()).toBe(true) + panMap(container) + expect(map.getCenter()).not.toEqual(before) + }) +}) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 9d0276ebe..c1e1c41ee 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -622,6 +622,32 @@ function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null ) } +/** + * Make a place pin a drag source without losing its clicks to an old pan. + * + * The drag wiring swallows mousedown so the map holds still while a drag starts. Leaflet + * tells a click from the end of a pan by a flag it only clears on the next mousedown it + * sees, so once the map had been dragged, every click on a pin was taken for the tail of + * that pan and dropped, until something else on the map was pressed (#2504). A press on + * the pin now clears that flag itself, as the map would have. Switching the pan handler + * off and on again is the public way to do that, and nothing is being panned at that + * moment. The click then takes Leaflet's normal route, so it still closes open popups + * and still reaches listeners further up the page, like an open context menu. + */ +function wireDraggablePin(el: HTMLElement, placeId: number, map: L.Map): () => void { + const undoDrag = makeMarkerDraggable(el, placeId) + const forgetPan = (e: MouseEvent) => { + if (e.button !== 0 || !map.dragging.enabled()) return + map.dragging.disable() + map.dragging.enable() + } + el.addEventListener('mousedown', forgetPan) + return () => { + undoDrag() + el.removeEventListener('mousedown', forgetPan) + } +} + interface MemoMarkerProps { place: any isSelected: boolean @@ -640,6 +666,7 @@ const MemoMarker = memo(function MemoMarker({ place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable, onRegister, }: MemoMarkerProps) { const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected) + const map = useMap() const cleanupRef = useRef<(() => void) | null>(null) // react-leaflet compares `position` by reference and calls setLatLng whenever it // differs, and the cluster group answers a moved child by taking it out and putting @@ -658,7 +685,7 @@ const MemoMarker = memo(function MemoMarker({ // so the wiring is redone on every add rather than once on mount. add: (e: any) => { cleanupRef.current?.() - cleanupRef.current = draggable ? makeMarkerDraggable(e.target.getElement() as HTMLElement, place.id) : null + cleanupRef.current = draggable ? wireDraggablePin(e.target.getElement() as HTMLElement, place.id, map) : null }, remove: () => { cleanupRef.current?.(); cleanupRef.current = null }, click: () => onClickPlace(place.id), diff --git a/client/src/components/Map/markerDrag.ts b/client/src/components/Map/markerDrag.ts index a4a4d6dad..a89394439 100644 --- a/client/src/components/Map/markerDrag.ts +++ b/client/src/components/Map/markerDrag.ts @@ -35,6 +35,8 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi // The map pans on mousedown anywhere on its surface, and a marker sits on that // surface. Without this the map slides away under the pointer while the drag // is starting, which makes the day plan an impossible target to reach. + // The map then never sees the press that starts a click on the marker, which is why the Leaflet + // renderer clears the map's pan flag itself when a pin is pressed (wireDraggablePin, #2504). const onMouseDown = (e: MouseEvent) => e.stopPropagation() el.addEventListener('dragstart', onDragStart) From 3c7f6e69dd17dc801fde1b6f5a5b992f98e7cf98 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 19:46:29 +0200 Subject: [PATCH 06/14] fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501) On a day that opens with a flight, where the traveller also put the hotel on the day as a stop of their own, the phone timeline started the day with a hotel row and the drive from that stop to the next place, above the flight, and then showed the same drive again in its own spot. hotelLegsForDay guessed the bookends from coordinates: the first segment leaving the hotel's spot was the morning leg. The route had correctly drawn no morning leg on that day, but the stop on the hotel's spot starts a leg there too, so that hop was taken for it. The route calculation now tags the two legs it draws from and to the accommodation, and the timeline reads that tag instead. The hotel end of a tagged leg still has to be the day's own stay, because the calculation keeps the previous day's legs until the new day has routed. The stop to stop connectors leave tagged legs alone, so one leg can no longer show up in both places. --- .../src/components/Map/dayRoutePlan.test.ts | 26 +++- client/src/components/Map/dayRoutePlan.ts | 28 +++- client/src/hooks/useRouteCalculation.ts | 9 +- .../screens/trip/plan/planTimelineModel.ts | 45 +++--- client/src/types.ts | 6 + .../hooks/useRouteCalculation.test.ts | 128 +++++++++++++++++- .../mobile/trip/planTimelineModel.test.ts | 118 +++++++++++++--- .../unit/mobile/trip/useMPlanTimeline.test.ts | 5 +- 8 files changed, 314 insertions(+), 51 deletions(-) diff --git a/client/src/components/Map/dayRoutePlan.test.ts b/client/src/components/Map/dayRoutePlan.test.ts index 13141f286..5c808e70d 100644 --- a/client/src/components/Map/dayRoutePlan.test.ts +++ b/client/src/components/Map/dayRoutePlan.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildDayRouteRuns } from './dayRoutePlan' +import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan' import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories' import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types' @@ -142,6 +142,30 @@ describe('buildDayRouteRuns', () => { expect(runs[0].map(p => p.lat)).toEqual([53.5503, 53.5465]) }) + it('FE-MAP-DRP-015: the hotel points say which end of the day they are, a stop on the same spot does not (#2501)', () => { + const hotel = { id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation + const runs = buildDayRouteRuns(2, inputs({ + days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })], + assignments: { '2': [at(48.86, 2.35, 0), at(48.80, 2.30, 1), at(48.88, 2.36, 2)] }, + accommodations: [hotel], + optimizeFromAccommodation: true, + })) + const legs = runs.flatMap(run => run.slice(1).map((p, i) => hotelBookendOf(run[i], p) ?? null)) + // Out of the hotel, three stops (one of them on the hotel's own spot), back to it. + expect(legs).toEqual(['morning', null, null, 'evening']) + }) + + it('FE-MAP-DRP-016: the one drive of a moving day without stops is its morning leg (#2476)', () => { + const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n })) + const stays = [ + { id: 1, trip_id: 1, place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 }, + { id: 2, trip_id: 1, place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 3 }, + ] as unknown as Accommodation[] + const [run] = buildDayRouteRuns(2, inputs({ days, accommodations: stays, optimizeFromAccommodation: true })) + expect(run.map(p => p.hotel)).toEqual(['morning', 'evening']) + expect(hotelBookendOf(run[0], run[1])).toBe('morning') + }) + it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => { expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([]) }) diff --git a/client/src/components/Map/dayRoutePlan.ts b/client/src/components/Map/dayRoutePlan.ts index 567446c10..033ae72b2 100644 --- a/client/src/components/Map/dayRoutePlan.ts +++ b/client/src/components/Map/dayRoutePlan.ts @@ -13,6 +13,19 @@ export interface DayRoutePoint { isPlace: boolean leg_transport_mode?: string | null incoming_leg_transport_mode?: string | null + /** Only on an accommodation bookend: the stay the day starts from or ends at. */ + hotel?: 'morning' | 'evening' +} + +/** + * Which accommodation bookend the leg between two neighbouring run points is, if any. + * A day that is nothing but the drive from one stay to the next counts as its morning + * leg, so it shows once at the top rather than at both ends (#2476). + */ +export function hotelBookendOf(from: DayRoutePoint, to: DayRoutePoint): 'morning' | 'evening' | undefined { + if (from.hotel === 'morning') return 'morning' + if (to.hotel === 'evening') return 'evening' + return undefined } /** Everything the plan depends on, passed in rather than read from the store, so the @@ -131,9 +144,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout } // A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode // falls through to the day default for hotel-adjacent legs unless the place endpoint - // carries its own override. - const hotelPt = (a?: Accommodation): DayRoutePoint | null => - a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null + // carries its own override. It says which end of the day it is, so the legs routed + // from it can be told apart from a stop planned on the hotel's own spot (#2501). + const hotelPt = (a: Accommodation | undefined, hotel: 'morning' | 'evening'): DayRoutePoint | null => + a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false, hotel } : null // Only draw a hotel bookend when the leg is a real drive: a place before check-in // (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on // an arrival/departure day (#1321, #2133) all draw no bookend. @@ -154,8 +168,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId)) const firstWay = flatPts[0] const lastWay = flatPts[flatPts.length - 1] - const morningHotel = hotelPt(bookends?.morning) - const eveningHotel = hotelPt(bookends?.evening) + const morningHotel = hotelPt(bookends?.morning, 'morning') + const eveningHotel = hotelPt(bookends?.evening, 'evening') const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier) && (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay)) const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier) @@ -176,8 +190,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout // waypoint behind, so the gates above never see it; no line beats a wrong one. const dayHasCarrierBooking = dayTransports.some(r => isCarrierTransport(r)) if (runsWithHotel.length === 0 && drawMorning && drawEvening && !dayHasCarrierBooking) { - const m = hotelPt(bookends?.morning) - const e = hotelPt(bookends?.evening) + const m = hotelPt(bookends?.morning, 'morning') + const e = hotelPt(bookends?.evening, 'evening') if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e]) } diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 06e0a358f..ae9bdd09d 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useTripStore } from '../store/tripStore' import { useSettingsStore } from '../store/settingsStore' import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator' -import { buildDayRouteRuns, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan' +import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan' import { resolveLegMode } from '../components/Planner/legMode' import type { TripStoreState } from '../store/tripStore' import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types' @@ -100,7 +100,12 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu try { const r = await calculateRouteWithLegs(chunk.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: mode, tripId, dayId }) pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight()) - for (const leg of r.legs) allLegs.push({ ...leg, mode }) + // Leg k runs from chunk[k] to chunk[k + 1]; a hotel bookend says so on the + // segment, which is how the phone timeline finds it (#2501). + r.legs.forEach((leg, k) => { + const hotelBookend = chunk[k + 1] ? hotelBookendOf(chunk[k], chunk[k + 1]) : undefined + allLegs.push(hotelBookend ? { ...leg, mode, hotelBookend } : { ...leg, mode }) + }) if (r.vias) allVias.push(...r.vias) } catch (err) { if (err instanceof Error && err.name === 'AbortError') throw err diff --git a/client/src/mobile/screens/trip/plan/planTimelineModel.ts b/client/src/mobile/screens/trip/plan/planTimelineModel.ts index 5c20e6d42..2a3a2d9eb 100644 --- a/client/src/mobile/screens/trip/plan/planTimelineModel.ts +++ b/client/src/mobile/screens/trip/plan/planTimelineModel.ts @@ -75,8 +75,10 @@ const sameCoord = (a: [number, number], b: [number, number]): boolean => * Map the day's merged items to render rows and slot a travel-time connector * after every located place whose next located stop is again a place. Segments * are matched by their exact waypoint coordinates (the calculator echoes the - * input waypoints back on each leg), so hotel-bookend or transport legs in the - * pool simply never match and no index bookkeeping is needed. + * input waypoints back on each leg), so transport legs in the pool simply never + * match and no index bookkeeping is needed. The hotel bookends are left out up + * front: they belong to the day's edges, and a stop planned on the hotel's own + * spot would otherwise pair with them (#2501). */ export function buildPlanRows(opts: { merged: MergedItem[] @@ -85,7 +87,7 @@ export function buildPlanRows(opts: { dayId: number }): PlanRow[] { const { merged, reservations, routeSegments, dayId } = opts - const pool = [...routeSegments] + const pool = routeSegments.filter(s => !s.hotelBookend) const takeSegment = (from: [number, number], to: [number, number]): RouteSegment | null => { const idx = pool.findIndex(s => sameCoord(s.from, from) && sameCoord(s.to, to)) return idx >= 0 ? pool.splice(idx, 1)[0] : null @@ -184,10 +186,18 @@ export interface HotelLegs { top: HotelLeg | null; bottom: HotelLeg | null } /** * The two accommodation bookend legs of a day: the drive from the day's hotel to * the first stop (top) and from the last stop back to the hotel (bottom). The - * route calc already produced these via withHotelBookends — honouring the - * optimize-from-accommodation setting and the should-draw gates — so we just - * locate the pooled segment that starts (top) / ends (bottom) at the hotel's - * coordinates. Its presence is exactly the signal that the leg should be drawn. + * route calc already produced these via withHotelBookends, honouring the + * optimize-from-accommodation setting and the should-draw gates, and tagged + * them, so we just pick the tagged segments. Their presence is exactly the signal + * that the leg should be drawn. + * + * Not by coordinates alone: a stop planned on the hotel's own spot starts and + * ends legs there too. Matched that way, the drive from that stop to the next one + * sat above a day that opens with a flight, and showed a second time in its own + * place (#2501); an earlier drive onto that spot sat at the day's end (#2476). + * The tag says which end of the day a leg is, and its hotel end still has to be + * this stay: the calc keeps the last day's legs until the new day has routed, so + * right after a day switch the tagged legs can belong to another hotel. */ export function hotelLegsForDay( day: Day, @@ -196,24 +206,15 @@ export function hotelLegsForDay( routeSegments: RouteSegment[], ): HotelLegs { const { morning, evening } = getDayBookendHotels(day, days, accommodations) - const legAt = (a: Accommodation | undefined, end: 'from' | 'to'): HotelLeg | null => { + const legOf = (a: Accommodation | undefined, bookend: 'morning' | 'evening'): HotelLeg | null => { if (!a || a.place_lat == null || a.place_lng == null) return null - const coord: [number, number] = [a.place_lat, a.place_lng] - // The morning leg is the first segment leaving the hotel, the evening leg the - // LAST one reaching it. Taking the first for both put an earlier drive that - // happens to end on the evening hotel's spot under the day's end, instead of - // the drive that closes the day (#2476). - const seg = end === 'from' - ? routeSegments.find(s => sameCoord(s.from, coord)) - : [...routeSegments].reverse().find(s => sameCoord(s.to, coord)) + const at: [number, number] = [a.place_lat, a.place_lng] + const seg = routeSegments.find(s => s.hotelBookend === bookend && sameCoord(bookend === 'morning' ? s.from : s.to, at)) return seg ? { seg, name: accommodationName(a) } : null } - const top = legAt(morning, 'from') - const bottom = legAt(evening, 'to') - // A moving day without stops is one drive from one hotel to the next (#1297): it - // both leaves the morning hotel and reaches the evening one, so it shows once, at - // the top, instead of again at the bottom (#2476). - return { top, bottom: top && bottom && bottom.seg === top.seg ? null : bottom } + // A moving day without stops is one drive from one hotel to the next (#1297). The + // calc tags it as the morning leg, so it shows once, at the top (#2476). + return { top: legOf(morning, 'morning'), bottom: legOf(evening, 'evening') } } /** The day headline as city pills — a "Tokyo → Kyoto" title becomes two pills with an arrow. */ diff --git a/client/src/types.ts b/client/src/types.ts index efbec7712..38d77a50c 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -255,6 +255,12 @@ export interface RouteSegment { noteText?: string /** The travel mode this leg was routed with (#1281) — drives the connector icon. */ mode?: string + /** + * Set on the drive from the day's accommodation to its first stop ('morning') or + * from its last stop back to it ('evening'), so a list can show that leg at the + * day's edge without guessing it from coordinates a stop may share (#2501). + */ + hotelBookend?: 'morning' | 'evening' } /** An intermediate stop a plugin route places on the drawn line (charging stop, rest area). */ diff --git a/client/tests/integration/hooks/useRouteCalculation.test.ts b/client/tests/integration/hooks/useRouteCalculation.test.ts index a4cfdd292..a6c0130da 100644 --- a/client/tests/integration/hooks/useRouteCalculation.test.ts +++ b/client/tests/integration/hooks/useRouteCalculation.test.ts @@ -2,9 +2,10 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation'; import { useTripStore } from '../../../src/store/tripStore'; +import { hotelLegsForDay } from '../../../src/mobile/screens/trip/plan/planTimelineModel'; import { buildAssignment, buildPlace } from '../../helpers/factories'; import type { TripStoreState } from '../../../src/store/tripStore'; -import type { RouteSegment } from '../../../src/types'; +import type { Day, RouteSegment } from '../../../src/types'; vi.mock('../../../src/components/Map/RouteCalculator', async (importActual) => { const actual = await importActual(); @@ -996,4 +997,129 @@ describe('useRouteCalculation', () => { expect(result.current.route).toBeNull(); expect(calculateRouteWithLegs).not.toHaveBeenCalled(); }); + + describe('hotel bookend legs are tagged on the segments (#2501)', () => { + // Every request answers one leg per neighbouring waypoint pair, echoing the + // waypoints the way OSRM's legs do. + const echoLegs = () => (calculateRouteWithLegs as ReturnType).mockImplementation( + (waypoints: { lat: number; lng: number }[]) => Promise.resolve({ + coordinates: [] as [number, number][], + distance: 0, + duration: 0, + legs: waypoints.slice(0, -1).map((w, i) => ({ + ...MOCK_SEGMENTS[0], + from: [w.lat, w.lng] as [number, number], + to: [waypoints[i + 1].lat, waypoints[i + 1].lng] as [number, number], + })), + }), + ); + const tagsOf = (segments: RouteSegment[]) => + segments.map(s => [`${s.from[0]},${s.from[1]} > ${s.to[0]},${s.to[1]}`, s.hotelBookend ?? null]); + + it('FE-HOOK-ROUTE-036: a day out of the hotel tags the drive out and the drive back, nothing else', async () => { + echoLegs(); + const hotel = { lat: 41.39, lng: 2.16 }; + const actA = buildPlace({ lat: 41.38, lng: 2.17 }); + const actB = buildPlace({ lat: 41.40, lng: 2.19 }); + const store = { assignments: { '2': [ + buildAssignment({ day_id: 2, order_index: 0, place: actA }), + buildAssignment({ day_id: 2, order_index: 1, place: actB }), + ] } } as unknown as TripStoreState; + useTripStore.setState({ + assignments: store.assignments, + reservations: [], + days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }], + } as unknown as Partial); + const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, place_lat: hotel.lat, place_lng: hotel.lng }]; + + const { result } = renderHook(() => + useRouteCalculation(store, 2, true, 'driving', accommodations as unknown as Parameters[4]) + ); + await act(async () => {}); + + expect(tagsOf(result.current.routeSegments)).toEqual([ + [`${hotel.lat},${hotel.lng} > ${actA.lat},${actA.lng}`, 'morning'], + [`${actA.lat},${actA.lng} > ${actB.lat},${actB.lng}`, null], + [`${actB.lat},${actB.lng} > ${hotel.lat},${hotel.lng}`, 'evening'], + ]); + }); + + it('FE-HOOK-ROUTE-037: a hotel stop of your own after landing is no morning bookend', async () => { + // The reported day: fly in, drive to the hotel you put on the day yourself, then + // on to the town hall. Only the drive back to the hotel tonight is a bookend. + echoLegs(); + const hotel = { lat: 53.5465, lng: 9.9727 }; + const ams = { lat: 52.3105, lng: 4.7683 }; + const ham = { lat: 53.6304, lng: 9.9882 }; + const hotelStop = buildPlace({ lat: hotel.lat, lng: hotel.lng }); + const townHall = buildPlace({ lat: 53.5503, lng: 9.9937 }); + const flight = { + id: 90, type: 'flight', day_id: 1, end_day_id: 1, day_plan_position: -0.5, + reservation_time: '2026-10-19T10:00', reservation_end_time: '2026-10-19T13:00', + endpoints: [ + { role: 'from', sequence: 0, lat: ams.lat, lng: ams.lng }, + { role: 'to', sequence: 1, lat: ham.lat, lng: ham.lng }, + ], + }; + const store = { assignments: { '1': [ + buildAssignment({ day_id: 1, order_index: 0, place: hotelStop }), + buildAssignment({ day_id: 1, order_index: 1, place: townHall }), + ] } } as unknown as TripStoreState; + useTripStore.setState({ + assignments: store.assignments, + reservations: [flight], + days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }], + } as unknown as Partial); + const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00', place_lat: hotel.lat, place_lng: hotel.lng }]; + + const { result } = renderHook(() => + useRouteCalculation(store, 1, true, 'driving', accommodations as unknown as Parameters[4]) + ); + await act(async () => {}); + + expect(tagsOf(result.current.routeSegments)).toEqual([ + [`${ham.lat},${ham.lng} > ${hotel.lat},${hotel.lng}`, null], + [`${hotel.lat},${hotel.lng} > ${townHall.lat},${townHall.lng}`, null], + [`${townHall.lat},${townHall.lng} > ${hotel.lat},${hotel.lng}`, 'evening'], + ]); + }); + + it('FE-HOOK-ROUTE-038: after a day switch the last day\'s bookends are no legs of the new day\'s hotel', async () => { + // Day 1 sleeps in Munich, day 3 in Hamburg. The segments of day 1 stay in place + // until day 3 has routed, and that request may take a while or never answer. + echoLegs(); + const days = [1, 2, 3, 4].map(n => ({ id: n, trip_id: 1, day_number: n, date: `2026-05-0${n}`, title: null })); + const munich = { id: 1, trip_id: 1, place_name: 'Munich Inn', place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 }; + const hamburg = { id: 2, trip_id: 1, place_name: 'Hamburg Inn', place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 4 }; + const stays = [munich, hamburg] as unknown as Parameters[4]; + const store = { assignments: { + '1': [ + buildAssignment({ day_id: 1, order_index: 0, place: buildPlace({ lat: 48.14, lng: 11.58 }) }), + buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: 48.15, lng: 11.59 }) }), + ], + '3': [ + buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 53.56, lng: 9.99 }) }), + buildAssignment({ day_id: 3, order_index: 1, place: buildPlace({ lat: 53.57, lng: 10.0 }) }), + ], + } } as unknown as TripStoreState; + useTripStore.setState({ assignments: store.assignments, reservations: [], days } as unknown as Partial); + const legsOf = (dayId: number, segments: RouteSegment[]) => + hotelLegsForDay(days[dayId - 1] as unknown as Day, days as unknown as Day[], stays, segments); + + const { result, rerender } = renderHook( + ({ dayId }: { dayId: number }) => useRouteCalculation(store, dayId, true, 'driving', stays), + { initialProps: { dayId: 1 } }, + ); + await act(async () => {}); + expect(legsOf(1, result.current.routeSegments)).toMatchObject({ top: { name: 'Munich Inn' }, bottom: { name: 'Munich Inn' } }); + + (calculateRouteWithLegs as ReturnType).mockImplementation(() => new Promise(() => {})); + rerender({ dayId: 3 }); + await act(async () => {}); + + // Day 1's legs are still there, and Hamburg shows none of them. + expect(result.current.routeSegments.some(s => s.hotelBookend)).toBe(true); + expect(legsOf(3, result.current.routeSegments)).toEqual({ top: null, bottom: null }); + }); + }); }); diff --git a/client/tests/unit/mobile/trip/planTimelineModel.test.ts b/client/tests/unit/mobile/trip/planTimelineModel.test.ts index 2784d87f3..bd5a2a2cb 100644 --- a/client/tests/unit/mobile/trip/planTimelineModel.test.ts +++ b/client/tests/unit/mobile/trip/planTimelineModel.test.ts @@ -5,14 +5,14 @@ import { hotelLegsForDay, itemHasTime, parseReservationMeta, transportSubtitle, weatherIconFor, type TransportEntry, } from '../../../../src/mobile/screens/trip/plan/planTimelineModel' -import { getDisplayTimeForDay, type MergedItem } from '../../../../src/utils/dayMerge' -import { buildDayRouteRuns } from '../../../../src/components/Map/dayRoutePlan' +import { getDisplayTimeForDay, getMergedItems, getTransportForDay, type MergedItem } from '../../../../src/utils/dayMerge' +import { buildDayRouteRuns, hotelBookendOf, type DayRoutePoint } from '../../../../src/components/Map/dayRoutePlan' import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories' import type { Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn, } from '../../../../src/types' -// FE-MOB-PTLM-001 to FE-MOB-PTLM-047 +// FE-MOB-PTLM-001 to FE-MOB-PTLM-050 const DAYS = [ { id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null }, @@ -44,6 +44,14 @@ function seg(from: [number, number], to: [number, number], overrides: Partial + runs.flatMap(run => run.slice(1).map((p, i) => { + const hotelBookend = hotelBookendOf(run[i], p) + return seg([run[i].lat, run[i].lng], [p.lat, p.lng], hotelBookend ? { hotelBookend } : {}) + })) + const placeItem = (a: Assignment): MergedItem => ({ type: 'place', sortKey: a.order_index, data: a }) const noteItem = (n: DayNote): MergedItem => ({ type: 'note', sortKey: n.sort_order ?? 0, data: n }) const transportItem = (r: TransportEntry): MergedItem => ({ type: 'transport', sortKey: 0, data: r }) @@ -295,30 +303,46 @@ describe('planTimelineModel — hotel chips and legs', () => { it('FE-MOB-PTLM-025: picks the hotel bookend legs out of the calculated segments', () => { const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 }) - const out = seg([48.0, 16.0], [48.1, 16.1]) - const back = seg([48.2, 16.2], [48.0, 16.0]) - const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, back, seg([48.1, 16.1], [48.2, 16.2])]) + const out = seg([48.0, 16.0], [48.1, 16.1], { hotelBookend: 'morning' }) + const back = seg([48.2, 16.2], [48.0, 16.0], { hotelBookend: 'evening' }) + const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, seg([48.1, 16.1], [48.2, 16.2]), back]) expect(legs.top).toEqual({ seg: out, name: 'Hotel Sacher' }) expect(legs.bottom).toEqual({ seg: back, name: 'Hotel Sacher' }) }) - it('FE-MOB-PTLM-026: returns no legs when no segment touches the hotel', () => { + it('FE-MOB-PTLM-026: returns no legs when the calculation drew no bookend, whatever touches the hotel (#2501)', () => { + // A stop on the hotel's own spot starts and ends legs at its coordinates too. const hotel = accommodation({ id: 8, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 }) - expect(hotelLegsForDay(DAY2, DAYS, [hotel], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null }) + const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [seg([48.1, 16.1], [48.0, 16.0]), seg([48.0, 16.0], [48.2, 16.2])]) + expect(legs).toEqual({ top: null, bottom: null }) }) it('FE-MOB-PTLM-027: returns no legs without an accommodation on the day', () => { - expect(hotelLegsForDay(DAY2, DAYS, [], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null }) + const tagged = [seg([48.0, 16.0], [48.1, 16.1], { hotelBookend: 'morning' }), seg([48.1, 16.1], [48.0, 16.0], { hotelBookend: 'evening' })] + expect(hotelLegsForDay(DAY2, DAYS, [], tagged)).toEqual({ top: null, bottom: null }) }) - it('FE-MOB-PTLM-045: the evening leg is the last drive into the hotel, not the first (#2476)', () => { + it('FE-MOB-PTLM-050: another stay\'s bookends are not this day\'s, as right after a day switch (#2501)', () => { + // The calc still holds the day before's legs, out of and back to Munich, while + // the new day, a night in Hamburg, is being routed. + const munich = { lat: 48.137, lng: 11.575 } + const hamburg = accommodation({ id: 9, start_day_id: 2, end_day_id: 3, place_name: 'Hamburg Inn', place_lat: 53.551, place_lng: 9.993 }) + const stale = [ + seg([munich.lat, munich.lng], [48.14, 11.58], { hotelBookend: 'morning' }), + seg([48.14, 11.58], [48.15, 11.59]), + seg([48.15, 11.59], [munich.lat, munich.lng], { hotelBookend: 'evening' }), + ] + expect(hotelLegsForDay(DAY2, DAYS, [hamburg], stale)).toEqual({ top: null, bottom: null }) + }) + + it('FE-MOB-PTLM-045: the evening leg is the drive that closes the day, not an earlier one onto the hotel spot (#2476)', () => { // A stop planned on the hotel's own spot early in the day: the drive there reaches // the hotel's coordinates first, but the day ends with the drive back from the park. const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 }) - const out = seg([48.0, 16.0], [48.1, 16.1]) + const out = seg([48.0, 16.0], [48.1, 16.1], { hotelBookend: 'morning' }) const toHotelSpot = seg([48.1, 16.1], [48.0, 16.0]) const onward = seg([48.0, 16.0], [48.2, 16.2]) - const back = seg([48.2, 16.2], [48.0, 16.0]) + const back = seg([48.2, 16.2], [48.0, 16.0], { hotelBookend: 'evening' }) const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, toHotelSpot, onward, back]) expect(legs.top?.seg).toBe(out) expect(legs.bottom?.seg).toBe(back) @@ -344,14 +368,11 @@ describe('planTimelineModel — hotel chips and legs', () => { ] : [], }) - // What the connector calculation hands the timeline: one segment per pair of - // neighbouring waypoints in each drawn run. const legsOf = (reservations: Reservation[]) => { const runs = buildDayRouteRuns(2, { days: DAYS, assignments: {}, reservations, accommodations: stays, optimizeFromAccommodation: true, }) - const segments = runs.flatMap(run => run.slice(1).map((p, i) => seg([run[i].lat, run[i].lng], [p.lat, p.lng]))) - return hotelLegsForDay(DAY2, DAYS, stays, segments) + return hotelLegsForDay(DAY2, DAYS, stays, segmentsOf(runs)) } it('FE-MOB-PTLM-046: shows no drive from one hotel to the other, above or below the flight', () => { @@ -375,6 +396,71 @@ describe('planTimelineModel — hotel chips and legs', () => { }) }) +describe('planTimelineModel: a day that lands and then drives to the hotel (#2501)', () => { + // Day 1 checks into a Hamburg hotel at three. The flight lands at one, and the + // traveller put the hotel on the day as a stop of their own, then the town hall. + const DAY1 = DAYS[0] + const HOTEL = { lat: 53.5465, lng: 9.9727 } + const AMS = { lat: 52.3105, lng: 4.7683 } + const HAM = { lat: 53.6304, lng: 9.9882 } + const TOWN_HALL = { lat: 53.5503, lng: 9.9937 } + const stay = accommodation({ + id: 30, place_id: 900, place_name: 'Hotel Hafen', place_lat: HOTEL.lat, place_lng: HOTEL.lng, + start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00', + }) + const flight = buildReservation({ + id: 7, type: 'flight', title: 'KL 1783', day_id: 1, end_day_id: 1, day_plan_position: -0.5, + reservation_time: '2026-05-01T10:00', reservation_end_time: '2026-05-01T13:00', + endpoints: [ + { role: 'from', sequence: 0, name: 'AMS', code: null, ...AMS, timezone: null, local_date: null, local_time: null }, + { role: 'to', sequence: 1, name: 'HAM', code: null, ...HAM, timezone: null, local_date: null, local_time: null }, + ], + }) + const hotelStop = buildAssignment({ id: 21, day_id: 1, order_index: 0, place: place(900, 'Hotel Hafen', HOTEL.lat, HOTEL.lng) }) + const townHall = buildAssignment({ id: 22, day_id: 1, order_index: 1, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) }) + + // The day as the phone timeline builds it: merged the way useMPlanTimeline merges, + // segments the way useRouteCalculation routes the same day. + const dayOne = () => { + const dayAssignments = [hotelStop, townHall] + const merged = getMergedItems({ + dayAssignments, + dayNotes: [], + dayTransports: getTransportForDay({ reservations: [flight], dayId: 1, dayAssignmentIds: dayAssignments.map(a => a.id), days: DAYS }), + dayId: 1, + }) + const segments = segmentsOf(buildDayRouteRuns(1, { + days: DAYS, assignments: { '1': dayAssignments }, reservations: [flight], accommodations: [stay], optimizeFromAccommodation: true, + })) + return { + rows: buildPlanRows({ merged, reservations: [flight], routeSegments: segments, dayId: 1 }), + legs: hotelLegsForDay(DAY1, DAYS, [stay], segments), + } + } + const ends = (s: RouteSegment) => [s.from, s.to] + + it('FE-MOB-PTLM-048: opens with the flight, shows the hop from the hotel stop once and ends at the hotel', () => { + const { rows, legs } = dayOne() + // No drive out of a hotel the traveller has not reached yet. + expect(legs.top).toBeNull() + const hops = rows.filter(r => r.kind === 'conn').map(r => r.kind === 'conn' && ends(r.seg)) + expect(hops.filter(h => JSON.stringify(h) === JSON.stringify([[HOTEL.lat, HOTEL.lng], [TOWN_HALL.lat, TOWN_HALL.lng]]))).toHaveLength(1) + expect(rows[0]).toMatchObject({ kind: 'transport', key: 'tr-7' }) + // The day closes with the drive back to the hotel. + expect(legs.bottom).toMatchObject({ name: 'Hotel Hafen', seg: { from: [TOWN_HALL.lat, TOWN_HALL.lng], to: [HOTEL.lat, HOTEL.lng] } }) + }) + + it('FE-MOB-PTLM-049: a bookend is never read as the hop between two stops that share its ends', () => { + // The tag decides, not the coordinates: two stops on the very spots a bookend + // joins get no connector out of it. + const bookend = seg([HOTEL.lat, HOTEL.lng], [TOWN_HALL.lat, TOWN_HALL.lng], { hotelBookend: 'morning' }) + const rows = buildPlanRows({ + merged: [placeItem(hotelStop), placeItem(townHall)], reservations: [], routeSegments: [bookend], dayId: 1, + }) + expect(rows.map(r => r.kind)).toEqual(['place', 'place']) + }) +}) + describe('planTimelineModel — cityPillsForDay', () => { const t = ((key: string, params?: Record) => params ? `${key}:${Object.values(params).join(',')}` : key) as unknown as TranslationFn diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts index d4ea468ff..6570a83c6 100644 --- a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts +++ b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts @@ -126,8 +126,9 @@ describe('useMPlanTimeline', () => { }) it('FE-MOB-PLTL-004: exposes the hotel bookend legs and header chips', async () => { - const out = seg([48, 16.05], [48, 16.1]) - const back = seg([48, 16.2], [48, 16.05]) + // The route calculation tags the two bookends it drew (#2501). + const out: RouteSegment = { ...seg([48, 16.05], [48, 16.1]), hotelBookend: 'morning' } + const back: RouteSegment = { ...seg([48, 16.2], [48, 16.05]), hotelBookend: 'evening' } routeCalc.segments = [out, back] const { result } = await renderTimeline(makePlanner({ tripAccommodations: [HOTEL] })) expect(result.current.hotelLegs.top?.seg).toBe(out) From a0952bd8717d58a1c3933d72777def85374af628 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 20:26:42 +0200 Subject: [PATCH 07/14] fix(mobile): show the drive from a flight's arrival airport in the plan timeline (#2502) The phone timeline only put a travel-time connector between two places. The drive from the arrival airport to the first stop after a flight is part of the day's route and drawn on the map, and the desktop day plan shows it under the flight, but on the phone the two rows sat on top of each other. The drive from the last stop to the departure airport was dropped the same way, and so was every drive past a booking saved without a location, like a taxi to the airport or a concert between two stops. buildPlanRows now walks the rows the way the route calculation walks the day: a located booking ends the drive before it at its departure point and starts the next one at its arrival point, and each leg goes under the row it leaves from. The route rides straight past a booking it has no location for, so the drive through it goes under that booking, as on the desktop, and keeps the mode menu of the stop it left. Only transport bookings count as located, as in the route calculation. Two bookings back to back still get no road between them. A booking whose station is the stop itself, as the transit planner saves a journey between two of the day's places, leaves a leg of no length, and that one gets no connector. --- .../screens/trip/plan/planTimelineModel.ts | 84 ++++--- .../mobile/trip/planTimelineModel.test.ts | 206 ++++++++++++++++-- 2 files changed, 250 insertions(+), 40 deletions(-) diff --git a/client/src/mobile/screens/trip/plan/planTimelineModel.ts b/client/src/mobile/screens/trip/plan/planTimelineModel.ts index 2a3a2d9eb..c39df2393 100644 --- a/client/src/mobile/screens/trip/plan/planTimelineModel.ts +++ b/client/src/mobile/screens/trip/plan/planTimelineModel.ts @@ -1,6 +1,9 @@ import { Cloud, CloudDrizzle, CloudLightning, CloudRain, CloudSnow, Sun, Wind } from 'lucide-react' import type { LucideIcon } from 'lucide-react' -import { getAssignmentReservations, getDisplayTimeForDay, getSpanPhase, hidesOnMiddleDay, parseTimeToMinutes } from '../../../../utils/dayMerge' +import { + TRANSPORT_TYPES, getAssignmentReservations, getDisplayTimeForDay, getSpanPhase, getTransportRouteEndpoints, hidesOnMiddleDay, + parseTimeToMinutes, +} from '../../../../utils/dayMerge' import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder' import type { MergedItem } from '../../../../utils/dayMerge' import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay' @@ -73,10 +76,9 @@ const sameCoord = (a: [number, number], b: [number, number]): boolean => /** * Map the day's merged items to render rows and slot a travel-time connector - * after every located place whose next located stop is again a place. Segments - * are matched by their exact waypoint coordinates (the calculator echoes the - * input waypoints back on each leg), so transport legs in the pool simply never - * match and no index bookkeeping is needed. The hotel bookends are left out up + * under the row each drawn leg belongs to. Segments are matched by their exact + * waypoint coordinates (the calculator echoes the input waypoints back on each + * leg), so no index bookkeeping is needed. The hotel bookends are left out up * front: they belong to the day's edges, and a stop planned on the hotel's own * spot would otherwise pair with them (#2501). */ @@ -123,31 +125,61 @@ export function buildPlanRows(opts: { } } - const coordOf = (row: PlanRow): [number, number] | null => - row.kind === 'place' && row.assignment.place?.lat != null && row.assignment.place?.lng != null - ? [row.assignment.place.lat, row.assignment.place.lng] - : null - - const out: PlanRow[] = [] + // Walk the rows the way the route calc walks the day (buildDayRouteRuns). A + // located place is a waypoint, possibly across notes. A located booking ends the + // drive before it at its departure point and starts the next one at its arrival + // point, so the drive from the arrival airport to the first stop gets its + // connector too (#2502). Each leg goes under the row it leaves from, the place or + // the booking it landed with, as on the desktop day plan. + // + // The route rides straight past a booking it has no location for, a taxi saved + // without its stops or a concert, and draws the drive through it. The desktop + // shows that leg under the booking, so it goes there, and it keeps the mode menu + // of the stop it left, since that stop's mode is the one it was routed in. Only + // transport bookings count, as in the route calc: a transit booking changed to + // another type in the booking form keeps its stations, but the route ignores it. + // + // A booking whose station is the stop itself leaves a leg that goes nowhere: the + // transit planner saves a journey between two of the day's places at their own + // coordinates. That is no walk and no drive, so it gets no connector. + type Origin = { at: [number, number]; row: number; assignmentId?: number } + const legs = new Map() + const connect = (origin: Origin | null, to: [number, number], toBooking = false) => { + if (!origin) return + const seg = takeSegment(origin.at, to) + const fromBooking = origin.assignmentId == null + if (!seg || ((toBooking || fromBooking) && sameCoord(origin.at, to))) return + legs.set(origin.row, { seg, assignmentId: origin.assignmentId }) + } + let prev: Origin | null = null for (let i = 0; i < base.length; i++) { const row = base[i] - out.push(row) - const from = coordOf(row) - if (!from) continue - // Next located stop: a following place connects (possibly across notes); - // any transport/transit in between means that hop is the ride, not a walk. - let seg: RouteSegment | null = null - for (let j = i + 1; j < base.length; j++) { - const next = base[j] - if (next.kind === 'transport' || next.kind === 'transit') break - const to = coordOf(next) - if (to) { seg = takeSegment(from, to); break } + if (row.kind === 'place') { + const place = row.assignment.place + if (place?.lat == null || place?.lng == null) continue + const at: [number, number] = [place.lat, place.lng] + connect(prev, at) + // The leg's mode is stored on its ORIGIN place assignment (#1281), so carry + // that id for the tap-to-change menu. + prev = { at, row: i, assignmentId: row.assignment.id } + } else if (row.kind === 'transport' || row.kind === 'transit') { + const { from, to } = TRANSPORT_TYPES.has(row.res.type) + ? getTransportRouteEndpoints(row.res, dayId) + : { from: null, to: null } + if (!from && !to) { + if (prev) prev = { ...prev, row: i } + continue + } + // Only out of a stop: two bookings back to back are no drive (#1394). + if (from && prev?.assignmentId != null) connect(prev, [from.lat, from.lng], true) + prev = to ? { at: [to.lat, to.lng], row: i } : null } - // The leg's mode is stored on its ORIGIN place assignment (#1281), so carry - // that id for the tap-to-change menu. - if (seg) out.push({ key: `conn-${row.key}`, kind: 'conn', seg, assignmentId: row.kind === 'place' ? row.assignment.id : undefined }) } - return out + + return base.flatMap((row, i): PlanRow[] => { + const leg = legs.get(i) + return leg ? [row, { key: `conn-${row.key}`, kind: 'conn', seg: leg.seg, assignmentId: leg.assignmentId }] : [row] + }) } export interface HotelChip { diff --git a/client/tests/unit/mobile/trip/planTimelineModel.test.ts b/client/tests/unit/mobile/trip/planTimelineModel.test.ts index bd5a2a2cb..ea2f4873c 100644 --- a/client/tests/unit/mobile/trip/planTimelineModel.test.ts +++ b/client/tests/unit/mobile/trip/planTimelineModel.test.ts @@ -12,7 +12,7 @@ import type { Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn, } from '../../../../src/types' -// FE-MOB-PTLM-001 to FE-MOB-PTLM-050 +// FE-MOB-PTLM-001 to FE-MOB-PTLM-060 const DAYS = [ { id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null }, @@ -231,7 +231,9 @@ describe('planTimelineModel — buildPlanRows', () => { expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'note', 'place']) }) - it('FE-MOB-PTLM-019: draws no connector when a transport is the hop between the places', () => { + it('FE-MOB-PTLM-019: puts the drive past a booking without a location under that booking (#2502)', () => { + // The route rides straight past a bus saved without its stops and draws the road + // between the two places; the desktop day plan shows that leg under the bus. const bus = buildReservation({ id: 51, type: 'bus', day_id: 2 }) const rows = buildPlanRows({ merged: [placeItem(museum), transportItem(bus), placeItem(park)], @@ -239,7 +241,9 @@ describe('planTimelineModel — buildPlanRows', () => { routeSegments: [seg([48.1, 16.1], [48.2, 16.2])], dayId: 2, }) - expect(rows.map(r => r.kind)).toEqual(['place', 'transport', 'place']) + expect(rows.map(r => r.key)).toEqual(['pl-11', 'tr-51', 'conn-tr-51', 'pl-12']) + // The leg was routed in the mode of the stop it left, so the menu edits that stop. + expect(rows[2]).toMatchObject({ kind: 'conn', assignmentId: 11 }) }) it('FE-MOB-PTLM-020: skips places without coordinates and unmatched segments', () => { @@ -396,10 +400,9 @@ describe('planTimelineModel — hotel chips and legs', () => { }) }) -describe('planTimelineModel: a day that lands and then drives to the hotel (#2501)', () => { +describe('planTimelineModel: a day that lands and then drives to the hotel (#2501, #2502)', () => { // Day 1 checks into a Hamburg hotel at three. The flight lands at one, and the // traveller put the hotel on the day as a stop of their own, then the town hall. - const DAY1 = DAYS[0] const HOTEL = { lat: 53.5465, lng: 9.9727 } const AMS = { lat: 52.3105, lng: 4.7683 } const HAM = { lat: 53.6304, lng: 9.9882 } @@ -419,24 +422,24 @@ describe('planTimelineModel: a day that lands and then drives to the hotel (#250 const hotelStop = buildAssignment({ id: 21, day_id: 1, order_index: 0, place: place(900, 'Hotel Hafen', HOTEL.lat, HOTEL.lng) }) const townHall = buildAssignment({ id: 22, day_id: 1, order_index: 1, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) }) - // The day as the phone timeline builds it: merged the way useMPlanTimeline merges, + // A day as the phone timeline builds it: merged the way useMPlanTimeline merges, // segments the way useRouteCalculation routes the same day. - const dayOne = () => { - const dayAssignments = [hotelStop, townHall] + const planOf = (dayId: number, dayAssignments: Assignment[], reservations: Reservation[], stays: Accommodation[]) => { const merged = getMergedItems({ dayAssignments, dayNotes: [], - dayTransports: getTransportForDay({ reservations: [flight], dayId: 1, dayAssignmentIds: dayAssignments.map(a => a.id), days: DAYS }), - dayId: 1, + dayTransports: getTransportForDay({ reservations, dayId, dayAssignmentIds: dayAssignments.map(a => a.id), days: DAYS }), + dayId, }) - const segments = segmentsOf(buildDayRouteRuns(1, { - days: DAYS, assignments: { '1': dayAssignments }, reservations: [flight], accommodations: [stay], optimizeFromAccommodation: true, + const segments = segmentsOf(buildDayRouteRuns(dayId, { + days: DAYS, assignments: { [String(dayId)]: dayAssignments }, reservations, accommodations: stays, optimizeFromAccommodation: true, })) return { - rows: buildPlanRows({ merged, reservations: [flight], routeSegments: segments, dayId: 1 }), - legs: hotelLegsForDay(DAY1, DAYS, [stay], segments), + rows: buildPlanRows({ merged, reservations, routeSegments: segments, dayId }), + legs: hotelLegsForDay(DAYS.find(d => d.id === dayId)!, DAYS, stays, segments), } } + const dayOne = () => planOf(1, [hotelStop, townHall], [flight], [stay]) const ends = (s: RouteSegment) => [s.from, s.to] it('FE-MOB-PTLM-048: opens with the flight, shows the hop from the hotel stop once and ends at the hotel', () => { @@ -459,6 +462,181 @@ describe('planTimelineModel: a day that lands and then drives to the hotel (#250 }) expect(rows.map(r => r.kind)).toEqual(['place', 'place']) }) + + it('FE-MOB-PTLM-051: the drive from the arrival airport to the first stop sits under the flight (#2502)', () => { + const { rows } = dayOne() + expect(rows.map(r => r.kind)).toEqual(['transport', 'conn', 'place', 'conn', 'place']) + const landed = rows[1] + expect(landed).toMatchObject({ key: 'conn-tr-7', seg: { from: [HAM.lat, HAM.lng], to: [HOTEL.lat, HOTEL.lng] } }) + // It leaves no stop, so there is no stop whose outgoing mode it would change. + expect(landed.kind === 'conn' && landed.assignmentId).toBeUndefined() + }) + + it('FE-MOB-PTLM-052: the drive to the departure airport sits under the stop it leaves from', () => { + const HARBOUR = { lat: 53.5436, lng: 9.9661 } + const homeFlight = buildReservation({ + id: 8, type: 'flight', title: 'KL 1790', day_id: 3, end_day_id: 3, day_plan_position: 5, + reservation_time: '2026-05-03T18:00', reservation_end_time: '2026-05-03T19:10', + endpoints: [ + { role: 'from', sequence: 0, name: 'HAM', code: null, ...HAM, timezone: null, local_date: null, local_time: null }, + { role: 'to', sequence: 1, name: 'AMS', code: null, ...AMS, timezone: null, local_date: null, local_time: null }, + ], + }) + const harbour = buildAssignment({ id: 23, day_id: 3, order_index: 0, place: place(902, 'Harbour', HARBOUR.lat, HARBOUR.lng) }) + const { rows } = planOf(3, [harbour], [homeFlight], []) + expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'transport']) + // Its mode is the stop's own, so the row keeps the stop's menu. + expect(rows[1]).toMatchObject({ key: 'conn-pl-23', assignmentId: 23, seg: { from: [HARBOUR.lat, HARBOUR.lng], to: [HAM.lat, HAM.lng] } }) + }) + + it('FE-MOB-PTLM-053: two flights back to back get no drive between the airports (#1394)', () => { + const endpoint = (role: 'from' | 'to', at: { lat: number; lng: number }) => + ({ role, sequence: role === 'from' ? 0 : 1, name: role, code: null, ...at, timezone: null, local_date: null, local_time: null }) + const IST = { lat: 41.2753, lng: 28.7519 } + const SAW = { lat: 40.8986, lng: 29.3092 } + const inbound = buildReservation({ id: 9, type: 'flight', day_id: 2, endpoints: [endpoint('from', AMS), endpoint('to', IST)] }) + const onward = buildReservation({ id: 10, type: 'flight', day_id: 2, endpoints: [endpoint('from', SAW), endpoint('to', HAM)] }) + const rows = buildPlanRows({ + merged: [transportItem(inbound), transportItem(onward)], + reservations: [], + routeSegments: [seg([IST.lat, IST.lng], [SAW.lat, SAW.lng])], + dayId: 2, + }) + expect(rows.map(r => r.kind)).toEqual(['transport', 'transport']) + }) + + it('FE-MOB-PTLM-054: a located transit ride gets the walk to its first stop and from its last one', () => { + const S1 = { lat: 48.11, lng: 16.11 } + const S2 = { lat: 48.19, lng: 16.19 } + const ride = buildReservation({ + id: 64, type: 'transit', day_id: 2, + metadata: JSON.stringify({ transit: { legs: [{ mode: 'subway', line: 'U2' }], transfers: 0 } }), + endpoints: [ + { role: 'from', sequence: 0, name: 'S1', code: null, ...S1, timezone: null, local_date: null, local_time: null }, + { role: 'to', sequence: 1, name: 'S2', code: null, ...S2, timezone: null, local_date: null, local_time: null }, + ], + }) + const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1)) + const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2)) + const rows = buildPlanRows({ + merged: [placeItem(museum), transportItem(ride), placeItem(park)], + reservations: [], + routeSegments: [seg([48.1, 16.1], [S1.lat, S1.lng]), seg([S2.lat, S2.lng], [48.2, 16.2])], + dayId: 2, + }) + expect(rows.map(r => r.key)).toEqual(['pl-11', 'conn-pl-11', 'tr-64', 'conn-tr-64', 'pl-12']) + }) + + it('FE-MOB-PTLM-055: a transit ride saved on the stops themselves gets no connector that goes nowhere', () => { + // The transit planner saves a journey between two of the day's places at their + // own coordinates, so the route has a leg of no length on either side of it. + const LANDING = { lat: 53.5457, lng: 9.9666 } + const ride = buildReservation({ + id: 65, type: 'transit', title: 'Town Hall → Landungsbruecken', day_id: 2, end_day_id: 2, day_plan_position: 0.5, + reservation_time: '2026-05-02T10:00', reservation_end_time: '2026-05-02T10:12', + metadata: JSON.stringify({ transit: { legs: [{ mode: 'SUBWAY', line: 'U3' }], transfers: 0 } }), + endpoints: [ + { role: 'from', sequence: 0, name: 'Town Hall', code: null, ...TOWN_HALL, timezone: null, local_date: null, local_time: null }, + { role: 'to', sequence: 1, name: 'Landungsbruecken', code: null, ...LANDING, timezone: null, local_date: null, local_time: null }, + ], + }) + const hall = buildAssignment({ id: 24, day_id: 2, order_index: 0, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) }) + const landing = buildAssignment({ id: 25, day_id: 2, order_index: 1, place: place(903, 'Landungsbruecken', LANDING.lat, LANDING.lng) }) + const drawn = segmentsOf(buildDayRouteRuns(2, { + days: DAYS, assignments: { '2': [hall, landing] }, reservations: [ride], accommodations: [stay], optimizeFromAccommodation: true, + })) + expect(drawn.filter(s => JSON.stringify(s.from) === JSON.stringify(s.to))).toHaveLength(2) + + const { rows, legs } = planOf(2, [hall, landing], [ride], [stay]) + expect(rows.map(r => r.key)).toEqual(['pl-24', 'tr-65', 'pl-25']) + // The drives out of and back to the hotel are real and stay. + expect(legs.top).toMatchObject({ seg: { from: [HOTEL.lat, HOTEL.lng], to: [TOWN_HALL.lat, TOWN_HALL.lng] } }) + expect(legs.bottom).toMatchObject({ seg: { from: [LANDING.lat, LANDING.lng], to: [HOTEL.lat, HOTEL.lng] } }) + }) + + it('FE-MOB-PTLM-056: two stops on the very same spot keep their connector', () => { + // Only a booking's station on the stop is no leg; the stop's own menu stays. + const inn = assignment(13, 0, place(103, 'Inn', 48.1, 16.1)) + const bar = assignment(14, 1, place(104, 'Bar in the inn', 48.1, 16.1)) + const rows = buildPlanRows({ + merged: [placeItem(inn), placeItem(bar)], reservations: [], routeSegments: [seg([48.1, 16.1], [48.1, 16.1])], dayId: 2, + }) + expect(rows.map(r => r.key)).toEqual(['pl-13', 'conn-pl-13', 'pl-14']) + }) + + describe('a booking the route has no location for', () => { + // The route rides straight past it, so the drive through it is drawn on the map, + // and the desktop day plan shows that leg under the booking. + const station = (role: 'from' | 'to', p: { lat: number; lng: number }, name: string) => + ({ role, sequence: role === 'from' ? 0 : 1, name, code: null, ...p, timezone: null, local_date: null, local_time: null }) + const LANDING = { lat: 53.5457, lng: 9.9666 } + + it('FE-MOB-PTLM-057: the drive to the departure airport past a taxi without its stops goes under the taxi (#2502)', () => { + const hall = buildAssignment({ id: 26, day_id: 3, order_index: 0, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) }) + const taxi = buildReservation({ id: 70, type: 'taxi', title: 'Taxi to the airport', day_id: 3, end_day_id: 3, day_plan_position: 0.5 }) + const homeFlight = buildReservation({ + id: 71, type: 'flight', title: 'KL 1790', day_id: 3, end_day_id: 3, day_plan_position: 0.8, + reservation_time: '2026-05-03T18:00', reservation_end_time: '2026-05-03T19:10', + endpoints: [station('from', HAM, 'HAM'), station('to', AMS, 'AMS')], + }) + const { rows } = planOf(3, [hall], [taxi, homeFlight], []) + expect(rows.map(r => r.key)).toEqual(['pl-26', 'tr-70', 'conn-tr-70', 'tr-71']) + // Routed in the mode of the stop it left, so the menu edits that stop. + expect(rows[2]).toMatchObject({ assignmentId: 26, seg: { from: [TOWN_HALL.lat, TOWN_HALL.lng], to: [HAM.lat, HAM.lng] } }) + }) + + it('FE-MOB-PTLM-058: an event between two stops carries the drive between them, stations or not (#2502)', () => { + const hall = buildAssignment({ id: 27, day_id: 2, order_index: 0, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) }) + const landing = buildAssignment({ id: 28, day_id: 2, order_index: 1, place: place(903, 'Landungsbruecken', LANDING.lat, LANDING.lng) }) + const concert = buildReservation({ id: 72, type: 'event', title: 'Concert', day_id: 2, end_day_id: 2, day_plan_position: 0.5 }) + // A transit booking turned into an event in the booking form keeps its stations, + // and the route still only rides transport bookings. + const retyped = { ...concert, endpoints: [station('from', TOWN_HALL, 'Town Hall'), station('to', HAM, 'HAM')] } + for (const booking of [concert, retyped]) { + const { rows } = planOf(2, [hall, landing], [booking], []) + expect(rows.map(r => r.key)).toEqual(['pl-27', 'tr-72', 'conn-tr-72', 'pl-28']) + expect(rows[2]).toMatchObject({ assignmentId: 27, seg: { from: [TOWN_HALL.lat, TOWN_HALL.lng], to: [LANDING.lat, LANDING.lng] } }) + } + }) + + it('FE-MOB-PTLM-059: an arrival carries past it into the next drive, and two flights around it stay apart (#1394)', () => { + const taxi = buildReservation({ id: 73, type: 'taxi', title: 'Taxi', day_id: 1, end_day_id: 1, day_plan_position: -0.2 }) + const { rows } = planOf(1, [hotelStop, townHall], [flight, taxi], [stay]) + expect(rows.map(r => r.key)).toEqual(['tr-7', 'tr-73', 'conn-tr-73', 'pl-21', 'conn-pl-21', 'pl-22']) + expect(rows[2]).toMatchObject({ seg: { from: [HAM.lat, HAM.lng], to: [HOTEL.lat, HOTEL.lng] } }) + // It leaves the airport, not a stop, so there is no stop's mode to change. + expect(rows[2].kind === 'conn' && rows[2].assignmentId).toBeUndefined() + + const IST = { lat: 41.2753, lng: 28.7519 } + const SAW = { lat: 40.8986, lng: 29.3092 } + const inbound = buildReservation({ id: 9, type: 'flight', day_id: 2, endpoints: [station('from', AMS, 'AMS'), station('to', IST, 'IST')] }) + const shuttle = buildReservation({ id: 74, type: 'bus', day_id: 2 }) + const onward = buildReservation({ id: 10, type: 'flight', day_id: 2, endpoints: [station('from', SAW, 'SAW'), station('to', HAM, 'HAM')] }) + const transfer = buildPlanRows({ + merged: [transportItem(inbound), transportItem(shuttle), transportItem(onward)], + reservations: [], + routeSegments: [seg([IST.lat, IST.lng], [SAW.lat, SAW.lng])], + dayId: 2, + }) + expect(transfer.map(r => r.kind)).toEqual(['transport', 'transport', 'transport']) + }) + + it('FE-MOB-PTLM-060: the drive goes under the last of several such bookings, and none of them opens the day', () => { + const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1)) + const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2)) + const early = buildReservation({ id: 75, type: 'taxi', day_id: 2 }) + const taxi = buildReservation({ id: 76, type: 'taxi', day_id: 2 }) + const note = buildDayNote({ id: 42, day_id: 2 }) + const concert = buildReservation({ id: 77, type: 'event', day_id: 2 }) + const rows = buildPlanRows({ + merged: [transportItem(early), placeItem(museum), transportItem(taxi), noteItem(note), transportItem(concert), placeItem(park)], + reservations: [], + routeSegments: [seg([48.1, 16.1], [48.2, 16.2])], + dayId: 2, + }) + expect(rows.map(r => r.key)).toEqual(['tr-75', 'pl-11', 'tr-76', 'note-42', 'tr-77', 'conn-tr-77', 'pl-12']) + }) + }) }) describe('planTimelineModel — cityPillsForDay', () => { From cf5920ebe09ff356108d2407f6611e6dd502f001 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 23:52:47 +0200 Subject: [PATCH 08/14] fix(atlas): a place that moves no longer keeps its old country (#2527) place_regions caches the country and region Atlas resolved for a place, and a row that was already there was never re-derived. Correcting a place's location in the trip left the old row in charge, so Atlas, the dashboard stats and the journey stats kept counting the country the place had just left, with the trip's dates on it. A trigger on places now drops the cached row whenever lat, lng or address actually change, so every writer is covered: the place editor, the MCP update tools, the plugin RPC and the import enrichment and address backfill. An edit that writes the same location back keeps the row. The background lookup that fills the cache also only writes while the place still sits where it was resolved from. A place moved while its lookup was running would otherwise get the old answer written back. Places corrected before this fix would have kept their old country until they were moved again, because the trigger only sees new edits. PlaceRegionsRepairJob goes over place_regions once, on the first start after the update, and remembers that in app_settings. It re-derives each row from the bundled borders only, never Nominatim, and replaces a row whose country or region no longer matches where its place is now. A row the bundle cannot answer for is left alone unless both the coordinates and the address put the place in another country; then it is dropped and the next Atlas load looks the place up again. A place without coordinates keeps no row. Its writes are guarded the same way as the background lookup, so a place edited during the pass is not overwritten. The in-memory region cache is now keyed by the country the address names as well as the coordinates, so correcting only the address of a border point no longer returns the old answer until a restart. --- server/src/db/migrations.ts | 28 +++ server/src/nest/atlas/atlas-geo.ts | 39 ++-- server/src/nest/atlas/atlas.module.ts | 8 +- server/src/nest/atlas/atlas.service.ts | 181 +++++++++++---- .../nest/atlas/place-regions-repair.job.ts | 53 +++++ server/tests/integration/atlas.test.ts | 70 ++++++ .../tests/unit/db/migration-hygiene.test.ts | 2 +- .../db/place-regions-follow-place.test.ts | 112 +++++++++ server/tests/unit/mcp/tools-places.test.ts | 20 ++ server/tests/unit/nest/atlas.service.test.ts | 54 +++++ .../unit/nest/place-regions-repair.test.ts | 216 ++++++++++++++++++ 11 files changed, 721 insertions(+), 62 deletions(-) create mode 100644 server/src/nest/atlas/place-regions-repair.job.ts create mode 100644 server/tests/unit/db/place-regions-follow-place.test.ts create mode 100644 server/tests/unit/nest/place-regions-repair.test.ts diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 47235d413..713cb737f 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -5257,6 +5257,34 @@ function runMigrations(db: Database.Database): void { `); } }, + + /* + * A place that moves takes its Atlas country with it (#2527). + * + * place_regions caches the country and region Atlas resolved from a + * place's coordinates and address, and nothing re-derives a row that is + * already there. Correcting a place's location left the old row in charge, + * so Atlas, the dashboard stats and the journey stats kept counting the + * country the place had just left. + * + * A trigger rather than a delete in the places code, because the row is + * derived from the place and every writer of lat, lng or address has to + * let go of it: the place editor, update_place, the plugin RPC, the import + * enrichment and its address backfill, and whatever comes next. The WHEN + * clause matters because every place edit writes lat, lng and address + * back whether they changed or not, and renaming a place must not throw + * away a good row. The next Atlas load resolves the place where it is now. + */ + () => { + db.exec(` + CREATE TRIGGER IF NOT EXISTS trg_place_regions_follow_place + AFTER UPDATE OF lat, lng, address ON places + WHEN OLD.lat IS NOT NEW.lat OR OLD.lng IS NOT NEW.lng OR OLD.address IS NOT NEW.address + BEGIN + DELETE FROM place_regions WHERE place_id = NEW.id; + END + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/atlas/atlas-geo.ts b/server/src/nest/atlas/atlas-geo.ts index aae5efbb1..a81a7794a 100644 --- a/server/src/nest/atlas/atlas-geo.ts +++ b/server/src/nest/atlas/atlas-geo.ts @@ -781,7 +781,7 @@ async function resolveCountryCode(place: Place): Promise { return null; } -export function resolveCountryCodeSync(place: Place): string | null { +export function resolveCountryCodeSync(place: Pick): string | null { const hasCoords = !!(place.lat && place.lng); if (hasCoords) { const fromCoords = getCountryFromCoords(place.lat!, place.lng!); @@ -937,10 +937,13 @@ function buildRegionInfo(address: Record, preferFinest: boolean) }; } -export async function reverseGeocodeRegion(lat: number, lng: number, placeAddress?: string | null): Promise { - const key = cacheKeyFor(lat, lng, 'region'); - if (regionCache.has(key)) return regionCache.get(key)!; - +/** + * The offline half of reverseGeocodeRegion: the bundled admin1 polygons, then the + * box-gated address fallback. Null when the bundle has no answer for the point, which + * is where reverseGeocodeRegion goes on to ask Nominatim. Deterministic and uncached, + * so it is also what the one time place_regions repair re-derives rows with (#2527). + */ +export async function resolveRegionFromBundle(lat: number, lng: number, placeAddress?: string | null): Promise { // Prefer resolving directly against the bundled polygons: offline, deterministic, and — // unlike Nominatim's address levels — guaranteed to match a feature the client can // actually highlight. Falls through to reverse geocoding when the country has no admin1 @@ -948,10 +951,7 @@ export async function reverseGeocodeRegion(lat: number, lng: number, placeAddres const coordCountry = getCountryFromCoords(lat, lng); if (coordCountry) { const fromBundle = await getRegionFromCoords(coordCountry, lat, lng); - if (fromBundle) { - regionCache.set(key, fromBundle); - return fromBundle; - } + if (fromBundle) return fromBundle; } // The coordinate-only lookup found no matching region — either no country polygon contains // the point, or a simplified admin0 border put it in the WRONG country (a place on the @@ -966,11 +966,22 @@ export async function reverseGeocodeRegion(lat: number, lng: number, placeAddres // trusting a region match in it. const addressCountry = getCountryFromAddress(placeAddress ?? null); if (addressCountry && addressCountry !== coordCountry && isPointInCountryBox(addressCountry, lat, lng)) { - const fromAddress = await getRegionFromCoords(addressCountry, lat, lng); - if (fromAddress) { - regionCache.set(key, fromAddress); - return fromAddress; - } + return getRegionFromCoords(addressCountry, lat, lng); + } + return null; +} + +export async function reverseGeocodeRegion(lat: number, lng: number, placeAddress?: string | null): Promise { + // The address takes part in the answer only through the country it names, so that + // country is part of the key. Keyed by coordinates alone, a place whose address was + // corrected on a border point got the old answer back from memory (#2527). + const key = `${cacheKeyFor(lat, lng, 'region')}|${getCountryFromAddress(placeAddress ?? null) ?? ''}`; + if (regionCache.has(key)) return regionCache.get(key)!; + + const fromBundle = await resolveRegionFromBundle(lat, lng, placeAddress); + if (fromBundle) { + regionCache.set(key, fromBundle); + return fromBundle; } // Only reached when the bundle's own polygons for this country don't cover the point at diff --git a/server/src/nest/atlas/atlas.module.ts b/server/src/nest/atlas/atlas.module.ts index c2b9d4617..2367554aa 100644 --- a/server/src/nest/atlas/atlas.module.ts +++ b/server/src/nest/atlas/atlas.module.ts @@ -11,6 +11,8 @@ import { PublicStatsController } from './public-stats.controller'; import { ApiTokenGuard } from '../public-api/api-token.guard'; import { TokensModule } from '../tokens/tokens.module'; import { RateLimitModule } from '../common/rate-limit.module'; +import { SchedulingModule } from '../scheduling/scheduling.module'; +import { PlaceRegionsRepairJob } from './place-regions-repair.job'; /** * Atlas addon domain (L7 leaf module). Registered in AppModule. Exports @@ -29,11 +31,13 @@ import { RateLimitModule } from '../common/rate-limit.module'; * than pulled in with PublicApiModule: @UseGuards instantiates a guard in the * declaring controller's module, so exporting it from over there would still leave * its TokenService unresolvable here. TokensModule is a leaf, so the edge is free. + * + * PlaceRegionsRepairJob puts right, once, the place_regions rows cached before #2527. */ @Module({ - imports: [AuthModule, PluginGuardsModule, AddonsModule, TokensModule, RateLimitModule], + imports: [AuthModule, PluginGuardsModule, AddonsModule, TokensModule, RateLimitModule, SchedulingModule], controllers: [AtlasController, TravelStatsController, PublicStatsController], - providers: [AtlasService, AtlasMcp, AtlasRpc, ApiTokenGuard], + providers: [AtlasService, AtlasMcp, AtlasRpc, ApiTokenGuard, PlaceRegionsRepairJob], exports: [AtlasService], }) export class AtlasModule {} diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index 736aea4a0..77ebae710 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -4,14 +4,17 @@ import type { AtlasLocateResponse } from '@trek/shared'; import { Trip, Place } from '../../types'; import { DatabaseService } from '../database/database.service'; import { + getCountryFromAddress, getCountryFromCoords, getCountryGeoGz, getRegionFromCoords, getRegionGeo, geocodingInFlight, resolveCountryCodeSync, + resolveRegionFromBundle, reverseGeocodeRegion, } from './atlas-geo'; +import type { RegionInfo } from './atlas-geo'; import { KNOWN_COUNTRIES } from './known-countries'; import { cityFromAddress } from './city-from-address'; import { transferEndpointIds } from './transfer-endpoints'; @@ -19,6 +22,15 @@ import type { FlightEndpointRow } from './transfer-endpoints'; import { countryVisitDates } from './visit-dates'; import { haversineKm } from '../common/geo'; +/** The part of a place that its cached region is derived from. */ +type LocatedPlace = Pick; + +/** A place_regions row next to the location of its place. */ +type CachedRegionRow = LocatedPlace & { country_code: string; region_code: string }; + +/** How many cached rows the #2527 repair checks before it lets other work run. */ +const REPAIR_YIELD_EVERY = 200; + /** * A reservation endpoint plus the two booking columns that decide whether it may * take part in layover pairing at all (see transfer-endpoints.ts). @@ -175,32 +187,131 @@ export class AtlasService { } } - if (uncachedForGeocode.length > 0) { - const insertStmt = this.db.prepare( - 'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)', - ); - for (const p of uncachedForGeocode) geocodingInFlight.add(p.id); - void (async () => { - try { - for (const place of uncachedForGeocode) { - try { - const info = await reverseGeocodeRegion(place.lat!, place.lng!, place.address); - if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name); - } catch { - /* continue */ - } finally { - geocodingInFlight.delete(place.id); - } - } - } catch { - for (const p of uncachedForGeocode) geocodingInFlight.delete(p.id); - } - })(); - } + this.cacheRegionsInBackground(uncachedForGeocode); return out; } + /** + * Resolve each place's region in the background and cache it in place_regions. + */ + private cacheRegionsInBackground(places: Place[]): void { + if (places.length === 0) return; + for (const p of places) geocodingInFlight.add(p.id); + void (async () => { + try { + for (const place of places) { + try { + const info = await reverseGeocodeRegion(place.lat!, place.lng!, place.address); + if (info) this.cacheRegionWhileUnmoved(place, info); + } catch { + // individual failure, continue with the remaining places + } finally { + geocodingInFlight.delete(place.id); + } + } + } catch { + for (const p of places) geocodingInFlight.delete(p.id); + } + })(); + } + + /** + * Cache a resolved region, but only while the place still sits where it was resolved + * from. A place moved while its lookup was running has already had its row dropped by + * the place_regions trigger (#2527), and writing the old answer back would pin it to + * the country it just left. A place deleted in the meantime is skipped the same way. + */ + private cacheRegionWhileUnmoved(place: LocatedPlace, info: RegionInfo): boolean { + const written = this.db.run( + `INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) + SELECT id, ?, ?, ? FROM places WHERE id = ? AND lat = ? AND lng = ? AND address IS ?`, + info.country_code, + info.region_code, + info.region_name, + place.id, + place.lat, + place.lng, + place.address ?? null, + ); + return written.changes > 0; + } + + // ── One time repair of the rows cached before #2527 ─────────────────────── + + /** + * Before #2527 nothing dropped a place_regions row when its place moved, so an + * install that upgrades still holds the country every corrected place left. This + * re-derives each row with the bundled resolver alone, never Nominatim, and puts + * right the ones that no longer match where their place is now. + * + * Where the bundle answers for the place's current location with another country + * or region, the row gets that answer, which is what a fresh lookup would cache. A + * good row matches it, the address fallback case included. + * + * Where the bundle has no answer (a coastal point outside the simplified polygons, + * a country the bundle has no regions for), the row came from Nominatim and only + * Nominatim could judge it, so it stays, unless the coordinates put the place in + * another country and the address does not name the cached one either. Then it is + * dropped and the next Atlas load looks the place up again. + * + * A place without coordinates keeps no row, the same as the trigger does. + * + * Every write only lands while the place still holds the location it was read with, + * so a place edited while this runs keeps what the trigger and the next lookup give it. + */ + async repairStaleRegionCache(): Promise<{ replaced: number; dropped: number }> { + const rows = this.db.all(` + SELECT pr.place_id AS id, pr.country_code, pr.region_code, p.lat, p.lng, p.address + FROM place_regions pr + JOIN places p ON p.id = pr.place_id + ORDER BY pr.place_id + `); + let replaced = 0; + let dropped = 0; + for (const [i, row] of rows.entries()) { + // A big install holds many rows, so requests get a turn every so often. + if (i > 0 && i % REPAIR_YIELD_EVERY === 0) await new Promise((resolve) => setImmediate(resolve)); + const fix = await this.staleRegionFix(row); + if (fix === 'drop') { + if (this.dropRegionWhileUnmoved(row)) dropped++; + } else if (fix && this.cacheRegionWhileUnmoved(row, fix)) { + replaced++; + } + } + return { replaced, dropped }; + } + + /** What a cached row needs: a new region, 'drop', or null when it is right. */ + private async staleRegionFix(row: CachedRegionRow): Promise { + if (row.lat == null || row.lng == null) return 'drop'; + const cachedCountry = row.country_code.toUpperCase(); + const fresh = await resolveRegionFromBundle(row.lat, row.lng, row.address); + if (fresh) { + const same = fresh.country_code.toUpperCase() === cachedCountry && fresh.region_code === row.region_code; + return same ? null : fresh; + } + const countryNow = resolveCountryCodeSync(row); + const contradicted = !!countryNow && countryNow !== cachedCountry && getCountryFromAddress(row.address) !== cachedCountry; + return contradicted ? 'drop' : null; + } + + private dropRegionWhileUnmoved(row: CachedRegionRow): boolean { + const removed = this.db.run( + `DELETE FROM place_regions + WHERE place_id = ? AND country_code = ? AND region_code = ? + AND EXISTS (SELECT 1 FROM places WHERE id = ? AND lat IS ? AND lng IS ? AND address IS ?)`, + row.id, + row.country_code, + row.region_code, + row.id, + row.lat ?? null, + row.lng ?? null, + row.address ?? null, + ); + return removed.changes > 0; + } + // ── Stats ───────────────────────────────────────────────────────────────── async stats(userId: number) { @@ -731,29 +842,9 @@ export class AtlasService { const cachedMap = new Map(cached.map((c) => [c.place_id, c])); // Kick off background geocoding for uncached places; return cached data immediately. - const uncached = places.filter((p) => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id)); - if (uncached.length > 0) { - const insertStmt = this.db.prepare( - 'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)', - ); - for (const p of uncached) geocodingInFlight.add(p.id); - void (async () => { - try { - for (const place of uncached) { - try { - const info = await reverseGeocodeRegion(place.lat!, place.lng!, place.address); - if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name); - } catch { - // individual failure — continue with remaining places - } finally { - geocodingInFlight.delete(place.id); - } - } - } catch { - for (const p of uncached) geocodingInFlight.delete(p.id); - } - })(); - } + this.cacheRegionsInBackground( + places.filter((p) => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id)), + ); // Group by country → regions with place counts const regionMap: Record< diff --git a/server/src/nest/atlas/place-regions-repair.job.ts b/server/src/nest/atlas/place-regions-repair.job.ts new file mode 100644 index 000000000..97a3bcc49 --- /dev/null +++ b/server/src/nest/atlas/place-regions-repair.job.ts @@ -0,0 +1,53 @@ +import { logInfo, logError } from '../audit/audit-log.logger'; +import { DatabaseService } from '../database/database.service'; +import { CronRegistrarService } from '../scheduling/cron-registrar.service'; +import { AtlasService } from './atlas.service'; +import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'; + +/** The app_settings row that says the pass below has been through every cached region. */ +export const PLACE_REGIONS_REPAIR_DONE_KEY = 'place_regions_repair_2527'; + +/** + * One pass over place_regions on the first start after #2527 (see + * AtlasService.repairStaleRegionCache). + * + * Not a cron: from this version on the trigger on places drops a row as soon as its + * place moves, so once the rows cached before it are put right there is nothing left + * to repair. app_settings remembers that the pass ran. A pass cut short by a restart + * or an error simply runs again on the next start; a second run changes nothing the + * first one wrote. + * + * In the background, like the other boot sweeps: the server does not wait for it. + */ +@Injectable() +export class PlaceRegionsRepairJob implements OnApplicationBootstrap { + constructor( + private readonly atlas: AtlasService, + private readonly registrar: CronRegistrarService, + private readonly db: DatabaseService, + ) {} + + onApplicationBootstrap(): void { + if (!this.registrar.isEnabled()) return; + void this.runOnce(); + } + + async runOnce(): Promise { + try { + if (this.db.get('SELECT 1 FROM app_settings WHERE key = ?', PLACE_REGIONS_REPAIR_DONE_KEY)) return; + const { replaced, dropped } = await this.atlas.repairStaleRegionCache(); + this.db.run( + 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', + PLACE_REGIONS_REPAIR_DONE_KEY, + new Date().toISOString(), + ); + if (replaced + dropped > 0) { + logInfo( + `Atlas region repair: ${replaced} place region(s) re-derived where the place is now, ${dropped} left for the next Atlas load`, + ); + } + } catch (err: unknown) { + logError(`Atlas region repair: ${err instanceof Error ? err.message : err}`); + } + } +} diff --git a/server/tests/integration/atlas.test.ts b/server/tests/integration/atlas.test.ts index 6eafbb90b..1176babe2 100644 --- a/server/tests/integration/atlas.test.ts +++ b/server/tests/integration/atlas.test.ts @@ -536,3 +536,73 @@ describe('Regions geo', () => { expect(res.body).toHaveProperty('type', 'FeatureCollection'); }); }); + +describe('A place that moves takes its Atlas country with it (#2527)', () => { + // The region cache is filled by a background task that GET /regions only starts, + // so wait for the row the way the next Atlas load would find it. + async function regionRowOf(placeId: number): Promise<{ country_code: string; region_code: string } | undefined> { + for (let i = 0; i < 100; i++) { + const row = testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(placeId) as + | { country_code: string; region_code: string } + | undefined; + if (row) return row; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return undefined; + } + + async function atlasCountries(userId: number): Promise { + const res = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(userId)); + expect(res.status).toBe(200); + return (res.body.countries as { code: string }[]).map((c) => c.code); + } + + it('ATLAS-015: correcting a place from France to Germany moves it on the Atlas', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Wrongly imported', start_date: '2025-05-01', end_date: '2025-05-05' }); + const place = testDb + .prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id') + .get(trip.id, 'Hotel', 48.8566, 2.3522, 'Rue de Rivoli, Paris, France') as { id: number }; + + // The place is seen on the Atlas once, which caches France for it. + await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id)); + expect((await regionRowOf(place.id))?.country_code).toBe('FR'); + expect(await atlasCountries(user.id)).toEqual(['FR']); + + // The user fixes the location in the trip. + const put = await request(app) + .put(`/api/trips/${trip.id}/places/${place.id}`) + .set('Cookie', authCookie(user.id)) + .send({ lat: 52.5163, lng: 13.3777, address: 'Pariser Platz, Berlin, Germany' }); + expect(put.status).toBe(200); + + expect(await atlasCountries(user.id)).toEqual(['DE']); + + // And the regions follow on the next load, with nothing left in France. + await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id)); + expect((await regionRowOf(place.id))?.country_code).toBe('DE'); + const regions = await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id)); + expect(Object.keys(regions.body.regions)).toEqual(['DE']); + expect(await atlasCountries(user.id)).toEqual(['DE']); + }); + + it('ATLAS-015: an edit that leaves the location alone keeps the cached country', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris', start_date: '2025-05-01', end_date: '2025-05-05' }); + const place = testDb + .prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id') + .get(trip.id, 'Louvre', 48.8606, 2.3376, 'Rue de Rivoli, Paris, France') as { id: number }; + await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id)); + const cached = await regionRowOf(place.id); + expect(cached?.country_code).toBe('FR'); + + // The place editor sends the whole form back, location included, for a notes edit. + const put = await request(app) + .put(`/api/trips/${trip.id}/places/${place.id}`) + .set('Cookie', authCookie(user.id)) + .send({ notes: 'Closed on Tuesdays', lat: 48.8606, lng: 2.3376, address: 'Rue de Rivoli, Paris, France' }); + expect(put.status).toBe(200); + + expect(testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(place.id)).toEqual(cached); + }); +}); diff --git a/server/tests/unit/db/migration-hygiene.test.ts b/server/tests/unit/db/migration-hygiene.test.ts index 1e117cebc..9031db506 100644 --- a/server/tests/unit/db/migration-hygiene.test.ts +++ b/server/tests/unit/db/migration-hygiene.test.ts @@ -134,7 +134,7 @@ const ALLOWED_DESTRUCTIVE: Record = { 'DELETE FROM journey_entries': "Migration 121: DELETE ... WHERE title IN ('Gallery','[Trip Photos]') — remove synthetic wrapper entries replaced by the gallery model.", 'DELETE FROM place_regions': - 'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) — invalidate stale region cache; re-resolved on next request.', + 'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) to invalidate a stale region cache, re-resolved on next request. Also the body of trg_place_regions_follow_place (#2527): DELETE ... WHERE place_id = NEW.id when that one place\'s lat, lng or address changed. place_regions is a re-derivable cache either way.', 'DELETE FROM visited_regions': 'Atlas geoBoundaries swap (#1119): DELETE ... WHERE id = ? — after UPDATE OR IGNORE re-codes a manually-marked region to its current code, drop only the single leftover row whose UNIQUE(user_id, region_code) collision caused the update to be skipped (a duplicate of a region the user already has).', 'DELETE FROM hidden_regions': diff --git a/server/tests/unit/db/place-regions-follow-place.test.ts b/server/tests/unit/db/place-regions-follow-place.test.ts new file mode 100644 index 000000000..f2edaec02 --- /dev/null +++ b/server/tests/unit/db/place-regions-follow-place.test.ts @@ -0,0 +1,112 @@ +import { runMigrations } from '../../../src/db/migrations'; +import { createTestDb } from '../../helpers/test-db'; + +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// place_regions is Atlas's cache of the country and region a place resolved to. +// It is derived from the place's lat, lng and address, so a change to any of them +// has to take the row with it, whichever code path wrote the change (#2527). + +// The trigger ships at schema version 244. Later migrations are appended after it, +// so the replay case rewinds to just before it; the steps after it are idempotent. +const PLACE_REGIONS_TRIGGER_VERSION = 244; + +describe('place_regions follows the place it was resolved from (#2527)', () => { + let db: Database.Database; + let placeId: number; + let otherPlaceId: number; + + const cached = (id: number) => + db.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(id) as + | { country_code: string; region_code: string } + | undefined; + + beforeEach(() => { + db = createTestDb(); + db.exec("INSERT INTO users (username, email, password_hash) VALUES ('traveller', 'traveller@example.test', 'x')"); + const userId = (db.prepare('SELECT id FROM users').get() as { id: number }).id; + const tripId = db.prepare("INSERT INTO trips (user_id, title) VALUES (?, 'Trip')").run(userId) + .lastInsertRowid as number; + const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?)'); + placeId = insertPlace.run(tripId, 'Hotel', 48.8566, 2.3522, 'Rue de Rivoli, Paris, France') + .lastInsertRowid as number; + otherPlaceId = insertPlace.run(tripId, 'Museum', 48.8606, 2.3376, 'Rue de Rivoli, Paris, France') + .lastInsertRowid as number; + const insertRegion = db.prepare( + "INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'FR', 'FR-IDF', 'Ile-de-France')", + ); + insertRegion.run(placeId); + insertRegion.run(otherPlaceId); + }); + + afterEach(() => db?.close()); + + it('drops the cached region when the coordinates move', () => { + db.prepare('UPDATE places SET lat = ?, lng = ? WHERE id = ?').run(52.5163, 13.3777, placeId); + + expect(cached(placeId)).toBeUndefined(); + expect(cached(otherPlaceId)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' }); + }); + + it('drops it when only the latitude or only the longitude changes', () => { + db.prepare('UPDATE places SET lat = ? WHERE id = ?').run(48.9, placeId); + db.prepare('UPDATE places SET lng = ? WHERE id = ?').run(2.4, otherPlaceId); + + expect(cached(placeId)).toBeUndefined(); + expect(cached(otherPlaceId)).toBeUndefined(); + }); + + it('drops it when the address changes, including one filled in where there was none', () => { + db.prepare('UPDATE places SET address = ? WHERE id = ?').run('Pariser Platz, Berlin, Germany', placeId); + db.prepare('UPDATE places SET address = NULL WHERE id = ?').run(otherPlaceId); + db.prepare( + "INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'FR', 'FR-IDF', 'Ile-de-France')", + ).run(otherPlaceId); + // The import backfill only ever fills an empty address. + db.prepare('UPDATE places SET address = COALESCE(address, ?) WHERE id = ?').run('Paris, France', otherPlaceId); + + expect(cached(placeId)).toBeUndefined(); + expect(cached(otherPlaceId)).toBeUndefined(); + }); + + it('drops it when the location is cleared', () => { + db.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(placeId); + + expect(cached(placeId)).toBeUndefined(); + }); + + it('keeps it when an edit writes the same location back', () => { + // The place editor and update_place write every column, location included. + db.prepare( + "UPDATE places SET name = 'Hotel du Louvre', notes = 'late check-in', lat = ?, lng = ?, address = ? WHERE id = ?", + ).run(48.8566, 2.3522, 'Rue de Rivoli, Paris, France', placeId); + db.prepare("UPDATE places SET place_time = '09:00', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(otherPlaceId); + + expect(cached(placeId)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' }); + expect(cached(otherPlaceId)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' }); + }); + + it('ships at its own schema version and replays without a second trigger', () => { + const triggers = () => + db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = 'places' AND name = 'trg_place_regions_follow_place'", + ) + .all(); + expect(triggers()).toHaveLength(1); + + db.exec('DROP TRIGGER trg_place_regions_follow_place'); + db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION); + runMigrations(db); + expect(triggers()).toHaveLength(0); + + db.prepare('UPDATE schema_version SET version = ?').run(PLACE_REGIONS_TRIGGER_VERSION - 1); + runMigrations(db); + runMigrations(db); + expect(triggers()).toHaveLength(1); + + db.prepare('UPDATE places SET lat = ? WHERE id = ?').run(52.5163, placeId); + expect(cached(placeId)).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index 763a9d818..0147c7766 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -237,6 +237,26 @@ describe('Tool: update_place', () => { }); }); + it('moving a place drops the country Atlas cached for it, renaming it does not (#2527)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Hotel', lat: 48.8566, lng: 2.3522 }); + testDb.prepare("INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'FR', 'FR-IDF', 'Ile-de-France')").run(place.id); + const cachedRegion = () => testDb.prepare('SELECT country_code FROM place_regions WHERE place_id = ?').get(place.id); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Hotel Adlon' } }); + expect(cachedRegion()).toEqual({ country_code: 'FR' }); + + const moved = await h.client.callTool({ + name: 'update_place', + arguments: { tripId: trip.id, placeId: place.id, lat: 52.5163, lng: 13.3777, address: 'Unter den Linden 77, Berlin, Germany' }, + }); + expect(moved.isError).toBeFalsy(); + expect(cachedRegion()).toBeUndefined(); + }); + }); + it('broadcasts place:updated event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index 6ccc44b79..784635446 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -1155,6 +1155,60 @@ describe('getVisitedRegions', () => { }); }); +// ── A place that moves (#2527) ─────────────────────────────────────────────── + +describe('a place that moves keeps no stale region', () => { + function cachedCountryOf(placeId: number): string | undefined { + const row = testDb.prepare('SELECT country_code FROM place_regions WHERE place_id = ?').get(placeId) as + | { country_code: string } + | undefined; + return row?.country_code; + } + + // The lookup runs in the background; the first one in a worker also loads the admin-1 bundle. + async function settledCountryOf(placeId: number): Promise { + for (let i = 0; i < 150; i++) { + const code = cachedCountryOf(placeId); + if (code) return code; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return undefined; + } + + it('ATLAS-SVC-2527a: a lookup still running when the place moves does not write the old country back', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Lyon, then Munich' }); + const place = insertPlaceWithCoords(testDb, trip.id, 'Hotel', 45.764, 4.8357, 'Lyon, France'); + + // The Atlas load starts resolving Lyon, and the place is corrected before it answers. + const pending = atlas.visitedRegions(user.id); + testDb + .prepare('UPDATE places SET lat = ?, lng = ?, address = ? WHERE id = ?') + .run(48.1374, 11.5755, 'Marienplatz, Munich, Germany', place.id); + await pending; + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(cachedCountryOf(place.id)).toBeUndefined(); + + // The next load resolves where the place is now. + expect((await atlas.stats(user.id)).countries.map((c) => c.code)).toEqual(['DE']); + await atlas.visitedRegions(user.id); + expect(await settledCountryOf(place.id)).toBe('DE'); + }); + + it('ATLAS-SVC-2527b: a place deleted while its lookup runs leaves nothing behind', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Cancelled' }); + const place = insertPlaceWithCoords(testDb, trip.id, 'Hotel', 43.2965, 5.3698, 'Marseille, France'); + + const pending = atlas.visitedRegions(user.id); + testDb.prepare('DELETE FROM places WHERE id = ?').run(place.id); + await pending; + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(testDb.prepare('SELECT COUNT(*) AS n FROM place_regions').get()).toEqual({ n: 0 }); + }); +}); + // ── unmarkRegionVisited — tombstones + country cascade ────────────────────── // Places are region-resolved by a fire-and-forget background task (see reverseGeocodeRegion diff --git a/server/tests/unit/nest/place-regions-repair.test.ts b/server/tests/unit/nest/place-regions-repair.test.ts new file mode 100644 index 000000000..e50298330 --- /dev/null +++ b/server/tests/unit/nest/place-regions-repair.test.ts @@ -0,0 +1,216 @@ +import { reverseGeocodeRegion } from '../../../src/nest/atlas/atlas-geo'; +import { AtlasService } from '../../../src/nest/atlas/atlas.service'; +import { PLACE_REGIONS_REPAIR_DONE_KEY, PlaceRegionsRepairJob } from '../../../src/nest/atlas/place-regions-repair.job'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service'; +import { createTrip, createUser } from '../../helpers/factories'; +import { createTestDb } from '../../helpers/test-db'; + +import type Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// An install that upgrades to the #2527 fix still holds the place_regions rows its +// corrected places were cached with before it. The trigger only stops new ones from +// going stale, so a one time pass re-derives the old rows from the bundled borders. + +const BERLIN = { lat: 52.5163, lng: 13.3777, address: 'Unter den Linden 77, 10117 Berlin, Germany' }; +// Kehl sits inside France's bounding box, so a box check alone would call its FR row fine. +const KEHL = { lat: 48.5761, lng: 7.8152, address: 'Hauptstrasse 1, 77694 Kehl, Germany' }; +const PARIS = { lat: 48.8566, lng: 2.3522, address: 'Rue de Rivoli, Paris, France' }; +const LYON = { lat: 45.764, lng: 4.8357, address: 'Place Bellecour, Lyon, France' }; +const MUNICH = { lat: 48.1374, lng: 11.5755, address: 'Marienplatz, Munich, Germany' }; +// The coordinates fall in Germany's simplified polygon, the address puts it in Luxembourg. +const BOLLENDORF_PONT = { + lat: 49.8502458, + lng: 6.3576404, + address: '4 Gruusswiss, Bollendorf-Pont, Distrikt Gréiwemaacher 6555, Luxembourg', +}; +// Points the bundle has no region for: only Nominatim can answer them. +const NICE_HARBOUR = { lat: 43.695, lng: 7.285, address: 'Quai des Etats-Unis, Nice, France' }; +const SYLT_BEACH = { lat: 54.9, lng: 8.29, address: 'Westerland, Sylt, Germany' }; + +type Location = { lat: number | null; lng: number | null; address: string | null }; + +describe('the one time repair of place_regions rows cached before #2527', () => { + let db: Database.Database; + let atlas: AtlasService; + let job: PlaceRegionsRepairJob; + let userId: number; + let tripId: number; + const fetchMock = vi.fn(); + + const addPlace = (name: string, at: Location, cached?: [string, string, string]) => { + const id = db + .prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?)') + .run(tripId, name, at.lat, at.lng, at.address).lastInsertRowid as number; + if (cached) { + db.prepare( + 'INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)', + ).run(id, ...cached); + } + return id; + }; + const cachedRow = (id: number) => + db.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(id) as + | { country_code: string; region_code: string } + | undefined; + const markedDone = () => db.prepare('SELECT 1 FROM app_settings WHERE key = ?').get(PLACE_REGIONS_REPAIR_DONE_KEY); + const registrar = (enabled: boolean) => ({ isEnabled: () => enabled }) as unknown as CronRegistrarService; + + beforeEach(() => { + db = createTestDb(); + const dbService = new DatabaseService(db); + atlas = new AtlasService(dbService); + job = new PlaceRegionsRepairJob(atlas, registrar(true), dbService); + userId = createUser(db).user.id; + tripId = createTrip(db, userId, { title: 'Rhine', start_date: '2025-05-01', end_date: '2025-05-05' }).id; + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + db.close(); + }); + + it('shows an upgraded install the country its corrected places are in now, without editing them again', async () => { + // The shape from the report: moved to Germany on 4.3.2, still cached in France. + const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); + const rhine = addPlace('Rhine Hotel', KEHL, ['FR', 'FR-GES', 'Grand Est']); + expect((await atlas.stats(userId)).countries.map((c) => c.code)).toEqual(['FR']); + + await job.runOnce(); + + expect(cachedRow(hotel)).toEqual({ country_code: 'DE', region_code: 'DE-BE' }); + expect(cachedRow(rhine)).toEqual({ country_code: 'DE', region_code: 'DE-BW' }); + expect((await atlas.stats(userId)).countries.map((c) => c.code)).toEqual(['DE']); + expect(Object.keys((await atlas.visitedRegions(userId)).regions)).toEqual(['DE']); + expect(atlas.getTravelStats(userId).countries).toEqual(['DE']); + expect(atlas.lastTrip(userId)?.countries).toEqual(['DE']); + expect(markedDone()).toBeTruthy(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('re-derives a region that moved inside the same country', async () => { + const museum = addPlace('Musee des Confluences', LYON, ['FR', 'FR-IDF', 'Ile-de-France']); + + expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 1, dropped: 0 }); + expect(cachedRow(museum)).toEqual({ country_code: 'FR', region_code: 'FR-ARA' }); + }); + + it('leaves good rows alone, the address fallback and rows only Nominatim could judge included', async () => { + const paris = addPlace('Louvre', PARIS, ['FR', 'FR-IDF', 'Ile-de-France']); + const airbnb = addPlace('Airbnb', BOLLENDORF_PONT, ['LU', 'LU-EC', 'Canton Echternach']); + // The bundle calls this point Italian and has no region for it; the address backs the row. + const harbour = addPlace('Port Lympia', NICE_HARBOUR, ['FR', 'FR-PAC', "Provence-Alpes-Cote d'Azur"]); + const ship = addPlace('Cruise ship', { lat: 0.5, lng: -30, address: null }, ['BR', 'BR-PE', 'Pernambuco']); + + expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 0 }); + expect(cachedRow(paris)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' }); + expect(cachedRow(airbnb)).toEqual({ country_code: 'LU', region_code: 'LU-EC' }); + expect(cachedRow(harbour)).toEqual({ country_code: 'FR', region_code: 'FR-PAC' }); + expect(cachedRow(ship)).toEqual({ country_code: 'BR', region_code: 'BR-PE' }); + }); + + it('drops a row the bundle cannot re-derive when coordinates and address both name another country', async () => { + const beach = addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']); + const cleared = addPlace('Somewhere', { lat: null, lng: null, address: null }, ['FR', 'FR-IDF', 'Ile-de-France']); + + expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 2 }); + expect(cachedRow(beach)).toBeUndefined(); + expect(cachedRow(cleared)).toBeUndefined(); + // The dashboard, which reads only the cache, no longer counts France. + expect(atlas.getTravelStats(userId).countries).toEqual([]); + }); + + it('does not write over a place that is edited while the pass runs', async () => { + const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); + const beach = addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']); + + // The pass has read both rows before it gives way for the first time. + const pass = atlas.repairStaleRegionCache(); + db.prepare('UPDATE places SET lat = ?, lng = ?, address = ? WHERE id = ?').run( + MUNICH.lat, + MUNICH.lng, + MUNICH.address, + hotel, + ); + db.prepare("UPDATE places SET address = 'Westerland, Germany' WHERE id = ?").run(beach); + db.prepare( + "INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'DE', 'DE-SH', 'Schleswig-Holstein')", + ).run(beach); + + expect(await pass).toEqual({ replaced: 0, dropped: 0 }); + // The trigger dropped the moved place's row and nothing wrote Berlin back. + expect(cachedRow(hotel)).toBeUndefined(); + // The row a fresh lookup wrote for the edited place stays. + expect(cachedRow(beach)).toEqual({ country_code: 'DE', region_code: 'DE-SH' }); + }); + + it('runs once, and a second pass over the same rows changes nothing', async () => { + // Enough rows for the pass to give way to other work along the way. + const moved = Array.from({ length: 250 }, (_, i) => + addPlace(`Stop ${i}`, BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']), + ); + + await job.runOnce(); + expect(moved.every((id) => cachedRow(id)?.region_code === 'DE-BE')).toBe(true); + expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 0 }); + + // Marked as done, the next start does not read the cache again. + const later = addPlace('Later', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); + await job.runOnce(); + expect(cachedRow(later)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' }); + }); + + it('is not marked as done when the pass fails, so the next start tries again', async () => { + const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); + vi.spyOn(atlas, 'repairStaleRegionCache').mockRejectedValueOnce(new Error('disk I/O error')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await job.runOnce(); + expect(markedDone()).toBeUndefined(); + expect(cachedRow(hotel)?.country_code).toBe('FR'); + + await job.runOnce(); + expect(markedDone()).toBeTruthy(); + expect(cachedRow(hotel)?.country_code).toBe('DE'); + }); + + it('starts from the boot hook, but not where the scheduler is off', () => { + const dbService = new DatabaseService(db); + const offJob = new PlaceRegionsRepairJob(atlas, registrar(false), dbService); + const onJob = new PlaceRegionsRepairJob(atlas, registrar(true), dbService); + const offRun = vi.spyOn(offJob, 'runOnce').mockResolvedValue(); + const onRun = vi.spyOn(onJob, 'runOnce').mockResolvedValue(); + + offJob.onApplicationBootstrap(); + onJob.onApplicationBootstrap(); + + expect(offRun).not.toHaveBeenCalled(); + expect(onRun).toHaveBeenCalledTimes(1); + }); +}); + +describe('the in-memory region cache (#2527)', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('answers again when only the address of a border point changes', async () => { + // Without an address this point falls to Nominatim, which is asked once. + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ address: { country_code: 'de', state: 'Rheinland-Pfalz', 'ISO3166-2-lvl4': 'DE-RP' } }), + }); + vi.stubGlobal('fetch', fetchMock); + + const before = await reverseGeocodeRegion(BOLLENDORF_PONT.lat, BOLLENDORF_PONT.lng, null); + const after = await reverseGeocodeRegion(BOLLENDORF_PONT.lat, BOLLENDORF_PONT.lng, BOLLENDORF_PONT.address); + const again = await reverseGeocodeRegion(BOLLENDORF_PONT.lat, BOLLENDORF_PONT.lng, null); + + expect(before?.country_code).toBe('DE'); + expect(after).toEqual({ country_code: 'LU', region_code: 'LU-EC', region_name: 'Canton Echternach' }); + expect(again?.country_code).toBe('DE'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); From 5f77c2cee9b4c13e4f3de67658aa92bd9f143931 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 23:59:29 +0200 Subject: [PATCH 09/14] fix(notifications): show the header logo in Gmail and Outlook (#2507) The logo at the top of every TREK mail was an SVG inlined into the HTML as a data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders SVG, so notification and password-reset mails showed a broken image with "TREK" as its alt text. The image data itself was fine; it only rendered in clients that accept both data: URIs and SVG, such as Apple Mail. The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg with the corner radius baked in, and MailerService attaches it to both HTML sends as an inline part. The header img points at it by cid:, so nodemailer puts the HTML and the image into multipart/related. That is the form Gmail, Outlook and Apple Mail all display, and it does not depend on the instance being reachable from the recipient's mail provider. The plain-text admin test mail carries no HTML and stays without an attachment. --- .../nest/notifications/mailer/email-html.ts | 6 +- .../nest/notifications/mailer/email-logo.ts | 36 ++++ .../notifications/mailer/mailer.service.ts | 3 + server/tests/e2e/mailer.e2e.test.ts | 160 ++++++++++++++++++ server/tests/unit/nest/mailer.service.test.ts | 58 +++++++ .../unit/nest/notification-transports.test.ts | 8 + 6 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 server/src/nest/notifications/mailer/email-logo.ts create mode 100644 server/tests/e2e/mailer.e2e.test.ts diff --git a/server/src/nest/notifications/mailer/email-html.ts b/server/src/nest/notifications/mailer/email-html.ts index 9fd35e1d6..8bef780a7 100644 --- a/server/src/nest/notifications/mailer/email-html.ts +++ b/server/src/nest/notifications/mailer/email-html.ts @@ -2,12 +2,16 @@ import { EMAIL_I18N as I18N, EVENT_TEXTS } from '@trek/shared/i18n/externalNotif import type { EventText, PasswordResetStrings } from '@trek/shared/i18n/externalNotifications'; import { getAppUrl } from '../../../app-config'; import type { NotifEventType } from '../notification-events'; +import { EMAIL_LOGO_CID } from './email-logo'; /** * Pure rendering for outgoing mail: the shell, the CTA and the password-reset * body. No database, no transport, no DI - which is why it stays a plain module * and not a provider. `getAppUrl()` is the canonical app URL from app-config, * never a request header. + * + * The header logo is not in the HTML itself: the shell points at it by cid:, so + * every mail carrying this HTML has to attach `emailLogoAttachment()`. */ function escapeHtml(str: string): string { @@ -49,7 +53,7 @@ export function buildEmailHtml( diff --git a/server/src/nest/notifications/mailer/email-logo.ts b/server/src/nest/notifications/mailer/email-logo.ts new file mode 100644 index 000000000..1969ba544 --- /dev/null +++ b/server/src/nest/notifications/mailer/email-logo.ts @@ -0,0 +1,36 @@ +import type Mail from 'nodemailer/lib/mailer'; + +/** + * The TREK mark in the mail header, sent as a PNG part of the message and + * addressed from the HTML by Content-ID (#2507). + * + * It used to be an SVG inlined as a data: URI. Gmail strips data: URIs, Outlook + * blocks them and neither of them renders SVG, so most people saw a broken image + * with "TREK" as its alt text. An inline part referenced through cid: is what + * Gmail, Outlook and Apple Mail all display, and unlike a hosted image it does + * not depend on the instance being reachable from the recipient's mail provider. + * + * Rasterized from client/public/icons/icon.svg at 96x96, twice the size the + * header shows it at, with the corner radius baked into the image because + * Outlook ignores border-radius on an img. + */ +export const EMAIL_LOGO_CID = 'logo@trek'; + +const EMAIL_LOGO_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAACXBIWXMAAFxGAABcRgEUlENBAAALT0lEQVR42u2deXRU1R3H5xy7WhUhA7NnI5nEzACZzCSZkEBYAohNIMGERRo9BLAsUghLQE3RhM2jCGUpW1BaqHhcwFYrrafYIC4UPOwiUCEhIihkIwuQgPD6+71hXuZlZpKZeffO3MLcc74n/+S8w/t8w333/n6/e38yGemRnv4TTXRysjYmZY4mJqVME2PdCz8rNNHWOk10yg2t3so5SuNK0XYlO0ndXlF2JbmVCtWzvRJdShmZ2KqMtNSBKhSRlr3ws0wZYZ6jCDcn47vJWBxxcXE/00ZbcwD0ToDaCPA5kfR2sQ2fVyRvgoMsbYowNygizDtUPc3Z+M4BB6+MSusOYBeDapyg323w2wmMqFFEWBap9Ga538F3j0t/QBtjfUmnT2l2C/4uhq+MaFOPCEtzj3DzUoWi96/8Ah+AZutiUr7VdQT+HoGv4GXmBSZUgUZSAx8Wlv4LALgKwQfhi+GLlbBVpTLfTxS+JipJq4uxHgnC7ww+KByVcChEZ1ITga+L6tsT5vszQfiewrepR5j5nDrcEiNt2tGnxMJf/uUgfO/gO5hwSR7RW+/bxzbaqgGglTTgRxr7cxmZ47nhOU+J9Gh7Zdv1pFsNQ41sr3z3GpHPDQXFJgymCp83IDwBTEg4r+5p0Xn9waU156cMHMX9cKmaC/T48cdb3KyiErrwBZkORkVF/dyLpaZ1I61pZ13ZNo6VcfL0GT/Av6Mw01pPp54cmnP+KXhpVsbezw/4Cb5NCp0pq0P4KrP5fvu8TwO+pV8Wd/v2bWYMKF22ym/wUd1DTVUd7pj58ALF1c7c55ZwLI2Bw8f6DX6bCQmLXcJXxyaG6PTWJppLzb//42Nm4ONCwBZ+9h983oAwUzPslOWuYjxLaMIPjU3h6q80MGPA9rf/5nf48CFGA1AlzvF8yiHl7DGTmZp+np6xIFDwOXlYfLXMMZ+giU0eRXuHu3Lta8zAxz1ArGlQQODzCjVxECsa0WYAZrIohxcOHzvBjAEHDh4NKPw7ersthxuT0kATvjFpGHfr1i1mDHh55YZAw+e660z1MlnefTD9pFppB9amz17I1Pw/HOJLAYUfKkxDFhlWL9COar7z3ofMwK+ru8JHSwMNHyXXmQpx/t9MEz7+7qXL1cwYsPP9fzIB36b4jWjApzTj+Rj6ZWnMmPuCCDru/JPSR3BJ/V0rsX+Ws/qJZTBn+AIf1GePTAOxH5rJlGXL1zEDH+NQvZKGCvAxB1FfT2Zz+O9PvoCNndkL+PEwBcVXyKBarZZmJuuL/QeZMeD4idOiv/6Pdn9C9PmZuRM8ho8KCe1TjQa00oIfEz+Qu3nzJjMGrF6/RTT1XL16jejz0zIe9xi+HA3QxrfIaOZwJ04rYmr+zx47WTAgb/xUos++cPEHe8jZI/gw/fCS0Uygb31zJzPwm5qv8u9oX+38cdNWos/fBu/qLXyRATSqF6rOX2DGgF0flYuWmicJZ+YKpszzGr5gAA34/YeOZmr6KSpeKsCPtw4nmpnD4J6+9wCv4fMG0KrbeWHxCqYMwDW93YDC+aVEn73/y8M+wXdtAKGiKVwXszK+OVMp2tm+/+G/iD7/Jdjr+ALf2QBC8CMMady1a9eZMWDja28I8NWQhiS1+bKPoVm/8Qm+XNfHwQCC5YLjC2YyNf2Me2qGYEBWbgHRZ9fW1vPP9QV+mwGEazU3bXmTGfgtLa1c+COpggGvri4j+vx3/7rLZ/g2AygUyuKcy8rAb5FjVPPw0a+IPn964e99hu9gADn45rQspqafhYteFeBjHphkZg6XskZLhs/w5VreALIl4kXFy5gyIC0jV4jhT5n5HNFnHz3+tST4Ia4MkFqfjztOVgYfn3FIory14wOiz1+5ZrMk+E4GSIUPpya5hsYmZgzYun2HAB/DEKTL4kfkFUiCLzKAxMmUAY+OZWr6mTBlrmCAqe9jRJ/d2NQMJ20skuALBpA6FoTx/4aGRibg19TWcRFxaYIBoRD/v1xdSzT6KRU+bwDpM1npw0bza+21G/4s0hqR/uRe66UJky4r1pRxqYNHOSXQrenZfE3Q6nVbfNDrgopLl3NaYCUVvrMB/8cH4ihXL0he7XRuQBC+3+GHaHvfMSAIPyDwbQYE4QcMfohGMEA6fCzAevqZBbwme6rpbZoEyp9UyOn7DHCCDwcH+QjrpGnz26nIK0101NSONE9QAQrSjfkTZ0HWK50o/DsGSIc/c96LxJZ356q+48LjUkV/+eV79zGxtL34/SWup6EfMfgdGuDNtHP46AnCO8yJAvy+A3OY2tyNeXI6MfhuDfAGvsEyhHiEsXfyMGGOLy5ZzpQBlrRMYvC7uTLA2w8uxsNJjmPHT4o+sh+Xf84M/LMVVUThOxngy2rnnZ1ka///AGfJ7PAxfMBSbrns9e1E4YsM8AU+/g7pCOPI0ZMFA8bkT2cst/wMUfiCAb6u84dk5ROPMGphWWw3YH3ZX5iB39rayoXBWWeS8HkDpGyySNf+40l6x/n/9H/PMmMALoVJw++m6QUGSNjh7tt/iOhLzn12iah8kLXcMmn4LgzwHH407ApvEK79N6dmCuGFOc8uZiu3PGgUcfjd1CIDvIvt4Bad5MDpxjG2w9LFHphbxs0XafgOBngfWNtGuPZ/w+Y3BPhYPniFkcyaPbdMA/4dA3yLap7/7iLxLb7dAAxFMJVb/u1cKvA7NqAD+P2G5hF9wevXW/jrbOwGYLkHx9DFHlFwyyMN+O4N6CSev5Bw7f/u8s9E8fwjx75mxoB9Bw5Rg9/VpQEeJFPKCdf+P1/yigD/EdNgpi72WPryWmrwnQ3wAD7G6nHKIDn6DsoRMlmkg3tSx+DHxlGDLzbAwzRiKpyFJTm+hYN8jmlE0sE9SbVFNXW2W00owecNwJ4p3uRw4SZ17vhXp4i9JNbY2OFjXri6ppYZA959bxdV+F1VxhYwwFLrbQIdK87GTfgd5HHnu9Y0dxLnaPEuZ8ek+RC4T5qlMXXm8/Tgq42oajSgkpXqBdYu9ohLGEQTPvwPMFTIFJGJn7JSOvKfA4eZMeAI3HFHFz5vwB4Z3yeLAfhYbXCDoYs9VkB9K134Ru5hlWGDDHpjzWahaKoASslZGpmjJlCFj+qiMsySYYc4FirWMODFymhsbOaU8O+iCd9mQC8zf20ldogLdLng+QvfM2PAB7t2U4ffVWmwXVuJA9vzBRJ+2pBcpqafwqJSuvBt8/9bbX0DIhNzAlkoy9rFHvFQGEYTPm+A2pAlurwbeyMGqkp5DyO1nzhOnT5LHT4sPy/LZOafim5Qx8aUgYAPPQuIB/ekjHVwkxZd+Pzq50XnBg7qxBBFuKXJ3/X5eJEGSyP3iSm04Tc/oNK77sDKdwX18+GITVDux8rA/4mYj6YF/2GbAaXum/hAE8oeEeZKf55M2fvZfmYMwMwcVfhKY1WnbW+xJas/jwUVQzaMleDbExDhpQUf1VVh/LVHvcSgJet6f57Jynx8AjdnwSJBs1HzO1Kpkwo9UZFrzZpXwndUogm/i9Kw2qtWhtiS9W47EEc7tuMOPmy6vpR508oQB/bDhemoMghfMvwKuTxW5VNHVeyHiy1Zg/B9hn/pQXWsXlJP4R6hlkgA+00QvtfwKx9U94oh0lXbNh2ZDgbhez7n+zztuBvYDxcgrwrC7xT+Rq8/uN4MbMkKDSnPBeG3h2+o8HidL3Xgjhm7ggL8pnsdfheVsRHDC1qt9Zcyfw8M4AH4UuyNeO/BN1zGqOZD2rhusoAPyCdgb0Rsz4cd4u5a+EpDHWaybMmUdvF8dkbefdAbJRGblGGfLGzVBODPgmrl2vhW5uGrjK2gWvgLPws/y/GjitULDymNFiGHS3D8D9feydUotaREAAAAAElFTkSuQmCC', + 'base64', +); + +/** + * A fresh attachment object per message, so nodemailer never shares state + * between two sends. Every HTML mail from buildEmailHtml needs it. + */ +export function emailLogoAttachment(): Mail.Attachment { + return { + filename: 'trek-logo.png', + content: EMAIL_LOGO_PNG, + contentType: 'image/png', + contentDisposition: 'inline', + cid: EMAIL_LOGO_CID, + }; +} diff --git a/server/src/nest/notifications/mailer/mailer.service.ts b/server/src/nest/notifications/mailer/mailer.service.ts index 60e3197e6..9630e044f 100644 --- a/server/src/nest/notifications/mailer/mailer.service.ts +++ b/server/src/nest/notifications/mailer/mailer.service.ts @@ -6,6 +6,7 @@ import { logError, logInfo, logDebug, logWarn } from '../../audit/audit-log.logg import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto'; import { DatabaseService } from '../../database/database.service'; import { buildEmailHtml, buildPasswordResetHtml } from './email-html'; +import { emailLogoAttachment } from './email-logo'; import { describeSmtpFailure, describeSmtpGap, parseSmtpPort, type SmtpTarget } from './smtp-diagnostics'; interface SmtpConfig { @@ -188,6 +189,7 @@ export class MailerService { subject: `TREK — ${strings.subject}`, text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`, html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang), + attachments: [emailLogoAttachment()], }); logInfo(`Password reset email sent to=${to}`); return { delivered: 'email' }; @@ -216,6 +218,7 @@ export class MailerService { subject: `TREK — ${subject}`, text: body, html: buildEmailHtml(subject, body, lang, navigateTarget), + attachments: [emailLogoAttachment()], }); logInfo(`Email sent to=${to} subject="${subject}"`); logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`); diff --git a/server/tests/e2e/mailer.e2e.test.ts b/server/tests/e2e/mailer.e2e.test.ts new file mode 100644 index 000000000..d5a881197 --- /dev/null +++ b/server/tests/e2e/mailer.e2e.test.ts @@ -0,0 +1,160 @@ +/** + * Mailer e2e (#2507): the message a real POST /api/auth/forgot-password puts on + * the wire, built by the real AuthModule, the real MailerService and nodemailer's + * real MIME composer against a temp SQLite db. Only the socket is gone: + * createTransport hands back nodemailer's stream transport, which produces the + * exact bytes an SMTP relay would receive. + * + * The header logo used to be an SVG data: URI inside the HTML. Gmail strips + * those, Outlook blocks them, and neither renders SVG, so the header showed a + * broken image. These cases pin the replacement: a PNG part inside the message, + * next to the HTML in multipart/related, that the HTML addresses by Content-ID. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec('PRAGMA foreign_keys = ON'); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, +})); +vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); +vi.mock('../../src/app-config', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAppUrl: () => 'https://trek.example' }; +}); + +const { wire } = vi.hoisted(() => ({ wire: [] as string[] })); +vi.mock('nodemailer', async (importOriginal) => { + const real = (await importOriginal<{ default: typeof import('nodemailer') }>()).default; + return { + default: { + ...real, + createTransport: () => { + const stream = real.createTransport({ streamTransport: true, buffer: true, newline: 'windows' }); + return { + async sendMail(mail: Parameters[0]) { + const info = await stream.sendMail(mail); + wire.push(info.message.toString()); + return info; + }, + }; + }, + }, + }; +}); + +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { createUser } from '../helpers/factories'; +import { AuthModule } from '../../src/nest/auth/auth.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; + +interface MimePart { + headers: string; + body: string; +} + +/** The leaf parts of a raw message, in order, with their header block and raw body. */ +function leafParts(raw: string): MimePart[] { + const split = raw.indexOf('\r\n\r\n'); + const headers = raw.slice(0, split); + const body = raw.slice(split + 4); + const boundary = /boundary="([^"]+)"/i.exec(headers.replace(/\r\n\s+/g, ' '))?.[1]; + if (!/^content-type:\s*multipart\//im.test(headers) || !boundary) return [{ headers, body }]; + return body + .split(`--${boundary}`) + .slice(1, -1) + .flatMap(chunk => leafParts(chunk.replace(/^\r\n/, ''))); +} + +/** The decoded text of a text/* part, honouring its transfer encoding. */ +function partText(part: MimePart): string { + if (!/^Content-Transfer-Encoding: quoted-printable/im.test(part.headers)) return part.body; + const bytes = part.body + .replace(/=\r\n/g, '') + .replace(/=([0-9A-F]{2})/g, (_m, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); + return Buffer.from(bytes, 'latin1').toString('utf8'); +} + +describe('Mailer e2e: the header logo in a mail on the wire (#2507)', () => { + let server: Server; + let app: Awaited>; + let email: string; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.useGlobalFilters(new TrekExceptionFilter()); + nest.useGlobalPipes(new ZodValidationPipe()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + createTables(db as never); + runMigrations(db as never); + email = createUser(db as never, { username: 'mail-e2e', email: 'mail-e2e@example.test' }).user.email; + const setting = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)'); + setting.run('smtp_host', 'mail.internal.example'); + setting.run('smtp_port', '587'); + setting.run('smtp_from', 'trek@example.test'); + app = await build(); + server = app.getHttpServer(); + + const res = await request(server).post('/api/auth/forgot-password').send({ email }); + expect(res.status).toBe(200); + }); + + afterAll(async () => { + await app.close(); + }); + + it('sends exactly one message, the HTML next to the logo in multipart/related', () => { + expect(wire).toHaveLength(1); + const raw = wire[0]; + expect(raw).toMatch(/^Content-Type: multipart\/alternative;/m); + expect(raw).toMatch(/^Content-Type: multipart\/related; type="text\/html";/m); + }); + + it('the HTML addresses the logo by Content-ID and carries no data: URI', () => { + const parts = leafParts(wire[0]); + const htmlPart = parts.find(p => /^Content-Type: text\/html/im.test(p.headers))!; + const html = partText(htmlPart); + expect(html).not.toContain('data:'); + + const cid = / p.headers.includes(`Content-ID: <${cid}>`)); + expect(logo, `no MIME part with Content-ID <${cid}>`).toBeDefined(); + expect(logo!.headers).toMatch(/^Content-Type: image\/png/im); + expect(logo!.headers).toMatch(/^Content-Disposition: inline/im); + expect(logo!.headers).toMatch(/^Content-Transfer-Encoding: base64/im); + const png = Buffer.from(logo!.body.replace(/\s+/g, ''), 'base64'); + expect(png.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + }); + + it('the plain-text alternative stays first and untouched', () => { + const parts = leafParts(wire[0]); + expect(parts[0].headers).toMatch(/^Content-Type: text\/plain/im); + expect(partText(parts[0])).toContain('https://trek.example/reset-password?token='); + }); +}); diff --git a/server/tests/unit/nest/mailer.service.test.ts b/server/tests/unit/nest/mailer.service.test.ts index 55d0707bb..cf8de3513 100644 --- a/server/tests/unit/nest/mailer.service.test.ts +++ b/server/tests/unit/nest/mailer.service.test.ts @@ -267,3 +267,61 @@ describe('MailerService test send', () => { expect(lines.some(line => line.includes('SMTP test email sent to=admin@example.com smtp=mail.internal.example:587'))).toBe(true); }); }); + +describe('MailerService header logo (#2507)', () => { + interface SentAttachment { + cid?: string; + contentType?: string; + contentDisposition?: string; + content?: Buffer; + } + + /** The options object of the most recent sendMail() call. */ + function lastMail(): { html?: string; attachments?: SentAttachment[] } { + const calls = sendMail.mock.calls; + return calls[calls.length - 1][0] as { html?: string; attachments?: SentAttachment[] }; + } + + /** + * The attachment the HTML's header image resolves to. Before #2507 the logo was + * an SVG data: URI, which Gmail strips and Outlook blocks; it has to be a PNG + * part inside the message, marked inline and addressed by Content-ID. + */ + function expectLogoResolves(mail: { html?: string; attachments?: SentAttachment[] }): void { + const cid = / a.cid === cid); + expect(logo).toBeDefined(); + expect(logo!.contentType).toBe('image/png'); + expect(logo!.contentDisposition).toBe('inline'); + const png = logo!.content!; + expect(png.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + // IHDR width and height: twice the 48px the header shows it at. + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([96, 96]); + } + + it('MAILER-015: a notification mail carries the logo its HTML points to', async () => { + configureSmtp(); + + expect(await newMailer().sendEmail('someone@example.com', 'Subject', 'Body')).toBe(true); + + expectLogoResolves(lastMail()); + }); + + it('MAILER-016: the password-reset mail carries it too', async () => { + configureSmtp(); + + expect((await newMailer().sendPasswordResetEmail('someone@example.com', 'https://trek.example/reset', null)).delivered).toBe('email'); + + expectLogoResolves(lastMail()); + }); + + it('MAILER-017: the plain-text test mail has no HTML, so it carries no logo either', async () => { + configureSmtp(); + + await newMailer().testSmtp('admin@example.com'); + + expect(lastMail().html).toBeUndefined(); + expect(lastMail().attachments).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/nest/notification-transports.test.ts b/server/tests/unit/nest/notification-transports.test.ts index 6a48ac339..d799a9c92 100644 --- a/server/tests/unit/nest/notification-transports.test.ts +++ b/server/tests/unit/nest/notification-transports.test.ts @@ -232,6 +232,14 @@ describe('buildEmailHtml', () => { // Both should have the same footer text expect(unknown).toContain('notifications enabled in TREK'); }); + + it('points the header logo at an inline part by Content-ID, not at a data: URI (#2507)', () => { + const html = buildEmailHtml('Subject', 'Body', 'en'); + // Gmail strips data: URIs and Outlook blocks them, which left a broken image + // in the header of every mail. + expect(html).not.toContain('data:'); + expect(html).toMatch(/TREK Date: Fri, 25 Sep 2026 19:48:12 +0200 Subject: [PATCH 10/14] fix(costs): read an expense entered in the display currency as it was typed (#2525) A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked cost is labelled in the trip currency, a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch. --- .../src/components/Budget/CostsPanel.test.tsx | 236 +++++++++++++++++- client/src/components/Budget/CostsPanel.tsx | 88 +++++-- .../src/components/Budget/expenseFx.test.ts | 63 +++++ client/src/components/Budget/expenseFx.ts | 87 +++++++ .../Planner/BookingCostsSection.tsx | 8 +- .../Planner/ReservationModal.test.tsx | 53 +++- .../components/Planner/ReservationModal.tsx | 18 +- .../Planner/TransportModal.test.tsx | 4 +- .../src/components/Planner/TransportModal.tsx | 18 +- .../src/components/Planner/importedPrice.ts | 25 ++ client/src/hooks/useExchangeRates.test.ts | 144 ++++++++++- client/src/hooks/useExchangeRates.ts | 143 +++++++++-- .../mobile/screens/trip/sheets/MCostSheet.tsx | 28 ++- .../trip/sheets/MTransportFormSheet.tsx | 8 +- .../mobile/screens/trip/tabs/MCostsTab.tsx | 40 +-- .../mobile/screens/trip/tabs/costsModel.ts | 21 +- client/src/pages/SharedTripPage.test.tsx | 106 ++++++++ client/src/pages/SharedTripPage.tsx | 37 ++- client/src/pages/sharedTrip/useSharedTrip.ts | 3 +- .../unit/mobile/trip/MCostSheet.test.tsx | 64 ++++- .../tests/unit/mobile/trip/MCostsTab.test.tsx | 71 +++++- .../mobile/trip/MTransportFormSheet.test.tsx | 12 + .../tests/unit/mobile/trip/costsModel.test.ts | 82 ++++++ server/src/nest/budget/budget.controller.ts | 4 +- server/src/nest/budget/budget.mcp.ts | 10 +- server/src/nest/budget/budget.service.ts | 216 +++++++++++++--- .../reservations/reservations.controller.ts | 9 +- .../src/nest/reservations/reservations.rpc.ts | 8 +- .../nest/reservations/reservations.service.ts | 23 +- .../trip-read-model.service.ts | 9 +- server/src/nest/trips/trip-prompts.mcp.ts | 20 +- server/src/nest/trips/trips.mcp.ts | 4 +- server/tests/e2e/budget.e2e.test.ts | 21 ++ server/tests/e2e/reservations.e2e.test.ts | 30 +++ .../unit/mcp/tools-budget-advanced.test.ts | 40 +++ server/tests/unit/mcp/tools-prompts.test.ts | 38 ++- .../tests/unit/nest/budget.controller.test.ts | 4 +- .../tests/unit/nest/budget.service.db.test.ts | 157 ++++++++++++ server/tests/unit/nest/budget.service.test.ts | 34 ++- .../unit/nest/reservations.controller.test.ts | 44 +++- .../unit/nest/reservations.service.test.ts | 33 ++- .../unit/nest/trip-read-model.service.test.ts | 56 ++++- server/tests/unit/nest/trips.service.test.ts | 6 +- .../unit/reservations/bookings.rpc.test.ts | 2 + 44 files changed, 1900 insertions(+), 227 deletions(-) create mode 100644 client/src/components/Budget/expenseFx.test.ts create mode 100644 client/src/components/Budget/expenseFx.ts create mode 100644 client/src/components/Planner/importedPrice.ts diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 0c099d0be..9105a0301 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -875,10 +875,11 @@ describe('CostsPanel — overview', () => { const alice = within(card).getByRole('button', { name: /You/ }) await user.click(alice) expect(alice).toHaveAttribute('aria-expanded', 'true') - // The dinner is the server's 92 €, once as the line and once as its only row; - // the raw 100 USD never shows up in the card. + // The dinner is the server's 92 €, once as the line and once as its only row; the + // 100 USD is never converted again here. What was entered labels the row (#2525). expect(within(card).getAllByText('+92,00 €')).toHaveLength(2) expect(within(card).queryByText(/100,00/)).toBeNull() + expect(within(card).getByText('· $100.00')).toBeInTheDocument() // Received and still pending both lower her cost, each line with its one row. expect(within(card).getAllByText('−15,00 €')).toHaveLength(2) expect(within(card).getAllByText('−16,00 €')).toHaveLength(2) @@ -2293,3 +2294,234 @@ describe('CostsPanel — expense modal in another language', () => { expect(screen.queryByText('Excluded')).not.toBeInTheDocument() }) }) + +// ── #2525: a bill entered in the display currency on a trip kept in another ─── +describe('CostsPanel: a bill entered in the display currency (#2525)', () => { + // The reporter's setup: a euro trip, read in dollars, with the bill entered in + // dollars. It was booked at 1.17 dollars to the euro, 685.26 EUR, and that is what + // the balances and settle-up net. Today the euro buys 1.1551 dollars, so those + // euros are $791.55. The dollar's own quote is rounded separately (0.865706 per + // dollar is 1.155126 per euro), which is why the panel converts with the euro's. + beforeEach(() => { + seedAlice() + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } }) + clearExchangeRateCache() + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551, SEK: 10 }, ts: Date.now() })) + }) + afterEach(clearExchangeRateCache) + + const hotel = (over: Partial = {}) => expense({ + id: 401, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17, + expense_date: '2026-08-06', + payers: [{ user_id: 1, amount: 801.76 }], + members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }], + ...over, + }) + + it('FE-W5COSTS-079: a bill whose rate moved shows what was entered and what it was booked at', async () => { + mount([hotel()]) + + await screen.findByText('Aparthotel Silver') + // The entered amount was nowhere on the row, so $791.57 read as a wrong copy of + // the 801.76 in the dialog. The pill beside the line is the last step. + expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument() + // Row total and payer chip stay the booked euros at today's rate, and so does what + // I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here + // had Bob pay back more than he owed and reopened the trip. + expect(screen.getAllByText('$791.55')).toHaveLength(2) + expect(screen.getByText('you lent $395.77')).toBeInTheDocument() + expect(screen.queryByText('you lent $400.88')).toBeNull() + }) + + it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => { + mount([ + hotel({ id: 407, name: 'Villa', total_price: 12345.67, exchange_rate: 1.1551, payers: [{ user_id: 1, amount: 12345.67 }] }), + hotel({ id: 408, name: 'Unpaid deposit', total_price: 250, exchange_rate: 1.1551, payers: [], members: [] }), + ]) + + await screen.findByText('Villa') + // Pill and payer chip; converting back with the dollar's own quote gave $12,346.05. + expect(screen.getAllByText('$12,345.67')).toHaveLength(2) + expect(screen.queryByText(/12,346\.0[56]/)).toBeNull() + expect(screen.getByText('$12,595.67 spent')).toBeInTheDocument() + const total = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement! + expect(total.textContent).toContain('$12,595.67') + const outstanding = screen.getByText('Outstanding amount').parentElement!.parentElement!.parentElement! + expect(outstanding.textContent).toContain('$250.00') + // Nothing to explain under either row. + expect(screen.queryByText(/^\$12,345\.67 →/)).toBeNull() + expect(screen.queryByText(/^\$250\.00 →/)).toBeNull() + }) + + it('FE-W5COSTS-088: the CSV export adds the trip-currency amount every sum is built from', async () => { + let exported: Blob | null = null + const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' }) + const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + try { + mount([hotel()]) + await screen.findByText('Aparthotel Silver') + fireEvent.click(screen.getByTitle('Export CSV')) + await waitFor(() => expect(exported).toBeTruthy()) + const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n') + expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note') + expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/) + } finally { + createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore() + } + }) + + it('FE-W5COSTS-080: the totals add up the figures the rows show', async () => { + mount([ + hotel(), + hotel({ id: 402, name: 'Dinner', category: 'food', total_price: 120, payers: [{ user_id: 2, amount: 120 }] }), + ]) + + await screen.findByText('Aparthotel Silver') + // 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is + // $910.02, on the day header and on the total spend card. + expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument() + expect(screen.getByText('$910.02 spent')).toBeInTheDocument() + // Label, its text block, the header row, then the card itself. + const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement! + expect(card.textContent).toContain('$910.02') + }) + + it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => { + // Nothing cached: the rates land after the expenses and the settlement, and the + // totals used to keep the figure they were first added up with. + clearExchangeRateCache() + server.use(http.get('https://api.frankfurter.dev/v2/rates', async () => { + await new Promise(r => setTimeout(r, 150)) + return HttpResponse.json([{ quote: 'EUR', rate: 0.8 }]) + })) + mount([hotel({ id: 406, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })]) + + // 100 euro at 0.8 euro to the dollar. + expect(await screen.findByText(/100,00\s€ → \$125\.00/)).toBeInTheDocument() + const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement! + await waitFor(() => expect(card.textContent).toContain('$125.00')) + }) + + it('FE-W5COSTS-081: a euro row converts live, a third currency shows the euros it was booked at', async () => { + mount([ + hotel({ id: 403, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] }), + hotel({ id: 404, name: 'Ferry', category: 'transport', total_price: 1000, currency: 'SEK', exchange_rate: 10, payers: [{ user_id: 1, amount: 1000 }] }), + // Written before the freeze existed: nothing was booked, so dollars stay dollars. + hotel({ id: 405, name: 'Old taxi', category: 'transport', total_price: 50, currency: 'USD', exchange_rate: 1, payers: [{ user_id: 1, amount: 50 }] }), + ]) + + await screen.findByText('Museum') + // The trip currency has no frozen rate of its own and goes to dollars live. + expect(screen.getByText(/^100,00\s€ → \$115\.51$/)).toBeInTheDocument() + // 1000 SEK at 10 per euro is 100 euro, then live to dollars in the pill. + expect(screen.getByText(/^1\s000,00\skr → 100,00\s€$/)).toBeInTheDocument() + expect(screen.getAllByText('$50.00').length).toBeGreaterThanOrEqual(2) + expect(screen.queryByText(/^\$50\.00 →/)).toBeNull() + }) + + it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => { + mount([hotel()], { + settlements: [ + { id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' }, + // Rows from before transfers had a currency were entered in the display + // currency, which is how the server settles them. + { id: 22, from_user_id: 2, to_user_id: 1, amount: 30, currency: null, created_at: '2026-08-08 09:00:00' }, + ], + }) + + await screen.findByText('Aparthotel Silver') + expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument() + expect(screen.getByText('$395.77')).toBeInTheDocument() + expect(screen.getByText('$30.00')).toBeInTheDocument() + expect(screen.queryByText('$34.65')).toBeNull() + }) + + it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => { + const user = userEvent.setup() + mount([hotel()]) + + await screen.findByText('Aparthotel Silver') + await user.click(screen.getByTitle('Edit')) + expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument() + // Same currency as the list, so the dialog said nothing, and 801.76 there beside + // $791.57 in the list looked like two different expenses. + const hint = screen.getByText(/live rate/).parentElement as HTMLElement + expect(within(hint).getByText('$801.76')).toBeInTheDocument() + expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument() + expect(within(hint).getByText('$791.55')).toBeInTheDocument() + // Each share next to what it counts as, the figure the row's "you lent" is made of. + expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument() + }) + + it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => { + const user = userEvent.setup() + mount([hotel()], { + balances: [ + { user_id: 1, username: 'alice', avatar_url: null, balance: 395.78 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -395.78 }, + ], + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 395.78 }], + finalBudgets: [ + { + user_id: 1, username: 'alice', avatar_url: null, expenses: 791.55, reimbursed: 0, pending: 395.78, final: 395.77, + sources: { fronted: [{ item_id: 401, cents: 79155 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 39578 }] }, + }, + ], + }) + + const card = (await screen.findByText('Final budget')).parentElement as HTMLElement + await user.click(await within(card).findByRole('button', { name: /You/ })) + expect(within(card).getAllByText('+$791.55')).toHaveLength(2) + expect(within(card).getByText('Aparthotel Silver')).toBeInTheDocument() + expect(within(card).getByText('· $801.76')).toBeInTheDocument() + }) + + it('FE-W5COSTS-089: the final budget lists a bill that reads as typed without a second amount', async () => { + const user = userEvent.setup() + mount([hotel({ exchange_rate: 1.1551 })], { + balances: [ + { user_id: 1, username: 'alice', avatar_url: null, balance: 400.88 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -400.88 }, + ], + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 400.88 }], + finalBudgets: [ + { + user_id: 1, username: 'alice', avatar_url: null, expenses: 801.76, reimbursed: 0, pending: 400.88, final: 400.88, + sources: { fronted: [{ item_id: 401, cents: 80176 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 40088 }] }, + }, + ], + }) + + const card = (await screen.findByText('Final budget')).parentElement as HTMLElement + await user.click(await within(card).findByRole('button', { name: /You/ })) + expect(within(card).getAllByText('+$801.76')).toHaveLength(2) + expect(within(card).queryByText('· $801.76')).toBeNull() + }) + + it('FE-W5COSTS-083: an expense saved without a currency opens in the trip currency, not the display one', async () => { + const user = userEvent.setup() + let put: Record | null = null + server.use(http.put('/api/trips/1/budget/405', async ({ request }) => { + put = await request.json() as Record + return HttpResponse.json({ item: hotel({ id: 405, currency: 'EUR' }) }) + })) + mount([hotel({ id: 405, name: 'Tram pass', category: 'transport', total_price: 100, currency: null, exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })]) + + await screen.findByText('Tram pass') + // The list reads the missing currency as the trip's own. + expect(screen.getByText(/100,00\s€ → \$115\.51/)).toBeInTheDocument() + + await user.click(screen.getByTitle('Edit')) + expect(await screen.findByDisplayValue('100,00')).toBeInTheDocument() + // Seeding the display currency here would label 100 euro as 100 dollars, and a + // plain save would then store it that way. + expect(screen.getByText(/^EUR/)).toBeInTheDocument() + expect(screen.queryByText(/^USD/)).toBeNull() + + // Saving without touching anything keeps the 100 euro. + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(put).toMatchObject({ total_price: 100, currency: 'EUR' })) + }) +}) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 95e2d5850..de8a4b03f 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -9,7 +9,8 @@ import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { budgetApi } from '../../api/client' import { saveWithReceipts } from './receiptUploads' -import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates' +import { convertBooked, convertedLine, tripAmountOf, useExchangeRates } from '../../hooks/useExchangeRates' +import { splitShareLabel, useExpenseFx } from './expenseFx' import { useIsMobile } from '../../hooks/useIsMobile' import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' import { downloadBlob, openFile } from '../../utils/fileDownload' @@ -85,7 +86,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() // Pre-rework rows stored currency = NULL, meaning "the trip's own currency". const tripCurrency = (trip?.currency || base).toUpperCase() - const { convert } = useExchangeRates(base) + // Anchored on the trip currency's quote, the one the server books with (#2525). + const { convert } = useExchangeRates(base, tripCurrency) const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency]) const [settlement, setSettlement] = useState(null) // A failed settlement read leaves `settlement` null, which the empty views would @@ -145,12 +147,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps [convert, tripCurrency], ) const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e) - // A transfer freezes its own rate at settle time, in its own table (#1445). + // A transfer freezes its own rate at settle time, in its own table (#1445). One + // without a currency predates that and was entered in the display currency, which + // is how the server settles it too, not in the trip's. const settled = useCallback( - (s: Settlement) => convertBooked(s.amount, s.currency, s.exchange_rate, tripCurrency, convert), - [convert, tripCurrency], + (s: Settlement) => convertBooked(s.amount, s.currency || base, s.exchange_rate, tripCurrency, convert), + [convert, tripCurrency, base], ) const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), e) + // The line under an amount shown converted: what was entered, then where it went (#2525). + const lineOf = (amount: number, e: BudgetItem, shown: number) => + convertedLine(amount, e.currency, e.exchange_rate, tripCurrency, base, shown) // "Unfinished": a recorded total nobody has paid yet — counts toward the trip // total but stays out of settlements until who-paid is filled in. A negative // total (a refund, #2176) is just as unfinished until its recipient is named. @@ -169,6 +176,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return booked(myShare, e) } + // `booked` carries the rates. They can land after the expenses, and without it in the + // deps the cards kept the sums they were first added up with while the rows moved on. const totals = useMemo(() => { const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0) @@ -178,7 +187,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0) const outstandingCount = budgetItems.filter(isUnfinished).length return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount } - }, [budgetItems, settlement, me]) + }, [budgetItems, settlement, me, booked]) // ── filtering + day grouping ──────────────────────────────────────────── const filtered = useMemo(() => { @@ -192,7 +201,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const q = search.trim().toLowerCase() if (q) list = list.filter(e => e.name.toLowerCase().includes(q)) return list - }, [budgetItems, filter, search, catFilter, dayFilter, me]) + }, [budgetItems, filter, search, catFilter, dayFilter, me, booked]) // Settlements ("payments") shown inline in the ledger. They have no name, so a // text search hides them; they're excluded from the "owed" expense filter and, @@ -290,15 +299,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } } - const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note'] + // Read in another currency than the trip's, what each row counts as in the trip + // currency too, the figure every sum is built from (#2525). + const tripCol = tripCurrency !== base + const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? ['Amount (' + tripCurrency + ')'] : []), 'Amount (' + base + ')', 'Note'] const rows = [header.join(sep)] const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')) for (const e of items) { const cur = curOf(e) const note = readUserNote(e) + const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, tripCurrency, convert) rows.push([ esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)), (e.total_price || 0).toFixed(currencyDecimals(cur)), cur, + ...(tripCol ? [inTrip.toFixed(currencyDecimals(tripCurrency))] : []), baseTotal(e).toFixed(currencyDecimals(base)), esc(note), ].join(sep)) @@ -768,7 +782,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps function ExpenseRow({ e }: { e: BudgetItem }) { const c = catMeta(e.category) const Icon = c.Icon - const cur = curOf(e) + const line = lineOf(e.total_price || 0, e, baseTotal(e)) const payers = (e.payers || []).filter(p => p.amount !== 0) const net = round2(myPaidOf(e) - myShareOf(e)) const unfinished = isUnfinished(e) @@ -837,9 +851,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps )} - {cur !== base && ( + {line && (
- {fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))} + {fmt(line.entered.amount, line.entered.currency)} {'→'} {fmt(line.into.amount, line.into.currency)}
)} {/* Under the name, because a chip reading "Y 122,00" says nothing on @@ -894,6 +908,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps function SettlementRow({ s }: { s: Settlement }) { // Legacy transfers carry no currency and were entered in the display base. const cur = (s.currency || base).toUpperCase() + // Booked in the trip currency like an expense, so it is explained the same way. + const line = convertedLine(s.amount, cur, s.exchange_rate, tripCurrency, base, settled(s)) return (
@@ -901,7 +917,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.payment')} - {cur !== base && · {fmt(s.amount, cur)} → {fmt(settled(s))}} + {line && · {fmt(line.entered.amount, line.entered.currency)} → {fmt(line.into.amount, line.into.currency)}}
@@ -1000,6 +1016,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // server's display cents, not a conversion of the expense list done here. const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v)) const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems) + // Beside a converted row's name, what was entered, so the breakdown names the bill + // the way the list above it does rather than by a figure nobody typed. + const enteredOf = (itemId: number, shown: number) => { + const e = budgetItems.find(i => i.id === itemId) + const line = e ? lineOf(paidByUser(e, row.user_id), e, shown) : null + return line ? fmt(line.entered.amount, line.entered.currency) : null + } const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const const detail = (label: string, value: string) => (
@@ -1022,12 +1045,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.finalExpenses')}
- {fronted.map(r => ( -
- {r.name} - {signed(r.amount)} -
- ))} + {fronted.map(r => { + const entered = enteredOf(r.item_id, r.amount) + return ( +
+ + {r.name}{entered && · {entered}} + + {signed(r.amount)} +
+ ) + })}
)} @@ -1237,12 +1265,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const toast = useToast() const isMobile = useIsMobile() const { addBudgetItem, updateBudgetItem } = useTripStore() - const { convert } = useExchangeRates(base) const sym = (c: string) => SYMBOLS[c] || (c + ' ') + // A saved expense without a currency opens in the trip's own (#2525). + const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing) const [name, setName] = useState(editing?.name || prefill?.name || '') const [cat, setCat] = useState(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) - const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) + const [currency, setCurrency] = useState(editingCurrency) const [day, setDay] = useState(editing?.expense_date || localToday()) const [note, setNote] = useState(() => readUserNote(editing)) // Edit and prefill seeds are padded to the currency's decimals (#2175): the DB @@ -1250,7 +1279,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo // 5,00 as "5". A prefill has no currency of its own — it is read as `base`, // which is also what the currency field starts on. const [total, setTotal] = useState(() => { - if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : '' + if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : '' if (prefill?.amount != null) return amountToInputString(prefill.amount, base) return '' }) @@ -1334,6 +1363,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo }, [ticketItems]) const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) + const fx = preview(totalNum, currency) const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) // How much is still to be handed out, read on the total's own side: on a refund @@ -1591,12 +1621,18 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
- {currency !== base && totalNum !== 0 && ( + {fx && (
{formatMoney(totalNum, currency, locale)} - ≈ - {formatMoney(convert(totalNum, currency), base, locale)} - · {t('costs.liveRate')} + {fx.inTrip != null && <> + → + {formatMoney(fx.inTrip, tripCur, locale)} + } + {fx.shown != null && <> + ≈ + {formatMoney(fx.shown, base, locale)} + · {t('costs.liveRate')} + }
)} @@ -1823,7 +1859,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{splitMode === 'equally' ? ( - {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} + {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })} ) : ( diff --git a/client/src/components/Budget/expenseFx.test.ts b/client/src/components/Budget/expenseFx.test.ts new file mode 100644 index 000000000..db6b10748 --- /dev/null +++ b/client/src/components/Budget/expenseFx.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { formatMoney } from '../../utils/formatters' +import { editingCurrencyOf, expenseFxPreview, splitShareLabel } from './expenseFx' + +// A euro trip read in dollars, converted with the euro's own quote: one euro is 1.1551 +// dollars today, and 1.17 on the day the hotel was booked (#2525). +const convert = (amount: number, from: string | null | undefined): number => + (amount / ({ EUR: 1, USD: 1.1551, JPY: 170 } as Record)[(from || 'USD').toUpperCase()]) * 1.1551 +const hotel = { currency: 'USD', exchange_rate: 1.17 } +const base = { editing: hotel, editingCurrency: 'USD', tripCurrency: 'EUR', base: 'USD', convert } + +describe('editingCurrencyOf', () => { + it('opens a saved expense without a currency in the trip currency', () => { + expect(editingCurrencyOf({ currency: null }, 'eur', 'USD')).toBe('EUR') + expect(editingCurrencyOf({ currency: 'jpy' }, 'EUR', 'USD')).toBe('JPY') + }) + + it('starts a new expense, or one on a trip without a currency, in the display currency', () => { + expect(editingCurrencyOf(null, 'EUR', 'usd')).toBe('USD') + expect(editingCurrencyOf({ currency: null }, null, 'usd')).toBe('USD') + }) +}) + +describe('expenseFxPreview', () => { + it('previews an edit that keeps the currency at the rate frozen on entry', () => { + const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })! + expect(fx.inTrip).toBeCloseTo(685.26, 2) + expect(fx.shown).toBeCloseTo(791.55, 2) + }) + + it('has nothing to preview for a bill in the display currency that reads back as typed', () => { + expect(expenseFxPreview({ ...base, editing: { currency: 'USD', exchange_rate: 1.1551 }, total: 12345.67, currency: 'USD' })).toBeNull() + expect(expenseFxPreview({ ...base, editing: null, total: 250, currency: 'USD' })).toBeNull() + expect(expenseFxPreview({ ...base, total: 0, currency: 'USD' })).toBeNull() + }) + + it('previews a new or re-currencied amount at today\'s rate', () => { + expect(expenseFxPreview({ ...base, editing: null, total: 1700, currency: 'JPY' })).toEqual({ inTrip: null, shown: convert(1700, 'JPY') }) + // Switching the hotel to yen re-freezes on save, so the old rate does not apply. + expect(expenseFxPreview({ ...base, total: 1700, currency: 'JPY' })?.inTrip).toBeNull() + }) + + it('stops at the trip amount when the viewer reads the trip currency', () => { + expect(expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })).toEqual({ inTrip: 801.76 / 1.17, shown: null }) + }) +}) + +describe('splitShareLabel', () => { + const sym = (c: string) => (c === 'USD' ? '$' : c + ' ') + + it('prints the entered share, then the share the row lends or borrows', () => { + const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' }) + expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', sym, 'en-US')).toBe('$400.88 → $395.77') + // Read in the trip currency, the share is the booked euros. + const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' }) + expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', sym, 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`) + }) + + it('prints the entered share alone when nothing is converted', () => { + expect(splitShareLabel(125, 'USD', null, 2, 'USD', sym, 'en-US')).toBe('$125.00') + expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', sym, 'en-US')).toBe('$0.00') + }) +}) diff --git a/client/src/components/Budget/expenseFx.ts b/client/src/components/Budget/expenseFx.ts new file mode 100644 index 000000000..3619bed06 --- /dev/null +++ b/client/src/components/Budget/expenseFx.ts @@ -0,0 +1,87 @@ +import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates' +import { useTripStore } from '../../store/tripStore' +import { currencyDecimals, formatMoney } from '../../utils/formatters' + +/** + * The conversion preview of the expense editors, shared by the desktop ExpenseModal and the + * phone MCostSheet so the two can never preview the same save differently (#2525). The + * editors only lay it out. + */ + +interface EditedExpense { + currency?: string | null + exchange_rate?: number | null +} + +/** + * The currency an edited expense opens in. A saved expense without a currency is in the + * trip's own, which is how the lists read it; opening it in the display currency relabelled + * the amount, and a save then stored it in that currency. + */ +export function editingCurrencyOf(editing: EditedExpense | null | undefined, tripCurrency: string | null | undefined, base: string): string { + return ((editing && (editing.currency || tripCurrency)) || base).toUpperCase() +} + +export interface ExpenseFx { + /** The amount in the trip currency, when the save keeps a rate frozen on entry. */ + inTrip: number | null + /** The amount in the display currency at today's rate; null when `inTrip` already is. */ + shown: number | null +} + +/** + * What the amount being edited will count as. An edit that keeps the currency keeps the rate + * frozen when the expense was entered (the server re-freezes only when the currency changes), + * so it is previewed at that rate, the way the list will show it after the save. Anything + * else is previewed at today's rate. Null when there is nothing to convert, including a bill + * in the display currency that still reads back as typed. + */ +export function expenseFxPreview(args: { + total: number + currency: string + editing: EditedExpense | null | undefined + editingCurrency: string + tripCurrency: string + base: string + convert: (amount: number, from: string | null | undefined) => number +}): ExpenseFx | null { + const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args + if (total === 0) return null + const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null + if (currency === base && inTrip == null) return null + const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency) + const unit = 10 ** currencyDecimals(base) + if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null + return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown } +} + +/** + * One equal share as the split summary prints it, "$400.88", followed by what that share + * counts as when the amount is converted, "$400.88 → $390.54". That second figure is the + * one the row's "you lent" is made of, so the editor and the row cannot disagree. + */ +export function splitShareLabel( + each: number, + currency: string, + fx: ExpenseFx | null, + participants: number, + base: string, + sym: (currency: string) => string, + locale: string, +): string { + const entered = sym(currency) + each.toFixed(2) + const whole = fx ? (fx.shown ?? fx.inTrip) : null + if (whole == null || participants <= 0) return entered + return `${entered} → ${formatMoney(whole / participants, base, locale)}` +} + +/** Everything an expense editor needs to preview a conversion, from the trip in the store. */ +export function useExpenseFx(base: string, editing: EditedExpense | null | undefined) { + const storedTripCurrency = useTripStore(s => s.trip?.currency) + const tripCurrency = (storedTripCurrency || base).toUpperCase() + const { convert } = useExchangeRates(base, tripCurrency) + const editingCurrency = editingCurrencyOf(editing, storedTripCurrency, base) + const preview = (total: number, currency: string) => + expenseFxPreview({ total, currency, editing, editingCurrency, tripCurrency, base, convert }) + return { tripCurrency, editingCurrency, preview } +} diff --git a/client/src/components/Planner/BookingCostsSection.tsx b/client/src/components/Planner/BookingCostsSection.tsx index b8a491985..2234c288b 100644 --- a/client/src/components/Planner/BookingCostsSection.tsx +++ b/client/src/components/Planner/BookingCostsSection.tsx @@ -33,6 +33,10 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = ' const trip = useTripStore(s => s.trip) const displayCurrency = useSettingsStore(s => s.settings.default_currency) const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() + // An amount is printed in its own currency, unconverted. One saved without a + // currency is in the trip's own (#2525), which is how Costs reads it; labelling + // it with the display currency turned a 120 EUR deposit into $120.00. + const ownCurrency = (currency: string | null | undefined) => currency || trip?.currency || base const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : placeId @@ -54,7 +58,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
{t(meta.labelKey)}
{t(hintKey)}
- {formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)} + {formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}
) @@ -72,7 +76,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
{linked.name}
{t(meta.labelKey)}
- {formatMoney(linked.total_price, linked.currency || base, locale)} + {formatMoney(linked.total_price, ownCurrency(linked.currency), locale)} diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index 61bff07bd..884f311a5 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-102 +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-105 import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -1026,7 +1026,7 @@ describe('ReservationModal', () => { expect(screen.getByText('Linked expense')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); - expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation' }); + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation', currency: 'EUR' }); }); it('FE-PLANNER-RESMODAL-061: a prefill without a price creates no cost entry', async () => { @@ -1336,6 +1336,55 @@ describe('ReservationModal', () => { delete window.__addToast; }); + it('FE-PLANNER-RESMODAL-103: a linked cost saved without a currency is shown in the trip currency, not the display one (#2525)', () => { + // A booking's own cost entry is written without a currency, which means the + // trip's. Reading in USD on a EUR trip must not turn the 120 EUR into $120.00. + budgetEnabled(); + seedStore(useSettingsStore, { settings: { default_currency: 'USD' } }); + seedStore(useTripStore, { + trip: buildTrip({ id: 1, currency: 'EUR' }), + budgetItems: [ + { id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: null, category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null }, + ], + }); + render( + , + ); + + expect(screen.getByText('Hotel deposit')).toBeInTheDocument(); + expect(screen.getByText('120,00 €')).toBeInTheDocument(); + expect(screen.queryByText('$120.00')).toBeNull(); + }); + + it('FE-PLANNER-RESMODAL-104: an imported price keeps the currency it was quoted in, in the preview and on save (#2525)', async () => { + // A euro trip, a confirmation priced in dollars. The preview said $801.76 and the + // save sent the amount alone, which the server stored as 801.76 EUR. + budgetEnabled(); + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) }); + const onSave = vi.fn().mockResolvedValue({ id: 82 }); + const prefill = hotelPrefill({ metadata: { price: 801.76, priceCurrency: 'usd' } }); + render(); + + expect(screen.getByText('$801.76')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 801.76, category: 'accommodation', currency: 'USD' }); + }); + + it('FE-PLANNER-RESMODAL-105: a parsed currency that is not a code previews and saves in the trip currency', async () => { + budgetEnabled(); + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) }); + const onSave = vi.fn().mockResolvedValue({ id: 83 }); + const prefill = hotelPrefill({ metadata: { price: 50, priceCurrency: 'dollars' } }); + render(); + + // The server drops such a currency too, so the preview names the one it will store. + expect(screen.getByText('50,00 €')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 50, category: 'accommodation' }); + }); + // ── File error paths ──────────────────────────────────────────────────────── it('FE-PLANNER-RESMODAL-077: a cancelled file dialog changes nothing', () => { diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index ea708266f..6dbdc59ad 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -19,6 +19,7 @@ import { parseReservationMetadata } from '../../utils/flightLegs' import { resolveDayId } from '../../utils/formatters' import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types' import { BookingCostsSection } from './BookingCostsSection' +import { importedPriceEntry } from './importedPrice' import { TravelerPicker } from './TravelerPicker' import type { TripMember } from '../Budget/BudgetPanelMemberChips' import type { BookingExpenseRequest } from './BookingCostsSection.types' @@ -306,11 +307,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p // Imported booking → auto-create the linked cost from the parsed price (what the // old direct import did). Only on create (not edit) and only when there's a price. if (!reservation && prefill && isBudgetEnabled) { - const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : {} - const price = Number(pmeta.price) - if (Number.isFinite(price) && price > 0) { - saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) } - } + const entry = importedPriceEntry(prefill.metadata, form.type) + if (entry) saveData.create_budget_entry = entry } const saved = await onSave(saveData) // Persist the traveler assignment once we have the reservation id (create → save @@ -352,12 +350,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) } } - // On an import review (not yet saved), preview the parsed price as the cost that will be linked. - const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : null - const prefillPrice = Number(prefillMeta?.price) - const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0 - ? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) } - : null + // On an import review (not yet saved), preview the parsed price as the cost that will be + // linked: the same entry the save sends, so the two cannot name different currencies. + const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null + const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null const handleFileChange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0] diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 419601716..957dc378f 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -1090,7 +1090,9 @@ describe('TransportModal', () => { await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); - expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights' }); + // The parsed currency travels with the price (#2525), or the server would store + // it in whatever the trip currency is. + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights', currency: 'EUR' }); }); // ── File edge cases ───────────────────────────────────────────────────────── diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 35815780c..197a12e45 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -18,6 +18,7 @@ import apiClient from '../../api/client' import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types' import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs' import { BookingCostsSection } from './BookingCostsSection' +import { importedPriceEntry } from './importedPrice' import { TravelerPicker } from './TravelerPicker' import type { TripMember } from '../Budget/BudgetPanelMemberChips' import type { BookingExpenseRequest } from './BookingCostsSection.types' @@ -612,11 +613,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel // Imported booking → auto-create the linked cost from the parsed price (what the // old direct import did). Only on create (not edit) and only when there's a price. if (!reservation && prefill && isBudgetEnabled) { - const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : {} - const price = Number(pmeta.price) - if (Number.isFinite(price) && price > 0) { - ;(payload as Record).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) } - } + const entry = importedPriceEntry(prefill.metadata, form.type) + if (entry) (payload as Record).create_budget_entry = entry } const saved = await onSave(payload) // Persist the traveler assignment once we have the reservation id (create → save @@ -660,12 +658,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) } } - // On an import review (not yet saved), preview the parsed price as the cost that will be linked. - const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : null - const prefillPrice = Number(prefillMeta?.price) - const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0 - ? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) } - : null + // On an import review (not yet saved), preview the parsed price as the cost that will be + // linked: the same entry the save sends, so the two cannot name different currencies. + const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null + const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] diff --git a/client/src/components/Planner/importedPrice.ts b/client/src/components/Planner/importedPrice.ts new file mode 100644 index 000000000..bca5f52c5 --- /dev/null +++ b/client/src/components/Planner/importedPrice.ts @@ -0,0 +1,25 @@ +import { typeToCostCategory } from '@trek/shared' + +/** + * The linked cost an imported booking's parsed price becomes, or null when it has none. + * + * The price keeps the currency it was quoted in (#2525). The forms used to send the + * amount alone, so an imported $801.76 was stored as 801.76 in the trip's own currency + * while the form had just previewed it in dollars. A currency that is not a three-letter + * code is left out, which is what the server does with one too, so the preview and the + * saved cost read the same either way. + */ +export function importedPriceEntry( + metadata: unknown, + type: string, +): { total_price: number; category: string; currency?: string } | null { + const meta = metadata && typeof metadata === 'object' ? (metadata as Record) : {} + const price = Number(meta.price) + if (!Number.isFinite(price) || price <= 0) return null + const code = typeof meta.priceCurrency === 'string' ? meta.priceCurrency.trim().toUpperCase() : '' + return { + total_price: price, + category: typeToCostCategory(type), + ...(/^[A-Z]{3}$/.test(code) ? { currency: code } : {}), + } +} diff --git a/client/src/hooks/useExchangeRates.test.ts b/client/src/hooks/useExchangeRates.test.ts index 45eaac209..1eca924b3 100644 --- a/client/src/hooks/useExchangeRates.test.ts +++ b/client/src/hooks/useExchangeRates.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { server } from '../../tests/helpers/msw/server' -import { convertBooked, fetchExchangeRates, clearExchangeRateCache } from './useExchangeRates' +import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, clearExchangeRateCache, tripAmountOf, useExchangeRates } from './useExchangeRates' const FX_URL = 'https://api.frankfurter.dev/v2/rates' @@ -92,4 +93,145 @@ describe('convertBooked', () => { expect(convertBooked(120, 'usd', 1.2, 'eur', live)).toBe(100) expect(convertBooked(100, 'eur', 1, 'EUR', live)).toBe(100) }) + + // #2525: a euro trip read in dollars, the bill entered in dollars. It was booked at 685.26 + // EUR, and that is what the balances and settle-up net and then show at today's rate. The + // row has to show the same figure, or paying back the "you lent" it printed reopens the trip. + it('reads an amount in the display currency through the trip currency too', () => { + const liveUsd = (amount: number, from: string | null | undefined): number => { + const rates: Record = { USD: 1, EUR: 1 / 1.1398 } + const r = rates[(from || 'USD').toUpperCase()] + return r && r > 0 ? amount / r : amount + } + // 685.26 EUR, then at today's 1.1398 dollars to the euro. + expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).toBeCloseTo((801.76 / 1.17) * 1.1398, 6) + expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).not.toBeCloseTo(801.76, 1) + // Without a booked rate there is nothing to go through, and dollars stay dollars. + expect(convertBooked(801.76, 'USD', 1, 'EUR', liveUsd)).toBe(801.76) + }) +}) + +describe('bookedInTrip', () => { + it('gives the trip-currency amount a foreign row was booked at', () => { + expect(bookedInTrip(801.76, 'USD', 1.17, 'EUR')).toBeCloseTo(685.26, 2) + expect(bookedInTrip(12000, 'jpy', 170, 'eur')).toBeCloseTo(70.59, 2) + }) + + it('has nothing to give for a row in the trip currency or one that never froze a rate', () => { + expect(bookedInTrip(100, 'EUR', 1.17, 'EUR')).toBeNull() + expect(bookedInTrip(100, null, 1.17, 'EUR')).toBeNull() + expect(bookedInTrip(100, 'USD', 1, 'EUR')).toBeNull() + expect(bookedInTrip(100, 'USD', null, 'EUR')).toBeNull() + expect(bookedInTrip(100, 'USD', 0, 'EUR')).toBeNull() + }) +}) + +// #2525: the server freezes an entry rate from the trip currency's quote and converts the +// settlement back with it. Read through the display currency's own quote, a separately +// rounded figure, a same-day 12,345.67 USD bill on a euro trip came back as $12,346.05. +describe('useExchangeRates anchored on the trip currency (#2525)', () => { + beforeEach(() => { + clearExchangeRateCache() + }) + + const quotes = (): void => { + server.use(http.get(FX_URL, ({ request }) => { + const base = new URL(request.url).searchParams.get('base') + if (base === 'EUR') return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }, { quote: 'JPY', rate: 170 }]) + if (base === 'USD') return HttpResponse.json([{ quote: 'EUR', rate: 0.87732 }, { quote: 'JPY', rate: 149.1 }]) + return HttpResponse.error() + })) + } + + it('converts the trip currency with its own quote, so a same-day bill reads as typed', async () => { + quotes() + const { result } = renderHook(() => useExchangeRates('usd', 'eur')) + await waitFor(() => expect(result.current.convert(1, 'EUR')).toBe(1.1398)) + const shown = convertBooked(12345.67, 'USD', 1.1398, 'EUR', result.current.convert) + expect(Math.round(shown * 100)).toBe(1234567) + expect(Math.round(convertBooked(250, 'USD', 1.1398, 'EUR', result.current.convert) * 100)).toBe(25000) + // A foreign row that never froze a rate goes through the same quote as well. + expect(result.current.convert(170, 'JPY')).toBeCloseTo(1.1398, 10) + // The display currency's own rates stay what the hook hands out. + expect(result.current.rates).toEqual({ USD: 1, EUR: 0.87732, JPY: 149.1 }) + }) + + it('falls back to the display currency\'s quote while the trip\'s is missing', async () => { + server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD' + ? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }]) + : HttpResponse.error()))) + const { result } = renderHook(() => useExchangeRates('USD', 'EUR')) + await waitFor(() => expect(result.current.convert(8, 'EUR')).toBe(10)) + expect(result.current.convert(5, 'USD')).toBe(5) + }) + + it('fetches one set of rates when the trip is in the display currency', async () => { + let calls = 0 + server.use(http.get(FX_URL, () => { calls++; return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }]) })) + const { result } = renderHook(() => useExchangeRates('EUR', 'EUR')) + await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1)) + expect(calls).toBe(1) + }) +}) + +describe('crossRate', () => { + it('reads units of one currency per another from rates on any base', () => { + expect(crossRate({ EUR: 1, USD: 1.25, GBP: 0.8 }, 'USD', 'GBP')).toBe(1.5625) + expect(crossRate({ EUR: 1, USD: 1.25 }, 'USD', 'EUR')).toBe(1.25) + }) + + it('has nothing to say without both quotes', () => { + expect(crossRate(null, 'USD', 'EUR')).toBeNull() + expect(crossRate({ EUR: 1 }, 'USD', 'EUR')).toBeNull() + expect(crossRate({ EUR: 0, USD: 1 }, 'USD', 'EUR')).toBeNull() + }) +}) + +describe('tripAmountOf', () => { + // Display USD, trip EUR: one euro buys 1.25 dollars and 100 yen. + const live = (amount: number, from: string | null | undefined): number => + amount * ({ EUR: 1.25, USD: 1, JPY: 0.0125 } as Record)[(from || 'USD').toUpperCase()] + + it('is the booked amount for a row booked through the trip currency', () => { + expect(tripAmountOf(801.76, 'USD', 1.17, 'EUR', live)).toBeCloseTo(685.26, 2) + }) + + it('is the amount itself for a row in the trip currency', () => { + expect(tripAmountOf(100, null, null, 'eur', live)).toBe(100) + expect(tripAmountOf(100, 'EUR', 1, 'EUR', live)).toBe(100) + }) + + it('counts a row that never froze a rate at today\'s', () => { + expect(tripAmountOf(1000, 'JPY', 1, 'EUR', live)).toBeCloseTo(10, 10) + // Without rates the conversion is the identity, as it is on the server. + expect(tripAmountOf(1000, 'JPY', null, 'EUR', (a: number) => a)).toBe(1000) + }) +}) + +describe('convertedLine', () => { + it('shows the entered amount and the booked trip amount for a bill booked through the trip currency', () => { + expect(convertedLine(801.76, 'usd', 1.17, 'EUR', 'USD', 791.55)).toEqual({ + entered: { amount: 801.76, currency: 'USD' }, + into: { amount: 801.76 / 1.17, currency: 'EUR' }, + }) + expect(convertedLine(12000, 'JPY', 170, 'EUR', 'USD', 80.46)?.into).toEqual({ amount: 12000 / 170, currency: 'EUR' }) + }) + + it('has no line for a bill in the display currency that still reads as typed', () => { + expect(convertedLine(12345.67, 'USD', 1.1398, 'EUR', 'USD', 12345.670000000002)).toBeNull() + // Rounded to the display currency's own decimals. + expect(convertedLine(1500, 'JPY', 0.0125, 'EUR', 'JPY', 1500.2)).toBeNull() + }) + + it('has no line for an amount of 0 or a row that was never converted', () => { + expect(convertedLine(0, 'USD', 1.17, 'EUR', 'USD', 0)).toBeNull() + expect(convertedLine(0, 'JPY', 170, 'EUR', 'USD', 0)).toEqual({ entered: { amount: 0, currency: 'JPY' }, into: { amount: 0, currency: 'USD' } }) + expect(convertedLine(50, 'USD', 1, 'EUR', 'USD', 50)).toBeNull() + expect(convertedLine(50, null, null, 'USD', '', 50)).toBeNull() + }) + + it('goes straight to the display value for a row in the trip currency, or when the viewer reads the trip currency', () => { + expect(convertedLine(100, null, null, 'EUR', 'USD', 113.98)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 113.98, currency: 'USD' } }) + expect(convertedLine(801.76, 'USD', 1.17, 'EUR', 'EUR', 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } }) + }) }) diff --git a/client/src/hooks/useExchangeRates.ts b/client/src/hooks/useExchangeRates.ts index 2652312fa..2ea56af58 100644 --- a/client/src/hooks/useExchangeRates.ts +++ b/client/src/hooks/useExchangeRates.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { currencyDecimals } from '../utils/formatters' /** * Live FX rates for the Costs panel, used to convert every amount into the user's @@ -59,6 +60,13 @@ export async function fetchExchangeRates(base: string): Promise number, ): number { const trip = (tripCurrency || 'EUR').toUpperCase() + const inTrip = bookedInTrip(amount, rowCurrency, frozenRate, trip) + if (inTrip != null) return convertLive(inTrip, trip) // A NULL currency means the trip's own, and then there was never anything to freeze. + return convertLive(amount, (rowCurrency || trip).toUpperCase()) +} + +/** + * The amount a row was booked at in the trip currency, when it was booked through one: a + * foreign currency with a frozen rate. Null for a row in the trip currency, and for one + * written before the freeze existed. A rate of exactly 1 is the column default, not a booked + * rate, and those rows still convert live, as they always did. + * + * The lists print it between the amount entered and the amount shown, so a row whose value + * moved with the rate shows where the move came from instead of a figure nobody typed. + */ +export function bookedInTrip( + amount: number, + rowCurrency: string | null | undefined, + frozenRate: number | null | undefined, + tripCurrency: string, +): number | null { + const trip = (tripCurrency || 'EUR').toUpperCase() const cur = (rowCurrency || trip).toUpperCase() - if (cur === trip) return convertLive(amount, trip) - // A rate of exactly 1 is the column default, not a booked rate: rows written before the - // freeze existed carry it, and those still convert live, as they always did. - if (frozenRate != null && frozenRate > 0 && frozenRate !== 1) return convertLive(amount / frozenRate, trip) - return convertLive(amount, cur) + if (cur === trip) return null + if (frozenRate != null && frozenRate > 0 && frozenRate !== 1) return amount / frozenRate + return null +} + +/** + * What an amount of a row counts as in the trip currency, the figure the settlement nets: + * the booked amount for a row booked through the trip currency, the amount itself for one + * in it, and today's rate for a foreign row that never froze one, the way the server + * counts that row too. + */ +export function tripAmountOf( + amount: number, + rowCurrency: string | null | undefined, + frozenRate: number | null | undefined, + tripCurrency: string, + convertLive: (amount: number, from: string | null | undefined) => number, +): number { + const trip = (tripCurrency || 'EUR').toUpperCase() + const inTrip = bookedInTrip(amount, rowCurrency, frozenRate, trip) + if (inTrip != null) return inTrip + const cur = (rowCurrency || trip).toUpperCase() + if (cur === trip) return amount + const perTrip = convertLive(1, trip) + return perTrip > 0 ? convertLive(amount, cur) / perTrip : amount +} + +/** An amount and the currency it is in. */ +export interface MoneyPart { amount: number; currency: string } + +/** + * The line under an amount the lists show converted (#2525): what was entered, then where + * it went. For a row booked through the trip currency that is the booked trip amount, + * "$801.76 -> 685,26 EUR", since the display value beside it already shows the last step. + * Any other foreign row goes straight to the display value, "100,00 EUR -> $113.98". + * + * Null when there is nothing to explain: a row in the display currency that still reads + * as typed, or an amount of 0. `shown` is the display value printed beside the line. + * Every list uses it, so the desktop and phone shells and the shared page can never + * explain the same row two ways. + */ +export function convertedLine( + amount: number, + rowCurrency: string | null | undefined, + frozenRate: number | null | undefined, + tripCurrency: string, + displayCurrency: string, + shown: number, +): { entered: MoneyPart; into: MoneyPart } | null { + const trip = (tripCurrency || 'EUR').toUpperCase() + const display = (displayCurrency || trip).toUpperCase() + const cur = (rowCurrency || trip).toUpperCase() + const entered = { amount, currency: cur } + const inTrip = display !== trip && amount !== 0 ? bookedInTrip(amount, cur, frozenRate, trip) : null + if (inTrip != null) { + const unit = 10 ** currencyDecimals(display) + if (cur === display && Math.round(shown * unit) === Math.round(amount * unit)) return null + return { entered, into: { amount: inTrip, currency: trip } } + } + return cur === display ? null : { entered, into: { amount: shown, currency: display } } } /** Test-only: the module-level cache outlives a vitest file's individual tests. */ @@ -89,29 +173,60 @@ export function clearExchangeRateCache(): void { } catch { /* ignore */ } } -export function useExchangeRates(base: string) { - const upper = (base || 'EUR').toUpperCase() - const [rates, setRates] = useState | null>(() => readCache(upper)?.rates ?? null) +/** Rates for one base, from the cache first and then the network; null for no base. */ +function useRatesFor(base: string | null): Record | null { + const [rates, setRates] = useState | null>(() => (base ? readCache(base)?.rates ?? null : null)) useEffect(() => { - const cached = readCache(upper) + if (!base) { setRates(null); return } + const cached = readCache(base) if (cached) setRates(cached.rates) if (cached && Date.now() - cached.ts < TTL_MS) return let cancelled = false - fetchExchangeRates(upper).then(r => { + fetchExchangeRates(base).then(r => { if (!cancelled && r) setRates(r) }) return () => { cancelled = true } - }, [upper]) + }, [base]) + + return rates +} + +/** + * Units of `to` per 1 `from`, from rates quoted against any base (each rate is units of + * that currency per 1 base). Null when either quote is missing. + */ +export function crossRate(rates: Record | null | undefined, to: string, from: string): number | null { + const rTo = rates?.[to] + const rFrom = rates?.[from] + return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null +} + +/** + * `tripCurrency`, when given, anchors the conversion on the trip currency's own quote + * (#2525). That is the quote the server froze every entry rate from and converts the + * settlement with, so an amount booked through the trip currency comes back exactly as + * entered while the rate has not moved. The display currency's own quote is a separately + * rounded figure, not the exact inverse: through it a same-day 12,345.67 USD bill on a + * euro trip read as $12,346.05. Both quotes are cached like any other, so the anchor works + * offline too; the display quote only stands in while the trip's is missing. + */ +export function useExchangeRates(base: string, tripCurrency?: string | null) { + const upper = (base || 'EUR').toUpperCase() + const anchor = (tripCurrency || upper).toUpperCase() + const rates = useRatesFor(upper) + const anchorRates = useRatesFor(anchor === upper ? null : anchor) const convert = useCallback( (amount: number, from: string | null | undefined): number => { const f = (from || upper).toUpperCase() - if (f === upper || !rates) return amount - const r = rates[f] + if (f === upper) return amount + const viaAnchor = crossRate(anchorRates, upper, f) + if (viaAnchor != null) return amount * viaAnchor + const r = rates?.[f] return r && r > 0 ? amount / r : amount }, - [rates, upper], + [rates, anchorRates, upper], ) return { rates, convert } diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index 0359c7e2f..fa2e86b4e 100644 --- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx @@ -8,10 +8,10 @@ import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } import { useTranslation } from '../../../../i18n' import { useToast } from '../../../../components/shared/Toast' import { useTripStore } from '../../../../store/tripStore' -import { useExchangeRates } from '../../../../hooks/useExchangeRates' import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters' import { openFile } from '../../../../utils/fileDownload' import { saveWithReceipts } from '../../../../components/Budget/receiptUploads' +import { splitShareLabel, useExpenseFx } from '../../../../components/Budget/expenseFx' import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants' import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories' import { localToday } from '../../../../components/Planner/today' @@ -58,8 +58,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const { t, locale } = useTranslation() const toast = useToast() const { addBudgetItem, updateBudgetItem, deleteBudgetItem } = useTripStore() - const { convert } = useExchangeRates(base) const sym = (c: string) => SYMBOLS[c] || (c + ' ') + // A saved expense without a currency opens in the trip's own (#2525), as on desktop. + const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing) // Internal open flag so the exit animation still plays even though the parent // unmounts us on close. @@ -75,13 +76,13 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const [cat, setCat] = useState(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) const [catOpen, setCatOpen] = useState(false) const [note, setNote] = useState(() => readUserNote(editing)) - const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) + const [currency, setCurrency] = useState(editingCurrency) const [day, setDay] = useState(editing?.expense_date || localToday()) // Edit and prefill seeds are padded to the currency's decimals (#2175), same // as the desktop modal: a saved 4,90 must reopen as "4,90", not "4,9". A // prefill has no currency of its own and is read as `base`. const [total, setTotal] = useState(() => { - if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : '' + if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : '' if (prefill?.amount != null) return amountToInputString(prefill.amount, base) return '' }) @@ -151,6 +152,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const ticketInfo = useMemo(() => calculateTicketShares(ticketItems), [ticketItems]) const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) + const fx = preview(totalNum, currency) const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) const each = participants.size > 0 ? totalNum / participants.size : 0 @@ -404,12 +406,18 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, {/* CONVERSION HINT */} - {currency !== base && totalNum !== 0 && ( + {fx && (
{formatMoney(totalNum, currency, locale)} - ≈ - {formatMoney(convert(totalNum, currency), base, locale)} - · {t('costs.liveRate')} + {fx.inTrip != null && <> + → + {formatMoney(fx.inTrip, tripCur, locale)} + } + {fx.shown != null && <> + ≈ + {formatMoney(fx.shown, base, locale)} + · {t('costs.liveRate')} + }
)} @@ -646,12 +654,12 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
{splitMode === 'equally' ? ( - {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} + {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })} ) : ( {customBalanced - ? t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) }) + ? t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) }) : `${sym(currency)}${splitSum.toFixed(2)} / ${sym(currency)}${totalNum.toFixed(2)}`} )} diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index 20e44c98f..952b214cf 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -12,6 +12,7 @@ import { BookingCodeInput } from '../../../../components/shared/BookingCode' import AirportSelect, { type Airport } from '../../../../components/Planner/AirportSelect' import LocationSelect, { type LocationPoint } from '../../../../components/Planner/LocationSelect' import { toLocationPicks } from '../../../../components/Planner/locationPicks' +import { importedPriceEntry } from '../../../../components/Planner/importedPrice' import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' import PlFileAttach from './PlFileAttach' @@ -572,11 +573,8 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo // Imported booking → auto-create the linked cost from the parsed price // (only on create and only when a price is present). if (!res && prefill && isBudgetEnabled) { - const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : {} - const price = Number(pmeta.price) - if (Number.isFinite(price) && price > 0) { - payload.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) } - } + const entry = importedPriceEntry(prefill.metadata, form.type) + if (entry) payload.create_budget_entry = entry } const saved = await saveTransport(payload) // Persist the traveler assignment once we have the reservation id (from the diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 3476410bd..1db224e33 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -1,4 +1,4 @@ -import { convertBooked } from '../../../../hooks/useExchangeRates' +import { convertBooked, convertedLine } from '../../../../hooks/useExchangeRates' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp, @@ -14,7 +14,7 @@ import { downloadBlob, openFile } from '../../../../utils/fileDownload' import { budgetApi } from '../../../../api/client' import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' -import { finalBudgetFor, finalBudgetSources, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' +import { finalBudgetFor, finalBudgetSources, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import CustomSelect from '../../../../components/shared/CustomSelect' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' @@ -27,8 +27,8 @@ import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/ import { CountPill, TabScroller } from './tabChrome' import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { - baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals, currencyOf, - dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, memberShareOf, tint, + baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals, + dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, lineOf, memberShareOf, tint, type CostsCtx, type CostsSegment, type CostsSettlement, type CostsSettlementResponse, } from './costsModel' import type { BudgetParticipantFinal } from '@trek/shared' @@ -55,8 +55,9 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { const displayCurrency = useSettingsStore(s => s.settings.default_currency) const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() const tripCurrency = (trip?.currency || base).toUpperCase() - const { convert } = useExchangeRates(base) - const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, convert }), [me, tripCurrency, convert]) + // Anchored on the trip currency's quote, the one the server books with (#2525). + const { convert } = useExchangeRates(base, tripCurrency) + const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, displayCurrency: base, convert }), [me, tripCurrency, base, convert]) const [settlement, setSettlement] = useState(null) // A failed settlement read leaves `settlement` null, and the final budget would @@ -339,7 +340,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { : } {open && ( - + )}
) @@ -622,8 +623,8 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT }) { const meta = catMeta(item.category) const Icon = meta.Icon - const cur = currencyOf(item, ctx) const total = baseTotal(item, ctx) + const line = lineOf(item.total_price || 0, item, ctx, total) const unfinished = isUnfinished(item, ctx) const borderColor = tint(meta.color, 0.55) const members = item.members || [] @@ -670,9 +671,9 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT )} - {cur !== base && ( + {line && (
- {formatMoney(item.total_price, cur, locale)} {'→'} {formatMoney(total, base, locale)} + {formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
)} {members.length > 0 && ( @@ -756,7 +757,9 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE }) { const cur = (settlement.currency || base).toUpperCase() // At the rate it was settled at, not today's (#1445), matching the desktop ledger. - const amount = convertBooked(settlement.amount, settlement.currency, settlement.exchange_rate, ctx.tripCurrency, ctx.convert) + // A transfer without a currency was entered in the display currency, not the trip's. + const amount = convertBooked(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, ctx.convert) + const line = convertedLine(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, base, amount) return (
@@ -767,9 +770,9 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
{t('costs.payment')}
{personName(settlement.from_user_id)} → {personName(settlement.to_user_id)}
- {cur !== base && ( + {line && (
- {formatMoney(settlement.amount, cur, locale)} {'→'} {formatMoney(amount, base, locale)} + {formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
)}
@@ -799,9 +802,10 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE * with the balances; each line is signed by what it does to the final, so the * column reads as a subtraction and the rows add up to the line above them. */ -function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: { +function FinalBudgetBreakdown({ row, items, ctx, base, locale, t, personName }: { row: BudgetParticipantFinal items: BudgetItem[] + ctx: CostsCtx base: string locale: string t: TFn @@ -810,6 +814,12 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: { const signed = (v: number) => (v < 0 ? '−' : '+') + formatMoney(Math.abs(v), base, locale) const { fronted, moved, outstanding } = finalBudgetSources(row, items) const transfer = (fromId: number, toId: number) => `${personName(fromId)} → ${personName(toId)}` + // Beside a converted row's name, what was entered, as the list above names it (#2525). + const nameOf = (itemId: number, name: string, shown: number) => { + const e = items.find(i => i.id === itemId) + const entered = e ? lineOf(paidByUser(e, row.user_id), e, ctx, shown)?.entered : null + return entered ? `${name} · ${formatMoney(entered.amount, entered.currency, locale)}` : name + } const line = (key: string, label: string, value: string) => (
{label} @@ -828,7 +838,7 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: { {line('expenses', t('costs.finalExpenses'), signed(row.expenses))} {line('reimbursed', t('costs.finalReimbursed'), signed(-row.reimbursed))} {line('pending', t('costs.finalPending'), signed(-row.pending))} - {section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, r.name, signed(r.amount))))} + {section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, nameOf(r.item_id, r.name, r.amount), signed(r.amount))))} {section(t('costs.finalReimbursed'), moved.map(r => line(`s${r.settlement_id}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))} {section(t('costs.finalPending'), outstanding.map((r, i) => line(`f${i}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))}
diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts index aa8c67962..4b6707a4c 100644 --- a/client/src/mobile/screens/trip/tabs/costsModel.ts +++ b/client/src/mobile/screens/trip/tabs/costsModel.ts @@ -1,7 +1,7 @@ import type { BudgetParticipantFinal, CostCategory } from '@trek/shared' import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories' -import { convertBooked } from '../../../../hooks/useExchangeRates' +import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates' import { currencyDecimals } from '../../../../utils/formatters' import type { BudgetItem } from '../../../../types' @@ -19,6 +19,8 @@ export interface CostsCtx { me: number /** The trip's own currency — what a NULL `budget_items.currency` means. */ tripCurrency: string + /** The currency everything is shown in, the base `convert` converts to. */ + displayCurrency: string convert: (amount: number, currency: string | null | undefined) => number } @@ -32,6 +34,15 @@ export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number { return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert) } +/** + * The line under an amount of this expense the list shows converted, what was entered and + * where it went (#2525); null when there is nothing to explain. `shown` is the display + * value printed beside it. + */ +export function lineOf(amount: number, e: BudgetItem, ctx: CostsCtx, shown: number) { + return convertedLine(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.displayCurrency, shown) +} + /** Expense total converted to the display/base currency, at its booked rate. */ export function baseTotal(e: BudgetItem, ctx: CostsCtx): number { return booked(e.total_price || 0, e, ctx) @@ -315,12 +326,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil } } - const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', `Amount (${opts.base})`, 'Note'] + // Read in another currency than the trip's, what each row counts as in the trip currency + // too, the figure every sum is built from (#2525). Same columns as the desktop export. + const trip = opts.ctx.tripCurrency.toUpperCase() + const tripCol = trip !== opts.base.toUpperCase() + const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? [`Amount (${trip})`] : []), `Amount (${opts.base})`, 'Note'] const rows = [header.join(sep)] const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')) for (const e of sorted) { const cur = currencyOf(e, opts.ctx) const note = readUserNote(e) + const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, trip, opts.ctx.convert) rows.push( [ esc(fmtDate(e.expense_date || '')), @@ -328,6 +344,7 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil esc(opts.t(catMeta(e.category).labelKey)), (e.total_price || 0).toFixed(currencyDecimals(cur)), cur, + ...(tripCol ? [inTrip.toFixed(currencyDecimals(trip))] : []), baseTotal(e, opts.ctx).toFixed(currencyDecimals(opts.base)), esc(note), ].join(sep), diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 6a67e073d..a18dc9877 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -597,6 +597,112 @@ describe('SharedTripPage', () => { }); }); + describe('FE-PAGE-SHARED-043: a foreign expense reads at the rate it was booked at, as in Costs (#2525)', () => { + it('shows the frozen-rate value in whole cents, not today\'s rate', async () => { + // CHF so no earlier test has cached rates for this base. Today 1 CHF buys 1.1395 + // USD; the expense froze 1.17 when it was entered. Costs reads 801.76 / 1.17 = + // 685.26 CHF. Today's rate alone gives 703.61, printed as "703.607". + server.use( + http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'USD', rate: 1.1395 }])), + http.get('/api/shared/:token', ({ params }) => { + if (params.token !== 'booked-token') return; + return HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' }, + baseCurrency: 'CHF', + days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [], + budget: [ + { id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 }, + { id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 }, + ], + categories: [], + permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false }, + collab: [], + }); + }), + ); + + renderSharedTrip('booked-token'); + await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /costs/i })); + + await waitFor(() => expect(screen.getByText('Aparthotel Silver')).toBeInTheDocument()); + // Row and its category both read the booked value; the trip total adds the + // 100 CHF tram pass to it. + await waitFor(() => expect(screen.getAllByText('685.26 CHF')).toHaveLength(2)); + expect(screen.getByText('785.26 CHF')).toBeInTheDocument(); + expect(screen.queryByText(/703\.6/)).toBeNull(); + // What was entered stays beside the converted row, as the Costs list shows it. + expect(screen.getByText('· 801.76 USD')).toBeInTheDocument(); + }); + + it('reads a same-day bill in the display currency exactly as typed, with the trip currency\'s quote', async () => { + // ZAR trip read in USD (ZAR so no earlier test has cached its rates). The bill froze + // 0.05911 USD per rand, today's ZAR quote. The USD quote, 16.918 rand per dollar, is + // rounded on its own and is not its inverse. + server.use( + http.get('https://api.frankfurter.dev/v2/rates', ({ request }) => { + const base = new URL(request.url).searchParams.get('base'); + return HttpResponse.json(base === 'ZAR' ? [{ quote: 'USD', rate: 0.05911 }] : [{ quote: 'ZAR', rate: 16.918 }]); + }), + http.get('/api/shared/:token', ({ params }) => { + if (params.token !== 'same-day-token') return; + return HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'ZAR' }, + baseCurrency: 'USD', + days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [], + budget: [ + { id: 1, name: 'Villa', total_price: 12345.67, category: 'Accommodation', currency: 'USD', exchange_rate: 0.05911 }, + ], + categories: [], + permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false }, + collab: [], + }); + }), + ); + + renderSharedTrip('same-day-token'); + await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /costs/i })); + + await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(3)); + expect(screen.queryByText(/^· /)).toBeNull(); + }); + + it('reads a bill in the display currency through the trip currency too, beside what was entered', async () => { + // The owner reads in USD on a CHF trip. The USD bill was booked at 685.26 CHF, + // which today is 780.86 USD, the figure Costs and its balances use; the CHF one + // converts at today's rate (100 CHF = 113.95 USD). + server.use( + http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'CHF', rate: 1 / 1.1395 }])), + http.get('/api/shared/:token', ({ params }) => { + if (params.token !== 'display-token') return; + return HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' }, + baseCurrency: 'USD', + days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [], + budget: [ + { id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 }, + { id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 }, + ], + categories: [], + permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false }, + collab: [], + }); + }), + ); + + renderSharedTrip('display-token'); + await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /costs/i })); + + await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(2)); + expect(screen.getAllByText('780.86 USD')).toHaveLength(2); + expect(screen.getByText('894.81 USD')).toBeInTheDocument(); + expect(screen.getByText('· 801.76 USD')).toBeInTheDocument(); + expect(screen.getByText('· 100.00 CHF')).toBeInTheDocument(); + }); + }); + // FE-PAGE-SHARED-021 to FE-PAGE-SHARED-037 drive the remaining render branches of // the page: header variants, the permission-driven tab strip, and every item kind // the day timeline, bookings, packing, costs and chat sections can produce. diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 26b1c569b..2080dcab2 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -27,13 +27,14 @@ import { getCategoryIcon } from '../components/shared/categoryIcons'; import PublicLanguagePicker from '../components/shared/PublicLanguagePicker'; import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults'; import VectorBasemap from '../components/Map/VectorBasemap'; +import { convertBooked, convertedLine } from '../hooks/useExchangeRates'; import { useTranslation } from '../i18n'; import { avatarSrc } from '../utils/avatarSrc'; import { safeHexColor } from '../utils/safeColor'; import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge'; import { isDayInAccommodationRange } from '../utils/dayOrder'; import { getFlightLegs, getTrainLegs } from '../utils/flightLegs'; -import { splitReservationDateTime } from '../utils/formatters'; +import { currencyDecimals, splitReservationDateTime } from '../utils/formatters'; import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport'; import { resolveBasemap } from '../utils/tileUrl'; import { useSharedTrip } from './sharedTrip/useSharedTrip'; @@ -981,16 +982,31 @@ export default function SharedTripPage() { {activeTab === 'budget' && (budget || []).length > 0 && (() => { - // Pre-rework rows store currency = NULL ("the trip's own currency"); convert - // each expense into the owner's display base via live FX, mirroring CostsPanel. - const curOf = (i: any) => i.currency || trip.currency || base; + // Pre-rework rows store currency = NULL ("the trip's own currency"). Each + // expense is read the way CostsPanel reads it (#2525): at the rate frozen when + // it was entered, into the trip currency, then into the owner's display base. + // Reading it at today's rate alone made the shared page disagree with the + // trip's own Costs tab. + type Expense = { total_price?: number | string | null; currency?: string | null; exchange_rate?: number | null }; + const tripCurrency = String(trip.currency || base).toUpperCase(); + const amountOf = (i: Expense) => Number.parseFloat(String(i.total_price ?? '')) || 0; + const valueOf = (i: Expense) => convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert); + // Whole cents: a converted amount otherwise printed a third decimal. + const money = (v: number) => + v.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + // What was entered, beside a row shown converted, as the Costs list prints it. + const enteredOf = (i: Expense): string | null => { + const entered = convertedLine(amountOf(i), i.currency, i.exchange_rate, tripCurrency, base, valueOf(i))?.entered; + if (!entered) return null; + const d = currencyDecimals(entered.currency); + return `${entered.amount.toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d })} ${entered.currency}`; + }; const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g; }, {}); - const sumIn = (items: any[]) => - items.reduce((s: number, i: any) => s + convert(Number.parseFloat(i.total_price) || 0, curOf(i)), 0); + const sumIn = (items: any[]) => items.reduce((s: number, i: any) => s + valueOf(i), 0); const total = sumIn(budget || []); return (
@@ -1015,7 +1031,7 @@ export default function SharedTripPage() { {t('shared.totalBudget')}
- {total.toLocaleString(locale, { minimumFractionDigits: 2 })} {base} + {money(total)} {base}
{/* By category */} @@ -1045,7 +1061,7 @@ export default function SharedTripPage() { className="text-[#6b7280]" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }} > - {sumIn(items).toLocaleString(locale, { minimumFractionDigits: 2 })} {base} + {money(sumIn(items))} {base}
{items.map((item: any) => ( @@ -1061,13 +1077,16 @@ export default function SharedTripPage() { > {item.name} + {item.total_price && enteredOf(item) ? ( + · {enteredOf(item)} + ) : null} {item.total_price - ? `${convert(Number.parseFloat(item.total_price) || 0, curOf(item)).toLocaleString(locale, { minimumFractionDigits: 2 })} ${base}` + ? `${money(valueOf(item))} ${base}` : '—'} diff --git a/client/src/pages/sharedTrip/useSharedTrip.ts b/client/src/pages/sharedTrip/useSharedTrip.ts index 9eb76cab2..975568d4f 100644 --- a/client/src/pages/sharedTrip/useSharedTrip.ts +++ b/client/src/pages/sharedTrip/useSharedTrip.ts @@ -77,7 +77,8 @@ export function useSharedTrip() { // Convert every expense into it via live FX, mirroring CostsPanel — a public // viewer has no settings store, so the base comes from the payload (#1361). const base = String(data?.baseCurrency || data?.trip?.currency || 'EUR').toUpperCase() - const { convert } = useExchangeRates(base) + // Anchored on the trip currency's quote, as the Costs tab converts (#2525). + const { convert } = useExchangeRates(base, data?.trip?.currency) return { data, diff --git a/client/tests/unit/mobile/trip/MCostSheet.test.tsx b/client/tests/unit/mobile/trip/MCostSheet.test.tsx index aba53cdbd..e5699a028 100644 --- a/client/tests/unit/mobile/trip/MCostSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MCostSheet.test.tsx @@ -4,10 +4,10 @@ import type { TripMember } from '../../../../src/components/Budget/BudgetPanelMe import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates' import { useTripStore, type TripStoreState } from '../../../../src/store/tripStore' import type { BudgetItem, BudgetItemMember } from '../../../../src/types' -import { buildBudgetItem } from '../../../helpers/factories' +import { buildBudgetItem, buildTrip } from '../../../helpers/factories' import { localToday } from '../../../../src/components/Planner/today' import { resetAllStores } from '../../../helpers/store' -import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' +import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render' // FE-MOB-COSTSH-001 to FE-MOB-COSTSH-030 // The sheet reads its copy from useTranslation(), so assertions are English. @@ -648,6 +648,66 @@ describe('MCostSheet', () => { }))) }) + it('FE-MOB-COSTSH-035: an expense saved without a currency reopens in the trip currency (#2525)', async () => { + // No currency on the row means the trip's own. Seeding the display currency + // instead labelled 100 euro as 100 dollars, and a plain save stored it so. + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.8 }, ts: Date.now() })) + useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial) + const editing = buildBudgetItem({ + id: 12, name: 'Tram pass', category: 'transport', currency: null, total_price: 100, + members: [member(1, null)], + payers: [{ user_id: 1, amount: 100 }], + }) + renderSheet({ base: 'USD', editing }) + + expect(totalField()).toHaveValue('100,00') + expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'USD $' })).toBeNull() + + fireEvent.click(saveBtn()) + await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledWith(1, 12, expect.objectContaining({ + total_price: 100, + currency: 'EUR', + }))) + }) + + it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => { + // A euro trip read in dollars, the bill entered in dollars at 1.17 to the euro. + // Same currency as the list, so the sheet said nothing, while the list showed the + // booked euros at today's rate beside the 801.76 typed here. + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551 }, ts: Date.now() })) + useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial) + const editing = buildBudgetItem({ + id: 13, name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17, total_price: 801.76, + members: [member(1, null), member(2, null)], + payers: [{ user_id: 1, amount: 801.76 }], + }) + renderSheet({ base: 'USD', editing }) + + const hint = screen.getByText(/live rate/).parentElement as HTMLElement + expect(within(hint).getByText('$801.76')).toBeInTheDocument() + expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument() + expect(within(hint).getByText('$791.55')).toBeInTheDocument() + // Each share next to what it counts as, the figure the row's "you lent" is made of. + expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument() + }) + + it('FE-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => { + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() })) + useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial) + const editing = buildBudgetItem({ + id: 14, name: 'Villa', category: 'accommodation', currency: 'USD', exchange_rate: 1.1398, total_price: 12345.67, + members: [member(1, null), member(2, null)], + payers: [{ user_id: 1, amount: 12345.67 }], + }) + renderSheet({ base: 'USD', editing }) + + expect(screen.queryByText(/live rate/)).toBeNull() + expect(screen.queryByText(/→/)).toBeNull() + }) + it('FE-MOB-COSTSH-030: a currency without a known symbol falls back to its code', () => { localStorage.setItem('trek_fx_XTS', JSON.stringify({ rates: { XTS: 1 }, ts: Date.now() })) renderSheet({ base: 'XTS' }) diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 6c6d11d3d..4b95a4b11 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -639,7 +639,8 @@ describe('MCostsTab', () => { expect(mine).toHaveAttribute('aria-expanded', 'false') expect(within(card).getAllByText('+$58.00')).toHaveLength(2) expect(within(card).queryByText(/60\.00/)).not.toBeInTheDocument() - expect(within(card).getByText('Museum')).toBeInTheDocument() + // Named by what Bob entered, as the list names it (#2525), never re-converted. + expect(within(card).getByText('Museum · £30.00')).toBeInTheDocument() fireEvent.click(bob) expect(bob).toHaveAttribute('aria-expanded', 'false') @@ -725,6 +726,74 @@ describe('MCostsTab', () => { expect(update).toHaveBeenCalledWith(7, 505, { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28' }) }) + it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => { + // A euro trip read in dollars, the bill entered in dollars at a frozen 1.17 to the + // euro while today the euro buys 1.1551. It is 685.26 EUR of trip money, $791.55 + // today, and that is what settle-up offers too; the row now says where the + // 801.76 that was entered went instead of hiding it. Converted with the euro's own + // quote, not the dollar's separately rounded one. + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } }) + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551 }, ts: Date.now() })) + const hotel = { + id: 21, trip_id: 7, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17, + expense_date: '2026-05-01', note: null, + members: [ + { user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null }, + { user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null }, + ], + payers: [{ user_id: 1, amount: 801.76, username: 'Me' }], + } as unknown as BudgetItem + serveSettlement({ + ...SETTLEMENT, + settlements: [ + { id: 601, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, settled_at: '2026-05-02', created_at: '2026-05-02T09:00:00Z' }, + // A transfer from before they carried a currency was entered in the display currency. + { id: 602, from_user_id: 3, to_user_id: 1, amount: 30, currency: null, settled_at: '2026-05-03', created_at: '2026-05-03T09:00:00Z' }, + ], + }) + await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [hotel] })) + + const card = cardOf('Aparthotel Silver') + expect(within(card).getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument() + expect(within(card).getByText('$791.55')).toBeInTheDocument() + // Each share is half of the booked euros, the figure the balances net. + expect(within(card).getAllByText('$395.77')).toHaveLength(2) + expect(within(card).queryByText('$400.88')).toBeNull() + expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$791.55') + expect(screen.getByText('costs.spent:$791.55')).toBeInTheDocument() + + expect(screen.getAllByText('costs.payment')).toHaveLength(2) + const paid = up(screen.getByText('Ada → costs.you'), 2) + expect(within(paid).getByText(/^\$400\.88 → 342,63\s€$/)).toBeInTheDocument() + expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument() + expect(screen.queryByText('$34.65')).toBeNull() + }) + + it('FE-MOB-COSTT-046: a bill booked at today\'s rate reads exactly as typed (#2525)', async () => { + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } }) + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() })) + const villa = { + id: 22, trip_id: 7, name: 'Villa', category: 'accommodation', total_price: 12345.67, currency: 'USD', exchange_rate: 1.1398, + expense_date: '2026-05-01', note: null, + members: [ + { user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null }, + { user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null }, + ], + payers: [{ user_id: 1, amount: 12345.67, username: 'Me' }], + } as unknown as BudgetItem + serveSettlement(SETTLEMENT) + await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [villa] })) + + const card = cardOf('Villa') + // Through the dollar's own quote it read $12,346.05. + expect(within(card).getByText('$12,345.67')).toBeInTheDocument() + expect(within(card).queryByText(/^\$12,345\.67 →/)).toBeNull() + expect(screen.getByText('costs.spent:$12,345.67')).toBeInTheDocument() + expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$12,345.67') + }) + it('FE-MOB-COSTT-044: a payment cannot be saved without a day', async () => { // Cleared, the server would store NULL and the ledger would quietly fall // back to the day the payment was recorded on. diff --git a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx index 80bdb9e1d..22fee84ed 100644 --- a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx @@ -826,6 +826,18 @@ describe('MTransportFormSheet', () => { expect((addFile.mock.calls[0][1] as FormData).get('description')).toBe('Rental car') }) + it('FE-MOB-TRFRM-029b: a parsed price keeps the currency it was quoted in (#2525)', async () => { + const handleSaveTransport = makeSave() + const planner = makePlanner({ + transportPrefill: { type: 'train', title: 'Amtrak', metadata: { price: 117, priceCurrency: 'USD' }, endpoints: [] }, + handleSaveTransport, + }) + renderSheet(planner) + await submit() + // Sent without it, the server stored 117 in the trip currency. + expect(handleSaveTransport.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 117, category: 'transport', currency: 'USD' }) + }) + it('FE-MOB-TRFRM-030: a prefill without a price and with the budget addon off skips the cost entry', async () => { seedStore(useAddonStore, { addons: [] }) const handleSaveTransport = makeSave() diff --git a/client/tests/unit/mobile/trip/costsModel.test.ts b/client/tests/unit/mobile/trip/costsModel.test.ts index 9a239cd1d..9d5db7772 100644 --- a/client/tests/unit/mobile/trip/costsModel.test.ts +++ b/client/tests/unit/mobile/trip/costsModel.test.ts @@ -12,6 +12,7 @@ import { groupByDay, groupLedgerByDay, isUnfinished, + lineOf, memberShareOf, myPaidOf, myShareOf, @@ -32,6 +33,7 @@ const ctx: CostsCtx = { me: 1, // lower case on purpose — currencyOf has to normalise it tripCurrency: 'eur', + displayCurrency: 'EUR', convert: (amount, currency) => amount * (RATES[(currency || 'EUR').toUpperCase()] ?? 1), }; @@ -306,6 +308,86 @@ describe('costsModel — list filters and grouping', () => { }); }); +describe('costsModel: a bill entered in the display currency (#2525)', () => { + // A euro trip read in dollars, converted with the euro's own quote the way + // useExchangeRates(base, tripCurrency) converts: one euro buys 1.1551 dollars today. + const usdCtx: CostsCtx = { + me: 1, + tripCurrency: 'EUR', + displayCurrency: 'usd', + convert: (amount, currency) => { + const perEuro = ({ USD: 1.1551, EUR: 1, SEK: 10 } as Record)[(currency || 'USD').toUpperCase()] ?? 1 + return (amount / perEuro) * 1.1551 + }, + }; + const hotel = (rate = 1.17) => expense({ + total_price: 801.76, currency: 'USD', exchange_rate: rate, + payers: [payer(1, 801.76)], members: [member(1), member(2)], + }); + + it('FE-MOB-CMOD-038: a bill booked at a rate that has moved since counts as its euros at today\'s rate', () => { + // 685.26 EUR, which the balances net and settle-up offers; the row, the payer's + // figure and the share read the same, or a payback of the row's half reopens the trip. + expect(baseTotal(hotel(), usdCtx)).toBeCloseTo(791.55, 2); + expect(myPaidOf(hotel(), usdCtx)).toBeCloseTo(791.55, 2); + expect(myShareOf(hotel(), usdCtx)).toBeCloseTo(395.77, 2); + // Under it, what was entered and the euros it was booked at, no third step. + const line = lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))!; + expect(line.entered).toEqual({ amount: 801.76, currency: 'USD' }); + expect(line.into.currency).toBe('EUR'); + expect(line.into.amount).toBeCloseTo(685.26, 2); + }); + + it('FE-MOB-CMOD-039: booked at today\'s rate, the bill reads exactly as typed and needs no line', () => { + const big = expense({ total_price: 12345.67, currency: 'USD', exchange_rate: 1.1551, payers: [payer(1, 12345.67)], members: [member(1), member(2)] }); + const unpaid = expense({ total_price: 250, currency: 'USD', exchange_rate: 1.1551 }); + expect(Math.round(baseTotal(big, usdCtx) * 100)).toBe(1234567); + expect(Math.round(baseTotal(unpaid, usdCtx) * 100)).toBe(25000); + expect(lineOf(12345.67, big, usdCtx, baseTotal(big, usdCtx))).toBeNull(); + const t = computeTotals([big, unpaid], [], usdCtx); + expect(Math.round(t.totalSpend * 100)).toBe(1259567); + expect(Math.round(t.outstanding * 100)).toBe(25000); + }); + + it('FE-MOB-CMOD-040: other currencies go through the trip currency at their booked rate', () => { + // 1000 SEK booked at 10 per euro is 100 euro, and 100 euro is 115.51 dollars today. + const sek = expense({ total_price: 1000, currency: 'SEK', exchange_rate: 10 }) + expect(baseTotal(sek, usdCtx)).toBeCloseTo(115.51, 2); + expect(lineOf(1000, sek, usdCtx, baseTotal(sek, usdCtx))).toEqual({ entered: { amount: 1000, currency: 'SEK' }, into: { amount: 100, currency: 'EUR' } }); + // A row in the trip currency goes straight to the dollars. + const eur = expense({ total_price: 100, currency: null }) + expect(baseTotal(eur, usdCtx)).toBeCloseTo(115.51, 2); + expect(lineOf(100, eur, usdCtx, 115.51)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 115.51, currency: 'USD' } }); + }); + + it('FE-MOB-CMOD-041: there is nothing to explain when nothing was converted', () => { + // An amount of 0, a row written before the freeze, and any row when the viewer + // reads the trip currency itself. + const zero = expense({ total_price: 0, currency: 'USD', exchange_rate: 1.17 }); + expect(lineOf(0, zero, usdCtx, 0)).toBeNull(); + const legacy = expense({ total_price: 50, currency: 'USD', exchange_rate: 1 }); + expect(baseTotal(legacy, usdCtx)).toBe(50); + expect(lineOf(50, legacy, usdCtx, 50)).toBeNull(); + const inEur = { ...usdCtx, displayCurrency: 'EUR', convert: (a: number) => a }; + expect(lineOf(801.76, hotel(), inEur, 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } }); + }); + + it('FE-MOB-CMOD-042: the CSV adds what each row counts as in the trip currency', () => { + const t = ((key: string) => key) as unknown as Parameters[1]['t']; + const items = [ + hotel(), + expense({ name: 'Old taxi', total_price: 115.51, currency: 'USD', exchange_rate: 1, expense_date: '2026-07-20' }), + ].map((e, i) => ({ ...e, name: e.name || 'Hotel', expense_date: e.expense_date || '2026-07-19', id: i + 1 })); + const inUsd = buildCostsCsv(items, { base: 'USD', ctx: usdCtx, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n'); + expect(inUsd[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note'); + expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/); + // Never booked, so counted at today's rate, as the server counts it. + expect(inUsd[2]).toMatch(/;115\.51;USD;100\.00;115\.51;$/); + const inEur = buildCostsCsv(items, { base: 'EUR', ctx: { ...usdCtx, displayCurrency: 'EUR' }, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n'); + expect(inEur[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note'); + }); +}); + describe('costsModel — ledger grouping (expenses + settlement payments)', () => { it('FE-MOB-CMOD-032: merges expenses and payments into shared day groups, newest first, no-date last', () => { const e = expense({ id: 50, expense_date: '2026-07-02' }); diff --git a/server/src/nest/budget/budget.controller.ts b/server/src/nest/budget/budget.controller.ts index e575bd441..c2dab9aca 100644 --- a/server/src/nest/budget/budget.controller.ts +++ b/server/src/nest/budget/budget.controller.ts @@ -62,8 +62,8 @@ export class BudgetController { } @Get('summary/per-person') - perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { summary: this.budget.perPersonSummary(tripId) }; + async perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { summary: await this.budget.perPersonSummary(tripId) }; } @Get('settlement') diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 77df7e97b..63d5b42b6 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -402,10 +402,9 @@ export class BudgetMcp { async getSettlementSummary({ tripId, base }: { tripId: number; base?: string }, ctx: McpContext) { if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId); - const tripCurrency = trip?.currency || 'EUR'; - const effectiveBase = (base || tripCurrency).toUpperCase(); - const rates = await this.exchangeRates.getRates(effectiveBase); - const summary = this.budget.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); + // The same call the REST route makes, so both convert with the trip currency's own + // quote and an amount entered in `base` reads back to the cent (#2525). + const summary = await this.budget.settlement(tripId, base, trip?.currency || 'EUR'); return ok({ summary }); } @@ -556,7 +555,8 @@ export class BudgetMcp { }], }; } - const summary = this.budget.getPerPersonSummary(id); + // In the trip currency, each share at the rate its expense was booked at (#2525). + const summary = await this.budget.perPersonSummary(id); return { contents: [{ uri: uri.href, diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index b2a6272f1..f66689234 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -88,6 +88,44 @@ function allocateDisplayCents(cents: number[], factor: number, total = Math.roun return out; } +/** + * An amount of a row, in the row's own currency, in the trip currency. The rate frozen + * when the row was entered wins (#1335), so a booked expense keeps the value it was + * booked at. A row written before the freeze existed (rate 1 or none) converts through + * today's `rates`, units of each currency per 1 of their base, and without them stays + * as it is. Pre-rework rows store currency = NULL, which means the trip's own. + * + * The settlement nets with it and every trip total adds up with it, so the two can never + * read the same rows differently. + */ +function toTripAmount( + amount: number, + itemCurrency: string | null | undefined, + itemRate: number | null | undefined, + tripCurrency: string, + rates: Record | null, +): number { + const cur = (itemCurrency || tripCurrency).toUpperCase(); + if (cur === tripCurrency) return amount; + if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate; + if (!rates) return amount; + const rCur = rates[cur]; + const rTrip = rates[tripCurrency]; + if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip; + return amount; +} + +/** + * Units of `to` per 1 `from`, from a set of rates quoted against any base (each rate is + * units of that currency per 1 base). 1 when either quote is missing, which leaves an + * amount as it is, the way every conversion here degrades without rates. + */ +function quoteRatio(rates: Record | null, to: string, from: string): number { + const rTo = rates?.[to]; + const rFrom = rates?.[from]; + return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : 1; +} + /** * Budget domain service — owns the budget SQL (moved from the legacy * services/budgetService.ts: identical statements, the `||` falsy-coercion @@ -541,7 +579,7 @@ export class BudgetService { linkBudgetItemToReservation( tripId: string | number, reservationId: number, - data: { name: string; category?: string; total_price: number }, + data: { name: string; category?: string; total_price: number; currency?: string | null; exchange_rate?: number }, ) { // createBudgetItem accepts reservation_id directly — the legacy separate // UPDATE after the insert was redundant (and non-atomic). @@ -814,20 +852,101 @@ export class BudgetService { // Per-person summary // ------------------------------------------------------------------------- - getPerPersonSummary(tripId: string | number) { - const summary = this.db.all<{ user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }>(` - SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, - SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned, - SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid, - COUNT(bi.id) as items_count + /** + * What each member is down for across the trip (`total_assigned`), and how much of + * that is marked paid (`total_paid`), in the trip currency. + * + * Each share is converted to trip cents the way the settlement converts it, at the + * rate frozen when the expense was entered, and an equal split is a largest-remainder + * split of those cents (#2525). The SQL this replaces summed raw amounts across + * currencies and divided them in floats, so a bill of 801.76 USD counted as 801.76 of + * the trip's euros and a third of 100 came out as 33.333… + */ + getPerPersonSummary(tripId: string | number, rates: Record | null = null) { + const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId); + const tripCurrency = (trip?.currency || 'EUR').toUpperCase(); + const items = this.db.all<{ id: number; total_price: number | null; currency: string | null; exchange_rate: number | null }>( + 'SELECT id, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ?', tripId, + ); + const members = this.db.all<{ budget_item_id: number; user_id: number; amount: number | null; paid: number | null; username: string; avatar: string | null }>(` + SELECT bm.budget_item_id, bm.user_id, bm.amount, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_members bm JOIN budget_items bi ON bm.budget_item_id = bi.id JOIN users u ON bm.user_id = u.id WHERE bi.trip_id = ? - GROUP BY bm.user_id `, tripId); + const toTripCents = (amount: number, item: { currency: string | null; exchange_rate: number | null }) => + Math.round(toTripAmount(amount, item.currency, item.exchange_rate, tripCurrency, rates) * 100); - return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) })); + const people = new Map(); + for (const item of items) { + const own = members.filter(m => m.budget_item_id === item.id); + if (own.length === 0) continue; + // A member with an amount of their own owes exactly that; the rest split the + // total evenly, as the query this replaces did. + const equal = this.splitEqualShares(toTripCents(item.total_price || 0, item), own, item.id); + for (const m of own) { + const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount, item) : (equal[m.user_id] || 0); + let p = people.get(m.user_id); + if (!p) { + p = { user_id: m.user_id, username: m.username, avatar: m.avatar, assigned: 0, paid: 0, items_count: 0 }; + people.set(m.user_id, p); + } + p.assigned += share; + if (m.paid === 1) p.paid += share; + p.items_count += 1; + } + } + + return [...people.values()] + .sort((a, b) => a.user_id - b.user_id) + .map(p => ({ + user_id: p.user_id, username: p.username, avatar: p.avatar, + total_assigned: p.assigned / 100, total_paid: p.paid / 100, items_count: p.items_count, + currency: tripCurrency, + avatar_url: avatarUrl(p), + })); + } + + /** + * What the trip's expenses add up to in the trip currency, overall and per category + * (#2525). Every row is converted once, the way the settlement converts it, and + * rounded to a whole cent before anything is added. Summing total_price as stored adds + * dollars to euros and labels the result with the trip currency. + */ + tripTotals(tripId: string | number, tripCurrency: string, rates: Record | null = null): { total: number; byCategory: Record } { + const trip = (tripCurrency || 'EUR').toUpperCase(); + const rows = this.db.all<{ category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>( + 'SELECT category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId, + ); + let total = 0; + const byCategory: Record = {}; + for (const r of rows) { + const cents = Math.round(toTripAmount(r.total_price || 0, r.currency, r.exchange_rate, trip, rates) * 100); + total += cents; + const cat = r.category || ''; + byCategory[cat] = (byCategory[cat] || 0) + cents; + } + return { + total: total / 100, + byCategory: Object.fromEntries(Object.entries(byCategory).map(([cat, cents]) => [cat, cents / 100])), + }; + } + + /** + * Today's rates against the trip currency, for the totals above. Fetched only when a + * row in another currency never froze a rate of its own: every other row converts + * without them, so a trip whose rows were all booked never waits on the network. + */ + async ratesForTripTotals(tripId: string | number, tripCurrency: string): Promise | null> { + const trip = (tripCurrency || 'EUR').toUpperCase(); + const unbooked = this.db.get(` + SELECT 1 FROM budget_items + WHERE trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? + AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1) + LIMIT 1 + `, tripId, trip); + return unbooked ? this.exchangeRates.getRates(trip) : null; } /** @@ -895,26 +1014,23 @@ export class BudgetService { // is the identity, so behaviour is unchanged. // rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency // per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency". - const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => { - const cur = (itemCurrency || tripCurrency).toUpperCase(); - if (cur === tripCurrency) return amount; - // Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate - // it was booked at, so a later live-rate drift doesn't re-open it with a residual. - if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate; - // Legacy rows without a frozen rate: convert via base with live rates. - if (!rates) return amount; - const rCur = rates[cur]; - const rTrip = rates[tripCurrency]; - if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip; - return amount; - }; + // Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate + // it was booked at, so a later live-rate drift doesn't re-open it with a residual. + // Legacy rows without a frozen rate convert via base with live rates. + const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => + toTripAmount(amount, itemCurrency, itemRate, tripCurrency, rates); // trip-currency → display currency, applied once to the final netted totals. // Held as a plain factor so it is exactly linear: the balances are converted as // one set (allocateDisplayCents) rather than one at a time, which is what keeps // them adding up to zero in whatever currency the viewer picked (#1382). - const displayFactor = base === tripCurrency - ? 1 - : (rates && rates[tripCurrency] > 0 ? 1 / rates[tripCurrency] : 1); + // + // `rates` may be quoted against any base; the ratio of the two quotes is the + // factor either way. Callers pass the trip currency's own quote (see settlement()), + // the one freezeForeignRate froze every entry rate from, so a bill entered today in + // the display currency converts back to exactly what was typed (#2525). Taken the + // other way round, from the display currency's quote, the two quotes are not exact + // inverses and 12,345.67 USD came back as 12,346.06. + const displayFactor = base === tripCurrency ? 1 : quoteRatio(rates, base, tripCurrency); // A recorded settle-up amount is entered in whatever display currency the payer // was viewing. New rows capture that currency and the rate frozen at settle time // (#1445), so a settled position stays balanced when live rates drift — mirroring @@ -934,7 +1050,8 @@ export class BudgetService { } return amount; } - return base === tripCurrency ? amount : (rates && rates[tripCurrency] > 0 ? amount * rates[tripCurrency] : amount); + // The inverse of the display factor, so such a transfer reads back as entered. + return amount / displayFactor; }; const items = this.db.all('SELECT * FROM budget_items WHERE trip_id = ?', tripId); @@ -974,6 +1091,12 @@ export class BudgetService { // cents rather than converting the expense list a second time on the client. const frontedRows: Record = {}; const movedRows: Record = {}; + // Read in the trip currency, those figures are the ledger's own trip cents. Read in + // another one, they are built from each row's unrounded trip amount instead: a trip + // cent is worth more than a dollar cent on a euro trip, so a bill of 12,345.67 USD + // rounded to 10,831.44 EUR first came back as 12,345.68 (#2525). The balances, and + // with them settle-up, still net whole trip cents. + const finalUnit = (exactTripCents: number) => (displayFactor === 1 ? Math.round(exactTripCents) : exactTripCents); for (const item of items) { const members = allMembers.filter(m => m.budget_item_id === item.id); @@ -1003,10 +1126,11 @@ export class BudgetService { // refund (#2176) — is debited by the same arithmetic: their credit is negative. let creditCents = 0; for (const p of payers) { - const paid = toTripCents(p.amount, item.currency, item.exchange_rate); + const exact = toTrip(p.amount, item.currency, item.exchange_rate) * 100; + const paid = Math.round(exact); ensure(p.user_id, p).cents += paid; - frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + paid; - if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: paid }); + frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact); + if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: finalUnit(exact) }); creditCents += paid; } // …and each split participant owes their share — a custom per-member amount @@ -1043,17 +1167,19 @@ export class BudgetService { for (const s of settlements) { // Rounded to a trip cent per transfer, so recording one in a display currency // can't leave a sliver of a cent behind to accumulate over a trip's lifetime. - const inTrip = Math.round(settleToTrip(s.amount, s.currency, s.exchange_rate) * 100); + const exact = settleToTrip(s.amount, s.currency, s.exchange_rate) * 100; + const inTrip = Math.round(exact); ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip; ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip; // Net of the transfers in both directions: sending one back is a reimbursement // received in reverse, and netting them is what keeps the final budget's // subtraction equal to the balance it is taken from. - reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + inTrip; - reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - inTrip; + const shown = finalUnit(exact); + reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + shown; + reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - shown; const moved = { settlement_id: s.id, from_user_id: s.from_user_id, to_user_id: s.to_user_id }; - (movedRows[s.to_user_id] ??= []).push({ ...moved, cents: inTrip }); - (movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -inTrip }); + (movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown }); + (movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown }); } // Into the display currency as one set, then simplify — balances and flows are @@ -1237,14 +1363,24 @@ export class BudgetService { return this.listBudgetItems(tripId); } - perPersonSummary(tripId: string) { - return this.getPerPersonSummary(tripId); + async perPersonSummary(tripId: string | number) { + const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId); + return this.getPerPersonSummary(tripId, await this.ratesForTripTotals(tripId, trip?.currency || 'EUR')); } - async settlement(tripId: string, base: string | undefined, tripCurrency: string) { - const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase(); - const rates = await this.exchangeRates.getRates(effectiveBase); - return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); + /** + * The settlement in the viewer's currency, for REST and MCP alike. Converted with the + * trip currency's own quote, the one every entry rate was frozen from, so a same-day + * amount in the display currency round-trips to the cent and paying what settle-up + * offers closes the balance (#2525). The display currency's quote only stands in when + * the trip's cannot be fetched. + */ + async settlement(tripId: string | number, base: string | undefined, tripCurrency: string) { + const trip = (tripCurrency || 'EUR').toUpperCase(); + const effectiveBase = (base || trip).toUpperCase(); + const rates = (await this.exchangeRates.getRates(trip)) + ?? (effectiveBase === trip ? null : await this.exchangeRates.getRates(effectiveBase)); + return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency: trip }); } async create(tripId: string, data: Parameters[1]) { diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index af8509e21..bbb21280c 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -26,7 +26,7 @@ import { type ReservationBody = Record & { title?: string; type?: string; - create_budget_entry?: { total_price?: number; category?: string }; + create_budget_entry?: { total_price?: number; category?: string; currency?: string | null }; }; /** @@ -61,7 +61,7 @@ export class ReservationsController { @RequirePermission('reservation_edit') @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() rawBody: ReservationCreateDto, @@ -69,11 +69,14 @@ export class ReservationsController { ) { const body = rawBody as ReservationBody & { title: string }; this.rejectForeignReferences(tripId, body); + // Before the synchronous writes: the price keeps the currency it was quoted in, + // at a rate frozen now (#2525). + const budgetEntry = await this.reservations.withFrozenRate(tripId, body.create_budget_entry); const { reservation, accommodationCreated } = this.reservations.create(tripId, body as never); if (accommodationCreated) { this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId); } - this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId); + this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, budgetEntry, socketId); this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId); this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? ''); return { reservation }; diff --git a/server/src/nest/reservations/reservations.rpc.ts b/server/src/nest/reservations/reservations.rpc.ts index 73a664d49..794d7d3dd 100644 --- a/server/src/nest/reservations/reservations.rpc.ts +++ b/server/src/nest/reservations/reservations.rpc.ts @@ -30,7 +30,7 @@ export class ReservationsRpc { ) {} @PluginMethod('reservations.create', { permission: 'db:write:reservations' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'reservation'); const parsed = reservationCreateRequestSchema.safeParse(params.input); @@ -39,10 +39,12 @@ export class ReservationsRpc { this.requireValidEndpoints(input.endpoints); this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); this.requireOwnReferences(tripId, input); + const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; + // Same as the REST route: the price keeps its currency, at a rate frozen now (#2525). + const budgetEntry = await this.reservations.withFrozenRate(tripId, i.create_budget_entry as never); const { reservation, accommodationCreated } = this.reservations.create(String(tripId), input as never); if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined); - const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; - this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined); + this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, budgetEntry, undefined); this.realtime.broadcast(tripId, 'reservation:created', { reservation }, undefined); this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? ''); return reservation; diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index eb4ae18a1..bbbb918ed 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -12,7 +12,7 @@ import { NotificationsService } from '../notifications/notifications.service'; import { AccommodationsService, noStayMirror, type AccommodationMirror } from '../accommodations/accommodations.service'; type Trip = TripAccess; -type BudgetEntry = { total_price?: number; category?: string } | undefined; +type BudgetEntry = { total_price?: number; category?: string; currency?: string | null; exchange_rate?: number } | undefined; export interface ReservationEndpoint { id?: number; @@ -1028,6 +1028,25 @@ export class ReservationsService { return answer; } + /** + * The linked cost a new booking's price becomes, in the currency the price was quoted + * in and at the rate frozen for it now (#2525). An imported booking previewed its + * $801.76 in dollars, then stored 801.76 in the trip's own currency, because only the + * amount travelled. The rate is resolved here, before the synchronous writes, the way + * the direct booking import and the Costs routes resolve it. A currency that is not a + * three-letter code is dropped, which leaves the price in the trip currency as before, + * and a rate is never taken from the caller. + */ + async withFrozenRate(tripId: string | number, entry: BudgetEntry): Promise { + if (!entry || typeof entry !== 'object') return entry; + const { currency: rawCurrency, exchange_rate: _callerRate, ...rest } = entry; + const currency = typeof rawCurrency === 'string' ? rawCurrency.trim().toUpperCase() : ''; + if (!/^[A-Z]{3}$/.test(currency)) return rest; + const priced: { currency?: string | null; exchange_rate?: number } = { currency }; + await this.budget.freezeForeignRate(tripId, priced); + return { ...rest, currency, ...(priced.exchange_rate != null ? { exchange_rate: priced.exchange_rate } : {}) }; + } + /** POST side effect: auto-create a linked budget item when a price is provided. */ syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void { if (!entry || !(Number(entry.total_price) > 0)) return; @@ -1036,6 +1055,8 @@ export class ReservationsService { name: title, category: entry.category || type || 'Other', total_price: entry.total_price!, + ...(entry.currency ? { currency: entry.currency } : {}), + ...(entry.exchange_rate != null ? { exchange_rate: entry.exchange_rate } : {}), }); this.realtime.broadcast(tripId, 'budget:created', { item }, socketId); } catch (err) { diff --git a/server/src/nest/trip-read-model/trip-read-model.service.ts b/server/src/nest/trip-read-model/trip-read-model.service.ts index 39deb1952..dab7b9650 100644 --- a/server/src/nest/trip-read-model/trip-read-model.service.ts +++ b/server/src/nest/trip-read-model/trip-read-model.service.ts @@ -47,7 +47,7 @@ export class TripReadModelService { // ── Trip summary (used by MCP get_trip_summary tool) ────────────────────── - getTripSummary(tripId: number, viewerUserId?: number) { + async getTripSummary(tripId: number, viewerUserId?: number) { const trip = withoutFeedToken( this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record | undefined, ); @@ -63,10 +63,15 @@ export class TripReadModelService { const accommodations = this.accommodations.list(tripId); const budgetItems = this.budget.listBudgetItems(tripId); + // In the trip currency, each row at the rate it was booked at (#2525). A raw sum of + // total_price added a dollar bill to the euros and called the result euros. + const tripCurrency = String(trip.currency || 'EUR'); + const totals = this.budget.tripTotals(tripId, tripCurrency, await this.budget.ratesForTripTotals(tripId, tripCurrency)); const budget = { items: budgetItems, item_count: budgetItems.length, - total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0), + total: totals.total, + by_category: totals.byCategory, currency: trip.currency, }; diff --git a/server/src/nest/trips/trip-prompts.mcp.ts b/server/src/nest/trips/trip-prompts.mcp.ts index 2f1506a10..991c94d16 100644 --- a/server/src/nest/trips/trip-prompts.mcp.ts +++ b/server/src/nest/trips/trip-prompts.mcp.ts @@ -43,18 +43,20 @@ export class TripPromptsMcp { if (!this.trips.canAccessTrip(tripId, ctx.userId)) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; } - const summary = this.readModel.getTripSummary(tripId, ctx.userId); + const summary = await this.readModel.getTripSummary(tripId, ctx.userId); if (!summary) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] }; } const { trip, budget } = summary; const currency = trip?.currency || 'EUR'; - const byCategory = (budget?.items || []).reduce((acc: Record, item: { category?: string; total_price?: number }) => { - const cat = item.category || 'Uncategorized'; - acc[cat] = (acc[cat] || 0) + (item.total_price || 0); - return acc; - }, {} as Record); - const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0); + // The summary's own totals, each row in the trip currency at the rate it was booked + // at (#2525). Adding total_price up here printed a dollar bill as that many euros. + const byCategory: Record = {}; + for (const [cat, amount] of Object.entries(budget?.by_category || {})) { + const key = cat || 'Uncategorized'; + byCategory[key] = Math.round(((byCategory[key] || 0) + amount) * 100) / 100; + } + const total = budget?.total || 0; const lines = Object.entries(byCategory) .sort(([, a], [, b]) => b - a) .map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`) @@ -94,7 +96,9 @@ export class TripPromptsMcp { const lines = Object.entries(grouped).map(([cat, catItems]) => `## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` ).join('\n\n'); - const { trip } = this.readModel.getTripSummary(tripId, ctx.userId) || {}; + // Only the title is needed. The whole summary also adds up the budget, which can + // wait on a rates fetch for a row that never froze its rate. + const trip = this.trips.getRaw(tripId); return { description: `Packing list for "${trip?.title || tripId}"`, messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 6d581dbf0..5199dc221 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -265,7 +265,7 @@ export class TripsMcp { }) async getTripSummary({ tripId }: { tripId: number }, ctx: McpContext) { if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); - const summary = this.readModel.getTripSummary(tripId, ctx.userId); + const summary = await this.readModel.getTripSummary(tripId, ctx.userId); if (!summary) return noAccess(); const R = canReadTrips(ctx.scopes); // Addon availability gates @@ -599,7 +599,7 @@ export class TripsMcp { if (!this.trips.canAccessTrip(tripId, ctx.userId)) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; } - const summary = this.readModel.getTripSummary(tripId, ctx.userId); + const summary = await this.readModel.getTripSummary(tripId, ctx.userId); if (!summary) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] }; } diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 04c4efd09..619e50f49 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -228,6 +228,27 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { } }); + it('GET /summary/per-person reads a dollar bill on a euro trip at its booked rate (#2525)', async () => { + const hotel = await request(server) + .post(`/api/trips/${tripId}/budget`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'Aparthotel Silver', currency: 'USD', exchange_rate: 1.17, payers: [{ user_id: 1, amount: 801.76 }], member_ids: [1, 2] }); + expect(hotel.status).toBe(201); + + const res = await request(server) + .get(`/api/trips/${tripId}/budget/summary/per-person`) + .set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + // 801.76 USD at 1.17 is 685.26 EUR, half of it each. The summary used to put + // 400.88 on both and leave the reader to guess the currency. + const row = (uid: number) => res.body.summary.find((r: { user_id: number }) => r.user_id === uid); + expect(row(1)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' }); + expect(row(2)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' }); + + const del = await request(server).delete(`/api/trips/${tripId}/budget/${hotel.body.item.id}`).set('Cookie', sessionCookie(1)); + expect(del.status).toBe(200); + }); + it('200 on settlement update with permission, persisting the new amount and day', async () => { const created = await request(server) .post(`/api/trips/${tripId}/budget/settlements`) diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 3a8320e60..d0f63c2b8 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -57,6 +57,7 @@ let checkPermission: MockInstance; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { NotificationsService } from '../../src/nest/notifications/notifications.service'; +import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => { let server: Server; @@ -67,6 +68,10 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] }) .overrideProvider(NotificationsService) .useValue({ send: notificationSend }) + // A price quoted in a foreign currency freezes the rate of the day; this is that + // day's table, so no case ever reaches the network. + .overrideProvider(ExchangeRatesService) + .useValue({ getRates: async (base: string) => (base.toUpperCase() === 'EUR' ? { EUR: 1, USD: 1.17 } : null) }) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); @@ -133,6 +138,31 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real expect(res.body).toEqual({ error: 'Trip not found' }); }); + it('201 create keeps an imported price in the currency it was quoted in, at a frozen rate (#2525)', async () => { + const res = await request(server) + .post(`/api/trips/${tripId}/reservations`) + .set('Cookie', sessionCookie(1)) + .send({ + title: 'Aparthotel Silver', type: 'hotel', + metadata: { price: '801.76', priceCurrency: 'USD' }, + create_budget_entry: { total_price: 801.76, category: 'accommodation', currency: 'USD' }, + }); + expect(res.status).toBe(201); + // It used to land as 801.76 with no currency, which is 801.76 of the trip's euros. + const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id); + expect(item).toEqual({ total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }); + }); + + it('201 create leaves a price without a currency in the trip currency, as before', async () => { + const res = await request(server) + .post(`/api/trips/${tripId}/reservations`) + .set('Cookie', sessionCookie(1)) + .send({ title: 'Museum', type: 'event', create_budget_entry: { total_price: 20, currency: 'not a code' } }); + expect(res.status).toBe(201); + const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id); + expect(item).toEqual({ total_price: 20, currency: null, exchange_rate: 1 }); + }); + it('201 create reservation (real insert + booking notification), 400 without title', async () => { const ok = await request(server) .post(`/api/trips/${tripId}/reservations`) diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index 4138d840b..c825852eb 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -374,6 +374,46 @@ describe('Settlement tools', () => { }); }); + it('get_settlement_summary reads a same-day bill in the viewer\'s currency to the cent, like REST (#2525)', async () => { + // Frankfurter's two quotes for the pair, each rounded, so not exact inverses. The entry + // rate is frozen from the euro's, and converting back with the dollar's read the + // 12,345.67 dollars as 12,346.06. + const quotes: Record> = { EUR: { EUR: 1, USD: 1.1398 }, USD: { USD: 1, EUR: 0.87732 } }; + const spy = vi.spyOn(ExchangeRatesService.prototype, 'getRates').mockImplementation(async (b: string) => quotes[b] ?? null); + try { + const { user, other, trip } = tripWithTwo(); + testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); + const item = createBudgetItem(testDb, trip.id, { total_price: 12345.67 }); + testDb.prepare("UPDATE budget_items SET currency = 'USD', exchange_rate = 1.1398 WHERE id = ?").run(item.id); + testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)') + .run(item.id, user.id, item.id, other.id); + testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)') + .run(item.id, user.id, 12345.67); + + const dbService = new DatabaseService(testDb); + const controller = new BudgetController( + new BudgetService(dbService, new PermissionsService(dbService), new ExchangeRatesService(), new RealtimeService()), + ); + const rest = await controller.settlement( + { id: user.id } as User, + { id: trip.id, user_id: user.id, currency: 'EUR' } as TripAccess, + String(trip.id), + 'USD', + ); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id, base: 'USD' } }); + const data = parseToolResult(result) as { summary: typeof rest }; + expect(data.summary).toEqual(rest); + const mine = data.summary.finalBudgets.find(f => f.user_id === user.id)!; + expect(mine.expenses).toBe(12345.67); + expect(mine.sources.fronted).toEqual([{ item_id: item.id, cents: 1234567 }]); + expect(data.summary.flows.map(f => f.amount)).toEqual([6172.84]); + }); + } finally { + spy.mockRestore(); + } + }); + it('get_settlement_summary returns balances and flows', async () => { try { const { user, other, trip } = tripWithTwo(); diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 3caa8ba43..ea953102a 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -93,6 +93,7 @@ import { notificationsStub } from '../../helpers/notifications'; // getTripSummary is the same controllable mock the legacy path used. const tripsStub = { canAccessTrip: (tripId: number, userId: number) => dbMock.canAccessTrip(tripId, userId), + getRaw: (tripId: number) => testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId), } as unknown as TripsService; // getTripSummary moved to TripReadModelService with the trip split; the mock is // the same controllable one, one constructor slot further along. @@ -119,8 +120,9 @@ const promptDbs = () => new DatabaseService(testDb); const authStub = { isDemoUser: () => false } as unknown as AuthService; const promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub()); const packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards); +const promptBudget = new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()); const budgetMcp = new BudgetMcp( - new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()), + promptBudget, new ExchangeRatesService(), promptDbs(), new RuntimeEnvService(), @@ -158,6 +160,8 @@ beforeEach(() => { `).all(tripId) as any[]; const budgetRows = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as any[]; const packingRows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(tripId) as any[]; + // The totals come from the same BudgetService.tripTotals the real summary uses. + const totals = promptBudget.tripTotals(tripId, trip.currency || 'EUR'); return { trip, days: [], @@ -165,7 +169,8 @@ beforeEach(() => { budget: { items: budgetRows, item_count: budgetRows.length, - total: budgetRows.reduce((sum, i) => sum + (i.total_price || 0), 0), + total: totals.total, + by_category: totals.byCategory, currency: trip.currency, }, packing: packingRows, // array shape; packing prompt tolerates it @@ -418,19 +423,18 @@ describe('Prompt: packing-list', () => { expect(text).toMatch(/\[[ x]\]/); }); - it('uses tripId as title fallback when getTripSummary returns null (covers || {} branch)', async () => { + it('reads the trip title without building the whole trip summary (#2525)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Null Trip' }); createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Hygiene' }); - - // Null out the getTripSummary call inside packing-list (line 94: || {}) - mockGetTripSummary.mockReturnValueOnce(null); + mockGetTripSummary.mockClear(); const client = await buildServer(user.id); const text = await invokePromptText(client, 'packing-list', { tripId: trip.id }); expect(text).toContain('Toothbrush'); - // Falls back to 'Trip' literal since trip?.title is undefined (getTripSummary null → || {}) - expect(text).toContain('Packing List: Trip'); + expect(text).toContain('Packing List: Null Trip'); + // The summary adds the budget up, and that can wait on a rates fetch. + expect(mockGetTripSummary).not.toHaveBeenCalled(); }); }); @@ -513,6 +517,24 @@ describe('Prompt: budget-overview', () => { expect(text).toContain('300'); // Accommodation total }); + it('prints a foreign-currency bill at the rate it was booked at, not as that many euros (#2525)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Dollar Trip' }); + promptBudget.createBudgetItem(trip.id, { + name: 'Aparthotel Silver', category: 'Accommodation', currency: 'USD', exchange_rate: 1.17, + payers: [{ user_id: user.id, amount: 801.76 }], members: [{ user_id: user.id }], + }); + createBudgetItem(testDb, trip.id, { name: 'Dinner', category: 'Food', total_price: 100 }); + + const client = await buildServer(user.id); + const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id }); + // 801.76 USD at 1.17 is 685.26 EUR. Summed as stored it read "Total: 901.76 EUR". + expect(text).toContain('Total: 785.26 EUR'); + expect(text).toContain('- Accommodation: 685.26 EUR'); + expect(text).toContain('- Food: 100 EUR'); + expect(text).not.toContain('801.76'); + }); + it('renders "No expenses recorded." when budget array is empty', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Empty Budget' }); diff --git a/server/tests/unit/nest/budget.controller.test.ts b/server/tests/unit/nest/budget.controller.test.ts index e33f96685..5df4a25dc 100644 --- a/server/tests/unit/nest/budget.controller.test.ts +++ b/server/tests/unit/nest/budget.controller.test.ts @@ -49,13 +49,13 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou expect(new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] }); }); - it('GET /summary/per-person + /settlement delegate', () => { + it('GET /summary/per-person + /settlement delegate', async () => { const settlement = vi.fn().mockReturnValue({ transfers: [] }); const svc = makeService({ perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]), settlement, } as Partial); - expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] }); + expect(await new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] }); // A trip with no currency set falls back to EUR rather than passing undefined on. expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] }); expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR'); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 97ad73ae5..171401252 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -39,6 +39,9 @@ const { RATES } = vi.hoisted(() => ({ RATES: { RUB: { RUB: 1, USD: 0.013042, EUR: 0.011412 }, EUR: { EUR: 1, USD: 1.1429, RUB: 87.63 }, + // The dollar's own quote, as Frankfurter prints it: rounded, so not the exact + // inverse of the euro's 1.1429 (1 / 0.87497 = 1.142896...). + USD: { USD: 1, EUR: 0.87497 }, } as Record>, })); // Constructor-injected since the fold; the class is mocked at the module path @@ -1156,3 +1159,157 @@ describe('an expense whose split leaves a remainder', () => { expect(after.id).toBe(before.id); }); }); + +// #2525: a bill entered in dollars on a euro trip was booked at the rate of the day. The +// totals the MCP summary, the budget prompt and the per-person resource hand out added its +// dollars to the euros as they stood and called the sum euros. +describe('trip totals read every row in the trip currency (#2525)', () => { + function seedDollarBill() { + const { user: me } = createUser(testDb); + const { user: bob } = createUser(testDb); + const trip = createTrip(testDb, me.id); + addTripMember(testDb, trip.id, bob.id); + const members = [{ user_id: me.id }, { user_id: bob.id }]; + const hotel = budget.createBudgetItem(trip.id, { + name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17, + payers: [{ user_id: me.id, amount: 801.76 }], members, + }); + budget.createBudgetItem(trip.id, { + name: 'Dinner', category: 'food', currency: 'EUR', + payers: [{ user_id: bob.id, amount: 100 }], members, + }); + return { trip, me, bob, hotel }; + } + + it('BUDGET-SVC-DB-044: adds each row up at the rate it was booked at, in whole cents', async () => { + const { trip } = seedDollarBill(); + // 801.76 USD at 1.17 is 685.26 EUR, the figure the settlement nets as well. + expect(budget.tripTotals(trip.id, 'EUR')).toEqual({ + total: 785.26, + byCategory: { accommodation: 685.26, food: 100 }, + }); + // Both rows carry what they need, so nothing waits on today's rates. + expect(await budget.ratesForTripTotals(trip.id, 'EUR')).toBeNull(); + }); + + it('BUDGET-SVC-DB-045: fetches today\'s rate only for a row that never froze one', async () => { + const { user: me } = createUser(testDb); + const trip = createTrip(testDb, me.id); + // Written before the freeze existed: the column default of 1 is not a booked rate. + budget.createBudgetItem(trip.id, { name: 'Old taxi', category: 'transport', total_price: 114.29, currency: 'USD' }); + const rates = await budget.ratesForTripTotals(trip.id, 'EUR'); + expect(rates).toEqual(RATES.EUR); + // 114.29 USD at 1.1429 per euro, the same way the settlement reads such a row. + expect(budget.tripTotals(trip.id, 'EUR', rates).total).toBe(100); + }); + + it('BUDGET-SVC-DB-046: the per-person summary splits trip cents, not raw amounts', async () => { + const { trip, me, bob, hotel } = seedDollarBill(); + budget.toggleMemberPaid(hotel.id, trip.id, bob.id, true); + + const summary = await budget.perPersonSummary(trip.id); + const of = (id: number) => summary.find(s => s.user_id === id)!; + // Half of 685.26 EUR plus half of 100 EUR each. The query this replaced put + // 450.88 on both, half the dollars counted as euros, and never said which. + expect(of(me.id)).toMatchObject({ total_assigned: 392.63, total_paid: 0, items_count: 2, currency: 'EUR' }); + expect(of(bob.id)).toMatchObject({ total_assigned: 392.63, total_paid: 342.63, items_count: 2, currency: 'EUR' }); + }); + + it('BUDGET-SVC-DB-047: an equal split hands out the odd cent instead of a float tail', async () => { + const { user: a } = createUser(testDb); + const { user: b } = createUser(testDb); + const { user: c } = createUser(testDb); + const trip = createTrip(testDb, a.id); + addTripMember(testDb, trip.id, b.id); + addTripMember(testDb, trip.id, c.id); + budget.createBudgetItem(trip.id, { + name: 'Boat', currency: 'EUR', payers: [{ user_id: a.id, amount: 100 }], + members: [{ user_id: a.id }, { user_id: b.id }, { user_id: c.id }], + }); + + const shares = (await budget.perPersonSummary(trip.id)).map(s => Math.round(s.total_assigned * 100)); + expect(shares.slice().sort((x, y) => x - y)).toEqual([3333, 3333, 3334]); + expect(shares.reduce((x, y) => x + y, 0)).toBe(10000); + }); +}); + +// #2525: a euro trip read in dollars froze a dollar bill's rate from the euro's quote and +// converted the ledger back with the dollar's. The two quotes are not exact inverses, so a +// bill entered today read a few cents off and paying what settle-up offered left a balance. +describe('the settlement converts with the quote the entry rate was frozen from (#2525)', () => { + function seedSameDayBill() { + const { user: me } = createUser(testDb); + const { user: bob } = createUser(testDb); + const trip = createTrip(testDb, me.id); + addTripMember(testDb, trip.id, bob.id); + return { trip, me, bob }; + } + + it('BUDGET-SVC-DB-048: a same-day bill in the display currency reads as typed and settles to zero', async () => { + const { trip, me, bob } = seedSameDayBill(); + // Frozen now, from the euro's quote: 1.1429. + const bill = await budget.create(String(trip.id), { + name: 'Villa', category: 'accommodation', currency: 'USD', total_price: 12345.67, + payers: [{ user_id: me.id, amount: 12345.67 }], members: [{ user_id: me.id }, { user_id: bob.id }], + }) as { id: number; exchange_rate: number }; + expect(bill.exchange_rate).toBe(1.1429); + + const before = await budget.settlement(trip.id, 'USD', 'EUR'); + const mine = before.finalBudgets.find(f => f.user_id === me.id)!; + expect(mine.expenses).toBe(12345.67); + expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 1234567 }]); + expect(before.flows.map(f => f.amount)).toEqual([6172.84]); + + // Bob pays exactly what settle-up offers, in dollars, as the Costs screen records it. + await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 6172.84, currency: 'USD' }, me.id); + const after = await budget.settlement(trip.id, 'USD', 'EUR'); + expect(after.balances.map(b => b.balance)).toEqual([0, 0]); + expect(after.flows).toEqual([]); + expect((await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => b.balance)).toEqual([0, 0]); + }); + + it('BUDGET-SVC-DB-051: the final budget lists a bill at what was typed even where its trip cent rounds away', async () => { + const { trip, me, bob } = seedSameDayBill(); + // 123.45 USD is 108.0147 EUR. As a whole trip cent, 108.01 EUR, it came back as 123.44. + const bill = await budget.create(String(trip.id), { + name: 'Taxi', currency: 'USD', total_price: 123.45, + payers: [{ user_id: me.id, amount: 123.45 }], members: [{ user_id: me.id }, { user_id: bob.id }], + }) as { id: number }; + const s = await budget.settlement(trip.id, 'USD', 'EUR'); + const mine = s.finalBudgets.find(f => f.user_id === me.id)!; + const bobs = s.finalBudgets.find(f => f.user_id === bob.id)!; + expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 12345 }]); + expect(mine.expenses).toBe(123.45); + // The two shares still add up to the bill, and the ledger in euros is untouched. + expect(Math.round((mine.final + bobs.final) * 100)).toBe(12345); + const inEur = await budget.settlement(trip.id, 'EUR', 'EUR'); + expect(inEur.finalBudgets.find(f => f.user_id === me.id)!.sources.fronted).toEqual([{ item_id: bill.id, cents: 10801 }]); + }); + + it('BUDGET-SVC-DB-049: a transfer saved without a currency reads back in the display currency as entered', async () => { + const { trip, me, bob } = seedSameDayBill(); + await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 30 }, me.id); + const s = await budget.settlement(trip.id, 'USD', 'EUR'); + const mine = s.finalBudgets.find(f => f.user_id === me.id)!; + expect(mine.reimbursed).toBe(30); + expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(-30); + }); + + it('BUDGET-SVC-DB-050: falls back to the display currency\'s quote when the trip\'s is unavailable', async () => { + const { trip, me, bob } = seedSameDayBill(); + budget.createBudgetItem(trip.id, { + name: 'Dinner', currency: 'EUR', payers: [{ user_id: me.id, amount: 100 }], + members: [{ user_id: me.id }, { user_id: bob.id }], + }); + const rates = (budget as unknown as { exchangeRates: ExchangeRatesService }).exchangeRates; + const spy = vi.spyOn(rates, 'getRates').mockImplementation(async (base: string) => (base === 'EUR' ? null : RATES[base] ?? null)); + try { + const s = await budget.settlement(trip.id, 'USD', 'EUR'); + expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']); + // 50 EUR at 1 / 0.87497 USD per euro. + expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(57.14); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index f7317cc7b..b28bb0edd 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -73,24 +73,36 @@ describe('BudgetService', () => { expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock'); }); - it('list / perPersonSummary resolve through the folded SQL methods', () => { + it('list / perPersonSummary resolve through the folded SQL methods', async () => { const s = svc(); const listSpy = vi.spyOn(s, 'listBudgetItems').mockReturnValue([{ id: 1 }] as never); expect(s.list('5')).toEqual([{ id: 1 }]); expect(listSpy).toHaveBeenCalledWith('5'); const summarySpy = vi.spyOn(s, 'getPerPersonSummary').mockReturnValue([{ userId: 1 }] as never); - expect(s.perPersonSummary('5')).toEqual([{ userId: 1 }]); - expect(summarySpy).toHaveBeenCalledWith('5'); + expect(await s.perPersonSummary('5')).toEqual([{ userId: 1 }]); + // No row is waiting on today's rates, so none are fetched for it. + expect(summarySpy).toHaveBeenCalledWith('5', null); + expect(getRates).not.toHaveBeenCalled(); }); describe('settlement', () => { - it('upper-cases the explicit base and forwards the rates', async () => { + it('upper-cases the explicit base and converts with the trip currency\'s own quote (#2525)', async () => { const s = svc(); const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never); - getRates.mockResolvedValue({ USD: 1.1 }); - await s.settlement('5', 'usd', 'EUR'); - expect(getRates).toHaveBeenCalledWith('USD'); - expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' }); + getRates.mockResolvedValue({ EUR: 1, USD: 1.1 }); + await s.settlement('5', 'usd', 'eur'); + // The quote every entry rate was frozen from; the dollar's is not its exact inverse. + expect(getRates.mock.calls).toEqual([['EUR']]); + expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { EUR: 1, USD: 1.1 }, tripCurrency: 'EUR' }); + }); + + it('stands in the display currency\'s quote when the trip\'s cannot be fetched', async () => { + const s = svc(); + const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never); + getRates.mockImplementation(async (b: string) => (b === 'USD' ? { USD: 1, EUR: 0.9 } : null)); + await s.settlement('5', 'USD', 'EUR'); + expect(getRates.mock.calls).toEqual([['EUR'], ['USD']]); + expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1, EUR: 0.9 }, tripCurrency: 'EUR' }); }); it('falls back to the trip currency when no base is given', async () => { @@ -98,8 +110,8 @@ describe('BudgetService', () => { const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never); getRates.mockResolvedValue(null); await s.settlement('5', undefined, 'gbp'); - expect(getRates).toHaveBeenCalledWith('GBP'); - expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' }); + expect(getRates.mock.calls).toEqual([['GBP']]); + expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'GBP' }); }); it('falls back to EUR when neither base nor trip currency is present', async () => { @@ -108,7 +120,7 @@ describe('BudgetService', () => { getRates.mockResolvedValue(null); await s.settlement('5', undefined, ''); expect(getRates).toHaveBeenCalledWith('EUR'); - expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' }); + expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: 'EUR' }); }); }); diff --git a/server/tests/unit/nest/reservations.controller.test.ts b/server/tests/unit/nest/reservations.controller.test.ts index bd21888d2..5bcb0171d 100644 --- a/server/tests/unit/nest/reservations.controller.test.ts +++ b/server/tests/unit/nest/reservations.controller.test.ts @@ -21,10 +21,22 @@ function makeService(overrides: Partial = {}): Reservations syncBudgetOnCreate: vi.fn(), syncBudgetOnUpdate: vi.fn(), notifyBookingChange: vi.fn(), + // Hands the entry back as it came: what freezing does to it is the service's test. + withFrozenRate: vi.fn(async (_tripId: string, entry: unknown) => entry), ...overrides, } as unknown as ReservationsService; } +/** The create route awaits the price's rate, so its 400s arrive as a rejection. */ +async function rejected(promise: Promise): Promise<{ status: number; body: unknown }> { + try { await promise; } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected throw'); +} + function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -46,31 +58,31 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese // The bespoke 'Title is required' 400 moved to the global ZodValidationPipe // (ReservationCreateDto) — covered by the e2e suite. - it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', () => { + it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', async () => { const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: true }); const broadcast = vi.fn(); const syncBudgetOnCreate = vi.fn(); const notifyBookingChange = vi.fn(); const svc = makeService({ create, broadcast, syncBudgetOnCreate, notifyBookingChange } as Partial); const body = { title: 'Hotel', type: 'lodging', create_budget_entry: { total_price: 200 } }; - expect(new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } }); + expect(await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } }); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock'); expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Hotel', 'lodging', { total_price: 200 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock'); expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging'); }); - it('400s on a body id belonging to another trip, without writing', () => { + it('400s on a body id belonging to another trip, without writing', async () => { const create = vi.fn(); const svc = makeService({ create, referencesOutsideTrip: vi.fn().mockReturnValue(['accommodation_id']), } as Partial); const body = { title: 'Hotel', accommodation_id: 4711 }; - expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))) + expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', body))) .toEqual({ status: 400, body: { error: 'Not part of this trip: accommodation_id' } }); expect(create).not.toHaveBeenCalled(); }); - it('400s on a body id that exists nowhere, in its own words, without writing', () => { + it('400s on a body id that exists nowhere, in its own words, without writing', async () => { const create = vi.fn(); const svc = makeService({ create, @@ -79,20 +91,36 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese const body = { title: 'Hotel', place_id: 4711 }; // Not 'Not part of this trip': an id that is part of nothing would send // the caller looking for it on another trip. - expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))) + expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', body))) .toEqual({ status: 400, body: { error: 'Unknown reference: place_id' } }); expect(create).not.toHaveBeenCalled(); }); - it('answers a foreign id with the older message when it is both', () => { + it('answers a foreign id with the older message when it is both', async () => { const svc = makeService({ create: vi.fn(), referencesOutsideTrip: vi.fn().mockReturnValue(['place_id']), unresolvedReferences: vi.fn().mockReturnValue(['place_id']), } as Partial); - expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 }))) + expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 }))) .toEqual({ status: 400, body: { error: 'Not part of this trip: place_id' } }); }); + + // #2525: an imported booking quoted in dollars. The price has to reach the linked + // cost in dollars, at a rate frozen before anything is written. + it('hands the budget sync the entry with its currency and frozen rate', async () => { + const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: false }); + const syncBudgetOnCreate = vi.fn(); + const withFrozenRate = vi.fn(async () => { + expect(create).not.toHaveBeenCalled(); + return { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }; + }); + const svc = makeService({ create, syncBudgetOnCreate, withFrozenRate } as Partial); + const body = { title: 'Aparthotel Silver', type: 'hotel', create_budget_entry: { total_price: 801.76, currency: 'usd' } }; + await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock'); + expect(withFrozenRate).toHaveBeenCalledWith('5', { total_price: 801.76, currency: 'usd' }); + expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Aparthotel Silver', 'hotel', { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }, 'sock'); + }); }); describe('PUT /positions', () => { diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index a43cece1e..778363c93 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -39,7 +39,7 @@ const permissionsStub = { checkPermission } as unknown as PermissionsService; // Constructor-injected since the budget fold (was a path mock of the deleted // services/budgetService). -const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() }; +const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn(), freezeForeignRate: vi.fn() }; const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } })); @@ -532,6 +532,37 @@ describe('ReservationsService (DI-native, real SQL)', () => { budget.linkBudgetItemToReservation.mockImplementation(() => { throw new Error('boom'); }); expect(() => svc.syncBudgetOnCreate('5', 9, 'Hotel', undefined, { total_price: 50 }, 'sock')).not.toThrow(); }); + + // #2525: an imported booking quoted in dollars became a cost of that many euros. + it('links the cost in the currency and at the rate it arrives with', () => { + budget.linkBudgetItemToReservation.mockReturnValue({ id: 7 }); + svc.syncBudgetOnCreate('5', 9, 'Hotel', 'hotel', { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }, 'sock'); + expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith('5', 9, { + name: 'Hotel', category: 'hotel', total_price: 801.76, currency: 'USD', exchange_rate: 1.17, + }); + }); + }); + + describe('withFrozenRate (#2525)', () => { + it('keeps a quoted currency and freezes a rate for it', async () => { + budget.freezeForeignRate.mockImplementation(async (_tripId: unknown, data: { exchange_rate?: number }) => { data.exchange_rate = 1.17; }); + expect(await svc.withFrozenRate('5', { total_price: 801.76, category: 'hotel', currency: ' usd ' })).toEqual({ + total_price: 801.76, category: 'hotel', currency: 'USD', exchange_rate: 1.17, + }); + expect(budget.freezeForeignRate).toHaveBeenCalledWith('5', { currency: 'USD', exchange_rate: 1.17 }); + }); + + it('leaves the rate off when none could be frozen, so the cost converts live', async () => { + budget.freezeForeignRate.mockResolvedValue(undefined); + expect(await svc.withFrozenRate('5', { total_price: 10, currency: 'EUR' })).toEqual({ total_price: 10, currency: 'EUR' }); + }); + + it('drops a currency that is not a code, and never takes a rate from the caller', async () => { + expect(await svc.withFrozenRate('5', { total_price: 10, currency: 'dollars', exchange_rate: 99 })).toEqual({ total_price: 10 }); + expect(await svc.withFrozenRate('5', { total_price: 10, exchange_rate: 99 })).toEqual({ total_price: 10 }); + expect(budget.freezeForeignRate).not.toHaveBeenCalled(); + expect(await svc.withFrozenRate('5', undefined)).toBeUndefined(); + }); }); describe('syncBudgetOnUpdate', () => { diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index f00cbab23..e2382565c 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -158,31 +158,31 @@ function ownerlessDbs(): DatabaseService { // ── Tests ───────────────────────────────────────────────────────────────────── describe('getTripSummary guards', () => { - it('TRIP-READ-001: returns null for a missing trip instead of throwing', () => { + it('TRIP-READ-001: returns null for a missing trip instead of throwing', async () => { // The MCP get_trip_summary tool hands over whatever id the model produced, so // an unknown id has to come back as an empty answer; a throw there surfaces as // a tool error rather than "no such trip". - expect(svc.getTripSummary(99999)).toBeNull(); - expect(svc.getTripSummary(99999, 1)).toBeNull(); + expect(await svc.getTripSummary(99999)).toBeNull(); + expect(await svc.getTripSummary(99999, 1)).toBeNull(); }); - it('TRIP-READ-002: returns null when the owner row cannot be read', () => { + it('TRIP-READ-002: returns null when the owner row cannot be read', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); // Trip row and owner id are two separate SELECTs; if the second one comes back // empty (trip deleted in between) the guard has to stop. Without it listMembers // runs with an undefined owner id and the summary reports an ownerless trip. - expect(buildReadModel(ownerlessDbs()).getTripSummary(trip.id, owner.id)).toBeNull(); + expect(await buildReadModel(ownerlessDbs()).getTripSummary(trip.id, owner.id)).toBeNull(); // Same trip through the real connection still aggregates — the null above is // the missing owner row, not a broken fixture. - expect(svc.getTripSummary(trip.id, owner.id)).not.toBeNull(); + expect(await svc.getTripSummary(trip.id, owner.id)).not.toBeNull(); }); }); describe('getTripSummary shaping', () => { - it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', () => { + it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); addBudgetItem(trip.id, 'Dinner', 40); @@ -193,13 +193,43 @@ describe('getTripSummary shaping', () => { // the offline clients both render as an empty budget. addBudgetItem(trip.id, 'Free walking tour', 0); - const summary = svc.getTripSummary(trip.id, owner.id)!; + const summary = (await svc.getTripSummary(trip.id, owner.id))!; expect(summary.budget.item_count).toBe(2); expect(summary.budget.total).toBe(40); expect(summary.budget.currency).toBe('EUR'); }); - it('TRIP-READ-004: counts only checked packing items, not the whole list', () => { + it('TRIP-READ-008: totals a foreign-currency bill in the trip currency, at its booked rate (#2525)', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addBudgetItem(trip.id, 'Dinner', 100); + // 801.76 USD booked when a euro bought 1.17 dollars: 685.26 EUR of trip money. The + // summary used to add the 801.76 to the euros and report 901.76 EUR. + testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate) VALUES (?, 'accommodation', 'Aparthotel Silver', 801.76, 'USD', 1.17)") + .run(trip.id); + + const summary = (await svc.getTripSummary(trip.id, owner.id))!; + expect(summary.budget.total).toBe(785.26); + expect(summary.budget.by_category).toEqual({ food: 100, accommodation: 685.26 }); + expect(summary.budget.currency).toBe('EUR'); + }); + + it('TRIP-READ-009: totals a trip saved without a currency in euros, the default the rest of the app reads it in (#2525)', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + testDb.prepare('UPDATE trips SET currency = NULL WHERE id = ?').run(trip.id); + addBudgetItem(trip.id, 'Dinner', 100); + // 117.33 USD booked at 1.1733 dollars to the euro is 100 EUR of trip money. + testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate) VALUES (?, 'transport', 'Taxi', 117.33, 'USD', 1.1733)") + .run(trip.id); + + const summary = (await svc.getTripSummary(trip.id, owner.id))!; + expect(summary.budget.total).toBe(200); + expect(summary.budget.by_category).toEqual({ food: 100, transport: 100 }); + expect(summary.budget.currency).toBeNull(); + }); + + it('TRIP-READ-004: counts only checked packing items, not the whole list', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); addPackingItem(trip.id, 'Socks', 1); @@ -208,7 +238,7 @@ describe('getTripSummary shaping', () => { // total and checked come from the same array; if the filter is ever widened the // packing progress the summary reports jumps to 100% while items are still open. - const summary = svc.getTripSummary(trip.id, owner.id)!; + const summary = (await svc.getTripSummary(trip.id, owner.id))!; expect(summary.packing.total).toBe(3); expect(summary.packing.checked).toBe(2); }); @@ -254,7 +284,7 @@ describe('bundle shaping', () => { }); describe('private packing items stay viewer-scoped (#858)', () => { - it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", () => { + it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); @@ -267,7 +297,7 @@ describe('private packing items stay viewer-scoped (#858)', () => { // ONLY thing filtering the list. If either call site loses it, listItems falls // back to the unfiltered query and the surprise the owner is carrying shows up // in the other member's MCP summary and in their offline cache. - const asViewer = svc.getTripSummary(trip.id, viewer.id)!; + const asViewer = (await svc.getTripSummary(trip.id, viewer.id))!; expect(asViewer.packing.items.map((i: any) => i.name)).toEqual(['Tent']); expect(asViewer.packing.total).toBe(1); @@ -276,7 +306,7 @@ describe('private packing items stay viewer-scoped (#858)', () => { // The owner still sees their own private item through both paths, so the // assertions above are the filter working, not an empty fixture. - expect(svc.getTripSummary(trip.id, owner.id)!.packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']); + expect((await svc.getTripSummary(trip.id, owner.id))!.packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']); expect((svc.bundle(String(trip.id), { user_id: owner.id }, owner.id) as any) .packingItems.map((i: any) => i.name)).toEqual(['Ring', 'Tent']); }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 5f021858e..1e640b8d8 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -928,7 +928,7 @@ describe('guest members (#1362)', () => { // ── Folded CRUD SQL (summary / list / create / delete / copy) ───────────────── describe('folded trip CRUD', () => { - it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', () => { + it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); @@ -936,7 +936,7 @@ describe('folded trip CRUD', () => { testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id); testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id); - const summary = readModelSvc.getTripSummary(trip.id, owner.id)!; + const summary = (await readModelSvc.getTripSummary(trip.id, owner.id))!; expect(summary).toBeTruthy(); expect((summary.trip as any).id).toBe(trip.id); expect(summary.members.owner.id).toBe(owner.id); @@ -950,7 +950,7 @@ describe('folded trip CRUD', () => { expect(summary.collab_notes).toEqual([]); // Missing trips return null instead of throwing. - expect(readModelSvc.getTripSummary(99999)).toBeNull(); + expect(await readModelSvc.getTripSummary(99999)).toBeNull(); }); it('TRIP-SVC-043: list returns owned + shared trips with is_owner, honoring the archived filter', () => { diff --git a/server/tests/unit/reservations/bookings.rpc.test.ts b/server/tests/unit/reservations/bookings.rpc.test.ts index 0cbac2d25..628f719ee 100644 --- a/server/tests/unit/reservations/bookings.rpc.test.ts +++ b/server/tests/unit/reservations/bookings.rpc.test.ts @@ -43,6 +43,8 @@ function build(opts: { canEdit?: boolean; cascade?: boolean; stop?: boolean; see ), syncBudgetOnCreate: vi.fn(), syncBudgetOnUpdate: vi.fn(), + // The price's currency and rate are the service's to resolve; here it passes through. + withFrozenRate: vi.fn(async (_tripId: number, entry: unknown) => entry), notifyBookingChange: vi.fn(), // Both guards run before a write now, so the fixture answers for them. referencesOutsideTrip: vi.fn(() => opts.foreign ?? []), From e6f66014a51bc485ae11529c224855fefc37922b Mon Sep 17 00:00:00 2001 From: gso Date: Sat, 26 Sep 2026 05:39:26 +0200 Subject: [PATCH 11/14] fix(planner): transport edit window can delete the booking A transport added from the day-plan sidebar could not be deleted: the edit window (TransportModal) had no delete action, and the only transport delete affordances lived on the Transports-tab cards, which that flow never reaches. Add an onDelete prop and a Delete action in the edit footer, guarded by the shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit (the permission the server enforces for reservation deletion). --- .../Planner/TransportModal.test.tsx | 30 ++++++++++++ .../src/components/Planner/TransportModal.tsx | 47 +++++++++++++++---- client/src/pages/TripPlannerPage.tsx | 2 +- .../src/pages/TripPlannerPage.wiring.test.tsx | 21 +++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 957dc378f..7932e40bd 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -95,6 +95,36 @@ describe('TransportModal', () => { expect(screen.getByText(/Edit transport/i)).toBeInTheDocument(); }); + it('FE-PLANNER-TRANSMODAL-003b: shows a delete action when editing with onDelete', () => { + const res = buildReservation({ title: 'Paris Flight', type: 'flight' }); + render(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-003c: no delete action when creating, or when onDelete is omitted', () => { + const res = buildReservation({ title: 'Paris Flight', type: 'flight' }); + const { rerender } = render(); + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-003d: confirming delete calls onDelete then onClose', async () => { + const onDelete = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + const res = buildReservation({ title: 'Paris Flight', type: 'flight' }); + render(); + + await userEvent.click(screen.getByRole('button', { name: /delete/i })); + expect(screen.getByText('Delete booking?')).toBeInTheDocument(); + + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + await userEvent.click(deleteButtons[deleteButtons.length - 1]); + + await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 197a12e45..3ef684565 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { useParams } from 'react-router' import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react' import Modal from '../shared/Modal' +import ConfirmDialog from '../shared/ConfirmDialog' import CustomSelect from '../shared/CustomSelect' import { BookingCodeInput } from '../shared/BookingCode' import CustomTimePicker from '../shared/CustomTimePicker' @@ -167,6 +168,8 @@ interface TransportModalProps { files?: TripFile[] onFileUpload?: (fd: FormData) => Promise onFileDelete?: (fileId: number) => Promise + /** Deletes the reservation being edited. Omit to hide the delete action (create mode, or no permission). */ + onDelete?: () => void | Promise onOpenExpense?: (req: BookingExpenseRequest) => void // Pre-fill a brand-new transport booking from a parsed import item (review- // before-save); like `reservation` for the form but stays in create mode. @@ -186,7 +189,7 @@ interface TransportModalProps { tripMembers?: TripMember[] } -export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) { +export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) { const { t, locale } = useTranslation() const toast = useToast() // The trip's places, offered by every location field of the manual tab (#2468). @@ -204,6 +207,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel // Manual vs Automated (public transit search) creation mode (#1065). const [automated, setAutomated] = useState(false) const [isSaving, setIsSaving] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [fromPick, setFromPick] = useState({}) const [toPick, setToPick] = useState({}) // Flight route as an ordered list of airports (origin .. stops .. destination). @@ -724,15 +728,24 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')} size="2xl" footer={ -
- - {!automated && ( - - )} +
+
+ {!automated && reservation?.id && onDelete && ( + + )} +
+
+ + {!automated && ( + + )} +
} > @@ -1245,6 +1258,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel )} + + setShowDeleteConfirm(false)} + title={t('reservations.confirm.deleteTitle')} + message={t('reservations.confirm.deleteBody', { name: reservation?.title ?? '' })} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + onConfirm={async () => { + setShowDeleteConfirm(false) + await onDelete?.() + onClose() + }} + /> ) } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 59deb9195..a9455f057 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -1103,7 +1103,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {showTransportModal && ( - { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} places={places} assignments={assignments} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} tripHasDates={tripHasDates} tripMembers={tripMembers} /> + { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onDelete={can('reservation_edit', trip) && editingTransport ? () => handleDeleteReservation(editingTransport.id) : undefined} onOpenExpense={openBookingExpense} places={places} assignments={assignments} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} tripHasDates={tripHasDates} tripMembers={tripMembers} /> )} diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index e46add188..2b58563f9 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -1170,6 +1170,27 @@ describe('TripPlannerPage — modals', () => { expect(tripActions.deleteFile).toHaveBeenCalledWith(42, 2) }) + it('FE-PAGE-TPW-053b: the transport edit window deletes the booking when the user may edit reservations', async () => { + const res = buildReservation({ id: 55, type: 'flight', title: 'LH 400' }) + renderPage({ showTransportModal: true, editingTransport: res }) + + await act(async () => { await props('transportModal').onDelete() }) + expect(hookState.handleDeleteReservation).toHaveBeenCalledWith(55) + }) + + it('FE-PAGE-TPW-053c: no transport delete without an edited booking or permission', () => { + renderPage({ showTransportModal: true }) + expect(props('transportModal').onDelete).toBeUndefined() + + cleanup() + renderPage({ + showTransportModal: true, + editingTransport: buildReservation({ id: 55, type: 'flight' }), + can: vi.fn((action: string) => action !== 'reservation_edit'), + }) + expect(props('transportModal').onDelete).toBeUndefined() + }) + it('FE-PAGE-TPW-054: during a review the transport modal exits advance the queue too', async () => { renderPage({ showTransportModal: true, importReviewActive: true }) From cd06f0f7f43ee0a41c4f9b6f284900001058b49c Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 26 Sep 2026 11:10:12 +0200 Subject: [PATCH 12/14] fix(journey): read GPS and the EXIF time offset from uploaded photos (#2512) The capture backfill for local Journey uploads asked exifr for 'latitude' and 'longitude'. Those are not tags: exifr derives them from the GPS tags, and a pick of only the derived names reads no GPS block at all, so every uploaded photo lost its location and never showed on the journey map. DateTimeOriginal was revived by exifr as a Date in the server's time zone and OffsetTimeOriginal was never read, so a photo taken at 15:52 in France was stored as 20:52 UTC on a server running in Chicago. Impossible stamps were rolled over into real dates as well (0000:00:00 became 1899, 30 February became 2 March). The reader now picks the raw GPS tags and reads the stamps unrevived. exifCaptureInstant validates the stamp and applies the first well formed offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags. Without a usable offset it keeps the old reading in the server zone. A stamp that is not a real date is dropped, CreateDate stands in when DateTimeOriginal is unusable, and coordinates are kept only as a valid pair. A GPS block of zeros, which a receiver without a fix writes, counts as no location instead of a pin at 0,0. Photos uploaded before this fix keep their stored time and location for now; correcting them is left for a later release. StorageService.withLocalFile now also removes its temp file when the download itself fails, not only when the consumer throws. The stamp is text any upload sets, so exifCaptureInstant turns down one longer than 64 characters before any pattern runs, and the pattern has no greedy tail in front of an end anchor, which cost quadratic time on a long run of digits. The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call share this one reader, so both are fixed. The MCP journey tools only attach provider photos, which never reach it. --- server/src/nest/memories/memories.helpers.ts | 73 ++++++++ .../photo-capture-backfill.service.ts | 73 ++++++-- server/src/nest/storage/storage.service.ts | 5 +- server/tests/helpers/exif-jpeg.ts | 138 ++++++++++++++ server/tests/integration/journey.test.ts | 89 ++++++++- .../tests/unit/nest/memories.helpers.test.ts | 149 ++++++++++++++- .../nest/photo-capture-backfill.exif.test.ts | 177 ++++++++++++++++++ .../photo-capture-backfill.service.test.ts | 37 +++- .../unit/nest/storage/storage.service.test.ts | 20 +- 9 files changed, 735 insertions(+), 26 deletions(-) create mode 100644 server/tests/helpers/exif-jpeg.ts create mode 100644 server/tests/unit/nest/photo-capture-backfill.exif.test.ts diff --git a/server/src/nest/memories/memories.helpers.ts b/server/src/nest/memories/memories.helpers.ts index 1f7c91cac..bc5777514 100644 --- a/server/src/nest/memories/memories.helpers.ts +++ b/server/src/nest/memories/memories.helpers.ts @@ -189,6 +189,79 @@ export function dayStartEpochSeconds(day: string, tzOffsetMinutes = 0): number { } +// 'YYYY:MM:DD HH:MM:SS' as EXIF writes it. The dashed date is what some +// converters produce and what exifr's own reviver accepted, so it keeps working, +// and so does a field written with one digit ('2026:5:30 9:05:00'), which that +// reviver read as well. Whatever follows the time is checked on its own below. +// No end anchor on purpose: a greedy tail in front of '$' makes a long run of +// digits cost quadratic time, and the stamp is text any upload can set. +const EXIF_STAMP = /^(\d{4})[:-](\d{1,2})[:-](\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?/; +// A stamp with nanoseconds and a zone is 36 characters. A longer one is no +// stamp, and is not worth reading. +const EXIF_STAMP_MAX = 64; +// '+02:00' is what the spec asks for; '+0200', 'Z', 'UTC' and 'GMT' say the same thing. +const EXIF_ZONE = /^(?:(Z|UTC|GMT)|([+-])(\d{2}):?(\d{2}))$/i; + +/** An EXIF zone ('+02:00', an OffsetTime* tag or a stamp's own) in minutes east of UTC, or null when it is not one. */ +function exifZoneMinutes(value: unknown): number | null { + if (typeof value !== 'string') return null; + const m = EXIF_ZONE.exec(value.trim()); + if (!m) return null; + if (m[1]) return 0; + const hours = Number(m[3]); + const minutes = Number(m[4]); + // Real zones run from -12:00 to +14:00; anything past that is a broken tag. + if (hours > 14 || minutes > 59) return null; + return (m[2] === '-' ? -1 : 1) * (hours * 60 + minutes); +} + + +/** + * The instant an EXIF capture stamp names, as an ISO string, or null. + * + * EXIF stores the photographer's wall clock ('2026:05:30 15:52:19') and, in a + * separate tag, the zone it was read in ('+02:00'). Handing the file to exifr + * with its defaults turns the stamp into a Date in the server's zone and never + * looks at the offset, so a photo taken in France and uploaded to a server in + * Chicago was stored seven hours late (#2512). This takes the raw stamp and + * applies the first well-formed offset in `offsets`. A stamp that carries its + * own zone ('... UTC', 'Z', '+02:00', the form exifr's reviver comment names) + * needs none of them: that zone is the most specific answer there is. + * + * Without one, the server's zone is all there is to go on. That is what every + * upload got before, and it stays right for a server in the photographer's zone. + * + * A stamp that is not a real moment (blank, the zeros a camera without a clock + * writes, 30 February, 25 o'clock) is null, not whatever Date rolls it over to. + */ +export function exifCaptureInstant(stamp: unknown, offsets: unknown[]): string | null { + if (typeof stamp !== 'string') return null; + const text = stamp.trim(); + if (text.length > EXIF_STAMP_MAX) return null; + const m = EXIF_STAMP.exec(text); + if (!m) return null; + const suffix = text.slice(m[0].length).trim(); + // After the time there is either nothing or a zone; anything else ('CEST', + // '+2', 'later') is not a stamp. A zone that is out of range leaves the + // decision to the tags. + if (suffix && !EXIF_ZONE.test(suffix)) return null; + const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number); + const wall = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + // Date.UTC quietly rolls an impossible part into the next one (and reads + // years below 100 as 19xx), so the parts have to survive the round trip. It + // is checked on the UTC reading so a local DST gap does not count as invalid. + if (wall.getUTCFullYear() !== year || wall.getUTCMonth() !== month - 1 || wall.getUTCDate() !== day + || wall.getUTCHours() !== hour || wall.getUTCMinutes() !== minute || wall.getUTCSeconds() !== second) { + return null; + } + const offset = exifZoneMinutes(suffix) ?? offsets.map(exifZoneMinutes).find(o => o !== null); + const instant = offset == null + ? new Date(year, month - 1, day, hour, minute, second) + : new Date(wall.getTime() - offset * 60000); + return instant.toISOString(); +} + + export type AssetInfo = { id: string; takenAt: string | null; diff --git a/server/src/nest/memories/photo-capture-backfill.service.ts b/server/src/nest/memories/photo-capture-backfill.service.ts index b7894d6fa..3eb649ed2 100644 --- a/server/src/nest/memories/photo-capture-backfill.service.ts +++ b/server/src/nest/memories/photo-capture-backfill.service.ts @@ -3,6 +3,60 @@ import exifr from 'exifr'; import { PhotoResolverService } from './photo-resolver.service'; import { StorageService } from '../storage/storage.service'; import { TrekPhotosRepository } from '../photos/trek-photos.repository'; +import { exifCaptureInstant } from './memories.helpers'; + +/** + * What readCapture asks exifr for. + * + * `latitude` and `longitude` are not tags. exifr works them out from the four + * GPS tags below, and only when it has read those. A pick of just the two + * derived names reads no GPS at all, which is how every upload used to lose + * its location (#2512). + */ +const CAPTURE_TAGS = [ + 'DateTimeOriginal', + 'CreateDate', + 'OffsetTimeOriginal', + 'OffsetTimeDigitized', + 'OffsetTime', + 'GPSLatitude', + 'GPSLatitudeRef', + 'GPSLongitude', + 'GPSLongitudeRef', +] as const; + +/** When and where an uploaded file says it was taken. */ +type LocalCapture = { takenAt: string | null; lat: number | null; lng: number | null }; + +type Exif = Partial>; + +function coordinate(value: unknown, limit: number): number | null { + return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= limit ? value : null; +} + +/** + * The capture time and place in a file's EXIF, or null when it names neither. + * Throws for a file exifr cannot read at all. + */ +async function readCapture(abs: string): Promise { + // Raw values on purpose: see exifCaptureInstant for why the stamps are not + // left to exifr's reviver. + const parsed = (await exifr.parse(abs, { pick: [...CAPTURE_TAGS], reviveValues: false })) as Exif | undefined; + if (!parsed) return null; + + // Each stamp has an offset tag of its own. The others stand in for a missing + // one: a camera writes all three from the same clock. + const { OffsetTimeOriginal: original, OffsetTimeDigitized: digitized, OffsetTime: modified } = parsed; + const takenAt = exifCaptureInstant(parsed.DateTimeOriginal, [original, digitized, modified]) + ?? exifCaptureInstant(parsed.CreateDate, [digitized, original, modified]); + const lat = coordinate(parsed.latitude, 90); + const lng = coordinate(parsed.longitude, 180); + // A receiver without a fix can write zeros into the GPS tags instead of + // leaving them out. Taken at its word, 0,0 pins the photo to open sea in the + // Gulf of Guinea on the journey map, so it counts as no location. + const hasPair = lat != null && lng != null && !(lat === 0 && lng === 0); + return takenAt || hasPair ? { takenAt, lat: hasPair ? lat : null, lng: hasPair ? lng : null } : null; +} /** * Ask the provider when and where a photo was taken, and record it (#1614). @@ -70,9 +124,7 @@ export class PhotoCaptureBackfillService { * here already stripped. Nothing to read is the expected outcome far more often * than not — hence no logging on the empty case. */ - private async readLocalExif( - filePath?: string | null, - ): Promise<{ takenAt: string | null; lat: number | null; lng: number | null } | null> { + private async readLocalExif(filePath?: string | null): Promise { if (!filePath) return null; // photos.file_path is uploads-relative 'journey/' by every writer; // anything else reads as a miss (same rule as photo-resolver). Central key @@ -81,25 +133,12 @@ export class PhotoCaptureBackfillService { if (!filePath.startsWith('journey/')) return null; const name = filePath.slice('journey/'.length); - type Exif = { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number }; - let parsed: Exif | null; try { - parsed = await this.storage.withLocalFile('journey', name, async abs => - (await exifr.parse(abs, { - pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'], - })) as Exif | null, - ); + return await this.storage.withLocalFile('journey', name, readCapture); } catch { // A vanished object, an invalid key, not an image, a truncated upload, // a video — none of it is an error here. return null; } - if (!parsed) return null; - - const taken = parsed.DateTimeOriginal ?? parsed.CreateDate ?? null; - const takenAt = taken instanceof Date && !Number.isNaN(taken.getTime()) ? taken.toISOString() : null; - const lat = typeof parsed.latitude === 'number' ? parsed.latitude : null; - const lng = typeof parsed.longitude === 'number' ? parsed.longitude : null; - return takenAt || lat != null ? { takenAt, lat, lng } : null; } } diff --git a/server/src/nest/storage/storage.service.ts b/server/src/nest/storage/storage.service.ts index 4c4f21e65..ae765cb55 100644 --- a/server/src/nest/storage/storage.service.ts +++ b/server/src/nest/storage/storage.service.ts @@ -253,9 +253,10 @@ export class StorageService { } const tmp = path.join(this.tempDir(), randomUUID()); - const { stream } = await driver.getStream(key); - await pipeline(stream, fs.createWriteStream(tmp)); try { + const { stream } = await driver.getStream(key); + // Inside the try, so a download cut short does not leave its part behind. + await pipeline(stream, fs.createWriteStream(tmp)); return await fn(tmp); } finally { await fs.promises.rm(tmp, { force: true }); diff --git a/server/tests/helpers/exif-jpeg.ts b/server/tests/helpers/exif-jpeg.ts new file mode 100644 index 000000000..35ed7877e --- /dev/null +++ b/server/tests/helpers/exif-jpeg.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * A real JPEG carrying the capture tags a phone writes, built byte by byte so + * no actual person's photo has to live in the repo. + * + * The image data is tests/fixtures/small-image.jpg (a decodable 1x1 JPEG); its + * JFIF APP0 is swapped for an Exif APP1 the way a camera lays the file out: + * SOI, APP1 "Exif", then the rest. Every value is written as the raw tag string + * or rational, so a test can also hand in a malformed one. + */ +export interface ExifJpegTags { + /** 0x9003, e.g. '2026:05:30 15:52:19'. */ + DateTimeOriginal?: string; + /** 0x9004 (DateTimeDigitized). */ + CreateDate?: string; + /** 0x9010, the offset of the IFD0 DateTime. */ + OffsetTime?: string; + /** 0x9011, the offset of DateTimeOriginal. */ + OffsetTimeOriginal?: string; + /** 0x9012, the offset of DateTimeDigitized. */ + OffsetTimeDigitized?: string; + /** Decimal degrees, stored as the usual degree/minute/second rationals plus N/S and E/W. */ + gps?: { lat: number; lng: number }; +} + +const BASE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg'); + +const BYTE = 1; +const ASCII = 2; +const LONG = 4; +const RATIONAL = 5; + +interface Entry { tag: number; type: number; count: number; data: Buffer } + +function ascii(tag: number, value: string): Entry { + const data = Buffer.from(`${value}\0`, 'latin1'); + return { tag, type: ASCII, count: data.length, data }; +} + +function long(tag: number, value: number): Entry { + const data = Buffer.alloc(4); + data.writeUInt32LE(value); + return { tag, type: LONG, count: 1, data }; +} + +function rationals(tag: number, pairs: Array<[number, number]>): Entry { + const data = Buffer.alloc(pairs.length * 8); + pairs.forEach(([num, den], i) => { + data.writeUInt32LE(num, i * 8); + data.writeUInt32LE(den, i * 8 + 4); + }); + return { tag, type: RATIONAL, count: pairs.length, data }; +} + +function dms(deg: number): Array<[number, number]> { + const abs = Math.abs(deg); + const d = Math.floor(abs); + const m = Math.floor((abs - d) * 60); + const s = Math.round(((abs - d) * 60 - m) * 60 * 10000); + return [[d, 1], [m, 1], [s, 10000]]; +} + +function ifdSize(entries: Entry[]): number { + const outOfLine = entries.reduce((sum, e) => sum + (e.data.length > 4 ? e.data.length + (e.data.length % 2) : 0), 0); + return 2 + entries.length * 12 + 4 + outOfLine; +} + +/** One IFD at TIFF offset `at`, entries sorted by tag as the spec wants. */ +function writeIfd(entries: Entry[], at: number): Buffer { + const sorted = [...entries].sort((a, b) => a.tag - b.tag); + const head = Buffer.alloc(2 + sorted.length * 12 + 4); + const tail: Buffer[] = []; + let dataAt = at + head.length; + head.writeUInt16LE(sorted.length, 0); + sorted.forEach((e, i) => { + const o = 2 + i * 12; + head.writeUInt16LE(e.tag, o); + head.writeUInt16LE(e.type, o + 2); + head.writeUInt32LE(e.count, o + 4); + if (e.data.length <= 4) { + e.data.copy(head, o + 8); + } else { + head.writeUInt32LE(dataAt, o + 8); + const padded = e.data.length % 2 ? Buffer.concat([e.data, Buffer.alloc(1)]) : e.data; + tail.push(padded); + dataAt += padded.length; + } + }); + head.writeUInt32LE(0, head.length - 4); + return Buffer.concat([head, ...tail]); +} + +export function jpegWithExif(tags: ExifJpegTags): Buffer { + const exifEntries: Entry[] = []; + if (tags.DateTimeOriginal !== undefined) exifEntries.push(ascii(0x9003, tags.DateTimeOriginal)); + if (tags.CreateDate !== undefined) exifEntries.push(ascii(0x9004, tags.CreateDate)); + if (tags.OffsetTime !== undefined) exifEntries.push(ascii(0x9010, tags.OffsetTime)); + if (tags.OffsetTimeOriginal !== undefined) exifEntries.push(ascii(0x9011, tags.OffsetTimeOriginal)); + if (tags.OffsetTimeDigitized !== undefined) exifEntries.push(ascii(0x9012, tags.OffsetTimeDigitized)); + + const gpsEntries: Entry[] = []; + if (tags.gps) { + gpsEntries.push({ tag: 0x0000, type: BYTE, count: 4, data: Buffer.from([2, 2, 0, 0]) }); + gpsEntries.push(ascii(0x0001, tags.gps.lat < 0 ? 'S' : 'N')); + gpsEntries.push(rationals(0x0002, dms(tags.gps.lat))); + gpsEntries.push(ascii(0x0003, tags.gps.lng < 0 ? 'W' : 'E')); + gpsEntries.push(rationals(0x0004, dms(tags.gps.lng))); + } + + // IFD0 holds only the two pointers, so its size is known before the offsets are. + const ifd0Count = (exifEntries.length ? 1 : 0) + (gpsEntries.length ? 1 : 0); + const ifd0At = 8; + const exifAt = ifd0At + 2 + ifd0Count * 12 + 4; + const gpsAt = exifAt + (exifEntries.length ? ifdSize(exifEntries) : 0); + const ifd0Entries: Entry[] = []; + if (exifEntries.length) ifd0Entries.push(long(0x8769, exifAt)); + if (gpsEntries.length) ifd0Entries.push(long(0x8825, gpsAt)); + + const tiffHeader = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]); + const tiff = Buffer.concat([ + tiffHeader, + writeIfd(ifd0Entries, ifd0At), + exifEntries.length ? writeIfd(exifEntries, exifAt) : Buffer.alloc(0), + gpsEntries.length ? writeIfd(gpsEntries, gpsAt) : Buffer.alloc(0), + ]); + const payload = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]); + const app1 = Buffer.alloc(4); + app1.writeUInt16BE(0xffe1, 0); + app1.writeUInt16BE(payload.length + 2, 2); + + const base = fs.readFileSync(BASE_JPEG); + // SOI, then skip the JFIF APP0 (marker + its big-endian length). + const app0Length = base.readUInt16BE(4); + const rest = base.subarray(4 + app0Length); + return Buffer.concat([base.subarray(0, 2), app1, payload, rest]); +} diff --git a/server/tests/integration/journey.test.ts b/server/tests/integration/journey.test.ts index 57790c6a9..a6a098e30 100644 --- a/server/tests/integration/journey.test.ts +++ b/server/tests/integration/journey.test.ts @@ -2,7 +2,7 @@ * Journey API integration tests. * Covers JOURNEY-INT-001 through JOURNEY-INT-020. */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; import type { INestApplication } from '@nestjs/common'; @@ -72,6 +72,7 @@ import { addJourneyContributor, } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { jpegWithExif } from '../helpers/exif-jpeg'; import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache'; let nestApp: INestApplication; @@ -1223,4 +1224,90 @@ describe('Journey upload parity', () => { const diskName = res.body.cover_image.replace(/^journey\//, ''); expect(fsMod.existsSync(pathMod.join(journeyDir, diskName))).toBe(true); }); + + describe('capture metadata read from the uploaded file (#2512)', () => { + // A server whose zone is neither UTC nor the photographer's: the setup the + // report came from, and the only one where a stamp read in the wrong zone shows. + let prevTz: string | undefined; + beforeEach(() => { + prevTz = process.env.TZ; + process.env.TZ = 'America/Chicago'; + }); + afterEach(() => { + if (prevTz === undefined) delete process.env.TZ; + else process.env.TZ = prevTz; + }); + + const phonePhoto = () => jpegWithExif({ + DateTimeOriginal: '2026:05:30 15:52:19', + CreateDate: '2026:05:30 15:52:19', + OffsetTime: '+02:00', + OffsetTimeOriginal: '+02:00', + OffsetTimeDigitized: '+02:00', + gps: { lat: 49.274523, lng: -0.703421 }, + }); + + type CaptureRow = { taken_at: string | null; lat: number | null; lng: number | null }; + // The backfill runs detached after the response, so wait for it to land. + async function captureOf(filePath: string): Promise { + let row: CaptureRow | undefined; + await vi.waitFor(() => { + row = testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE file_path = ?').get(filePath) as CaptureRow; + expect(row?.taken_at).toBeTruthy(); + }); + return row!; + } + + it('JOURNEY-P10: an entry photo stores the instant from DateTimeOriginal plus its offset, and its GPS', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-05-30' }); + + const res = await request(app) + .post(`/api/journeys/entries/${entry.id}/photos`) + .set('Cookie', authCookie(user.id)) + .attach('photos', phonePhoto(), { filename: 'PXL_20260530_135219.jpg', contentType: 'image/jpeg' }); + expect(res.status).toBe(201); + + const row = await captureOf(res.body.photos[0].file_path); + // Not 2026-05-30T20:52:19.000Z, which is the stamp read as Chicago time. + expect(row.taken_at).toBe('2026-05-30T13:52:19.000Z'); + expect(row.lat).toBeCloseTo(49.274523, 5); + expect(row.lng).toBeCloseTo(-0.703421, 5); + }); + + it('JOURNEY-P11: a gallery photo gets the same', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + + const res = await request(app) + .post(`/api/journeys/${journey.id}/gallery/photos`) + .set('Cookie', authCookie(user.id)) + .attach('photos', phonePhoto(), { filename: 'PXL_20260530_135219.jpg', contentType: 'image/jpeg' }); + expect(res.status).toBe(201); + + const row = await captureOf(res.body.photos[0].file_path); + expect(row.taken_at).toBe('2026-05-30T13:52:19.000Z'); + expect(row.lat).toBeCloseTo(49.274523, 5); + expect(row.lng).toBeCloseTo(-0.703421, 5); + }); + + it('JOURNEY-P12: zeros in the GPS block, as a receiver without a fix writes them, put no pin at 0,0', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + + const res = await request(app) + .post(`/api/journeys/${journey.id}/gallery/photos`) + .set('Cookie', authCookie(user.id)) + .attach('photos', jpegWithExif({ + DateTimeOriginal: '2026:05:30 15:52:19', + OffsetTimeOriginal: '+02:00', + gps: { lat: 0, lng: 0 }, + }), { filename: 'nofix.jpg', contentType: 'image/jpeg' }); + expect(res.status).toBe(201); + + const row = await captureOf(res.body.photos[0].file_path); + expect(row).toEqual({ taken_at: '2026-05-30T13:52:19.000Z', lat: null, lng: null }); + }); + }); }); diff --git a/server/tests/unit/nest/memories.helpers.test.ts b/server/tests/unit/nest/memories.helpers.test.ts index b5aa95f47..5be3755df 100644 --- a/server/tests/unit/nest/memories.helpers.test.ts +++ b/server/tests/unit/nest/memories.helpers.test.ts @@ -7,10 +7,11 @@ * headings in the picker mean something, so it is pinned here rather than * inside either provider suite. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { dayStartEpochSeconds, describeFetchFailure, + exifCaptureInstant, isWithinLocalDayRange, shiftCalendarDay, sortAssetsByTakenAtDesc, @@ -181,3 +182,149 @@ describe('describeFetchFailure', () => { expect(describeFetchFailure(new TypeError('fetch failed', { cause }))).toBe('fetch failed (self-signed certificate)'); }); }); + +describe('exifCaptureInstant (#2512)', () => { + // Pinned to a zone that is neither UTC nor the photographer's, because that + // is the only setup where reading the stamp in the server's zone shows. + let prevTz: string | undefined; + beforeEach(() => { + prevTz = process.env.TZ; + process.env.TZ = 'America/Chicago'; + }); + afterEach(() => { + if (prevTz === undefined) delete process.env.TZ; + else process.env.TZ = prevTz; + }); + + it('MEM-EXIF-001: applies the offset, so the stamp becomes the instant it names', () => { + expect(exifCaptureInstant('2026:05:30 15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['-07:00'])).toBe('2026-05-30T22:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['+05:45'])).toBe('2026-05-30T10:07:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['+00:00'])).toBe('2026-05-30T15:52:19.000Z'); + }); + + it('MEM-EXIF-002: an evening photo east of UTC stays on its own day', () => { + expect(exifCaptureInstant('2026:05:30 22:30:00', ['+02:00'])).toBe('2026-05-30T20:30:00.000Z'); + }); + + it('MEM-EXIF-003: takes the first offset that is well formed', () => { + expect(exifCaptureInstant('2026:05:30 15:52:19', [undefined, ' : ', '+2:00', '+09:00', '+02:00'])) + .toBe('2026-05-30T06:52:19.000Z'); + }); + + it('MEM-EXIF-004: accepts the widest real zones and refuses what is past them', () => { + expect(exifCaptureInstant('2026:05:30 12:00:00', ['+14:00'])).toBe('2026-05-29T22:00:00.000Z'); + expect(exifCaptureInstant('2026:05:30 12:00:00', ['-12:00'])).toBe('2026-05-31T00:00:00.000Z'); + // Out of range falls through to the server zone, like no offset at all. + const local = exifCaptureInstant('2026:05:30 12:00:00', []); + expect(exifCaptureInstant('2026:05:30 12:00:00', ['+15:00'])).toBe(local); + expect(exifCaptureInstant('2026:05:30 12:00:00', ['+02:60'])).toBe(local); + }); + + it('MEM-EXIF-005: without an offset the stamp is read in the server zone, as before', () => { + // 15:52 CDT is 20:52 UTC. + expect(exifCaptureInstant('2026:05:30 15:52:19', [])).toBe('2026-05-30T20:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', [null, 42, '0200', '+02:00:00', '+2', 'CEST'])).toBe('2026-05-30T20:52:19.000Z'); + }); + + it('MEM-EXIF-006: a wall clock inside a DST gap is still a date, not garbage', () => { + // 02:30 on 8 March 2026 does not exist in Chicago; the old reader moved it an + // hour on and so does this one. What matters is that it is not dropped. + expect(exifCaptureInstant('2026:03:08 02:30:00', [])).toBe(new Date(2026, 2, 8, 2, 30, 0).toISOString()); + expect(exifCaptureInstant('2026:03:08 02:30:00', ['+01:00'])).toBe('2026-03-08T01:30:00.000Z'); + }); + + it('MEM-EXIF-007: tolerates dashes, a T and fractional seconds', () => { + expect(exifCaptureInstant('2026-05-30 15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30T15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19.123', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant(' 2026:05:30 15:52:19 ', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2028:02:29 08:00:00', ['+00:00'])).toBe('2028-02-29T08:00:00.000Z'); + }); + + it('MEM-EXIF-008: a stamp that is not a real moment is null, not a rolled over date', () => { + for (const stamp of [ + '0000:00:00 00:00:00', // camera without a clock + ' : : : : ', // blanked as the spec allows + '2026:02:30 10:00:00', + '2026:02:29 10:00:00', + '2026:13:01 10:00:00', + '2026:05:30 24:00:00', + '2026:05:30 15:60:00', + '2026:05:30 15:52:60', + '0099:05:30 15:52:19', // Date would read this as 1999 + '2026:05:30', + '2026:05:30 15:52', + 'yesterday', + '', + ]) { + expect(exifCaptureInstant(stamp, ['+02:00']), stamp).toBeNull(); + expect(exifCaptureInstant(stamp, []), stamp).toBeNull(); + } + }); + + it('MEM-EXIF-009: anything but a string is null', () => { + expect(exifCaptureInstant(undefined, ['+02:00'])).toBeNull(); + expect(exifCaptureInstant(null, [])).toBeNull(); + expect(exifCaptureInstant(new Date('2026-05-30T13:52:19Z'), [])).toBeNull(); + expect(exifCaptureInstant(1780149139, [])).toBeNull(); + }); + + it('MEM-EXIF-010: an offset tag written without the colon, or as Z or UTC, says the same thing', () => { + expect(exifCaptureInstant('2026:05:30 15:52:19', ['+0200'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['-0330'])).toBe('2026-05-30T19:22:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['Z'])).toBe('2026-05-30T15:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['utc'])).toBe('2026-05-30T15:52:19.000Z'); + }); + + it('MEM-EXIF-011: a zone written onto the stamp itself is honoured, and wins over the offset tags', () => { + // exifr's reviver names this form, and read it as Chicago time: 20:52:19Z. + expect(exifCaptureInstant('2026:05:30 15:52:19 UTC', [])).toBe('2026-05-30T15:52:19.000Z'); + expect(exifCaptureInstant('2009-09-23 17:40:52 UTC', [])).toBe('2009-09-23T17:40:52.000Z'); + // The reviver turned these into local midnight of the day. + expect(exifCaptureInstant('2026:05:30 15:52:19Z', [])).toBe('2026-05-30T15:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19+02:00', [])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026-05-30T15:52:19.250-07:00', [])).toBe('2026-05-30T22:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19 +0200', [])).toBe('2026-05-30T13:52:19.000Z'); + // The stamp's own zone is the more specific answer. + expect(exifCaptureInstant('2026:05:30 15:52:19+02:00', ['+09:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19 UTC', ['+02:00'])).toBe('2026-05-30T15:52:19.000Z'); + }); + + it('MEM-EXIF-012: a zone on the stamp that is out of range leaves the tags to decide', () => { + expect(exifCaptureInstant('2026:05:30 15:52:19+15:00', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19+15:00', [])).toBe('2026-05-30T20:52:19.000Z'); + }); + + it('MEM-EXIF-014: a field written with one digit, and a GMT zone, still read as the old reviver read them', () => { + expect(exifCaptureInstant('2026:5:30 15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 5:52:19', ['+02:00'])).toBe('2026-05-30T03:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19 GMT', ['+02:00'])).toBe('2026-05-30T15:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['GMT'])).toBe('2026-05-30T15:52:19.000Z'); + // One digit is not a licence for three. + expect(exifCaptureInstant('2026:005:30 15:52:19', ['+02:00'])).toBeNull(); + expect(exifCaptureInstant('2026:05:30 15:52:190', ['+02:00'])).toBeNull(); + }); + + it('MEM-EXIF-013: anything else after the time is still not a stamp', () => { + for (const stamp of ['2026:05:30 15:52:19 CEST', '2026:05:30 15:52:19 +2', '2026:05:30 15:52:19 UTC+2', '2026:05:30 15:52:19 later']) { + expect(exifCaptureInstant(stamp, ['+02:00']), stamp).toBeNull(); + } + }); + + it('MEM-EXIF-015: a huge stamp from a hostile upload is turned down at once, not backtracked over', () => { + // A run of digits after the seconds, then a line break: a greedy tail in + // front of an end anchor tries every split of it. 200k of them took 20 s. + const hostile = '2026:05:30 15:52:19.' + '1'.repeat(200_000) + '\nx'; + const started = performance.now(); + const read = exifCaptureInstant(hostile, ['+02:00']); + const withHostileOffset = exifCaptureInstant('2026:05:30 15:52:19', [hostile]); + expect(performance.now() - started).toBeLessThan(250); + expect(read).toBeNull(); + expect(withHostileOffset).toBe('2026-05-30T20:52:19.000Z'); + // Up to the limit a stamp still reads, and a line break is no zone. + expect(exifCaptureInstant('2026:05:30 15:52:19.' + '1'.repeat(38) + '+02:00', [])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19.' + '1'.repeat(39) + '+02:00', [])).toBeNull(); + expect(exifCaptureInstant('2026:05:30 15:52:19\nx', ['+02:00'])).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/photo-capture-backfill.exif.test.ts b/server/tests/unit/nest/photo-capture-backfill.exif.test.ts new file mode 100644 index 000000000..7049cafc1 --- /dev/null +++ b/server/tests/unit/nest/photo-capture-backfill.exif.test.ts @@ -0,0 +1,177 @@ +/** + * PhotoCaptureBackfillService against real files (#2512). + * + * The sibling suite mocks exifr, which is how a pick that could never return GPS + * and a date read in the wrong zone both passed it: the mock handed back exactly + * what the code hoped exifr would. Here exifr reads real JPEG bytes carrying the + * tags a phone writes, through a real StorageService, with the process in a zone + * that is neither UTC nor the photographer's. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service'; +import type { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service'; +import type { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { jpegWithExif, type ExifJpegTags } from '../../helpers/exif-jpeg'; +import { makeStorageFixture, type StorageFixture } from '../../helpers/storage-fixture'; + +// Normandy, west of Greenwich, so a dropped sign would show. +const NORMANDY = { lat: 49.274523, lng: -0.703421 }; + +let fx: StorageFixture; +let prevTz: string | undefined; +let seq = 0; + +beforeAll(() => { fx = makeStorageFixture('journey/'); }); +afterAll(() => fx.cleanup()); +beforeEach(() => { + prevTz = process.env.TZ; + process.env.TZ = 'America/Chicago'; +}); +afterEach(() => { + if (prevTz === undefined) delete process.env.TZ; + else process.env.TZ = prevTz; +}); + +/** Stores `bytes` as a local journey upload, runs the backfill on it, returns what was recorded. */ +async function backfill(bytes: Buffer): Promise { + const name = `upload-${++seq}.jpg`; + await fx.storage.put('journey', name, Readable.from(bytes), { contentType: 'image/jpeg' }); + const recordCaptureMetadata = vi.fn(); + const photos = { + resolve: (id: number) => ({ id, provider: 'local', file_path: `journey/${name}`, taken_at: null, lat: null, lng: null }), + recordCaptureMetadata, + } as unknown as TrekPhotosRepository; + const svc = new PhotoCaptureBackfillService({} as PhotoResolverService, photos, fx.storage); + await svc.run([7], 1); + return recordCaptureMetadata.mock.calls; +} + +const photo = (tags: ExifJpegTags) => backfill(jpegWithExif(tags)); + +describe('PhotoCaptureBackfillService reading real EXIF', () => { + it('EXIF-001: the reported phone photo gets its true instant and its GPS', async () => { + const calls = await photo({ + DateTimeOriginal: '2026:05:30 15:52:19', + CreateDate: '2026:05:30 15:52:19', + OffsetTime: '+02:00', + OffsetTimeOriginal: '+02:00', + OffsetTimeDigitized: '+02:00', + gps: NORMANDY, + }); + + expect(calls).toHaveLength(1); + const [id, meta] = calls[0] as [number, { takenAt: string; lat: number; lng: number }]; + expect(id).toBe(7); + // Not 20:52:19Z, which is 15:52 read as Chicago time. + expect(meta.takenAt).toBe('2026-05-30T13:52:19.000Z'); + expect(meta.lat).toBeCloseTo(NORMANDY.lat, 5); + expect(meta.lng).toBeCloseTo(NORMANDY.lng, 5); + }); + + it('EXIF-002: the offset is found in OffsetTimeDigitized when it is the only one', async () => { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTimeDigitized: '+02:00' }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-003: the offset is found in OffsetTime when it is the only one', async () => { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTime: '+09:00' }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T06:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-004: DateTimeOriginal goes with its own offset when the tags disagree', async () => { + const calls = await photo({ + DateTimeOriginal: '2026:05:30 15:52:19', + OffsetTimeOriginal: '+02:00', + OffsetTimeDigitized: '+05:00', + OffsetTime: '-03:00', + }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-005: CreateDate stands in for a missing DateTimeOriginal, with its own offset first', async () => { + const calls = await photo({ + CreateDate: '2026:05:30 21:10:00', + OffsetTimeDigitized: '+01:00', + OffsetTimeOriginal: '+02:00', + }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T20:10:00.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-006: CreateDate also stands in when DateTimeOriginal is garbage', async () => { + const calls = await photo({ + DateTimeOriginal: '0000:00:00 00:00:00', + CreateDate: '2026:05:30 15:52:19', + OffsetTimeOriginal: '+02:00', + }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-007: with no offset the stamp is read in the server zone, exactly as before', async () => { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19' }); + // 15:52 CDT. + expect(calls).toEqual([[7, { takenAt: '2026-05-30T20:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-008: a malformed offset is ignored like a missing one', async () => { + for (const bad of ['+2:00', ' : ', '+99:00', 'CEST']) { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTimeOriginal: bad }); + expect(calls, bad).toEqual([[7, { takenAt: '2026-05-30T20:52:19.000Z', lat: null, lng: null }]]); + } + }); + + it('EXIF-009: an impossible or garbage date is not stored, and does not cost the location', async () => { + for (const bad of ['0000:00:00 00:00:00', '2026:02:30 25:61:00', ' : : : : ', 'not a date']) { + const calls = await photo({ DateTimeOriginal: bad, OffsetTimeOriginal: '+02:00', gps: NORMANDY }); + expect(calls, bad).toHaveLength(1); + const [, meta] = calls[0] as [number, { takenAt: string | null; lat: number; lng: number }]; + expect(meta.takenAt, bad).toBeNull(); + expect(meta.lat, bad).toBeCloseTo(NORMANDY.lat, 5); + expect(meta.lng, bad).toBeCloseTo(NORMANDY.lng, 5); + } + }); + + it('EXIF-010: an impossible date with nothing else to go on records nothing', async () => { + expect(await photo({ DateTimeOriginal: '2026:02:30 10:00:00' })).toEqual([]); + }); + + it('EXIF-011: GPS without any date still puts the photo on the map', async () => { + const calls = await photo({ gps: { lat: -33.856784, lng: 151.215297 } }); + expect(calls).toHaveLength(1); + const [, meta] = calls[0] as [number, { takenAt: string | null; lat: number; lng: number }]; + expect(meta.takenAt).toBeNull(); + expect(meta.lat).toBeCloseTo(-33.856784, 5); + expect(meta.lng).toBeCloseTo(151.215297, 5); + }); + + it('EXIF-012: a JPEG without EXIF and a file that is not an image both record nothing', async () => { + const plain = fs.readFileSync(path.join(__dirname, '../../fixtures/small-image.jpg')); + expect(await backfill(plain)).toEqual([]); + expect(await backfill(Buffer.from('definitely not a jpeg'))).toEqual([]); + }); + + it('EXIF-013: a GPS block of zeros, as a receiver without a fix writes it, is no location', async () => { + const zeros = { lat: 0, lng: 0 }; + expect(await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTimeOriginal: '+02:00', gps: zeros })) + .toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]); + // Nothing else to go on: nothing is recorded, rather than a pin in the Gulf of Guinea. + expect(await photo({ gps: zeros })).toEqual([]); + }); + + it('EXIF-014: a real place on the equator or the prime meridian keeps its location', async () => { + for (const gps of [{ lat: 0, lng: 32.58 }, { lat: 51.4779, lng: 0 }]) { + const calls = await photo({ gps }); + expect(calls, JSON.stringify(gps)).toHaveLength(1); + const [, meta] = calls[0] as [number, { lat: number; lng: number }]; + expect(meta.lat).toBeCloseTo(gps.lat, 5); + expect(meta.lng).toBeCloseTo(gps.lng, 5); + } + }); + + it('EXIF-015: a stamp that carries its own zone is read in that zone', async () => { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19 UTC', OffsetTimeOriginal: '+02:00' }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T15:52:19.000Z', lat: null, lng: null }]]); + }); +}); diff --git a/server/tests/unit/nest/photo-capture-backfill.service.test.ts b/server/tests/unit/nest/photo-capture-backfill.service.test.ts index d1970adfb..c175c4d75 100644 --- a/server/tests/unit/nest/photo-capture-backfill.service.test.ts +++ b/server/tests/unit/nest/photo-capture-backfill.service.test.ts @@ -131,8 +131,10 @@ describe('PhotoCaptureBackfillService — local files', () => { } it('CAPTURE-007: reads a local file rather than asking a provider', async () => { + // Raw values, as exifr hands them over with reviveValues off (#2512). vi.mocked(exifr.parse).mockResolvedValue({ - DateTimeOriginal: new Date('2026-03-15T10:20:00Z'), + DateTimeOriginal: '2026:03:15 11:20:00', + OffsetTimeOriginal: '+01:00', latitude: 48.8584, longitude: 2.2945, }); @@ -149,7 +151,7 @@ describe('PhotoCaptureBackfillService — local files', () => { }); it('CAPTURE-008: falls back to CreateDate when the original timestamp is missing', async () => { - vi.mocked(exifr.parse).mockResolvedValue({ CreateDate: new Date('2026-03-16T08:00:00Z') }); + vi.mocked(exifr.parse).mockResolvedValue({ CreateDate: '2026:03:16 08:00:00', OffsetTimeDigitized: '+00:00' }); const { svc, recordCaptureMetadata } = localBuild([ { id: 7, provider: 'local', file_path: 'journey/a.jpg' }, ]); @@ -161,6 +163,35 @@ describe('PhotoCaptureBackfillService — local files', () => { }); }); + it('CAPTURE-016: a coordinate that is not a real one drops the pair, not the capture time', async () => { + // 0,0 is what a receiver without a fix writes, not a place (#2512). + for (const [latitude, longitude] of [[Number.NaN, 2.2945], [48.8584, 200], [91, 2.2945], [48.8584, undefined], [0, 0]]) { + vi.mocked(exifr.parse).mockResolvedValue({ + DateTimeOriginal: '2026:03:15 11:20:00', OffsetTimeOriginal: '+01:00', latitude, longitude, + }); + const { svc, recordCaptureMetadata } = localBuild([ + { id: 7, provider: 'local', file_path: 'journey/a.jpg' }, + ]); + + await svc.run([7], 1); + + expect(recordCaptureMetadata).toHaveBeenCalledWith(7, { + takenAt: '2026-03-15T10:20:00.000Z', lat: null, lng: null, + }); + } + }); + + it('CAPTURE-017: a bad coordinate with no usable date records nothing', async () => { + vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: 'garbage', latitude: 48.8584 }); + const { svc, recordCaptureMetadata } = localBuild([ + { id: 7, provider: 'local', file_path: 'journey/a.jpg' }, + ]); + + await svc.run([7], 1); + + expect(recordCaptureMetadata).not.toHaveBeenCalled(); + }); + it('CAPTURE-009: a file with nothing readable is left alone', async () => { vi.mocked(exifr.parse).mockResolvedValue({}); const { svc, recordCaptureMetadata } = localBuild([ @@ -224,7 +255,7 @@ describe('PhotoCaptureBackfillService — local files', () => { }); it('CAPTURE-015: schedule kicks the run off for a non-empty batch', async () => { - vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: new Date('2026-03-15T10:20:00Z') }); + vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: '2026:03:15 10:20:00', OffsetTimeOriginal: '+00:00' }); const { svc, recordCaptureMetadata } = localBuild([ { id: 7, provider: 'local', file_path: 'journey/a.jpg' }, ]); diff --git a/server/tests/unit/nest/storage/storage.service.test.ts b/server/tests/unit/nest/storage/storage.service.test.ts index 5b56eb1f6..8c304a47d 100644 --- a/server/tests/unit/nest/storage/storage.service.test.ts +++ b/server/tests/unit/nest/storage/storage.service.test.ts @@ -51,7 +51,7 @@ function makeFixture(keyPrefix = 'files/'): Fixture { } /** A driver with no getLocalPath — the remote-driver branch every helper must handle. */ -function makeStreamOnlyFixture(contents: string): Fixture & { driverCalls: string[] } { +function makeStreamOnlyFixture(contents: string, body = (bytes: Buffer): Readable => Readable.from(bytes)): Fixture & { driverCalls: string[] } { const fx = makeFixture(''); const driverCalls: string[] = []; const bytes = Buffer.from(contents); @@ -61,7 +61,7 @@ function makeStreamOnlyFixture(contents: string): Fixture & { driverCalls: strin put: async () => undefined, getStream: async (key: string) => { driverCalls.push(`getStream:${key}`); - return { stream: Readable.from(bytes), stat: { ...stat, key } }; + return { stream: body(bytes), stat: { ...stat, key } }; }, stat: async (key: string) => ({ ...stat, key }), delete: async () => undefined, @@ -216,6 +216,22 @@ describe('StorageService withLocalFile', () => { expect(fs.existsSync(seenPath)).toBe(false); // cleaned up on throw too }); + it('leaves nothing in tempDir when the download itself fails midway', async () => { + const cut = new Error('socket hang up'); + const remote = makeStreamOnlyFixture('first half of the bytes', bytes => + Readable.from((async function* () { + yield bytes; + throw cut; + })()), + ); + let called = false; + + await expect(remote.storage.withLocalFile('files', 'remote.bin', async () => { called = true; })).rejects.toBe(cut); + + expect(called).toBe(false); + expect(fs.readdirSync(remote.tempDir)).toEqual([]); + }); + it('falls through to the stream branch when getLocalPath resolves but the file is not actually on disk', async () => { // Composite-driver stand-in (task C9-i): the façade must fs-check the // local path itself rather than trust driver.stat() as "the file is at From c6a089107f8dd89237ea2f3bbd5141118ea6f119 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 26 Sep 2026 11:22:25 +0200 Subject: [PATCH 13/14] chore(notices): the release notice points at 4.3.3 --- server/src/systemNotices/registry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/systemNotices/registry.ts b/server/src/systemNotices/registry.ts index 0c6a1eeb2..809cf2191 100644 --- a/server/src/systemNotices/registry.ts +++ b/server/src/systemNotices/registry.ts @@ -48,7 +48,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ titleKey: 'system_notice.release_notes.headline', bodyKey: 'system_notice.release_notes.intro', release: { - version: '4.3.2', + version: '4.3.3', eyebrowKey: 'system_notice.release_notes.eyebrow', headlineKey: 'system_notice.release_notes.headline', introKey: 'system_notice.release_notes.intro', @@ -84,7 +84,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ footnoteKey: 'system_notice.release_notes.footnote', notes: { labelKey: 'system_notice.release_notes.notes_label', - href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.2', + href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.3', }, note: { eyebrowKey: 'system_notice.release_notes.note_eyebrow', From aa1f76ad11a74084d7e98648ba6d9ee5c1147808 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 26 Sep 2026 12:41:05 +0200 Subject: [PATCH 14/14] fix(costs): leave out an expense no rate is known for instead of counting it 1:1 A foreign expense without a frozen rate is converted with today's rate. When the server could not fetch rates itself, it counted the amount 1:1 in the trip currency, although the browser had the rates all along. A VND bill on an AUD trip was settled as millions of AUD. Such a row is now left out of the balances, flows, final budgets, trip totals and per-person summaries. The settlement reports it under `unconverted` and says which currency its figures are in; the trip summary lists the ids and the budget prompt says how many are missing. The browser lends the rates it already holds (`fallback_fx`) when it saves an expense or a settle-up transfer, and to heal existing rows through the new POST .../budget/freeze-rates route, which an editor's Costs view calls for the rows the settlement left out. The server still prefers its own rate, uses a lent one only while it is quoted against the trip currency, and never replaces a frozen or explicit rate. The settlement also takes the browser's display rate (`base_rate`) in place of a display quote the server cannot fetch; the browser leaves out a figure the query would refuse. The browser's own fetch gives up after 10 s. Changing a row's currency while no rate is available no longer keeps the old currency's rate; the row goes back to unfrozen. MCP gets the same through the new freeze_budget_rates tool, which only ever pins the server's own rate. --- client/src/api/client.surface.test.ts | 11 +- client/src/api/client.ts | 12 +- .../src/components/Budget/CostsPanel.test.tsx | 157 +++++++++ client/src/components/Budget/CostsPanel.tsx | 36 +- .../Budget/useFreezeMissingRates.test.ts | 123 +++++++ .../Budget/useFreezeMissingRates.ts | 73 +++++ client/src/hooks/useExchangeRates.test.ts | 131 +++++++- client/src/hooks/useExchangeRates.ts | 72 +++- .../mobile/screens/trip/tabs/MCostsTab.tsx | 17 +- .../mobile/screens/trip/tabs/costsModel.ts | 6 +- client/src/store/slices/budgetSlice.test.ts | 77 ++++- client/src/store/slices/budgetSlice.ts | 30 +- .../tests/unit/mobile/trip/MCostsTab.test.tsx | 67 +++- server/src/nest/budget/budget.controller.ts | 47 ++- server/src/nest/budget/budget.dto.ts | 5 + server/src/nest/budget/budget.mcp.ts | 38 ++- server/src/nest/budget/budget.service.ts | 307 +++++++++++++----- .../src/nest/budget/exchange-rates.service.ts | 10 +- .../trip-read-model.service.ts | 2 + server/src/nest/trips/trip-prompts.mcp.ts | 6 +- server/tests/e2e/budget.e2e.test.ts | 156 +++++++++ server/tests/integration/budget.test.ts | 3 + .../unit/mcp/tools-budget-advanced.test.ts | 3 +- server/tests/unit/mcp/tools-budget.test.ts | 134 ++++++++ server/tests/unit/mcp/tools-prompts.test.ts | 27 ++ .../tests/unit/nest/budget.controller.test.ts | 67 +++- .../unit/nest/budget.service.calc.test.ts | 159 +++++++++ .../tests/unit/nest/budget.service.db.test.ts | 243 ++++++++++++++ .../unit/nest/trip-read-model.service.test.ts | 2 + shared/src/budget/budget.schema.spec.ts | 96 ++++++ shared/src/budget/budget.schema.ts | 88 ++++- wiki/MCP-Tools-and-Resources.md | 3 +- 32 files changed, 2071 insertions(+), 137 deletions(-) create mode 100644 client/src/components/Budget/useFreezeMissingRates.test.ts create mode 100644 client/src/components/Budget/useFreezeMissingRates.ts diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index b5076504c..5776a2744 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -1,4 +1,4 @@ -// FE-APISURF-001 to FE-APISURF-058 +// FE-APISURF-001 to FE-APISURF-059 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { AxiosResponse } from 'axios' import { http, HttpResponse } from 'msw' @@ -455,6 +455,7 @@ describe('client > endpoint wiring', () => { { n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' }, { n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' }, { n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' }, + { n: 'freezeRates', r: () => budgetApi.freezeRates(1, {}), e: 'POST /api/trips/1/budget/freeze-rates' }, { n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' }, { n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' }, ]) @@ -572,6 +573,14 @@ describe('client > request payloads', () => { expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] }) }) + it('FE-APISURF-059: the settlement carries the display rate only when the caller has one', async () => { + expect((await traceOne(() => budgetApi.settlement(1, 'EUR', 0.61))).url).toBe('/api/trips/1/budget/settlement?base=EUR&base_rate=0.61') + expect((await traceOne(() => budgetApi.settlement(1, 'EUR', null))).url).toBe('/api/trips/1/budget/settlement?base=EUR') + expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement') + const lent = { fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } } + expect((await traceOne(() => budgetApi.freezeRates(1, lent))).body).toEqual(lent) + }) + it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => { const rec = await traceOne(() => daysApi.create(1, { dated: true })) expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } }) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index fbf25b227..5974240e2 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -40,6 +40,8 @@ import { type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest, type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest, type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest, + type BudgetCreateSettlementRequest, type BudgetUpdateSettlementRequest, type BudgetSettlementQuery, + type BudgetFreezeRatesRequest, type BudgetFreezeRatesResponse, type TodoCategoryAssigneesRequest, type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest, type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest, @@ -1352,9 +1354,13 @@ export const budgetApi = { togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data), setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), - settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data), - createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), - updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), + // `baseRate` is units of `base` per 1 trip currency from the client's own rates. The server + // uses it only in place of a display quote it cannot fetch itself, which also reads legacy + // transfers without a currency (in the display currency, as with a live quote). + settlement: (tripId: number | string, base?: string, baseRate?: number | null) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base, ...(baseRate != null ? { base_rate: baseRate } : {}) } satisfies BudgetSettlementQuery } : undefined).then(r => r.data), + createSettlement: (tripId: number | string, data: BudgetCreateSettlementRequest) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), + updateSettlement: (tripId: number | string, settlementId: number, data: BudgetUpdateSettlementRequest) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), + freezeRates: (tripId: number | string, data: BudgetFreezeRatesRequest): Promise => apiClient.post(`/trips/${tripId}/budget/freeze-rates`, data).then(r => r.data), deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 9105a0301..d970c160a 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -15,6 +15,7 @@ import type { BudgetParticipantFinal } from '@trek/shared' import type { BudgetItem } from '../../types' import CostsPanel, { ExpenseModal } from './CostsPanel' import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers' +import { resetFreezeAttempts } from './useFreezeMissingRates' const tripMembers = [ { id: 1, username: 'alice', avatar_url: null }, @@ -2525,3 +2526,159 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => { await waitFor(() => expect(put).toMatchObject({ total_price: 100, currency: 'EUR' })) }) }) + +// ── A bill the server cannot convert: VND on an AUD trip ──────────────────────── +describe('CostsPanel: a bill the server cannot convert', () => { + // The reporter's setup: an AUD trip whose server cannot fetch rates while the browser + // can. An 8,920,000 VND dinner that never froze a rate was counted as 8,920,000 AUD. + // The settlement now leaves it out and lists it, and an editor's browser lends its AUD + // table to freeze a rate on it. + const pho = () => expense({ + id: 501, name: 'Pho', category: 'food', total_price: 8920000, currency: 'VND', exchange_rate: 1, + expense_date: '2026-09-20', + payers: [{ user_id: 1, amount: 8920000 }], + members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }], + }) + const leftOut = { balances: [], flows: [], settlements: [], currency: 'AUD', unconverted: { item_ids: [501], settlement_ids: [], currencies: ['VND'] } } + + beforeEach(() => { + seedAlice() + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'AUD' } }) + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) }) + clearExchangeRateCache() + resetFreezeAttempts() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() })) + }) + afterEach(clearExchangeRateCache) + + it('FE-W5COSTS-090: VND/AUD regression: an editor heals the bill with the cached quote and the healed balances load', async () => { + let healed = false + const frozen: unknown[] = [] + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [pho()] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json(healed + ? { + // 8,920,000 VND at 18,241.3 is 489.00 AUD, half of it Bob's. + balances: [ + { user_id: 1, username: 'alice', avatar_url: null, balance: 244.5 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -244.5 }, + ], + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 244.5 }], + settlements: [], + currency: 'AUD', + unconverted: { item_ids: [], settlement_ids: [], currencies: [] }, + } + : leftOut)), + http.post('/api/trips/1/budget/freeze-rates', async ({ request }) => { + frozen.push(await request.json()) + healed = true + return HttpResponse.json({ items: [{ ...pho(), exchange_rate: 18241.3 }], settlements: [], unresolved: [] }) + }), + ) + render() + + // Only the currency the settlement is missing, at the rate the browser holds. + await waitFor(() => expect(frozen).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }])) + // The settlement is read again and Bob's half can be settled. + expect(await screen.findByRole('button', { name: 'Settle' })).toBeInTheDocument() + expect(screen.getAllByText(/244\.50/).length).toBeGreaterThan(0) + // Nothing on the page counts the bill as 8,920,000 dollars any more. + expect(screen.queryByText(/\$8,920,000/)).toBeNull() + expect(frozen).toHaveLength(1) + }) + + it('FE-W5COSTS-091: a viewer\'s browser never freezes anything', async () => { + seedStore(usePermissionsStore, { permissions: { budget_edit: 'admin' } }) + let frozen = 0 + server.use(http.post('/api/trips/1/budget/freeze-rates', () => { + frozen += 1 + return HttpResponse.json({ items: [], settlements: [], unresolved: [] }) + })) + mount([pho()], leftOut) + + await screen.findByText('Pho') + await new Promise(r => setTimeout(r, 50)) + expect(frozen).toBe(0) + }) + + it('FE-W5COSTS-092: settle-up in the display currency lends its rate and asks the settlement in it', async () => { + // Read in euros: the browser has the AUD table, the server has no quote at all. + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) + const asked: { base: string | null; baseRate: string | null }[] = [] + const posted: Record[] = [] + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/budget/settlement', ({ request }) => { + const url = new URL(request.url) + asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') }) + return HttpResponse.json({ + balances: [], settlements: [], currency: 'EUR', unconverted: { item_ids: [], settlement_ids: [], currencies: [] }, + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }], + }) + }), + http.post('/api/trips/1/budget/settlements', async ({ request }) => { + posted.push(await request.json() as Record) + return HttpResponse.json({ settlement: { id: 1 } }) + }), + ) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Settle' })) + await waitFor(() => expect(posted).toHaveLength(1)) + // The transfer freezes at the rate the settlement was labelled with, so it cancels its + // flow to the cent. + expect(posted[0]).toMatchObject({ from_user_id: 2, to_user_id: 1, amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } }) + expect(asked.length).toBeGreaterThan(0) + expect(asked.every(a => a.base === 'EUR' && a.baseRate === '0.61')).toBe(true) + + const settleAll = screen.getByRole('button', { name: 'Settle up' }) + fireEvent.click(settleAll) + await waitFor(() => expect(posted).toHaveLength(2)) + expect(posted[1]).toMatchObject({ amount: 20, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } }) + }) + + it('FE-W5COSTS-093: a payment added by hand lends the rate for its currency too', async () => { + const user = userEvent.setup() + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) + let posted: Record | null = null + server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ settlement: { id: 1 } }) + })) + mount([]) + + await user.click(await screen.findByRole('button', { name: 'Add payment' })) + await user.type(await screen.findByPlaceholderText('0.00'), '25') + const submits = screen.getAllByRole('button', { name: 'Add payment' }) + await user.click(submits[submits.length - 1]) + + await waitFor(() => expect(posted).toBeTruthy()) + expect(posted).toMatchObject({ amount: 25, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } }) + }) + + it('FE-W5COSTS-094: a display rate the settlement query would refuse is not sent', async () => { + // A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the query + // takes, and sent as `base_rate` it would fail the whole settlement with a 400. + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XAU' }) }) + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'IRR' } }) + localStorage.setItem('trek_fx_XAU', JSON.stringify({ rates: { XAU: 1, IRR: 2.6e9 }, ts: Date.now() })) + localStorage.setItem('trek_fx_IRR', JSON.stringify({ rates: { IRR: 1, XAU: 1 / 2.6e9 }, ts: Date.now() })) + const asked: { base: string | null; baseRate: string | null }[] = [] + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/budget/settlement', ({ request }) => { + const url = new URL(request.url) + asked.push({ base: url.searchParams.get('base'), baseRate: url.searchParams.get('base_rate') }) + return HttpResponse.json({ + balances: [], flows: [], settlements: [], currency: 'XAU', unconverted: { item_ids: [], settlement_ids: [], currencies: [] }, + }) + }), + ) + render() + + await waitFor(() => expect(asked.length).toBeGreaterThan(0)) + // Still asked in rials, only without the figure; the server answers in the trip + // currency when it has no quote of its own. + expect(asked.every(a => a.base === 'IRR' && a.baseRate === null)).toBe(true) + }) +}) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index de8a4b03f..1c79124c1 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -9,8 +9,9 @@ import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { budgetApi } from '../../api/client' import { saveWithReceipts } from './receiptUploads' -import { convertBooked, convertedLine, tripAmountOf, useExchangeRates } from '../../hooks/useExchangeRates' +import { convertBooked, convertedLine, tripAmountOf, useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates' import { splitShareLabel, useExpenseFx } from './expenseFx' +import { useFreezeMissingRates } from './useFreezeMissingRates' import { useIsMobile } from '../../hooks/useIsMobile' import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' import { downloadBlob, openFile } from '../../utils/fileDownload' @@ -22,7 +23,7 @@ import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import { ReceiptPreviewModal } from './ReceiptPreviewModal' -import type { BudgetParticipantFinal } from '@trek/shared' +import type { BudgetParticipantFinal, BudgetUnconverted } from '@trek/shared' import type { BudgetItem, BudgetItemReceipt } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' import GuestBadge from '../shared/GuestBadge' @@ -60,6 +61,11 @@ interface SettlementData { // What the trip ends up costing each participant. Computed server-side off the // same ledger as the balances, so the breakdown can't contradict them. finalBudgets: BudgetParticipantFinal[] + // The currency the figures are in: the display currency, or the trip's own when + // neither the server nor `base_rate` could quote the pair. + currency?: string + // Rows no rate could convert, left out of every figure above. + unconverted?: BudgetUnconverted } // One row in the unified Costs ledger — either an expense or a settle-up payment, @@ -87,7 +93,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // Pre-rework rows stored currency = NULL, meaning "the trip's own currency". const tripCurrency = (trip?.currency || base).toUpperCase() // Anchored on the trip currency's quote, the one the server books with (#2525). - const { convert } = useExchangeRates(base, tripCurrency) + const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency) const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency]) const [settlement, setSettlement] = useState(null) // A failed settlement read leaves `settlement` null, which the empty views would @@ -121,14 +127,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale]) const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale]) + // The browser's own figure for the display currency goes along, for the server to + // answer in it when it cannot fetch a quote itself. const loadSettlement = useCallback(() => { - budgetApi.settlement(tripId, base) + budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) .then(s => { setSettlement(s); setSettlementError(false) }) .catch(() => setSettlementError(true)) - }, [tripId, base]) + }, [tripId, base, tripCurrency, displayPerTrip]) useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId]) - useEffect(() => { loadSettlement() }, [budgetItems.length, base]) + useEffect(() => { loadSettlement() }, [budgetItems.length, loadSettlement]) + + // Rows the server could not count get a rate frozen from the browser's, and the + // settlement is read again once they count. + useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement }) // The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense. const [searchParams, setSearchParams] = useSearchParams() @@ -254,7 +266,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // ── settle actions ────────────────────────────────────────────────────── const settleFlow = async (fromId: number, toId: number, amount: number) => { try { - await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base }) + await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency)) loadSettlement() } catch { toast.error(t('common.unknownError')) } } @@ -265,7 +277,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const flows = settlement?.flows || [] if (!flows.length) return try { - for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }) + for (const f of flows) await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }, tripCurrency)) } catch { toast.error(t('common.unknownError')) } // Refresh even when one transfer failed: the ones created before it are real, // and leaving them in the flow list invites a second, doubled settle-up. @@ -535,7 +547,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps )} {(editingSettlement || addingPayment) && ( - { setEditingSettlement(null); setAddingPayment(false) }} onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> )} @@ -1173,8 +1185,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A // A transfer can be made in any currency — paying a rouble debt in euros is normal — // so it carries its own, defaulting to the display currency. The server freezes its // FX rate on write, the same way an expense's is frozen. -function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: { - tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void +function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, onClose, onSaved }: { + tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; tripCurrency: string; onClose: () => void; onSaved: () => void }) { const { t } = useTranslation() const toast = useToast() @@ -1195,7 +1207,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav const save = async () => { if (!valid) return setSaving(true) - const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day } + const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }, tripCurrency) try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) diff --git a/client/src/components/Budget/useFreezeMissingRates.test.ts b/client/src/components/Budget/useFreezeMissingRates.test.ts new file mode 100644 index 000000000..52d3060ce --- /dev/null +++ b/client/src/components/Budget/useFreezeMissingRates.test.ts @@ -0,0 +1,123 @@ +// FE-FREEZE-RATES-001 to FE-FREEZE-RATES-006 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import type { BudgetUnconverted } from '@trek/shared' +import { server } from '../../../tests/helpers/msw/server' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { buildBudgetItem } from '../../../tests/helpers/factories' +import { useTripStore } from '../../store/tripStore' +import { setForcedOffline } from '../../sync/networkMode' +import { clearExchangeRateCache } from '../../hooks/useExchangeRates' +import { resetFreezeAttempts, useFreezeMissingRates } from './useFreezeMissingRates' + +// The reporter's setup: an AUD trip whose server cannot fetch rates, and a VND bill that +// never froze one. The browser has today's AUD table. +const bill = () => buildBudgetItem({ id: 11, trip_id: 5, name: 'Pho', total_price: 8920000, currency: 'VND', exchange_rate: 1 }) +const pending: BudgetUnconverted = { item_ids: [11], settlement_ids: [3], currencies: ['VND'] } + +type Props = Parameters[0] + +function mountHook(over: Partial = {}) { + const onHealed = vi.fn() + const props: Props = { tripId: 5, tripCurrency: 'AUD', canEdit: true, unconverted: pending, onHealed, ...over } + const view = renderHook((p: Props) => useFreezeMissingRates(p), { initialProps: props }) + return { ...view, onHealed, props } +} + +let posted: unknown[] = [] + +function serveFreeze(reply: () => Response = () => HttpResponse.json({ + items: [{ ...bill(), exchange_rate: 18241.3 }], + settlements: [{ id: 3, trip_id: 5, from_user_id: 2, to_user_id: 1, amount: 100000, currency: 'VND', exchange_rate: 18241.3 }], + unresolved: [], +})) { + server.use(http.post('/api/trips/5/budget/freeze-rates', async ({ request }) => { + posted.push(await request.json()) + return reply() + })) +} + +/** Lets a heal that was never going to send anything run to its end. */ +const settle = () => act(async () => { await new Promise(r => setTimeout(r, 30)) }) + +beforeEach(() => { + resetAllStores() + resetFreezeAttempts() + clearExchangeRateCache() + posted = [] + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() })) + seedStore(useTripStore, { budgetItems: [bill()] }) + serveFreeze() +}) + +afterEach(() => { + setForcedOffline(false) + vi.restoreAllMocks() +}) + +describe('useFreezeMissingRates', () => { + it('FE-FREEZE-RATES-001: lends the fresh rates for the listed currencies and reloads once rows count', async () => { + const { onHealed } = mountHook() + + await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1)) + // Only the currency the settlement is missing goes out, never the whole table. + expect(posted).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }]) + expect(useTripStore.getState().budgetItems[0].exchange_rate).toBe(18241.3) + }) + + it('FE-FREEZE-RATES-002: asks once per set of rows, however often the settlement is read', async () => { + const { onHealed, props, rerender } = mountHook() + await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1)) + + // The same rows in another order, then again after a reload: nothing new to ask. + rerender({ ...props, unconverted: { item_ids: [11], settlement_ids: [3], currencies: ['VND'] } }) + rerender({ ...props, unconverted: { settlement_ids: [3], item_ids: [11], currencies: ['VND'] } }) + await settle() + expect(posted).toHaveLength(1) + + // Another row is another attempt. + rerender({ ...props, unconverted: { item_ids: [11, 12], settlement_ids: [3], currencies: ['VND'] } }) + await waitFor(() => expect(posted).toHaveLength(2)) + }) + + it('FE-FREEZE-RATES-003: never asks for a viewer, offline, or with nothing left out', async () => { + mountHook({ canEdit: false }) + mountHook({ unconverted: { item_ids: [], settlement_ids: [], currencies: [] } }) + mountHook({ unconverted: undefined }) + setForcedOffline(true) + mountHook({ unconverted: { item_ids: [21], settlement_ids: [], currencies: ['VND'] } }) + await settle() + + expect(posted).toEqual([]) + }) + + it('FE-FREEZE-RATES-004: sends nothing when the browser has no rate for the currency either', async () => { + const { onHealed } = mountHook({ unconverted: { item_ids: [31], settlement_ids: [], currencies: ['JPY'] } }) + await settle() + + expect(posted).toEqual([]) + expect(onHealed).not.toHaveBeenCalled() + }) + + it('FE-FREEZE-RATES-005: does not reload when the server froze nothing', async () => { + serveFreeze(() => HttpResponse.json({ items: [], settlements: [], unresolved: ['VND'] })) + const { onHealed } = mountHook() + + await waitFor(() => expect(posted).toHaveLength(1)) + await settle() + expect(onHealed).not.toHaveBeenCalled() + }) + + it('FE-FREEZE-RATES-006: a refused heal is logged, not thrown, and not retried in a loop', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + serveFreeze(() => HttpResponse.json({ error: 'The trip currency changed. Reload and try again.' }, { status: 409 })) + const { onHealed, props, rerender } = mountHook() + + await waitFor(() => expect(warn).toHaveBeenCalled()) + rerender({ ...props }) + await settle() + expect(posted).toHaveLength(1) + expect(onHealed).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Budget/useFreezeMissingRates.ts b/client/src/components/Budget/useFreezeMissingRates.ts new file mode 100644 index 000000000..9fdbccd2a --- /dev/null +++ b/client/src/components/Budget/useFreezeMissingRates.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react' +import type { BudgetFallbackFx, BudgetUnconverted } from '@trek/shared' +import { fetchFreshRates, isSendableRate } from '../../hooks/useExchangeRates' +import { useTripStore } from '../../store/tripStore' +import { isEffectivelyOffline } from '../../sync/networkMode' + +/** + * Every set of rows already tried, by trip, trip currency and ids. The settlement lists a + * row it cannot count until a rate is frozen on it; one the browser has no rate for either + * stays listed, and without this every reload of the settlement would ask again. + */ +const tried = new Set() + +/** Test-only: the attempts outlive a vitest file's individual tests. */ +export function resetFreezeAttempts(): void { + tried.clear() +} + +/** + * Heals the rows the settlement leaves out (`unconverted`): expenses and transfers in a + * foreign currency that never froze a rate, while the server cannot fetch one. That is the + * setup where the server's own fetch fails and the browser's works, so an editor's browser + * lends its fresh rates to POST …/budget/freeze-rates. The server still prefers its own rate + * and never touches a row that is frozen already. + * + * Runs for an editor only, online, and once per set of rows. No request goes out when the + * browser has no rate for any of the listed currencies either. `onHealed` runs when the + * server froze something, so the caller can reload the settlement. + * + * Deliberately not in the mutation queue: the heal is derived and idempotent, and a replay + * hours later would freeze a rate from another day. + */ +export function useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted, onHealed }: { + tripId: number + tripCurrency: string + canEdit: boolean + unconverted: BudgetUnconverted | null | undefined + onHealed: () => void +}): void { + const onHealedRef = useRef(onHealed) + useEffect(() => { onHealedRef.current = onHealed }) + const mounted = useRef(true) + useEffect(() => { + mounted.current = true + return () => { mounted.current = false } + }, []) + + const trip = (tripCurrency || '').toUpperCase() + const itemIds = [...(unconverted?.item_ids ?? [])].sort((a, b) => a - b).join(',') + const settlementIds = [...(unconverted?.settlement_ids ?? [])].sort((a, b) => a - b).join(',') + const currencies = (unconverted?.currencies ?? []).join(',') + + useEffect(() => { + if (!canEdit || !trip || (!itemIds && !settlementIds) || isEffectivelyOffline()) return + const key = `${tripId}:${trip}:${itemIds}:${settlementIds}` + if (tried.has(key)) return + tried.add(key) + + const heal = async (): Promise => { + const rates = await fetchFreshRates(trip) + const lent: Record = {} + for (const cur of currencies.split(',')) { + const rate = rates?.[cur] + if (isSendableRate(rate)) lent[cur] = rate + } + if (Object.keys(lent).length === 0) return + const fallback: BudgetFallbackFx = { base: trip, rates: lent } + const result = await useTripStore.getState().freezeMissingRates(tripId, fallback) + if (mounted.current && (result.items.length > 0 || result.settlements.length > 0)) onHealedRef.current() + } + heal().catch((err: unknown) => console.warn('Could not freeze the missing exchange rates:', err)) + }, [canEdit, trip, tripId, itemIds, settlementIds, currencies]) +} diff --git a/client/src/hooks/useExchangeRates.test.ts b/client/src/hooks/useExchangeRates.test.ts index 1eca924b3..b096fb3da 100644 --- a/client/src/hooks/useExchangeRates.test.ts +++ b/client/src/hooks/useExchangeRates.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { server } from '../../tests/helpers/msw/server' -import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, clearExchangeRateCache, tripAmountOf, useExchangeRates } from './useExchangeRates' +import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates' const FX_URL = 'https://api.frankfurter.dev/v2/rates' @@ -45,6 +45,94 @@ describe('fetchExchangeRates (#1561)', () => { server.use(http.get(FX_URL, () => HttpResponse.error())) expect(await fetchExchangeRates('NOK')).toEqual({ NOK: 1, USD: 0.1 }) }) + + // A request the network never answers held the Costs panel's heal for as long as the + // browser kept the socket open. It gives up after 10 s like any other failure. + it('gives up on a request that hangs after 10 s and serves the stale cache', async () => { + vi.useFakeTimers() + const fetchSpy = vi.spyOn(globalThis, 'fetch') + try { + localStorage.setItem('trek_fx_NOK', JSON.stringify({ rates: { NOK: 1, USD: 0.1 }, ts: Date.now() - 24 * 60 * 60 * 1000 })) + let signal: AbortSignal | undefined + fetchSpy.mockImplementation((_input, init) => new Promise((_resolve, reject) => { + signal = init?.signal ?? undefined + signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))) + })) + const pending = fetchExchangeRates('NOK') + await vi.advanceTimersByTimeAsync(9_999) + expect(signal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(signal?.aborted).toBe(true) + expect(await pending).toEqual({ NOK: 1, USD: 0.1 }) + } finally { + fetchSpy.mockRestore() + vi.useRealTimers() + } + }) +}) + +// The rates the client lends the server to freeze a rate with. Only a fresh table counts: +// a stale one would pin a figure from another day on the row for good. +describe('freshRates and fetchFreshRates', () => { + beforeEach(() => { + clearExchangeRateCache() + }) + + it('reads a fresh cache and ignores a stale or missing one', () => { + expect(freshRates('AUD')).toBeNull() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 })) + expect(freshRates('aud')).toBeNull() + clearExchangeRateCache() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() })) + expect(freshRates('aud')).toEqual({ AUD: 1, VND: 18241.3 }) + }) + + it('fetches when the cache is stale and gives the new table', async () => { + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 })) + server.use(http.get(FX_URL, () => HttpResponse.json([{ quote: 'VND', rate: 18241.3 }]))) + expect(await fetchFreshRates('AUD')).toEqual({ AUD: 1, VND: 18241.3 }) + }) + + it('is null when the fetch fails and only a stale table is left', async () => { + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 })) + server.use(http.get(FX_URL, () => HttpResponse.error())) + expect(await fetchFreshRates('AUD')).toBeNull() + }) +}) + +describe('withFallbackFx', () => { + beforeEach(() => { + clearExchangeRateCache() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61, XAU: 1e-10 }, ts: Date.now() })) + }) + + it('lends the fresh trip-currency rate for a foreign currency', () => { + expect(withFallbackFx({ name: 'Pho', currency: 'vnd', total_price: 8920000 }, 'aud')).toEqual({ + name: 'Pho', currency: 'vnd', total_price: 8920000, + fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } }, + }) + expect(withFallbackFx({ from_user_id: 1, to_user_id: 2, amount: 10, currency: 'EUR' }, 'AUD').fallback_fx) + .toEqual({ base: 'AUD', rates: { EUR: 0.61 } }) + }) + + it('leaves the data alone when there is nothing to lend', () => { + const inTrip = { name: 'Tram', currency: 'AUD' } + expect(withFallbackFx(inTrip, 'AUD')).toBe(inTrip) + // No currency at all is the trip's own. + expect(withFallbackFx({ name: 'Tram', currency: null }, 'AUD')).toEqual({ name: 'Tram', currency: null }) + // An explicit rate is the caller's and wins on the server anyway. + expect(withFallbackFx({ currency: 'VND', exchange_rate: 17000 }, 'AUD')).toEqual({ currency: 'VND', exchange_rate: 17000 }) + // No quote for the currency, no trip currency, or a rate the contract would refuse. + expect(withFallbackFx({ currency: 'JPY' }, 'AUD')).toEqual({ currency: 'JPY' }) + expect(withFallbackFx({ currency: 'VND' }, null)).toEqual({ currency: 'VND' }) + expect(withFallbackFx({ currency: 'XAU' }, 'AUD')).toEqual({ currency: 'XAU' }) + }) + + it('lends nothing from a stale table', () => { + clearExchangeRateCache() + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 })) + expect(withFallbackFx({ currency: 'VND' }, 'AUD')).toEqual({ currency: 'VND' }) + }) }) // A cost entered in a foreign currency is money that was actually paid, at a rate that @@ -171,6 +259,45 @@ describe('useExchangeRates anchored on the trip currency (#2525)', () => { const { result } = renderHook(() => useExchangeRates('EUR', 'EUR')) await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1)) expect(calls).toBe(1) + expect(result.current.displayPerTrip).toBe(1) + }) + + // What the settlement is asked to label its figures with when the server has no quote + // (`base_rate`): the same figure `convert` takes a trip amount to the display currency with. + it('gives the display currency per 1 trip currency from the trip\'s quote', async () => { + quotes() + const { result } = renderHook(() => useExchangeRates('USD', 'EUR')) + await waitFor(() => expect(result.current.displayPerTrip).toBe(1.1398)) + expect(result.current.convert(1, 'EUR')).toBe(result.current.displayPerTrip) + }) + + it('falls back to the display currency\'s quote for it, and is null without either', async () => { + server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD' + ? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }]) + : HttpResponse.error()))) + const { result } = renderHook(() => useExchangeRates('USD', 'EUR')) + await waitFor(() => expect(result.current.displayPerTrip).toBe(1.25)) + + clearExchangeRateCache() + let calls = 0 + server.use(http.get(FX_URL, () => { calls++; return HttpResponse.error() })) + const none = renderHook(() => useExchangeRates('GBP', 'AUD')) + await waitFor(() => expect(calls).toBe(2)) + expect(none.result.current.displayPerTrip).toBeNull() + }) + + // A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the + // settlement query takes, and sent as `base_rate` it would fail the whole read with a 400. + it('is null for a figure the settlement query would refuse, which convert still uses', () => { + localStorage.setItem('trek_fx_XAU', JSON.stringify({ rates: { XAU: 1, IRR: 2.6e9 }, ts: Date.now() })) + localStorage.setItem('trek_fx_IRR', JSON.stringify({ rates: { IRR: 1, XAU: 1 / 2.6e9 }, ts: Date.now() })) + const high = renderHook(() => useExchangeRates('IRR', 'XAU')) + expect(high.result.current.convert(1, 'XAU')).toBe(2.6e9) + expect(high.result.current.displayPerTrip).toBeNull() + // The other way round the figure falls under 1e-9. + const low = renderHook(() => useExchangeRates('XAU', 'IRR')) + expect(low.result.current.convert(2.6e9, 'IRR')).toBeCloseTo(1, 10) + expect(low.result.current.displayPerTrip).toBeNull() }) }) diff --git a/client/src/hooks/useExchangeRates.ts b/client/src/hooks/useExchangeRates.ts index 2ea56af58..c8e1ddd17 100644 --- a/client/src/hooks/useExchangeRates.ts +++ b/client/src/hooks/useExchangeRates.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import type { BudgetFallbackFx } from '@trek/shared' import { currencyDecimals } from '../utils/formatters' /** @@ -10,6 +11,9 @@ import { currencyDecimals } from '../utils/formatters' */ const TTL_MS = 6 * 60 * 60 * 1000 // 6h +// A hung request would otherwise hold every caller, the freeze-rates heal included, +// for as long as the browser keeps the socket open. +const FETCH_TIMEOUT_MS = 10_000 const mem = new Map; ts: number }>() function readCache(base: string): { rates: Record; ts: number } | null { @@ -35,8 +39,12 @@ export async function fetchExchangeRates(base: string): Promise stop.abort(), FETCH_TIMEOUT_MS) try { - const d = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`) + const d = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`, { signal: stop.signal }) .then(r => r.json()) as Array<{ quote?: string; rate?: number }> if (!Array.isArray(d)) return cached?.rates ?? null // Frankfurter omits the base's own self-rate, so seed it with `base = 1`. @@ -50,9 +58,58 @@ export async function fetchExchangeRates(base: string): Promise | null { + const cached = readCache((base || 'EUR').toUpperCase()) + return cached && Date.now() - cached.ts < TTL_MS ? cached.rates : null +} + +/** `freshRates`, fetching first when the cache is not fresh. Null when the fetch fails too. */ +export async function fetchFreshRates(base: string): Promise | null> { + await fetchExchangeRates(base) + return freshRates(base) +} + +const CURRENCY_CODE = /^[A-Z]{3}$/ + +/** + * Whether a rate is one the server's contract takes (budget.schema.ts): finite and from + * 1e-9 to 1e9, for a lent `fallback_fx` rate as for the settlement's `base_rate`. Anything + * else fails the whole request with a 400, so the client leaves it out instead. + */ +export function isSendableRate(rate: number | null | undefined): rate is number { + return rate != null && Number.isFinite(rate) && rate >= 1e-9 && rate <= 1e9 +} + +/** + * `data` with its currency's rate against the trip currency lent alongside, for a write the + * server freezes a rate on (an expense, a transfer). The server prefers its own rate and + * uses this one only when it has none, which is the setup where its fetch fails and the + * browser's does not. Sent only for a currency foreign to the trip with a fresh quote in + * the cache, and never beside an explicit `exchange_rate`; anything else comes back as it + * was. A rate outside what the contract accepts would fail the whole write, so it is left out. + */ +export function withFallbackFx( + data: T, + tripCurrency: string | null | undefined, +): T & { fallback_fx?: BudgetFallbackFx } { + const trip = (tripCurrency || '').toUpperCase() + const cur = (data.currency || '').toUpperCase() + if (!CURRENCY_CODE.test(trip) || !CURRENCY_CODE.test(cur) || cur === trip || data.exchange_rate != null) return data + const rate = freshRates(trip)?.[cur] + if (!isSendableRate(rate)) return data + return { ...data, fallback_fx: { base: trip, rates: { [cur]: rate } } } +} + /** * Convert a booked amount the way the server's settlement does (#1335, #1445): the FX * rate frozen when the expense or transfer was entered wins over today's rate, so a cost @@ -229,5 +286,16 @@ export function useExchangeRates(base: string, tripCurrency?: string | null) { [rates, anchorRates, upper], ) - return { rates, convert } + // Units of the display currency per 1 trip currency, from the same quotes `convert` uses. + // The settlement takes it as `base_rate` to answer in the display currency when the + // server has no quote of its own. Null while neither quote is known, and null for a + // figure the settlement query refuses (a trip in XAU shown in IRR runs past 1e9): sent + // along, it would fail the whole read with a 400, healthy server or not. + const perTrip = rates?.[anchor] + const quoted = anchor === upper + ? 1 + : crossRate(anchorRates, upper, anchor) ?? (perTrip && perTrip > 0 ? 1 / perTrip : null) + const displayPerTrip = isSendableRate(quoted) ? quoted : null + + return { rates, convert, displayPerTrip } } diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 1db224e33..0fb55490d 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -7,13 +7,14 @@ import { import MDancingTrek from '../../../components/MDancingTrek' import { useAuthStore } from '../../../../store/authStore' import { useSettingsStore } from '../../../../store/settingsStore' -import { useExchangeRates } from '../../../../hooks/useExchangeRates' +import { useExchangeRates, withFallbackFx } from '../../../../hooks/useExchangeRates' import { useTranslation } from '../../../../i18n' import { amountToInputString, formatMoney } from '../../../../utils/formatters' import { downloadBlob, openFile } from '../../../../utils/fileDownload' import { budgetApi } from '../../../../api/client' import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' +import { useFreezeMissingRates } from '../../../../components/Budget/useFreezeMissingRates' import { finalBudgetFor, finalBudgetSources, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import CustomSelect from '../../../../components/shared/CustomSelect' @@ -56,18 +57,19 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() const tripCurrency = (trip?.currency || base).toUpperCase() // Anchored on the trip currency's quote, the one the server books with (#2525). - const { convert } = useExchangeRates(base, tripCurrency) + const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency) const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, displayCurrency: base, convert }), [me, tripCurrency, base, convert]) const [settlement, setSettlement] = useState(null) // A failed settlement read leaves `settlement` null, and the final budget would // read that as "the trip cost nobody anything", a claim we cannot make. const [settlementError, setSettlementError] = useState(false) + // Sends the browser's own figure for the display currency, as CostsPanel.tsx does. const loadSettlement = useCallback(() => { - budgetApi.settlement(tripId, base) + budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) .then(s => { setSettlement(s); setSettlementError(false) }) .catch(() => setSettlementError(true)) - }, [tripId, base]) + }, [tripId, base, tripCurrency, displayPerTrip]) // Mirrors CostsPanel.tsx: items reload on trip change, settlement reloads on // trip/base change; further refreshes are explicit after each mutation below @@ -79,6 +81,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { useEffect(() => { loadSettlement() }, [loadSettlement]) + useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement }) const [search, setSearch] = useState('') const [segment, setSegment] = useState('all') @@ -571,6 +574,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { }} tripId={tripId} base={base} + tripCurrency={tripCurrency} people={tripMembers} me={me} toast={toast} @@ -881,12 +885,13 @@ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: { * (editable like an expense's, unlike the legacy created_at-only date, see * `settlementDate` in CostsPanel.helpers.ts). */ -function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toast, t, onSaved }: { +function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, people, me, toast, t, onSaved }: { open: boolean editing: CostsSettlement | null onClose: () => void tripId: number base: string + tripCurrency: string people: TripMember[] me: number toast: { error: (message: string) => void } @@ -917,7 +922,7 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toa const save = async () => { if (!valid || saving) return setSaving(true) - const data = { from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day } + const data = withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day }, tripCurrency) try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts index 4b6707a4c..bd5fb6ef7 100644 --- a/client/src/mobile/screens/trip/tabs/costsModel.ts +++ b/client/src/mobile/screens/trip/tabs/costsModel.ts @@ -1,4 +1,4 @@ -import type { BudgetParticipantFinal, CostCategory } from '@trek/shared' +import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared' import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories' import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates' @@ -116,6 +116,10 @@ export interface CostsSettlementResponse { settlements: CostsSettlement[] /** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */ finalBudgets: BudgetParticipantFinal[] + /** The currency the figures are in: the display currency, or the trip's own without a quote for it. */ + currency?: string + /** Rows no rate could convert, left out of every figure above. */ + unconverted?: BudgetUnconverted } // ── hero / tile totals (spec §3.1-§3.3) ──────────────────────────────────── diff --git a/client/src/store/slices/budgetSlice.test.ts b/client/src/store/slices/budgetSlice.test.ts index a3eade30b..77bd378cd 100644 --- a/client/src/store/slices/budgetSlice.test.ts +++ b/client/src/store/slices/budgetSlice.test.ts @@ -1,8 +1,9 @@ -// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-016 +// FE-STORE-BUDGET-001 to FE-STORE-BUDGET-021 import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; -import { buildBudgetItem } from '../../../tests/helpers/factories'; +import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories'; +import { clearExchangeRateCache } from '../../hooks/useExchangeRates'; import { useTripStore } from '../tripStore'; let addToast: ReturnType; @@ -284,3 +285,75 @@ describe('budgetSlice', () => { expect(addToast).toHaveBeenCalledTimes(2); }); }); + +// An AUD trip whose server cannot fetch rates while the browser can: a write in another +// currency lends the browser's rate, so the server can freeze one on entry. +describe('budgetSlice: lent rates', () => { + let sent: Record[] = []; + + beforeEach(() => { + clearExchangeRateCache(); + sent = []; + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) }); + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() })); + server.use( + http.post('/api/trips/:id/budget', async ({ request }) => { + const body = await request.json() as Record; + sent.push(body); + return HttpResponse.json({ item: buildBudgetItem({ trip_id: 1, name: String(body.name) }) }); + }), + http.put('/api/trips/1/budget/:itemId', async ({ request }) => { + const body = await request.json() as Record; + sent.push(body); + return HttpResponse.json({ item: { ...buildBudgetItem({ id: 50, trip_id: 1 }), ...body } }); + }), + ); + }); + + afterEach(clearExchangeRateCache); + + it('FE-STORE-BUDGET-018: addBudgetItem lends the fresh trip-currency rate for a foreign currency', async () => { + await useTripStore.getState().addBudgetItem(1, { name: 'Pho', total_price: 8920000, currency: 'VND' }); + + expect(sent[0]).toMatchObject({ name: 'Pho', currency: 'VND', fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }); + }); + + it('FE-STORE-BUDGET-019: nothing is lent for the trip currency, another trip or a stale table', async () => { + await useTripStore.getState().addBudgetItem(1, { name: 'Tram', currency: 'AUD' }); + // The store only knows the open trip's currency, and a rate has to be quoted against it. + await useTripStore.getState().addBudgetItem(2, { name: 'Pho', currency: 'VND' }); + clearExchangeRateCache(); + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 })); + await useTripStore.getState().addBudgetItem(1, { name: 'Banh mi', currency: 'VND' }); + + expect(sent).toHaveLength(3); + for (const body of sent) expect(body).not.toHaveProperty('fallback_fx'); + }); + + it('FE-STORE-BUDGET-020: updateBudgetItem lends it too, for a change into a foreign currency', async () => { + seedStore(useTripStore, { budgetItems: [buildBudgetItem({ id: 50, trip_id: 1, currency: 'USD', exchange_rate: 0.66 })] }); + + await useTripStore.getState().updateBudgetItem(1, 50, { currency: 'VND', total_price: 8920000 }); + + expect(sent[0]).toEqual({ currency: 'VND', total_price: 8920000, fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }); + }); + + it('FE-STORE-BUDGET-021: freezeMissingRates sends the lent rates and takes the healed rows in', async () => { + const bill = buildBudgetItem({ id: 60, trip_id: 1, currency: 'VND', exchange_rate: 1 }); + const other = buildBudgetItem({ id: 61, trip_id: 1, currency: 'AUD', exchange_rate: 1 }); + seedStore(useTripStore, { budgetItems: [bill, other] }); + const bodies: unknown[] = []; + server.use(http.post('/api/trips/1/budget/freeze-rates', async ({ request }) => { + bodies.push(await request.json()); + return HttpResponse.json({ items: [{ ...bill, exchange_rate: 18241.3 }], settlements: [], unresolved: [] }); + })); + + const result = await useTripStore.getState().freezeMissingRates(1, { base: 'AUD', rates: { VND: 18241.3 } }); + // Without a table of its own to lend, the call asks the server to use its own rates. + await useTripStore.getState().freezeMissingRates(1); + + expect(bodies).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }, {}]); + expect(result.unresolved).toEqual([]); + expect(useTripStore.getState().budgetItems.map(i => [i.id, i.exchange_rate])).toEqual([[60, 18241.3], [61, 1]]); + }); +}); diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index dd237ac66..7d5f20921 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -3,8 +3,9 @@ import { budgetRepo } from '../../repo/budgetRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { BudgetItem, BudgetItemMember } from '../../types' -import type { BudgetCreateItemRequest, BudgetUpdateItemRequest } from '@trek/shared' +import type { BudgetCreateItemRequest, BudgetFallbackFx, BudgetFreezeRatesResponse, BudgetUpdateItemRequest } from '@trek/shared' import { getApiErrorMessage } from '../../types' +import { withFallbackFx } from '../../hooks/useExchangeRates' import { notify } from '../notify' type SetState = StoreApi['setState'] @@ -19,6 +20,16 @@ export interface BudgetSlice { toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise + freezeMissingRates: (tripId: number | string, fallback?: BudgetFallbackFx) => Promise +} + +/** + * The open trip's currency when `tripId` is that trip, which is what a lent rate has to be + * quoted against. Null for any other trip: the store only knows the one that is open. + */ +function openTripCurrency(get: GetState, tripId: number | string): string | null { + const trip = get().trip + return trip && String(trip.id) === String(tripId) ? trip.currency : null } export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({ @@ -33,7 +44,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => addBudgetItem: async (tripId, data) => { try { - const result = await budgetApi.create(tripId, data) + // A foreign currency goes out with the rate the browser holds for it, so the server + // can freeze one even when its own fetch fails. + const result = await budgetApi.create(tripId, withFallbackFx(data, openTripCurrency(get, tripId))) set(state => ({ budgetItems: [...state.budgetItems, result.item] })) return result.item } catch (err: unknown) { @@ -43,7 +56,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => updateBudgetItem: async (tripId, id, data) => { try { - const result = await budgetApi.update(tripId, id, data) + const result = await budgetApi.update(tripId, id, withFallbackFx(data, openTripCurrency(get, tripId))) set(state => ({ budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item) })) @@ -151,4 +164,15 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => notify(getApiErrorMessage(err, 'Error reordering budget items'), 'error') } }, + + // Freezes a rate onto the rows the settlement could not count (see useFreezeMissingRates). + // Not optimistic: nothing changes locally until the server says which rows it froze. + freezeMissingRates: async (tripId, fallback) => { + const result = await budgetApi.freezeRates(tripId, fallback ? { fallback_fx: fallback } : {}) + if (result.items.length > 0) { + const healed = new Map(result.items.map(item => [item.id, item])) + set(state => ({ budgetItems: state.budgetItems.map(item => healed.get(item.id) ?? item) })) + } + return result + }, }) diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 4b95a4b11..504ce0549 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -4,6 +4,7 @@ import MCostsTab from '../../../../src/mobile/screens/trip/tabs/MCostsTab' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { budgetApi } from '../../../../src/api/client' import { localToday } from '../../../../src/components/Planner/today' +import { resetFreezeAttempts } from '../../../../src/components/Budget/useFreezeMissingRates' import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates' import { useAuthStore } from '../../../../src/store/authStore' import { useSettingsStore } from '../../../../src/store/settingsStore' @@ -14,7 +15,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store' import { server } from '../../../helpers/msw/server' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' -// FE-MOB-COSTT-001 to FE-MOB-COSTT-044 +// FE-MOB-COSTT-001 to FE-MOB-COSTT-049 // The add/edit expense sheet is the shared desktop-sized form; the panel only // owns when it opens and what happens on save, so it is stubbed here. @@ -723,7 +724,12 @@ describe('MCostsTab', () => { expect(within(dialog).getByRole('button', { name: /GBP/ })).toBeInTheDocument() fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' })) - expect(update).toHaveBeenCalledWith(7, 505, { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28' }) + // The pound rate the phone holds goes along; the server only uses it when the + // currency changes and it has no rate of its own, so the frozen one stays. + expect(update).toHaveBeenCalledWith(7, 505, { + from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28', + fallback_fx: { base: 'USD', rates: { GBP: 0.5 } }, + }) }) it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => { @@ -794,6 +800,63 @@ describe('MCostsTab', () => { expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$12,345.67') }) + // The server could not fetch rates, so the museum's pounds never froze one and the + // settlement left them out. An editor's phone lends its own rate and reads again. + it('FE-MOB-COSTT-047: an editor heals a bill the server could not convert and reads the settlement again', async () => { + resetFreezeAttempts() + let healed = false + server.use( + http.get('/api/trips/:id/budget/settlement', ({ request }) => { + settlementBases.push(new URL(request.url).searchParams.get('base') ?? '') + return HttpResponse.json(healed + ? { ...SETTLEMENT, unconverted: { item_ids: [], settlement_ids: [], currencies: [] } } + : { ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } }) + }), + ) + const freeze = vi.spyOn(budgetApi, 'freezeRates').mockImplementation(async () => { + healed = true + return { items: [{ ...MUSEUM, exchange_rate: 0.5 }], settlements: [], unresolved: [] } + }) + await renderTab() + + await waitFor(() => expect(freeze).toHaveBeenCalledWith(7, { fallback_fx: { base: 'USD', rates: { GBP: 0.5 } } })) + await waitFor(() => expect(settlementBases).toHaveLength(2)) + expect(freeze).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COSTT-048: a read-only member\'s phone never freezes a rate', async () => { + resetFreezeAttempts() + serveSettlement({ ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } }) + const freeze = vi.spyOn(budgetApi, 'freezeRates') + await renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] })) + + await new Promise(r => setTimeout(r, 50)) + expect(freeze).not.toHaveBeenCalled() + expect(settlementBases).toHaveLength(1) + }) + + it('FE-MOB-COSTT-049: asks the settlement in the display currency with the phone\'s own rate for it', async () => { + seedStore(useSettingsStore, { + settings: { ...useSettingsStore.getState().settings, default_currency: 'GBP' }, + }) + const rates: (string | null)[] = [] + server.use( + http.get('/api/trips/:id/budget/settlement', ({ request }) => { + const url = new URL(request.url) + settlementBases.push(url.searchParams.get('base') ?? '') + rates.push(url.searchParams.get('base_rate')) + return HttpResponse.json(SETTLEMENT) + }), + ) + render() + await screen.findByText('+£12.50') + + // Pounds per dollar from the trip currency's own table, what the server labels with + // when it cannot fetch a quote itself. + expect(settlementBases).toEqual(['GBP']) + expect(rates).toEqual(['0.5']) + }) + it('FE-MOB-COSTT-044: a payment cannot be saved without a day', async () => { // Cleared, the server would store NULL and the ledger would quietly fall // back to the day the payment was recorded on. diff --git a/server/src/nest/budget/budget.controller.ts b/server/src/nest/budget/budget.controller.ts index c2dab9aca..241b14cf2 100644 --- a/server/src/nest/budget/budget.controller.ts +++ b/server/src/nest/budget/budget.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, Headers, + HttpCode, HttpException, Param, Post, @@ -28,6 +29,8 @@ import { BudgetReorderCategoriesDto, BudgetCreateSettlementDto, BudgetUpdateSettlementDto, + BudgetFreezeRatesDto, + BudgetSettlementQueryDto, } from './budget.dto'; /** @@ -37,8 +40,9 @@ import { * every handler verifies trip access (404); mutations check 'budget_edit' (403); * create is 201, the rest 200; bespoke 404 bodies reproduced; mutations * broadcast over WebSocket with the forwarded X-Socket-Id. Static sub-routes - * (summary, settlement, reorder/*) are declared before /:id so they win over the - * param. Updating total_price on a reservation-linked item syncs the price back. + * (summary, settlement, freeze-rates, reorder/*) are declared before /:id so they + * win over the param. Updating total_price on a reservation-linked item syncs the + * price back. * * Bodies are validated against the @trek/shared budget schemas via budget.dto.ts * (global ZodValidationPipe). This replaced the legacy bespoke 400s ('Name is @@ -66,14 +70,16 @@ export class BudgetController { return { summary: await this.budget.perPersonSummary(tripId) }; } + // `base_rate` is the caller's own quote for the display currency (units per 1 trip + // currency), used only when the server has none; a malformed one is a 400. @Get('settlement') settlement( @CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, - @Query('base') base?: string, + @Query() query: BudgetSettlementQueryDto, ) { - return this.budget.settlement(tripId, base, trip.currency || 'EUR'); + return this.budget.settlement(tripId, query.base, trip.currency || 'EUR', query.base_rate); } @Get('settlements') @@ -91,7 +97,10 @@ export class BudgetController { ) { const settlement = await this.budget.createSettlement( tripId, - { from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency, settled_at: body.settled_at }, + { + from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, + currency: body.currency, settled_at: body.settled_at, fallback_fx: body.fallback_fx, + }, user.id, ); // A party who is not on this trip gets the same answer as a settlement that @@ -118,6 +127,7 @@ export class BudgetController { amount: body.amount, currency: body.currency, settled_at: body.settled_at, + fallback_fx: body.fallback_fx, }); if (!settlement) { throw new HttpException({ error: 'Settlement not found' }, 404); @@ -141,6 +151,33 @@ export class BudgetController { return { success: true }; } + /** + * Freeze a rate onto every foreign row that has none frozen yet, the ones the + * settlement lists as `unconverted` and the ones it still converts at today's rate: + * the server's own rate first, `fallback_fx` (the browser's) for a currency it cannot + * quote. A frozen row, a row in the trip currency and a row without a currency are + * never touched. The same service call as the freeze_budget_rates MCP tool, which + * lends no rates. 409 when the trip currency changed while the rates were fetched; + * nothing is written then. + */ + @RequirePermission('budget_edit') + @Post('freeze-rates') + @HttpCode(200) + async freezeRates( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body() body: BudgetFreezeRatesDto, + @Headers('x-socket-id') socketId?: string, + ) { + const healed = await this.budget.freezeMissingRates(tripId, body.fallback_fx); + if (!healed) { + throw new HttpException({ error: 'The trip currency changed. Reload and try again.' }, 409); + } + for (const item of healed.items) this.budget.broadcast(tripId, 'budget:updated', { item }, socketId); + for (const settlement of healed.settlements) this.budget.broadcast(tripId, 'budget:settlement-updated', { settlement }, socketId); + return healed; + } + @RequirePermission('budget_edit') @Post() async create( diff --git a/server/src/nest/budget/budget.dto.ts b/server/src/nest/budget/budget.dto.ts index 6a56bab77..0dd6993ec 100644 --- a/server/src/nest/budget/budget.dto.ts +++ b/server/src/nest/budget/budget.dto.ts @@ -9,6 +9,8 @@ import { budgetReorderCategoriesRequestSchema, budgetCreateSettlementRequestSchema, budgetUpdateSettlementRequestSchema, + budgetFreezeRatesRequestSchema, + budgetSettlementQuerySchema, } from '@trek/shared'; /** @@ -26,3 +28,6 @@ export class BudgetReorderItemsDto extends createZodDto(budgetReorderItemsReques export class BudgetReorderCategoriesDto extends createZodDto(budgetReorderCategoriesRequestSchema) {} export class BudgetCreateSettlementDto extends createZodDto(budgetCreateSettlementRequestSchema) {} export class BudgetUpdateSettlementDto extends createZodDto(budgetUpdateSettlementRequestSchema) {} +export class BudgetFreezeRatesDto extends createZodDto(budgetFreezeRatesRequestSchema) {} +/** GET …/settlement query (`base`, and `base_rate` coerced from the query string). */ +export class BudgetSettlementQueryDto extends createZodDto(budgetSettlementQuerySchema) {} diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 63d5b42b6..2e79f480c 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -72,7 +72,8 @@ function formatCents(cents: number): string { * through the freeze-then-write composites so REST and MCP can't diverge on * the #1445 FX freeze, and the settlement resource resolves the trip currency * and live rates like get_settlement_summary instead of silently defaulting - * to an unconverted EUR base. + * to an unconverted EUR base. freeze_budget_rates has no legacy counterpart; it + * is the REST freeze-rates route without the rate table a browser may lend. */ @McpController() export class BudgetMcp { @@ -177,7 +178,7 @@ export class BudgetMcp { @Tool({ name: 'create_budget_item', - description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split); for an uneven split, give `members` the amount each participant owes instead. Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid (resolve user IDs with list_trip_members) rather than guessing.', + description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split); for an uneven split, give `members` the amount each participant owes instead. Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid (resolve user IDs with list_trip_members) rather than guessing. A foreign currency is frozen at the exchange rate the server quotes today; when the server has none, the expense counts in no balance or total and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.', inputSchema: { tripId: z.number().int().positive(), name: z.string().min(1).max(200), @@ -249,7 +250,7 @@ export class BudgetMcp { @Tool({ name: 'update_budget_item', - description: 'Update an existing budget/expense item in a trip. You can also re-split it (equally via member_ids, unevenly via members), change the currency it was entered in, move it to another date, and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members.', + description: 'Update an existing budget/expense item in a trip. You can also re-split it (equally via member_ids, unevenly via members), change the currency it was entered in, move it to another date, and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members. A foreign currency is frozen at the exchange rate the server quotes today; when the server has none, the expense counts in no balance or total and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.', inputSchema: { tripId: z.number().int().positive(), itemId: z.number().int().positive(), @@ -390,7 +391,7 @@ export class BudgetMcp { @Tool({ name: 'get_settlement_summary', - description: "See each member's net balance, the suggested payments to settle shared expenses, and what the trip finally costs each member once every reimbursement is accounted for (`finalBudgets`, each figure with the rows it is made of under `sources`). Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.", + description: "See each member's net balance, the suggested payments to settle shared expenses, and what the trip finally costs each member once every reimbursement is accounted for (`finalBudgets`, each figure with the rows it is made of under `sources`). Amounts are in `summary.currency`: the `base` asked for when the server can quote it, otherwise the trip's base currency. An expense or payment in a foreign currency with no exchange rate is left out of every figure and listed under `summary.unconverted` (item_ids, settlement_ids, currencies); freeze_budget_rates pins today's rate on those. Call this before recording a settlement so you know who should pay whom and how much.", inputSchema: { tripId: z.number().int().positive(), base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'), @@ -425,7 +426,7 @@ export class BudgetMcp { @Tool({ name: 'create_settlement', - description: "Record a settle-up payment: from_user_id paid to_user_id the given amount to settle shared expenses. The amount is in the trip's base currency unless `currency` says otherwise. Use get_settlement_summary first to find who owes whom and how much.", + description: "Record a settle-up payment: from_user_id paid to_user_id the given amount to settle shared expenses. The amount is in the trip's base currency unless `currency` says otherwise. Use get_settlement_summary first to find who owes whom and how much. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.", inputSchema: { tripId: z.number().int().positive(), from_user_id: z.number().int().positive().describe('User ID of the member who paid'), @@ -455,7 +456,7 @@ export class BudgetMcp { @Tool({ name: 'update_settlement', - description: 'Update a recorded settle-up payment (who paid, who received, the amount and the currency it was made in). Every field is a full replace, so restate the ones that stay the same.', + description: 'Update a recorded settle-up payment (who paid, who received, the amount and the currency it was made in). Every field is a full replace, so restate the ones that stay the same. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.', inputSchema: { tripId: z.number().int().positive(), settlementId: z.number().int().positive(), @@ -505,6 +506,31 @@ export class BudgetMcp { return ok({ success: true }); } + // --- EXCHANGE RATES --- + + @Tool({ + name: 'freeze_budget_rates', + description: "Pin today's server exchange rate on every expense and settle-up payment of a trip that is in a foreign currency and has no rate frozen yet. That takes in the rows get_settlement_summary lists under `unconverted`, which count in no balance or total until then, and also foreign rows it still converts at today's live rate. Rows that already carry a frozen rate, rows in the trip currency and rows without a currency are never touched, and no rate is taken from the caller. Returns the rows it froze (`items`, `settlements`) and the currencies the server could not quote (`unresolved`), whose rows stay unfrozen.", + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + when: budgetAddonOn, + access: { group: 'budget', mode: 'write' }, + }) + async freezeBudgetRates({ tripId }: { tripId: number }, ctx: McpContext) { + if (this.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); + // The same service call as POST …/budget/freeze-rates, without a lent rate table: + // a rate from an MCP caller is never frozen, like everywhere else on this surface. + const healed = await this.budget.freezeMissingRates(tripId); + if (!healed) return errorResult('The trip currency changed. Reload and try again.'); + for (const item of healed.items) this.guards.safeBroadcast(tripId, 'budget:updated', { item }); + for (const settlement of healed.settlements) this.guards.safeBroadcast(tripId, 'budget:settlement-updated', { settlement }); + return ok(healed); + } + // --- RESOURCES --- @ResourceTemplate({ diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index f66689234..1563c08b9 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService, type TripAccess } from '../database/database.service'; -import type { BudgetParticipantFinal, TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; +import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { avatarUrl } from '../common/avatarUrl'; +import { byCodeUnit } from '../common/compare'; import type { User, BudgetItem, BudgetItemMember, BudgetItemPayer, BudgetItemReceipt } from '../../types'; import { ExchangeRatesService } from './exchange-rates.service'; @@ -89,41 +90,58 @@ function allocateDisplayCents(cents: number[], factor: number, total = Math.roun } /** - * An amount of a row, in the row's own currency, in the trip currency. The rate frozen - * when the row was entered wins (#1335), so a booked expense keeps the value it was - * booked at. A row written before the freeze existed (rate 1 or none) converts through - * today's `rates`, units of each currency per 1 of their base, and without them stays - * as it is. Pre-rework rows store currency = NULL, which means the trip's own. + * What `exchange_rate` holds on a row whose rate was never frozen. The column is + * `REAL NOT NULL DEFAULT 1`, so without a migration 1 is the only way to store "no + * rate". It is read in exactly two places, isFrozenRate and UNFROZEN_SQL; nothing + * else in this file compares a rate with 1. + */ +const NOT_FROZEN_RATE = 1; + +/** Whether a stored rate is one that was frozen, rather than the "not frozen" sentinel. */ +function isFrozenRate(rate: number | null | undefined): rate is number { + return rate != null && Number.isFinite(rate) && rate > 0 && rate !== NOT_FROZEN_RATE; +} + +/** The SQL twin of `!isFrozenRate(exchange_rate)`, for the row tables' own column. */ +const UNFROZEN_SQL = '(exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)'; + +/** + * How an amount of a row, in the row's own currency, becomes trip currency, or null when + * nothing can convert it. The rate frozen when the row was entered wins (#1335), so a + * booked expense keeps the value it was booked at. A row written before the freeze + * existed (no frozen rate) converts through today's `rates`, units of each currency per + * 1 of their base. Pre-rework rows store currency = NULL, which means the trip's own. + * + * A foreign row with neither is left out by every caller and reported, rather than read + * as if it were already in the trip currency: 8,920,000 VND counted as that many dollars + * swamps every balance on the trip. * * The settlement nets with it and every trip total adds up with it, so the two can never * read the same rows differently. */ -function toTripAmount( - amount: number, +function tripConverter( itemCurrency: string | null | undefined, itemRate: number | null | undefined, tripCurrency: string, rates: Record | null, -): number { +): ((amount: number) => number) | null { const cur = (itemCurrency || tripCurrency).toUpperCase(); - if (cur === tripCurrency) return amount; - if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate; - if (!rates) return amount; - const rCur = rates[cur]; - const rTrip = rates[tripCurrency]; - if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip; - return amount; + if (cur === tripCurrency) return amount => amount; + if (isFrozenRate(itemRate)) return amount => amount / itemRate; + const rCur = rates?.[cur]; + const rTrip = rates?.[tripCurrency]; + if (rCur && rCur > 0 && rTrip && rTrip > 0) return amount => (amount / rCur) * rTrip; + return null; } /** * Units of `to` per 1 `from`, from a set of rates quoted against any base (each rate is - * units of that currency per 1 base). 1 when either quote is missing, which leaves an - * amount as it is, the way every conversion here degrades without rates. + * units of that currency per 1 base). Null when either quote is missing. */ -function quoteRatio(rates: Record | null, to: string, from: string): number { +function quoteRatio(rates: Record | null, to: string, from: string): number | null { const rTo = rates?.[to]; const rFrom = rates?.[from]; - return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : 1; + return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null; } /** @@ -375,18 +393,25 @@ export class BudgetService { * "units of the item/display currency per 1 trip currency" — the settlement * converts with it via `amount / rate`. * - * Only freezes for a foreign currency with no explicit rate; degrades to live - * rates if the fetch fails. On update it (re)freezes only when the currency - * changes (checked against `budget_items`), so an unrelated edit never moves - * money. Callers must invoke this *before* the (synchronous) DB write — the raw - * create/update stay sync because better-sqlite3 transactions can't await. + * Only freezes for a foreign currency with no explicit rate. The server's own rate + * comes first; `fallback_fx`, rates the caller lent for this write, only fills a + * currency the server cannot quote (resolveRate). With neither, a new row is + * stored unfrozen and left out of every figure until a rate turns up. On update it + * (re)freezes only when the currency changes (checked against `budget_items`), so + * an unrelated edit never moves money, and a change with no rate at all resets the + * row to unfrozen instead of keeping the old currency's rate. `fallback_fx` is taken + * off `data` either way, so it never reaches the write. Callers must invoke this + * *before* the (synchronous) DB write: the raw create/update stay sync because + * better-sqlite3 transactions can't await. */ async freezeForeignRate( tripId: string | number, - data: { currency?: string | null; exchange_rate?: number }, + data: { currency?: string | null; exchange_rate?: number; fallback_fx?: BudgetFallbackFx }, existingItemId?: string | number, existingCurrency?: string | null, ): Promise { + const fallback = data.fallback_fx; + delete data.fallback_fx; if (data.exchange_rate != null) return; // an explicit rate from the caller wins const cur = (data.currency || '').toUpperCase(); if (!cur) return; // currency not being set in this request @@ -397,16 +422,98 @@ export class BudgetService { if (existingCurrency !== undefined) { prior = (existingCurrency || '').toUpperCase(); } else if (existingItemId != null) { - const existing = this.db.get<{ currency?: string }>('SELECT currency FROM budget_items WHERE id = ?', existingItemId); + const existing = this.db.get<{ currency?: string }>('SELECT currency FROM budget_items WHERE id = ? AND trip_id = ?', existingItemId, tripId); if (existing) prior = (existing.currency || '').toUpperCase(); } if (prior !== undefined && prior === cur) return; // currency unchanged - const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId); - const tripCur = (trip?.currency || 'EUR').toUpperCase(); + const tripCur = this.tripCurrency(tripId); if (cur === tripCur) return; // same as the trip currency → no conversion to freeze - const rates = await this.exchangeRates.getRates(tripCur); - const r = rates?.[cur]; - if (r && r > 0) data.exchange_rate = r; + const rate = this.resolveRate(tripCur, cur, await this.exchangeRates.getRates(tripCur), fallback); + if (rate !== null) data.exchange_rate = rate; + // The update SQL keeps the stored rate when none is sent, which left the old + // currency's rate on the row (a USD 1.08 on a VND bill) and counted it as frozen. + else if (prior !== undefined) data.exchange_rate = NOT_FROZEN_RATE; + } + + /** The trip's currency, upper case, EUR when it has none (the default the app reads). */ + private tripCurrency(tripId: string | number): string { + const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId); + return (trip?.currency || 'EUR').toUpperCase(); + } + + /** + * The rate to freeze `cur` at on a `tripCur` trip: the server's own quote, else the one + * the caller lent, but only while the lent table is quoted against the trip currency + * (a table against the old currency after a switch would freeze the wrong figure). + * A result that is not a usable frozen rate, exactly 1 among them, counts as none. + */ + private resolveRate( + tripCur: string, + cur: string, + serverRates: Record | null, + fallback: BudgetFallbackFx | undefined, + ): number | null { + const own = serverRates?.[cur]; + if (isFrozenRate(own)) return own; + const lent = fallback && fallback.base.toUpperCase() === tripCur ? fallback.rates[cur] : undefined; + return isFrozenRate(lent) ? lent : null; + } + + /** + * Freeze a rate onto every expense and transfer in a foreign currency that never + * froze one: the rows the settlement lists as `unconverted`, and also those it still + * converts at today's live rate. Today's server rate first, `fallback` (rates the + * caller holds) for what the server cannot quote. A row that is frozen, in the trip + * currency or without a currency is never touched, so a second call heals nothing. + * + * The write is a compare-and-set in one transaction: only rows that are still + * unfrozen are updated, and nothing is written when the trip currency changed while + * the rates were fetched (null, so the caller can say so). A healthy trip with + * nothing pending returns before any fetch. + */ + async freezeMissingRates( + tripId: string | number, + fallback?: BudgetFallbackFx, + ): Promise<{ items: BudgetItem[]; settlements: NonNullable>[]; unresolved: string[] } | null> { + const tripCur = this.tripCurrency(tripId); + const pendingWhere = `trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? AND ${UNFROZEN_SQL}`; + const pending = this.db.all<{ cur: string }>(` + SELECT UPPER(currency) AS cur FROM budget_items WHERE ${pendingWhere} + UNION + SELECT UPPER(currency) AS cur FROM budget_settlements WHERE ${pendingWhere} + ORDER BY cur + `, tripId, tripCur, tripId, tripCur).map(r => r.cur); + if (pending.length === 0) return { items: [], settlements: [], unresolved: [] }; + + const serverRates = await this.exchangeRates.getRates(tripCur); + const resolved: [string, number][] = []; + const unresolved: string[] = []; + for (const cur of pending) { + const rate = this.resolveRate(tripCur, cur, serverRates, fallback); + if (rate === null) unresolved.push(cur); + else resolved.push([cur, rate]); + } + if (resolved.length === 0) return { items: [], settlements: [], unresolved }; + + const written = this.db.transaction(() => { + if (this.tripCurrency(tripId) !== tripCur) return null; + const ids = { budget_items: [] as number[], budget_settlements: [] as number[] }; + for (const table of ['budget_items', 'budget_settlements'] as const) { + const where = `trip_id = ? AND UPPER(currency) = ? AND ${UNFROZEN_SQL}`; + for (const [cur, rate] of resolved) { + ids[table].push(...this.db.all<{ id: number }>(`SELECT id FROM ${table} WHERE ${where}`, tripId, cur).map(r => r.id)); + this.db.run(`UPDATE ${table} SET exchange_rate = ? WHERE ${where}`, rate, tripId, cur); + } + } + return ids; + }); + if (!written) return null; + + return { + items: written.budget_items.sort((a, b) => a - b).map(id => this.getBudgetItem(id, tripId)).filter(i => i !== null), + settlements: written.budget_settlements.sort((a, b) => a - b).map(id => this.getSettlement(id, tripId)).filter(s => s !== null), + unresolved, + }; } /** @@ -875,18 +982,19 @@ export class BudgetService { JOIN users u ON bm.user_id = u.id WHERE bi.trip_id = ? `, tripId); - const toTripCents = (amount: number, item: { currency: string | null; exchange_rate: number | null }) => - Math.round(toTripAmount(amount, item.currency, item.exchange_rate, tripCurrency, rates) * 100); - const people = new Map(); for (const item of items) { const own = members.filter(m => m.budget_item_id === item.id); if (own.length === 0) continue; + // A foreign row nothing can convert counts for nobody, as in the settlement. + const convert = tripConverter(item.currency, item.exchange_rate, tripCurrency, rates); + if (!convert) continue; + const toTripCents = (amount: number) => Math.round(convert(amount) * 100); // A member with an amount of their own owes exactly that; the rest split the // total evenly, as the query this replaces did. - const equal = this.splitEqualShares(toTripCents(item.total_price || 0, item), own, item.id); + const equal = this.splitEqualShares(toTripCents(item.total_price || 0), own, item.id); for (const m of own) { - const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount, item) : (equal[m.user_id] || 0); + const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount) : (equal[m.user_id] || 0); let p = people.get(m.user_id); if (!p) { p = { user_id: m.user_id, username: m.username, avatar: m.avatar, assigned: 0, paid: 0, items_count: 0 }; @@ -912,17 +1020,24 @@ export class BudgetService { * What the trip's expenses add up to in the trip currency, overall and per category * (#2525). Every row is converted once, the way the settlement converts it, and * rounded to a whole cent before anything is added. Summing total_price as stored adds - * dollars to euros and labels the result with the trip currency. + * dollars to euros and labels the result with the trip currency. A foreign row nothing + * can convert is left out and its id listed under `unconverted`. */ - tripTotals(tripId: string | number, tripCurrency: string, rates: Record | null = null): { total: number; byCategory: Record } { + tripTotals(tripId: string | number, tripCurrency: string, rates: Record | null = null): { total: number; byCategory: Record; unconverted: number[] } { const trip = (tripCurrency || 'EUR').toUpperCase(); - const rows = this.db.all<{ category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>( - 'SELECT category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId, + const rows = this.db.all<{ id: number; category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>( + 'SELECT id, category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId, ); let total = 0; const byCategory: Record = {}; + const unconverted: number[] = []; for (const r of rows) { - const cents = Math.round(toTripAmount(r.total_price || 0, r.currency, r.exchange_rate, trip, rates) * 100); + const convert = tripConverter(r.currency, r.exchange_rate, trip, rates); + if (!convert) { + unconverted.push(r.id); + continue; + } + const cents = Math.round(convert(r.total_price || 0) * 100); total += cents; const cat = r.category || ''; byCategory[cat] = (byCategory[cat] || 0) + cents; @@ -930,6 +1045,7 @@ export class BudgetService { return { total: total / 100, byCategory: Object.fromEntries(Object.entries(byCategory).map(([cat, cents]) => [cat, cents / 100])), + unconverted, }; } @@ -943,7 +1059,7 @@ export class BudgetService { const unbooked = this.db.get(` SELECT 1 FROM budget_items WHERE trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? - AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1) + AND ${UNFROZEN_SQL} LIMIT 1 `, tripId, trip); return unbooked ? this.exchangeRates.getRates(trip) : null; @@ -998,13 +1114,19 @@ export class BudgetService { * behind each figure (`sources`) travel with it, in the same display cents: an * expense list converted again on the client with today's rate would not add up * to a figure that was booked at the rate frozen on entry. + * + * A row nothing can convert (a foreign currency with no frozen rate and no live + * one) is left out whole and listed under `unconverted`, so Σ balances stays 0. + * `currency` says what the amounts are in: the display currency when there is a + * quote for it (the rates, else `baseRate`, the caller's own units of display + * currency per 1 trip currency), otherwise the trip currency. */ calculateSettlement( tripId: string | number, - opts: { base?: string; rates?: Record | null; tripCurrency?: string } = {}, + opts: { base?: string; rates?: Record | null; tripCurrency?: string; baseRate?: number } = {}, ) { - const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase(); - const tripCurrency = (opts.tripCurrency || base).toUpperCase(); + const requested = (opts.base || opts.tripCurrency || 'EUR').toUpperCase(); + const tripCurrency = (opts.tripCurrency || requested).toUpperCase(); const rates = opts.rates ?? null; // Net the whole settlement in the trip's canonical currency and convert the final // totals to the display currency once, instead of netting in the (moving) display @@ -1017,8 +1139,8 @@ export class BudgetService { // Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate // it was booked at, so a later live-rate drift doesn't re-open it with a residual. // Legacy rows without a frozen rate convert via base with live rates. - const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => - toTripAmount(amount, itemCurrency, itemRate, tripCurrency, rates); + const converterFor = (itemCurrency: string | null | undefined, itemRate?: number | null) => + tripConverter(itemCurrency, itemRate, tripCurrency, rates); // trip-currency → display currency, applied once to the final netted totals. // Held as a plain factor so it is exactly linear: the balances are converted as // one set (allocateDisplayCents) rather than one at a time, which is what keeps @@ -1030,29 +1152,32 @@ export class BudgetService { // the display currency converts back to exactly what was typed (#2525). Taken the // other way round, from the display currency's quote, the two quotes are not exact // inverses and 12,345.67 USD came back as 12,346.06. - const displayFactor = base === tripCurrency ? 1 : quoteRatio(rates, base, tripCurrency); + // + // Without a quote, the caller's own figure for the pair (`baseRate`) stands in for + // the missing display quote, and for nothing else: a row with a currency of its own + // is never converted with it. Like a live quote, it also reads a legacy transfer + // without a currency, which is taken to be in the display currency. With neither, + // the answer stays in the trip currency and says so, rather than printing trip cents + // as the display currency. + const baseRate = opts.baseRate != null && Number.isFinite(opts.baseRate) && opts.baseRate > 0 ? opts.baseRate : null; + const quoted = requested === tripCurrency ? 1 : (quoteRatio(rates, requested, tripCurrency) ?? baseRate); + const currency = quoted === null ? tripCurrency : requested; + const displayFactor = quoted ?? 1; // A recorded settle-up amount is entered in whatever display currency the payer // was viewing. New rows capture that currency and the rate frozen at settle time // (#1445), so a settled position stays balanced when live rates drift — mirroring - // toTrip for expenses. Legacy rows (currency = NULL) have no frozen rate, so fall - // back to the old behaviour: assume they were entered in the current display base - // and convert with live rates. - const settleToTrip = (amount: number, sCurrency?: string | null, sRate?: number | null): number => { - if (sCurrency) { - const cur = sCurrency.toUpperCase(); - if (cur === tripCurrency) return amount; - if (sRate != null && sRate > 0 && sRate !== 1) return amount / sRate; - // Frozen currency but no usable rate (fetch failed at settle time): live fallback. - if (rates) { - const rCur = rates[cur]; - const rTrip = rates[tripCurrency]; - if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip; - } - return amount; - } - // The inverse of the display factor, so such a transfer reads back as entered. - return amount / displayFactor; - }; + // the expenses; a frozen currency without a usable rate falls back to live rates, + // and without those the transfer is left out like an expense. Legacy rows + // (currency = NULL) have no frozen rate, so fall back to the old behaviour: assume + // they were entered in the current display base and convert with live rates. + const settleConverterFor = (sCurrency?: string | null, sRate?: number | null) => + sCurrency + ? tripConverter(sCurrency, sRate, tripCurrency, rates) + // The inverse of the display factor, so such a transfer reads back as entered. + : (amount: number) => amount / displayFactor; + const unconvertedItems: number[] = []; + const unconvertedSettlements: number[] = []; + const unconvertedCurrencies = new Set(); const items = this.db.all('SELECT * FROM budget_items WHERE trip_id = ?', tripId); const allMembers = this.db.all(` @@ -1073,8 +1198,6 @@ export class BudgetService { // and rounded to a cent once, at the boundary — from there on the ledger is // integer arithmetic, so Σ(balances) is exactly 0 and no sub-cent residual can // build up behind the two-decimal figures the user sees (#1382). - const toTripCents = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => - Math.round(toTrip(amount, itemCurrency, itemRate) * 100); const balances: Record = {}; const ensure = (id: number, src: { username?: string; avatar?: string | null }) => { if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), cents: 0 }; @@ -1099,6 +1222,15 @@ export class BudgetService { const finalUnit = (exactTripCents: number) => (displayFactor === 1 ? Math.round(exactTripCents) : exactTripCents); for (const item of items) { + // Before anything else, planning-only and unpaid rows included: a row no rate can + // convert is reported whole and moves nothing, credits and shares alike. + const convert = converterFor(item.currency, item.exchange_rate); + if (!convert) { + unconvertedItems.push(item.id); + unconvertedCurrencies.add((item.currency || '').toUpperCase()); + continue; + } + const toTripCents = (amount: number): number => Math.round(convert(amount) * 100); const members = allMembers.filter(m => m.budget_item_id === item.id); const payers = allPayers.filter(p => p.budget_item_id === item.id); if (members.length === 0) continue; // planning-only entry → doesn't affect balances @@ -1126,7 +1258,7 @@ export class BudgetService { // refund (#2176) — is debited by the same arithmetic: their credit is negative. let creditCents = 0; for (const p of payers) { - const exact = toTrip(p.amount, item.currency, item.exchange_rate) * 100; + const exact = convert(p.amount) * 100; const paid = Math.round(exact); ensure(p.user_id, p).cents += paid; frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact); @@ -1148,7 +1280,7 @@ export class BudgetService { const equalShares = !hasCustomSplit ? this.splitEqualShares(creditCents, members, item.id) : {}; for (const m of members) { const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined - ? toTripCents(m.amount, item.currency, item.exchange_rate) + ? toTripCents(m.amount) : (equalShares[m.user_id] || 0); ensure(m.user_id, m).cents -= memberShare; } @@ -1165,9 +1297,15 @@ export class BudgetService { return balances[id]; }; for (const s of settlements) { + const convert = settleConverterFor(s.currency, s.exchange_rate); + if (!convert) { + unconvertedSettlements.push(s.id); + unconvertedCurrencies.add((s.currency || '').toUpperCase()); + continue; + } // Rounded to a trip cent per transfer, so recording one in a display currency // can't leave a sliver of a cent behind to accumulate over a trip's lifetime. - const exact = settleToTrip(s.amount, s.currency, s.exchange_rate) * 100; + const exact = convert(s.amount) * 100; const inTrip = Math.round(exact); ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip; ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip; @@ -1259,6 +1397,12 @@ export class BudgetService { }, }; }) satisfies BudgetParticipantFinal[], + currency, + unconverted: { + item_ids: unconvertedItems, + settlement_ids: unconvertedSettlements, + currencies: [...unconvertedCurrencies].sort(byCodeUnit), + } satisfies BudgetUnconverted, }; } @@ -1373,22 +1517,25 @@ export class BudgetService { * trip currency's own quote, the one every entry rate was frozen from, so a same-day * amount in the display currency round-trips to the cent and paying what settle-up * offers closes the balance (#2525). The display currency's quote only stands in when - * the trip's cannot be fetched. + * the trip's cannot be fetched, and `baseRate` (the caller's own units of display + * currency per 1 trip currency) only when neither can. It only stands in for that + * missing display quote, which also reads a legacy transfer without a currency (taken + * to be in the display currency, as with a live quote). */ - async settlement(tripId: string | number, base: string | undefined, tripCurrency: string) { + async settlement(tripId: string | number, base: string | undefined, tripCurrency: string, baseRate?: number) { const trip = (tripCurrency || 'EUR').toUpperCase(); const effectiveBase = (base || trip).toUpperCase(); const rates = (await this.exchangeRates.getRates(trip)) ?? (effectiveBase === trip ? null : await this.exchangeRates.getRates(effectiveBase)); - return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency: trip }); + return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency: trip, baseRate }); } - async create(tripId: string, data: Parameters[1]) { + async create(tripId: string, data: Parameters[1] & { fallback_fx?: BudgetFallbackFx }) { await this.freezeForeignRate(tripId, data); return this.createBudgetItem(tripId, data); } - async update(id: string | number, tripId: string | number, data: Parameters[2]) { + async update(id: string | number, tripId: string | number, data: Parameters[2] & { fallback_fx?: BudgetFallbackFx }) { await this.freezeForeignRate(tripId, data, id); return this.updateBudgetItem(id, tripId, data); } @@ -1414,7 +1561,7 @@ export class BudgetService { return roster.has(data.from_user_id) && roster.has(data.to_user_id); } - async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }, userId: number) { + async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; fallback_fx?: BudgetFallbackFx }, userId: number) { if (!this.settlementPartiesOnTrip(tripId, data)) return null; // Freeze the FX rate for the display currency the amount was entered in so the // transfer keeps cancelling its expense when live rates drift (#1445). @@ -1422,7 +1569,7 @@ export class BudgetService { return this.insertSettlement(tripId, data, userId); } - async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null }) { + async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; fallback_fx?: BudgetFallbackFx }) { // Pass the settlement's stored currency so an edit that doesn't change it keeps // the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a // settled position on an unrelated edit. diff --git a/server/src/nest/budget/exchange-rates.service.ts b/server/src/nest/budget/exchange-rates.service.ts index d13510029..d04b253ae 100644 --- a/server/src/nest/budget/exchange-rates.service.ts +++ b/server/src/nest/budget/exchange-rates.service.ts @@ -8,14 +8,16 @@ import { Injectable } from '@nestjs/common'; * settlement request never hammers the upstream. Rates are "units of X per 1 * base", so an amount in currency C converts to base as `amount / rates[C]`. * - * Everything degrades gracefully: if the fetch fails (offline, upstream down), - * callers get `null`/identity conversion and amounts are treated as already in - * the base currency rather than throwing. + * Everything degrades without throwing: if the fetch fails (offline, upstream + * down), callers get `null`. Budget rows that already froze a rate still convert; + * a foreign row that did not is left out of the figures and reported rather than + * read as if it were already in the base currency. A failed fetch is not + * remembered: the next request for that base asks the upstream again. * * Moved from the legacy `services/exchangeRateService.ts` (the budget-domain * fold): identical cache/coalescing behavior, `||` falsy-coercion defaults and * null degradation. The cache and inflight maps are deliberately MODULE-scoped, - * not instance fields — the DI singleton and the out-of-container instances + * not instance fields: the DI singleton and the out-of-container instances * (the trips/airtrail/auth bridges each construct their own) wrap the same * upstream feed, so they must share one cache (same reasoning as the * permissions-cache.ts cache). diff --git a/server/src/nest/trip-read-model/trip-read-model.service.ts b/server/src/nest/trip-read-model/trip-read-model.service.ts index dab7b9650..dc37335ee 100644 --- a/server/src/nest/trip-read-model/trip-read-model.service.ts +++ b/server/src/nest/trip-read-model/trip-read-model.service.ts @@ -73,6 +73,8 @@ export class TripReadModelService { total: totals.total, by_category: totals.byCategory, currency: trip.currency, + // Rows in a foreign currency with no rate to convert them: in no total above. + unconverted_item_ids: totals.unconverted, }; // Thread the viewer so another member's private/personal packing items (#858) diff --git a/server/src/nest/trips/trip-prompts.mcp.ts b/server/src/nest/trips/trip-prompts.mcp.ts index 991c94d16..4e1f7a291 100644 --- a/server/src/nest/trips/trip-prompts.mcp.ts +++ b/server/src/nest/trips/trip-prompts.mcp.ts @@ -63,9 +63,13 @@ export class TripPromptsMcp { .join('\n'); const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length); const perPerson = (total / memberCount).toFixed(2); + // Rows no rate could convert are in none of the figures above; say so rather than + // let the total pass for the whole trip. + const uncounted = budget?.unconverted_item_ids?.length || 0; + const uncountedLine = uncounted > 0 ? `\n\n${uncounted} expense(s) not counted yet: no exchange rate.` : ''; return { description: `Budget overview for "${trip?.title || tripId}"`, - messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }], + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}${uncountedLine}` } }], }; } diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 619e50f49..8f8bd870a 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -294,4 +294,160 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Settlement not found' }); }); + + // The VND/AUD report. The rates override above answers null for every base, which is + // exactly that server: it never reached the rates provider, so the only rates there + // are the ones the browser lends. + describe('rows no rate can convert', () => { + /** A fresh AUD trip the owner shares with user 2, so these cases never meet the ledger above. */ + function audTrip(): number { + const id = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'AUD Trip', 'AUD')").run().lastInsertRowid); + db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, 2)').run(id); + canAccessTrip.mockReturnValue({ id, user_id: 1, currency: 'AUD' }); + return id; + } + const balanceOf = (body: { balances: { user_id: number; balance: number }[] }, uid: number) => + body.balances.find(b => b.user_id === uid)!.balance; + + it('VND/AUD regression: unfrozen VND bill is listed as unconverted, freeze-rates heals it from the browser quote, settlement nets 489.00, second freeze heals nothing', async () => { + const trip = audTrip(); + const bill = await request(server) + .post(`/api/trips/${trip}/budget`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'Pho', currency: 'VND', payers: [{ user_id: 1, amount: 8920000 }], member_ids: [1, 2] }); + expect(bill.status).toBe(201); + expect(bill.body.item.exchange_rate).toBe(1); + + // Left out whole before anything is written: no balance in VND's order of magnitude. + const before = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1)); + expect(before.status).toBe(200); + expect(before.body.currency).toBe('AUD'); + expect(before.body.unconverted).toEqual({ item_ids: [bill.body.item.id], settlement_ids: [], currencies: ['VND'] }); + expect(before.body.balances).toEqual([]); + + const fallback_fx = { base: 'AUD', rates: { VND: 18241.3, EUR: 0.61 } }; + const healed = await request(server) + .post(`/api/trips/${trip}/budget/freeze-rates`) + .set('Cookie', sessionCookie(1)) + .send({ fallback_fx }); + expect(healed.status).toBe(200); + expect(healed.body.items.map((i: { id: number; exchange_rate: number }) => [i.id, i.exchange_rate])).toEqual([[bill.body.item.id, 18241.3]]); + expect(healed.body).toMatchObject({ settlements: [], unresolved: [] }); + + const after = await request(server).get(`/api/trips/${trip}/budget/settlement`).set('Cookie', sessionCookie(1)); + expect(after.body.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] }); + // 8,920,000 VND at 18,241.3 per dollar is 489.00 AUD, half of it user 2's. + expect(after.body.finalBudgets.find((f: { user_id: number }) => f.user_id === 1).expenses).toBe(489); + expect(balanceOf(after.body, 1)).toBe(244.5); + expect(balanceOf(after.body, 2)).toBe(-244.5); + + const again = await request(server) + .post(`/api/trips/${trip}/budget/freeze-rates`) + .set('Cookie', sessionCookie(1)) + .send({ fallback_fx: { base: 'AUD', rates: { VND: 25000 } } }); + expect(again.status).toBe(200); + expect(again.body).toEqual({ items: [], settlements: [], unresolved: [] }); + expect((db.prepare('SELECT exchange_rate FROM budget_items WHERE id = ?').get(bill.body.item.id) as { exchange_rate: number }).exchange_rate).toBe(18241.3); + }); + + it('POST with fallback_fx freezes at entry', async () => { + const trip = audTrip(); + const res = await request(server) + .post(`/api/trips/${trip}/budget`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'Pho', currency: 'VND', total_price: 100000, fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }); + expect(res.status).toBe(201); + expect(res.body.item.exchange_rate).toBe(18241.3); + expect(res.body.item).not.toHaveProperty('fallback_fx'); + }); + + it('POST /settlements in EUR with fallback_fx freezes the transfer', async () => { + const trip = audTrip(); + const res = await request(server) + .post(`/api/trips/${trip}/budget/settlements`) + .set('Cookie', sessionCookie(1)) + .send({ from_user_id: 2, to_user_id: 1, amount: 30.5, currency: 'EUR', fallback_fx: { base: 'AUD', rates: { EUR: 0.61 } } }); + expect(res.status).toBe(201); + expect(res.body.settlement).toMatchObject({ currency: 'EUR', exchange_rate: 0.61 }); + + // Read back in euros at the same browser quote, the transfer is what was typed. + const s = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`).set('Cookie', sessionCookie(1)); + expect(s.body.currency).toBe('EUR'); + expect(s.body.unconverted.settlement_ids).toEqual([]); + expect(balanceOf(s.body, 2)).toBe(30.5); + expect(balanceOf(s.body, 1)).toBe(-30.5); + }); + + it('GET /settlement?base=EUR answers in AUD without base_rate and in EUR with it', async () => { + const trip = audTrip(); + const dinner = await request(server) + .post(`/api/trips/${trip}/budget`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'Dinner', payers: [{ user_id: 1, amount: 100 }], member_ids: [1, 2] }); + expect(dinner.status).toBe(201); + + const plain = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR`).set('Cookie', sessionCookie(1)); + expect(plain.status).toBe(200); + // Trip dollars, labelled as such rather than printed as euros. + expect(plain.body.currency).toBe('AUD'); + expect(balanceOf(plain.body, 2)).toBe(-50); + + const quoted = await request(server).get(`/api/trips/${trip}/budget/settlement?base=EUR&base_rate=0.61`).set('Cookie', sessionCookie(1)); + expect(quoted.status).toBe(200); + expect(quoted.body.currency).toBe('EUR'); + expect(balanceOf(quoted.body, 2)).toBe(-30.5); + }); + + it('PUT currency change to VND without a rate stores 1 instead of the USD rate', async () => { + const trip = audTrip(); + const created = await request(server) + .post(`/api/trips/${trip}/budget`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'Taxi', currency: 'USD', exchange_rate: 0.65, total_price: 20 }); + expect(created.body.item.exchange_rate).toBe(0.65); + + const res = await request(server) + .put(`/api/trips/${trip}/budget/${created.body.item.id}`) + .set('Cookie', sessionCookie(1)) + .send({ currency: 'VND' }); + expect(res.status).toBe(200); + expect(res.body.item).toMatchObject({ currency: 'VND', exchange_rate: 1 }); + expect(db.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(created.body.item.id)) + .toEqual({ currency: 'VND', exchange_rate: 1 }); + }); + + it('freeze-rates 403 without budget_edit, 400 on malformed fallback_fx, other base heals nothing (unresolved VND)', async () => { + const trip = audTrip(); + const bill = await request(server) + .post(`/api/trips/${trip}/budget`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'Pho', currency: 'VND', total_price: 100000 }); + expect(bill.status).toBe(201); + const freeze = (body: object) => request(server).post(`/api/trips/${trip}/budget/freeze-rates`).set('Cookie', sessionCookie(1)).send(body); + + checkPermission.mockReturnValue(false); + const denied = await freeze({ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }); + expect(denied.status).toBe(403); + expect(denied.body).toEqual({ error: 'No permission' }); + checkPermission.mockReturnValue(true); + + for (const fallback_fx of [{ base: 'aud', rates: { VND: 18241.3 } }, { base: 'AUD', rates: { VND: 0 } }, { base: 'AUD', rates: {} }]) { + const malformed = await freeze({ fallback_fx }); + expect(malformed.status).toBe(400); + expect(malformed.body.error).toContain('fallback_fx'); + } + + // A table against euros would freeze the bill against the wrong currency. + const otherBase = await freeze({ fallback_fx: { base: 'EUR', rates: { VND: 27000 } } }); + expect(otherBase.status).toBe(200); + expect(otherBase.body).toEqual({ items: [], settlements: [], unresolved: ['VND'] }); + expect((db.prepare('SELECT exchange_rate FROM budget_items WHERE id = ?').get(bill.body.item.id) as { exchange_rate: number }).exchange_rate).toBe(1); + }); + + it('400 on base_rate=abc', async () => { + const res = await request(server).get(`/api/trips/${tripId}/budget/settlement?base=EUR&base_rate=abc`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(400); + expect(res.body.error).toContain('base_rate'); + }); + }); }); diff --git a/server/tests/integration/budget.test.ts b/server/tests/integration/budget.test.ts index a7b4c19ea..d13742c10 100644 --- a/server/tests/integration/budget.test.ts +++ b/server/tests/integration/budget.test.ts @@ -394,6 +394,9 @@ describe('Budget summary and settlement', () => { expect(res.status).toBe(200); expect(res.body.balances).toEqual([]); expect(res.body.flows).toEqual([]); + // Additive since the VND/AUD report: what the amounts are in, and what was left out. + expect(res.body.currency).toBe('EUR'); + expect(res.body.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] }); }); }); diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index c825852eb..b9c51e96e 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -362,6 +362,7 @@ describe('Settlement tools', () => { { id: user.id } as User, { id: trip.id, user_id: user.id } as TripAccess, String(trip.id), + {}, ); await withHarness(user.id, async (h) => { @@ -398,7 +399,7 @@ describe('Settlement tools', () => { { id: user.id } as User, { id: trip.id, user_id: user.id, currency: 'EUR' } as TripAccess, String(trip.id), - 'USD', + { base: 'USD' }, ); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id, base: 'USD' } }); diff --git a/server/tests/unit/mcp/tools-budget.test.ts b/server/tests/unit/mcp/tools-budget.test.ts index a5084b376..8f052dc66 100644 --- a/server/tests/unit/mcp/tools-budget.test.ts +++ b/server/tests/unit/mcp/tools-budget.test.ts @@ -40,6 +40,8 @@ import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createBudgetItem, createPlace, addTripMember } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; +import { BudgetService } from '../../../src/nest/budget/budget.service'; +import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache'; beforeAll(() => { createTables(testDb); @@ -953,3 +955,135 @@ describe('Budget tools: a custom split cannot be certified against a total the r }); }); }); + +// --------------------------------------------------------------------------- +// Foreign rows with no exchange rate (the VND/AUD report) +// +// The rate cache is module-scoped and outlives a case, so each case below uses a +// trip currency no other case in this file asks the rates for: AUD is quoted by +// its stub, NZD is left to the failing fetch every case starts with. +// --------------------------------------------------------------------------- + +/** A trip in `currency` with one unfrozen VND bill and one unfrozen VND transfer. */ +function tripWithVndRows(currency: string) { + const { user, other, trip } = tripWithTwo(); + testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run(currency, trip.id); + testDb.prepare('DELETE FROM budget_settlements WHERE trip_id = ?').run(trip.id); + const item = createBudgetItem(testDb, trip.id, { name: 'Pho', total_price: 8920000 }); + testDb.prepare("UPDATE budget_items SET currency = 'VND', exchange_rate = 1 WHERE id = ?").run(item.id); + testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)') + .run(item.id, user.id, item.id, other.id); + testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)') + .run(item.id, user.id, 8920000); + const settlementId = Number(testDb.prepare( + "INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate) VALUES (?, ?, ?, ?, 'VND', 1)", + ).run(trip.id, other.id, user.id, 100000).lastInsertRowid); + return { user, other, trip, item, settlementId }; +} + +describe('Tool: freeze_budget_rates', () => { + it('refuses a demo user, a stranger and a member without budget_edit, writing nothing', async () => { + const { other, trip, item, settlementId } = tripWithVndRows('AUD'); + const { user: stranger } = createUser(testDb); + const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates'); + try { + await withHarness(stranger.id, async (h) => { + const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + + // budget_edit lowered to the owner, the way the admin permission panel does it. + testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_budget_edit', 'trip_owner'); + invalidatePermissionsCache(); + await withHarness(other.id, async (h) => { + const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run(); + invalidatePermissionsCache(); + + process.env.DEMO_MODE = 'true'; + const { user: demo } = createUser(testDb, { email: 'demo@nomad.app' }); + addTripMember(testDb, trip.id, demo.id); + await withHarness(demo.id, async (h) => { + const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + + expect(spy).not.toHaveBeenCalled(); + expect(itemRow(item.id).exchange_rate).toBe(1); + expect(settlementRow(settlementId).exchange_rate).toBe(1); + } finally { + spy.mockRestore(); + testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run(); + invalidatePermissionsCache(); + } + }); + + it('calls freezeMissingRates without a quote of its own, freezes the server rate and broadcasts', async () => { + const { user, trip, item, settlementId } = tripWithVndRows('AUD'); + stubRates({ VND: 18241.3 }); + const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates'); + try { + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } }); + expect(result.isError).toBeFalsy(); + // No rate table travels with the MCP call: only the server's own rate is frozen. + expect(spy.mock.calls).toEqual([[trip.id]]); + const data = parseToolResult(result) as { items: { id: number }[]; settlements: { id: number }[]; unresolved: string[] }; + expect(data.items.map(i => i.id)).toEqual([item.id]); + expect(data.settlements.map(s => s.id)).toEqual([settlementId]); + expect(data.unresolved).toEqual([]); + expect(itemRow(item.id).exchange_rate).toBe(18241.3); + expect(settlementRow(settlementId).exchange_rate).toBe(18241.3); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:updated', expect.objectContaining({ item: expect.objectContaining({ id: item.id }) })); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-updated', expect.objectContaining({ settlement: expect.objectContaining({ id: settlementId }) })); + + // Everything is frozen now, so a second call finds nothing to heal. + const again = parseToolResult(await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } })); + expect(again).toEqual({ items: [], settlements: [], unresolved: [] }); + }); + } finally { + spy.mockRestore(); + } + }); + + it('answers with an error and broadcasts nothing when the trip currency changed during the fetch', async () => { + const { user, trip, item, settlementId } = tripWithVndRows('AUD'); + // The service's own answer for that race (BUDGET-SVC-DB-066): null, nothing written. + const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates').mockResolvedValueOnce(null); + try { + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + expect(errorText(result)).toContain('The trip currency changed'); + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(broadcastMock).not.toHaveBeenCalled(); + expect(itemRow(item.id).exchange_rate).toBe(1); + expect(settlementRow(settlementId).exchange_rate).toBe(1); + } finally { + spy.mockRestore(); + } + }); +}); + +describe('Tool: get_settlement_summary (unconverted rows)', () => { + it('returns the currency it answers in and the rows no rate could convert', async () => { + const { user, trip, item, settlementId } = tripWithVndRows('NZD'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id } }); + const { summary } = parseToolResult(result) as { + summary: { + currency: string; balances: unknown[]; flows: unknown[]; + unconverted: { item_ids: number[]; settlement_ids: number[]; currencies: string[] }; + }; + }; + expect(summary.currency).toBe('NZD'); + expect(summary.unconverted).toEqual({ item_ids: [item.id], settlement_ids: [settlementId], currencies: ['VND'] }); + // Left out whole: no balance in VND's order of magnitude, and nothing to settle. + expect(summary.balances).toEqual([]); + expect(summary.flows).toEqual([]); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index ea953102a..b662214bc 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -172,6 +172,7 @@ beforeEach(() => { total: totals.total, by_category: totals.byCategory, currency: trip.currency, + unconverted_item_ids: totals.unconverted, }, packing: packingRows, // array shape; packing prompt tolerates it reservations: [], @@ -535,6 +536,32 @@ describe('Prompt: budget-overview', () => { expect(text).not.toContain('801.76'); }); + it('says how many expenses no exchange rate could put into the total', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Dong Trip' }); + // Stored with the "not frozen" rate 1, and no rates to convert it with. + promptBudget.createBudgetItem(trip.id, { + name: 'Pho', category: 'Food', currency: 'VND', total_price: 8920000, members: [{ user_id: user.id }], + }); + createBudgetItem(testDb, trip.id, { name: 'Dinner', category: 'Food', total_price: 100 }); + + const client = await buildServer(user.id); + const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id }); + expect(text).toContain('Total: 100 EUR'); + expect(text).toContain('1 expense(s) not counted yet: no exchange rate.'); + expect(text).not.toContain('8920000'); + }); + + it('adds no such line when every expense converts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Euro Trip' }); + createBudgetItem(testDb, trip.id, { name: 'Dinner', category: 'Food', total_price: 100 }); + + const client = await buildServer(user.id); + const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id }); + expect(text).not.toContain('not counted yet'); + }); + it('renders "No expenses recorded." when budget array is empty', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Empty Budget' }); diff --git a/server/tests/unit/nest/budget.controller.test.ts b/server/tests/unit/nest/budget.controller.test.ts index 5df4a25dc..75871bc4e 100644 --- a/server/tests/unit/nest/budget.controller.test.ts +++ b/server/tests/unit/nest/budget.controller.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; +import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; import { BudgetController } from '../../../src/nest/budget/budget.controller'; +import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard'; import type { BudgetService } from '../../../src/nest/budget/budget.service'; import type { User } from '../../../src/types'; @@ -57,8 +59,8 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou } as Partial); expect(await new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] }); // A trip with no currency set falls back to EUR rather than passing undefined on. - expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] }); - expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR'); + expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5', {})).toEqual({ transfers: [] }); + expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR', undefined); }); it('GET /settlement forwards the base query and the trip currency', () => { @@ -67,8 +69,16 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }), settlement, } as Partial); - new BudgetController(svc).settlement(user, tripRow, '5', 'GBP'); - expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD'); + new BudgetController(svc).settlement(user, tripRow, '5', { base: 'GBP' }); + expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD', undefined); + }); + + it('GET /settlement forwards base and base_rate', () => { + const settlement = vi.fn().mockReturnValue({ transfers: [] }); + const svc = makeService({ settlement } as Partial); + // The Zod pipe has coerced base_rate to a number by the time the handler runs. + new BudgetController(svc).settlement(user, tripRow, '5', { base: 'EUR', base_rate: 0.61 }); + expect(settlement).toHaveBeenCalledWith('5', 'EUR', 'USD', 0.61); }); describe('settlements ledger', () => { @@ -92,6 +102,18 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock'); }); + it('POST and PUT /settlements forward fallback_fx', async () => { + const fallback_fx = { base: 'AUD', rates: { EUR: 0.61 } }; + const createSettlement = vi.fn().mockResolvedValue({ id: 3 }); + const updateSettlement = vi.fn().mockResolvedValue({ id: 3 }); + const svc = makeService({ createSettlement, updateSettlement } as Partial); + const body = { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'EUR', fallback_fx }; + await new BudgetController(svc).createSettlement(user, '5', body); + expect(createSettlement).toHaveBeenCalledWith('5', expect.objectContaining({ currency: 'EUR', fallback_fx }), user.id); + await new BudgetController(svc).updateSettlement(user, '5', '3', body); + expect(updateSettlement).toHaveBeenCalledWith('3', '5', expect.objectContaining({ currency: 'EUR', fallback_fx })); + }); + it('DELETE /settlements/:id 404 when missing', () => { const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial); expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({ @@ -125,6 +147,43 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou }); }); + describe('POST /freeze-rates', () => { + const fallback_fx = { base: 'AUD', rates: { VND: 18241.3 } }; + + it('forwards fallback_fx and broadcasts per row', async () => { + const healed = { + items: [{ id: 7, exchange_rate: 18241.3 }, { id: 8, exchange_rate: 18241.3 }], + settlements: [{ id: 3, exchange_rate: 18241.3 }], + unresolved: ['XAF'], + }; + const freezeMissingRates = vi.fn().mockResolvedValue(healed); + const broadcast = vi.fn(); + const svc = makeService({ freezeMissingRates, broadcast } as Partial); + expect(await new BudgetController(svc).freezeRates(user, '5', { fallback_fx }, 'sock')).toEqual(healed); + expect(freezeMissingRates).toHaveBeenCalledWith('5', fallback_fx); + expect(broadcast.mock.calls).toEqual([ + ['5', 'budget:updated', { item: healed.items[0] }, 'sock'], + ['5', 'budget:updated', { item: healed.items[1] }, 'sock'], + ['5', 'budget:settlement-updated', { settlement: healed.settlements[0] }, 'sock'], + ]); + }); + + it('answers 409 and broadcasts nothing when the trip currency changed meanwhile', async () => { + const broadcast = vi.fn(); + const svc = makeService({ freezeMissingRates: vi.fn().mockResolvedValue(null), broadcast } as Partial); + expect(await thrownAsync(() => new BudgetController(svc).freezeRates(user, '5', {}))).toEqual({ + status: 409, body: { error: 'The trip currency changed. Reload and try again.' }, + }); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('requires budget_edit like freeze_budget_rates, and answers 200', () => { + const handler = BudgetController.prototype.freezeRates; + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBe('budget_edit'); + expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200); + }); + }); + describe('POST /', () => { // The legacy 'Name is required' 400 is now produced by the global diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index 9fe0398e2..46d6bdfcb 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -955,6 +955,165 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { }); }); +// ── Rows no rate can convert (the VND/AUD report) ─────────────────────────── +// A foreign row with no frozen rate and no live one used to be read 1:1, as if it +// were already in the trip currency: 8,920,000 VND became 8,920,000 AUD of debt. + +describe('calculateSettlement: unconverted rows', () => { + const four = () => [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), makeMember(1, 4, 'dave')]; + const vndBill = (exchange_rate: number) => ({ ...makeItem(1, 8920000), currency: 'VND', exchange_rate } as BudgetItem); + const none = { item_ids: [], settlement_ids: [], currencies: [] }; + + it('VND/AUD regression: an unfrozen 8,920,000 VND bill on an AUD trip with no rates is left out whole, Σ balances 0, no VND-scale balance', () => { + setupDb( + [vndBill(1), makeItem(2, 40)], + [...four(), makeMember(2, 2, 'bob'), makeMember(2, 3, 'carol')], + [makePayer(1, 1, 8920000, 'alice'), makePayer(2, 2, 40, 'bob')], + ); + const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null }); + expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['VND'] }); + expect(result.currency).toBe('AUD'); + // Only the 40 AUD dinner settles; the bill moves nobody, its payer included. + expect(result.balances.map(b => [b.user_id, b.balance])).toEqual([[2, 20], [3, -20]]); + expect(centSum(result.balances.map(b => b.balance))).toBe(0); + expect(result.balances.every(b => Math.abs(b.balance) < 1000)).toBe(true); + expect(result.finalBudgets.flatMap(f => f.sources.fronted.map(r => r.item_id))).toEqual([2]); + }); + + it('VND/AUD regression: frozen at 18241.3 it books 489.00 AUD, split 4 ways +366.75 / -122.25 x3', () => { + setupDb([vndBill(18241.3)], four(), [makePayer(1, 1, 8920000, 'alice')]); + const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null }); + expect(result.unconverted).toEqual(none); + expect(result.balances.map(b => b.balance)).toEqual([366.75, -122.25, -122.25, -122.25]); + expect(result.finalBudgets.find(f => f.user_id === 1)!.expenses).toBe(489); + }); + + it('a foreign row the rates do not quote is left out and listed while others convert live', () => { + setupDb( + [vndBill(1), { ...makeItem(2, 65), currency: 'USD', exchange_rate: 1 } as BudgetItem], + [...four(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')], + [makePayer(1, 1, 8920000, 'alice'), makePayer(2, 1, 65, 'alice')], + ); + const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: { AUD: 1, USD: 0.65 } }); + expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['VND'] }); + // 65 USD at today's 0.65 per dollar is 100 AUD, half of it Bob's. + expect(result.balances.map(b => [b.user_id, b.balance])).toEqual([[1, 50], [2, -50]]); + }); + + it('a frozen row converts without any rates (unchanged)', () => { + setupDb( + [{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 110, 'alice')], + ); + const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: null }); + expect(result.unconverted).toEqual(none); + expect(result.balances.find(b => b.user_id === 2)!.balance).toBe(-50); + }); + + it('trip-currency and NULL-currency rows with rate 1 count as they are', () => { + setupDb( + [{ ...makeItem(1, 100), currency: 'aud', exchange_rate: 1 } as BudgetItem, { ...makeItem(2, 60), currency: null, exchange_rate: 1 } as BudgetItem], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')], + [makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 60, 'alice')], + ); + const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null }); + expect(result.unconverted).toEqual(none); + expect(result.balances.find(b => b.user_id === 2)!.balance).toBe(-80); + }); + + it('planning-only and unpaid unconvertible rows are listed but never touch balances', () => { + setupDb( + [ + { ...makeItem(1, 500000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // planning-only + { ...makeItem(2, 700000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // nobody paid yet + makeItem(3, 30), + ], + [makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob'), makeMember(3, 1, 'alice'), makeMember(3, 2, 'bob')], + [makePayer(3, 1, 30, 'alice')], + ); + const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null }); + expect(result.unconverted).toEqual({ item_ids: [1, 2], settlement_ids: [], currencies: ['VND'] }); + expect(result.balances.map(b => [b.user_id, b.balance])).toEqual([[1, 15], [2, -15]]); + }); + + it('an unfrozen foreign transfer without a quote is left out and listed in settlement_ids', () => { + setupDb( + [makeItem(1, 100)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 100, 'alice')], + [makeSettlementRow(9, 2, 1, 1000000, 'VND', 1)], + ); + const result = budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null }); + expect(result.unconverted).toEqual({ item_ids: [], settlement_ids: [9], currencies: ['VND'] }); + // A million dong did not square a 50 dollar debt 20,000 times over. + expect(result.balances.find(b => b.user_id === 2)!.balance).toBe(-50); + expect(result.finalBudgets.every(f => f.sources.moved.length === 0)).toBe(true); + // Still on the ledger list, so it can be edited or undone. + expect(result.settlements.map(s => s.id)).toEqual([9]); + }); + + it('a NULL-currency transfer still reads in the display currency', () => { + setupDb( + [makeItem(1, 100)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 100, 'alice')], + [makeSettlementRow(9, 2, 1, 62.5, null, 1)], + ); + const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.25 } }); + expect(result.currency).toBe('USD'); + expect(result.unconverted).toEqual(none); + expect(result.balances.map(b => b.balance)).toEqual([0, 0]); + }); + + it('base EUR on an AUD trip without quote or baseRate answers in AUD with currency \'AUD\'', () => { + setupDb([makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')]); + for (const rates of [null, { AUD: 1, USD: 0.65 }]) { + const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'AUD', rates }); + // Trip cents, labelled as what they are rather than printed as euros. + expect(result.currency).toBe('AUD'); + expect(result.balances.map(b => b.balance)).toEqual([50, -50]); + } + }); + + it('baseRate stands in for the missing display quote and a transfer frozen at it cancels its flow to the cent (#2525)', () => { + const bill = () => [makeItem(1, 123.45)]; + const pair = () => [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')]; + const paid = () => [makePayer(1, 1, 123.45, 'alice')]; + setupDb(bill(), pair(), paid()); + const before = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: null, baseRate: 1.1429 }); + expect(before.currency).toBe('USD'); + // Bob's 61.73 EUR at the browser's 1.1429 dollars per euro. + expect(before.flows.map(f => f.amount)).toEqual([70.55]); + + // Bob pays what settle-up offers, frozen at the same browser quote (fallback_fx). + setupDb(bill(), pair(), paid(), [makeSettlementRow(9, 2, 1, 70.55, 'USD', 1.1429)]); + const after = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: null, baseRate: 1.1429 }); + expect(after.balances.map(b => b.balance)).toEqual([0, 0]); + expect(after.flows).toEqual([]); + }); + + it('a server quote wins over baseRate', () => { + setupDb([makeItem(1, 123.45)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 123.45, 'alice')]); + const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.1429 }, baseRate: 2 }); + expect(result.currency).toBe('USD'); + expect(result.flows.map(f => f.amount)).toEqual([70.55]); + }); + + it('baseRate never converts a row', () => { + setupDb( + [{ ...makeItem(1, 100), currency: 'EUR', exchange_rate: 1 } as BudgetItem], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 100, 'alice')], + ); + // 0.61 EUR per AUD would convert this bill arithmetically; it only relabels the result. + const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'AUD', rates: null, baseRate: 0.61 }); + expect(result.currency).toBe('EUR'); + expect(result.unconverted).toEqual({ item_ids: [1], settlement_ids: [], currencies: ['EUR'] }); + expect(result.balances).toEqual([]); + }); +}); + // ── freezeForeignRate (write-path FX freeze, #1445) ─────────────────────────── describe('freezeForeignRate', () => { diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 171401252..9fd5de96a 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -1187,6 +1187,7 @@ describe('trip totals read every row in the trip currency (#2525)', () => { expect(budget.tripTotals(trip.id, 'EUR')).toEqual({ total: 785.26, byCategory: { accommodation: 685.26, food: 100 }, + unconverted: [], }); // Both rows carry what they need, so nothing waits on today's rates. expect(await budget.ratesForTripTotals(trip.id, 'EUR')).toBeNull(); @@ -1313,3 +1314,245 @@ describe('the settlement converts with the quote the entry rate was frozen from } }); }); + +// The VND/AUD report: the server reached no rates, so a VND bill was stored with the +// "not frozen" rate 1 and read as 8,920,000 AUD. RATES has no AUD quote, so every AUD +// trip below is that server; a rate can only come from what the caller lends. +describe('rows no rate can convert, and the rates that heal them', () => { + const VND_FX = { base: 'AUD', rates: { VND: 18241.3 } }; + const rateOf = (table: 'budget_items' | 'budget_settlements', id: number) => + (testDb.prepare(`SELECT exchange_rate FROM ${table} WHERE id = ?`).get(id) as { exchange_rate: number }).exchange_rate; + const exchangeRates = () => (budget as unknown as { exchangeRates: ExchangeRatesService }).exchangeRates; + + function seedAudTrip() { + const { user: me } = createUser(testDb); + const { user: bob } = createUser(testDb); + const { user: carol } = createUser(testDb); + const { user: dave } = createUser(testDb); + const trip = createTrip(testDb, me.id); + for (const u of [bob, carol, dave]) addTripMember(testDb, trip.id, u.id); + testDb.prepare("UPDATE trips SET currency = 'AUD' WHERE id = ?").run(trip.id); + const members = [me, bob, carol, dave].map(u => ({ user_id: u.id })); + return { trip, me, bob, carol, members }; + } + + it('BUDGET-SVC-DB-056: freezes fallback_fx when getRates is null', async () => { + const { trip, me, members } = seedAudTrip(); + const bill = await budget.create(String(trip.id), { + name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members, fallback_fx: VND_FX, + }); + expect(bill.exchange_rate).toBe(18241.3); + const s = await budget.settlement(trip.id, 'AUD', 'AUD'); + expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(366.75); + }); + + it('BUDGET-SVC-DB-057: server quote wins', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const bill = await budget.create(String(trip.id), { + name: 'Taxi', currency: 'USD', total_price: 20, fallback_fx: { base: 'EUR', rates: { USD: 2 } }, + }); + expect(bill.exchange_rate).toBe(RATES.EUR.USD); + }); + + it('BUDGET-SVC-DB-058: fallback_fx on another base is ignored', async () => { + const { trip } = seedAudTrip(); + // Quoted against euros, the figure would freeze the bill against the wrong currency. + const bill = await budget.create(String(trip.id), { + name: 'Pho', currency: 'VND', total_price: 100000, fallback_fx: { base: 'EUR', rates: { VND: 27000 } }, + }); + expect(bill.exchange_rate).toBe(1); + }); + + it('BUDGET-SVC-DB-059: explicit exchange_rate wins, fallback_fx never reaches the row', async () => { + const { trip } = seedAudTrip(); + const data = { name: 'Pho', currency: 'VND', total_price: 100000, exchange_rate: 18000, fallback_fx: VND_FX }; + const bill = await budget.create(String(trip.id), data); + expect(bill.exchange_rate).toBe(18000); + expect('fallback_fx' in data).toBe(false); + }); + + it('BUDGET-SVC-DB-060: currency change without any rate drops the old rate (items)', async () => { + const { trip } = seedAudTrip(); + const item = budget.createBudgetItem(trip.id, { name: 'Taxi', currency: 'USD', exchange_rate: 0.65, total_price: 20 }); + // The update SQL keeps the stored rate when none is sent: a USD rate on a VND bill. + expect(await budget.update(item.id, trip.id, { currency: 'VND' })).toMatchObject({ currency: 'VND', exchange_rate: 1 }); + // With a lent rate the new currency freezes instead. + expect(await budget.update(item.id, trip.id, { currency: 'THB', fallback_fx: { base: 'AUD', rates: { THB: 23.5 } } })) + .toMatchObject({ currency: 'THB', exchange_rate: 23.5 }); + }); + + it('BUDGET-SVC-DB-061: same for settlements', async () => { + const { trip, me, bob } = seedAudTrip(); + const transfer = await budget.createSettlement(trip.id, { + from_user_id: bob.id, to_user_id: me.id, amount: 20, currency: 'USD', fallback_fx: { base: 'AUD', rates: { USD: 0.65 } }, + }, me.id); + expect(transfer).toMatchObject({ currency: 'USD', exchange_rate: 0.65 }); + const moved = await budget.updateSettlement(transfer!.id, trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 20, currency: 'VND' }); + expect(moved).toMatchObject({ currency: 'VND', exchange_rate: 1 }); + }); + + it('BUDGET-SVC-DB-062: unchanged currency never touches the rate', async () => { + const { trip } = seedAudTrip(); + const frozen = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', exchange_rate: 18241.3, total_price: 100000 }); + const open = budget.createBudgetItem(trip.id, { name: 'Bus', currency: 'VND', total_price: 50000 }); + const fx = { base: 'AUD', rates: { VND: 20000 } }; + expect(await budget.update(frozen.id, trip.id, { name: 'Pho bo', currency: 'vnd', fallback_fx: fx })).toMatchObject({ exchange_rate: 18241.3 }); + // Not even an unfrozen row: an edit is no place to heal, freeze-rates is. + expect(await budget.update(open.id, trip.id, { name: 'Night bus', currency: 'VND', fallback_fx: fx })).toMatchObject({ exchange_rate: 1 }); + }); + + it('BUDGET-SVC-DB-063: freezeMissingRates heals items and transfers from fallback_fx', async () => { + const { trip, me, bob, carol, members } = seedAudTrip(); + const bill = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members }); + const transfer = budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 2229963, currency: 'VND' }, bob.id)!; + // Carol pays in baht. The rows are healed currency by currency, THB before VND, so her + // later transfer is written first and the answer still lists the rows by id. + const bahtTransfer = budget.insertSettlement(trip.id, { from_user_id: carol.id, to_user_id: me.id, amount: 2872.88, currency: 'THB' }, carol.id)!; + + const before = await budget.settlement(trip.id, 'AUD', 'AUD'); + expect(before.unconverted).toEqual({ item_ids: [bill.id], settlement_ids: [bahtTransfer.id, transfer.id], currencies: ['THB', 'VND'] }); + expect(before.balances).toEqual([]); + + const healed = await budget.freezeMissingRates(trip.id, { base: 'AUD', rates: { VND: 18241.3, THB: 23.5 } }); + expect(healed!.items.map(i => [i.id, i.exchange_rate])).toEqual([[bill.id, 18241.3]]); + expect(healed!.settlements.map(s => [s.id, s.exchange_rate])).toEqual([[transfer.id, 18241.3], [bahtTransfer.id, 23.5]]); + expect(healed!.unresolved).toEqual([]); + + const after = await budget.settlement(trip.id, 'AUD', 'AUD'); + expect(after.unconverted).toEqual({ item_ids: [], settlement_ids: [], currencies: [] }); + // 489.00 AUD four ways. Bob's 2,229,963 VND and Carol's 2,872.88 THB (122.25 AUD + // each) square their shares. + expect(after.balances.find(b => b.user_id === me.id)!.balance).toBe(122.25); + expect(after.balances.find(b => b.user_id === bob.id)!.balance).toBe(0); + expect(after.balances.find(b => b.user_id === carol.id)!.balance).toBe(0); + expect(after.balances.reduce((a, b) => a + Math.round(b.balance * 100), 0)).toBe(0); + }); + + it('BUDGET-SVC-DB-064: prefers the server quote, never touches frozen, trip-currency or NULL-currency rows', async () => { + const { user: me } = createUser(testDb); + const { user: bob } = createUser(testDb); + const trip = createTrip(testDb, me.id); + addTripMember(testDb, trip.id, bob.id); + const row = (currency: string | null, exchange_rate?: number) => + budget.createBudgetItem(trip.id, { name: 'Row', currency, exchange_rate, total_price: 10 }).id; + const usd = row('USD'); + const gbp = row('GBP'); + const xaf = row('XAF'); + const rub = row('RUB', 90); + const eur = row('EUR'); + const implicit = row(null); + const lower = row('usd'); + const usdTransfer = budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5, currency: 'USD' }, bob.id)!; + const plainTransfer = budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5 }, bob.id)!; + + const healed = await budget.freezeMissingRates(trip.id, { base: 'EUR', rates: { USD: 2, GBP: 0.85 } }); + expect(healed!.items.map(i => i.id)).toEqual([usd, gbp, lower]); + expect(healed!.settlements.map(s => s.id)).toEqual([usdTransfer.id]); + expect(healed!.unresolved).toEqual(['XAF']); + + expect(rateOf('budget_items', usd)).toBe(RATES.EUR.USD); + expect(rateOf('budget_items', lower)).toBe(RATES.EUR.USD); + expect(rateOf('budget_items', gbp)).toBe(0.85); + expect(rateOf('budget_items', xaf)).toBe(1); + expect(rateOf('budget_items', rub)).toBe(90); + expect(rateOf('budget_items', eur)).toBe(1); + expect(rateOf('budget_items', implicit)).toBe(1); + expect(rateOf('budget_settlements', usdTransfer.id)).toBe(RATES.EUR.USD); + expect(rateOf('budget_settlements', plainTransfer.id)).toBe(1); + }); + + it('BUDGET-SVC-DB-065: second call heals nothing even with another quote', async () => { + const { trip, me, members } = seedAudTrip(); + const bill = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members }); + await budget.freezeMissingRates(trip.id, VND_FX); + expect(await budget.freezeMissingRates(trip.id, { base: 'AUD', rates: { VND: 25000 } })) + .toEqual({ items: [], settlements: [], unresolved: [] }); + expect(rateOf('budget_items', bill.id)).toBe(18241.3); + }); + + it('BUDGET-SVC-DB-066: returns null and writes nothing when the trip currency changes during the fetch', async () => { + const { trip, me, members } = seedAudTrip(); + const bill = budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members }); + const spy = vi.spyOn(exchangeRates(), 'getRates').mockImplementation(async () => { + // Someone switches the trip to euros while the rates are on their way. + testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); + return null; + }); + try { + expect(await budget.freezeMissingRates(trip.id, VND_FX)).toBeNull(); + expect(rateOf('budget_items', bill.id)).toBe(1); + } finally { + spy.mockRestore(); + } + }); + + it('BUDGET-SVC-DB-067: returns without fetching when nothing is pending', async () => { + const { trip, me, bob, members } = seedAudTrip(); + budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'AUD', payers: [{ user_id: me.id, amount: 80 }], members }); + budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 20, members }); + budget.createBudgetItem(trip.id, { name: 'Pho', currency: 'VND', exchange_rate: 18241.3, total_price: 100000, members }); + budget.insertSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 5 }, bob.id); + const spy = vi.spyOn(exchangeRates(), 'getRates'); + try { + expect(await budget.freezeMissingRates(trip.id)).toEqual({ items: [], settlements: [], unresolved: [] }); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it('BUDGET-SVC-DB-068: tripTotals and per-person leave the VND row out and tripTotals reports its id', async () => { + const { trip, me, bob, members } = seedAudTrip(); + const bill = budget.createBudgetItem(trip.id, { + name: 'Pho', category: 'food', currency: 'VND', payers: [{ user_id: me.id, amount: 8920000 }], members, + }); + budget.createBudgetItem(trip.id, { + name: 'Ferry', category: 'transport', currency: 'AUD', payers: [{ user_id: bob.id, amount: 40 }], + members: [{ user_id: me.id }, { user_id: bob.id }], + }); + const rates = await budget.ratesForTripTotals(trip.id, 'AUD'); + expect(rates).toBeNull(); + expect(budget.tripTotals(trip.id, 'AUD', rates)).toEqual({ total: 40, byCategory: { transport: 40 }, unconverted: [bill.id] }); + const summary = await budget.perPersonSummary(trip.id); + expect(summary.map(s => [s.user_id, s.total_assigned, s.items_count])).toEqual([[me.id, 20, 1], [bob.id, 20, 1]]); + }); + + it('BUDGET-SVC-DB-069: settlement() passes baseRate only as display fallback (fetch order of 050 unchanged)', async () => { + const { user: me } = createUser(testDb); + const { user: bob } = createUser(testDb); + const trip = createTrip(testDb, me.id); + addTripMember(testDb, trip.id, bob.id); + budget.createBudgetItem(trip.id, { + name: 'Dinner', currency: 'EUR', payers: [{ user_id: me.id, amount: 100 }], + members: [{ user_id: me.id }, { user_id: bob.id }], + }); + const mine = (s: { balances: { user_id: number; balance: number }[] }) => s.balances.find(b => b.user_id === me.id)!.balance; + + // Neither quote: the caller's own figure labels the answer... + let spy = vi.spyOn(exchangeRates(), 'getRates').mockResolvedValue(null); + try { + const s = await budget.settlement(trip.id, 'USD', 'EUR', 1.2); + expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']); + expect(s.currency).toBe('USD'); + expect(mine(s)).toBe(60); + // ...and without it the answer stays in euros and says so. + const plain = await budget.settlement(trip.id, 'USD', 'EUR'); + expect(plain.currency).toBe('EUR'); + expect(mine(plain)).toBe(50); + } finally { + spy.mockRestore(); + } + + // With the display currency's quote (050's setup) it changes nothing. + spy = vi.spyOn(exchangeRates(), 'getRates').mockImplementation(async (base: string) => (base === 'EUR' ? null : RATES[base] ?? null)); + try { + const s = await budget.settlement(trip.id, 'USD', 'EUR', 2); + expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']); + expect(s.currency).toBe('USD'); + expect(mine(s)).toBe(57.14); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index e2382565c..ae468ce17 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -212,6 +212,8 @@ describe('getTripSummary shaping', () => { expect(summary.budget.total).toBe(785.26); expect(summary.budget.by_category).toEqual({ food: 100, accommodation: 685.26 }); expect(summary.budget.currency).toBe('EUR'); + // Both rows convert, so none is reported as left out of the total. + expect(summary.budget.unconverted_item_ids).toEqual([]); }); it('TRIP-READ-009: totals a trip saved without a currency in euros, the default the rest of the app reads it in (#2525)', async () => { diff --git a/shared/src/budget/budget.schema.spec.ts b/shared/src/budget/budget.schema.spec.ts index 86642bbc8..6b9773fa4 100644 --- a/shared/src/budget/budget.schema.spec.ts +++ b/shared/src/budget/budget.schema.spec.ts @@ -1,5 +1,11 @@ import { budgetCreateItemRequestSchema, + budgetUpdateItemRequestSchema, + budgetCreateSettlementRequestSchema, + budgetUpdateSettlementRequestSchema, + budgetFreezeRatesRequestSchema, + budgetSettlementQuerySchema, + budgetUnconvertedSchema, budgetUpdateMembersRequestSchema, budgetToggleMemberPaidRequestSchema, budgetReorderItemsRequestSchema, @@ -61,3 +67,93 @@ describe('typeToCostCategory', () => { expect(typeToCostCategory('taxi')).toBe('transport'); }); }); + +describe('fallback_fx (rates lent for a write)', () => { + const fx = { base: 'AUD', rates: { VND: 18241.3 } }; + + it('is optional on all four write bodies and kept when well formed', () => { + const cases = [ + [budgetCreateItemRequestSchema, { name: 'Pho' }], + [budgetUpdateItemRequestSchema, {}], + [budgetCreateSettlementRequestSchema, { from_user_id: 1, to_user_id: 2, amount: 10 }], + [budgetUpdateSettlementRequestSchema, { from_user_id: 1, to_user_id: 2, amount: 10 }], + ] as const; + for (const [schema, body] of cases) { + expect(schema.safeParse(body).success).toBe(true); + const parsed = schema.safeParse({ ...body, fallback_fx: fx }); + expect(parsed.success).toBe(true); + expect(parsed.data?.fallback_fx).toEqual(fx); + } + }); + + const accepted = (fallback_fx: unknown) => + budgetCreateItemRequestSchema.safeParse({ name: 'Pho', fallback_fx }).success; + + it('refuses a base or a currency that is not three upper-case letters', () => { + expect(accepted({ base: 'aud', rates: { VND: 1.5 } })).toBe(false); + expect(accepted({ base: 'AUDX', rates: { VND: 1.5 } })).toBe(false); + expect(accepted({ base: 'AUD', rates: { vnd: 1.5 } })).toBe(false); + expect(accepted({ base: 'AUD', rates: { VNDX: 1.5 } })).toBe(false); + }); + + it('refuses a rate that is zero, negative, NaN, infinite or above 1e9', () => { + for (const rate of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1e9 + 1]) { + expect(accepted({ base: 'AUD', rates: { VND: rate } })).toBe(false); + } + expect(accepted({ base: 'AUD', rates: { VND: 1e9 } })).toBe(true); + expect(accepted({ base: 'AUD', rates: { VND: 1e-9 } })).toBe(true); + }); + + it('refuses an empty table and one with more than 200 currencies', () => { + // AAA, AAB, … : n distinct three-letter codes. + const codes = (n: number) => + Object.fromEntries( + Array.from({ length: n }, (_, i) => [ + String.fromCharCode(65 + Math.floor(i / 676), 65 + (Math.floor(i / 26) % 26), 65 + (i % 26)), + 1.5, + ]), + ); + expect(accepted({ base: 'AUD', rates: {} })).toBe(false); + expect(accepted({ base: 'AUD', rates: codes(200) })).toBe(true); + expect(accepted({ base: 'AUD', rates: codes(201) })).toBe(false); + }); +}); + +describe('budgetFreezeRatesRequestSchema', () => { + it('accepts an empty body and a well-formed fallback table', () => { + expect(budgetFreezeRatesRequestSchema.safeParse({}).success).toBe(true); + expect( + budgetFreezeRatesRequestSchema.safeParse({ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }).success, + ).toBe(true); + expect(budgetFreezeRatesRequestSchema.safeParse({ fallback_fx: { base: 'AUD', rates: { VND: 0 } } }).success).toBe( + false, + ); + }); +}); + +describe('budgetSettlementQuerySchema', () => { + it('coerces base_rate from the query string and leaves base as it came', () => { + expect(budgetSettlementQuerySchema.parse({})).toEqual({}); + expect(budgetSettlementQuerySchema.parse({ base: 'eur', base_rate: '0.61' })).toEqual({ + base: 'eur', + base_rate: 0.61, + }); + }); + + it('refuses a base_rate that is not a positive number in range', () => { + for (const base_rate of ['abc', '0', '-1', '1e10', '']) { + expect(budgetSettlementQuerySchema.safeParse({ base_rate }).success).toBe(false); + } + }); +}); + +describe('budgetUnconvertedSchema', () => { + it('lists row ids and currencies', () => { + expect(budgetUnconvertedSchema.safeParse({ item_ids: [3], settlement_ids: [], currencies: ['VND'] }).success).toBe( + true, + ); + expect(budgetUnconvertedSchema.safeParse({ item_ids: ['3'], settlement_ids: [], currencies: [] }).success).toBe( + false, + ); + }); +}); diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index 1e7978014..f86aff2ca 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -1,3 +1,5 @@ +import { idSchema } from '../common/primitives.schema'; + import { z } from 'zod'; /** @@ -113,6 +115,9 @@ export type BudgetItemReceipt = z.infer; * plus the embedded `members` (equal-split participants), `payers` and `receipts` arrays. * total_price is the sum of payer amounts in `currency`; `exchange_rate` converts * that to the trip base currency (NULL currency + rate 1 = base currency). + * On a row in another currency, rate 1 means the rate was never frozen: the + * server converts it with today's rate, and without one leaves it out of every + * figure and lists it under the settlement's `unconverted`. */ export const budgetItemSchema = z.object({ id: z.number(), @@ -151,6 +156,29 @@ const memberInputSchema = z.object({ amount: z.number().nullable().optional(), }); +const currencyCodeSchema = z.string().regex(/^[A-Z]{3}$/); + +/** + * A rate table the caller already holds (the client's cached rates), lent to the + * server for a write. `rates` are units of each currency per 1 `base`, which is + * how a frozen `exchange_rate` reads when `base` is the trip currency. + * + * The server uses a rate from here only for a currency it cannot quote itself, + * and only while `base` is the trip's current currency; its own rate always wins, + * and a rate already frozen or sent as `exchange_rate` is never replaced. + */ +export const budgetFallbackFxSchema = z.object({ + base: currencyCodeSchema, + rates: z.record(currencyCodeSchema, z.number().finite().min(1e-9).max(1e9)).refine( + (rates) => { + const n = Object.keys(rates).length; + return n >= 1 && n <= 200; + }, + { message: 'rates must hold between 1 and 200 currencies' }, + ), +}); +export type BudgetFallbackFx = z.infer; + export const budgetCreateItemRequestSchema = z.object({ name: z.string().min(1), category: z.string().optional(), @@ -176,6 +204,8 @@ export const budgetCreateItemRequestSchema = z.object({ place_id: z.number().optional(), // Receipt files to link to this expense receipt_file_ids: z.array(z.number()).optional(), + // Rates to freeze a foreign currency with when the server has none of its own. + fallback_fx: budgetFallbackFxSchema.optional(), }); export type BudgetCreateItemRequest = z.infer; @@ -195,6 +225,7 @@ export const budgetUpdateItemRequestSchema = z.object({ ticket_json: z.string().nullable().optional(), expense_date: z.string().nullable().optional(), receipt_file_ids: z.array(z.number()).optional(), + fallback_fx: budgetFallbackFxSchema.optional(), }); export type BudgetUpdateItemRequest = z.infer; @@ -209,7 +240,9 @@ export type BudgetUpdatePayersRequest = z.infer; @@ -253,9 +288,60 @@ export const budgetUpdateSettlementRequestSchema = z.object({ amount: z.number(), currency: z.string().nullable().optional(), settled_at: z.string().nullable().optional(), + fallback_fx: budgetFallbackFxSchema.optional(), }); export type BudgetUpdateSettlementRequest = z.infer; +/** + * POST …/budget/freeze-rates: pin a rate on every row in a foreign currency that + * has none frozen yet, not only the rows the settlement lists under `unconverted` + * but also those it still converts at today's rate. The server's own rate comes + * first; `fallback_fx` only fills a currency it cannot quote. A row that is frozen, + * in the trip currency or without a currency is never touched, so a second call + * never moves a rate the first one froze. + */ +export const budgetFreezeRatesRequestSchema = z.object({ + fallback_fx: budgetFallbackFxSchema.optional(), +}); +export type BudgetFreezeRatesRequest = z.infer; + +/** The rows the call froze, and the currencies no rate was found for. */ +export const budgetFreezeRatesResponseSchema = z.object({ + items: z.array(budgetItemSchema), + settlements: z.array(budgetSettlementSchema), + unresolved: z.array(z.string()), +}); +export type BudgetFreezeRatesResponse = z.infer; + +/** + * GET …/budget/settlement query. `base` is the display currency. `base_rate` is + * units of it per 1 trip currency, from the caller's own rates; the server uses + * it only when it cannot quote that pair itself, and then only in place of the + * missing display quote. That quote also reads a legacy transfer without a + * currency, which is taken to be in the display currency, as with a live quote. + */ +export const budgetSettlementQuerySchema = z.object({ + base: z.string().optional(), + base_rate: z.coerce.number().finite().min(1e-9).max(1e9).optional(), +}); +export type BudgetSettlementQuery = z.infer; + +/** + * What the settlement left out because no rate could convert it: expenses and + * transfers in a foreign currency with no frozen rate, while no live rate for + * that currency is to be had. Such a row counts in none of the figures, so the + * balances still add up to zero. `currencies` are upper case, deduplicated and + * sorted. The settlement also says which `currency` its amounts are in: the + * display currency asked for, or the trip currency when that one cannot be + * quoted. + */ +export const budgetUnconvertedSchema = z.object({ + item_ids: z.array(idSchema), + settlement_ids: z.array(idSchema), + currencies: z.array(z.string()), +}); +export type BudgetUnconverted = z.infer; + /** * What the trip actually costs one participant, as GET …/budget/settlement * returns it alongside the balances and the suggested flows. diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index d470e23e2..d88becf17 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -157,11 +157,12 @@ Requires `budget:read` or `budget:write` scope. The Budget addon must be enabled | `delete_budget_item` | Remove a budget item. | | `set_budget_item_members` | Set which members are splitting a budget item (replaces current list). | | `toggle_budget_member_paid` | Mark or unmark a member as having paid their share. | -| `get_settlement_summary` | Each member's net balance, the suggested payments to settle shared expenses, and each member's final budget (`finalBudgets`: expenses paid, net reimbursements, pending reimbursements, final cost, each figure with the rows it is made of under `sources`), in the trip's base currency. Call this before recording a settlement. | +| `get_settlement_summary` | Each member's net balance, the suggested payments to settle shared expenses, and each member's final budget (`finalBudgets`: expenses paid, net reimbursements, pending reimbursements, final cost, each figure with the rows it is made of under `sources`). `currency` says what the amounts are in: the `base` asked for when the server can quote it, otherwise the trip's base currency. An expense or payment in a foreign currency with no frozen rate, while the server has no live rate for it either, is left out of every figure and listed under `unconverted` (`item_ids`, `settlement_ids`, `currencies`). Call this before recording a settlement. | | `list_settlements` | List the recorded settle-up payments for a trip — who paid whom, how much, and when. | | `create_settlement` | Record a settle-up payment: one member paid another the given amount, with the payment's currency and the day it happened. | | `update_settlement` | Update a recorded settle-up payment (payer, recipient, amount, currency and the day it happened). | | `delete_settlement` | Delete a recorded settle-up payment. This is the undo for `create_settlement` and restores the affected balances. | +| `freeze_budget_rates` | Pin today's server exchange rate on every expense and settle-up payment in a foreign currency that has no rate frozen yet, the rows under `unconverted` included. Rows with a frozen rate, in the trip currency or without a currency are never touched, and no rate is taken from the caller. Returns the rows it froze (`items`, `settlements`) and the currencies the server could not quote (`unresolved`). Needs the budget edit permission; an error when the trip currency changed meanwhile, with nothing written. | ### Tags
- TREK + TREK
TREK
Travel Resource & Exploration Kit