import { test, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import request from 'supertest'; import { createApp } from '../src/server/app.js'; import { computeCosts } from '../src/server/util/costs.js'; let tmpDir; let app; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-costs-')); app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' }); }); afterEach(() => { try { if (app.locals.db) app.locals.db.close(); } catch { /* ignore */ } fs.rmSync(tmpDir, { recursive: true, force: true }); }); async function createAccount() { const agent = request.agent(app); const res = await agent.post('/api/auth/account').send({}); assert.equal(res.status, 201); return { agent, user: res.body.user, token: res.body.token }; } // Build a trip owned by `owner`; each member agent joins via the join code. async function costTrip(owner, memberAccounts = []) { const trip = (await owner.agent.post('/api/trips').send({ name: 'Costs', start_date: '2026-08-01', end_date: '2026-08-05', currency: 'USD', })).body.trip; for (const m of memberAccounts) { const res = await m.agent.post('/api/trips/join').send({ code: trip.join_code }); assert.equal(res.status, 200); } return trip; } function findUser(costs, userId) { return costs.perUser.find((u) => u.userId === userId); } // --------------------------------------------------------------------------- // Currency // --------------------------------------------------------------------------- test('currency: defaults to USD, validates format, patchable', async () => { const { agent } = await createAccount(); const def = await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-05' }); assert.equal(def.status, 201); assert.equal(def.body.trip.currency, 'USD'); const bad = await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-05', currency: 'us' }); assert.equal(bad.status, 400); const eur = await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-05', currency: 'EUR' }); assert.equal(eur.status, 201); assert.equal(eur.body.trip.currency, 'EUR'); const patched = await agent.patch(`/api/trips/${eur.body.trip.id}`).send({ currency: 'THB' }); assert.equal(patched.status, 200); assert.equal(patched.body.trip.currency, 'THB'); const listed = await agent.get('/api/trips'); assert.ok(listed.body.trips.every((t) => typeof t.currency === 'string')); }); // --------------------------------------------------------------------------- // Costs & splitting (endpoint) // --------------------------------------------------------------------------- test('costs: equal split with payer produces net balances and a settlement', async () => { const anna = await createAccount(); const ben = await createAccount(); const trip = await costTrip(anna, [ben]); // anna pays 950 (stay), ben pays 500 (transport); both split equally between the two. await anna.agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'stay', title: 'Hotel', price: 950, paid_by: anna.user.id, }); await anna.agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'transport', title: 'Van', price: 500, paid_by: ben.user.id, }); const res = await anna.agent.get(`/api/trips/${trip.id}/costs`); assert.equal(res.status, 200); const c = res.body; assert.equal(c.currency, 'USD'); assert.equal(c.totalCost, 1450); assert.deepEqual(c.byType, { stay: 950, transport: 500 }); assert.equal(c.unassigned, 0); const a = findUser(c, anna.user.id); const b = findUser(c, ben.user.id); assert.equal(typeof a.displayName, 'string'); assert.deepEqual([a.share, a.paid, a.net], [725, 950, 225]); assert.deepEqual([b.share, b.paid, b.net], [725, 500, -225]); assert.equal(c.settlements.length, 1); assert.deepEqual(c.settlements[0], { fromUserId: ben.user.id, toUserId: anna.user.id, amount: 225 }); }); test('costs: own mode creates no debt and totals price x participants', async () => { const anna = await createAccount(); const ben = await createAccount(); const trip = await costTrip(anna, [ben]); await anna.agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'flight', title: 'Flights', price: 300, split_mode: 'own', }); const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body; assert.equal(c.totalCost, 600); // 300 per person x 2 assert.deepEqual(c.byType, { flight: 600 }); for (const u of c.perUser) { assert.deepEqual([u.share, u.paid, u.net], [300, 300, 0]); } assert.deepEqual(c.settlements, []); assert.equal(c.unassigned, 0); }); test('costs: payer mode is a personal expense (requires paid_by)', async () => { const anna = await createAccount(); const ben = await createAccount(); const trip = await costTrip(anna, [ben]); // payer without paid_by -> validation error const bad = await anna.agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'activity', title: 'Solo tour', price: 100, split_mode: 'payer', }); assert.equal(bad.status, 400); await anna.agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'activity', title: 'Solo tour', price: 100, split_mode: 'payer', paid_by: ben.user.id, }); const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body; assert.equal(c.totalCost, 100); const a = findUser(c, anna.user.id); const b = findUser(c, ben.user.id); assert.deepEqual([a.share, a.paid, a.net], [0, 0, 0]); assert.deepEqual([b.share, b.paid, b.net], [100, 100, 0]); assert.deepEqual(c.settlements, []); }); test('costs: participants subset only splits among the chosen members', async () => { const anna = await createAccount(); const ben = await createAccount(); const carol = await createAccount(); const trip = await costTrip(anna, [ben, carol]); // 90 split equally between anna & ben only (carol excluded), anna pays. await anna.agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'transport', title: 'Taxi', price: 90, paid_by: anna.user.id, participants: [anna.user.id, ben.user.id], }); const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body; assert.equal(c.totalCost, 90); const a = findUser(c, anna.user.id); assert.deepEqual([a.share, a.paid, a.net], [45, 90, 45]); assert.deepEqual([findUser(c, ben.user.id).share, findUser(c, ben.user.id).net], [45, -45]); assert.deepEqual([findUser(c, carol.user.id).share, findUser(c, carol.user.id).net], [0, 0]); assert.equal(c.settlements.length, 1); assert.deepEqual(c.settlements[0], { fromUserId: ben.user.id, toUserId: anna.user.id, amount: 45 }); }); test('costs: equal with no payer accumulates into unassigned', async () => { const anna = await createAccount(); const ben = await createAccount(); const trip = await costTrip(anna, [ben]); await anna.agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'stay', title: 'Hotel', price: 200, // paid_by omitted (null) }); const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body; assert.equal(c.totalCost, 200); assert.equal(c.unassigned, 200); assert.deepEqual(c.byType, { stay: 200 }); for (const u of c.perUser) { assert.deepEqual([u.share, u.paid, u.net], [100, 0, -100]); } // Nobody paid, so there is no creditor to settle toward. assert.deepEqual(c.settlements, []); }); // --------------------------------------------------------------------------- // Costs computation (pure unit test — greedy minimal-transfer) // --------------------------------------------------------------------------- test('computeCosts: greedy settlement matches largest debtor with largest creditor', () => { const members = [ { id: 1, display_name: 'brave-otter' }, { id: 2, display_name: 'calm-heron' }, { id: 3, display_name: 'wise-lynx' }, ]; // id 1 pays 300 for a 3-way equal split (100 each); id 1 is owed 200 total. const c = computeCosts({ currency: 'USD', members, entries: [{ type: 'stay', price: 300, paid_by: 1, split_mode: 'equal', participants: [] }], }); assert.equal(c.totalCost, 300); assert.equal(findUser(c, 1).displayName, 'brave-otter'); assert.equal(findUser(c, 1).net, 200); assert.equal(findUser(c, 2).net, -100); assert.equal(findUser(c, 3).net, -100); // Two transfers of 100 into the single creditor (id 1). assert.equal(c.settlements.length, 2); assert.ok(c.settlements.every((s) => s.toUserId === 1 && s.amount === 100)); });