Files
TREK/server/tests/unit/mcp/tools-budget.test.ts
T
Maurice aa1f76ad11 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.
2026-09-26 13:19:26 +02:00

1090 lines
47 KiB
TypeScript

/**
* Unit tests for MCP budget tools: create_budget_item, update_budget_item,
* delete_budget_item, plus the settlement cases that turn on the currency the
* payment was made in (the rest of the settlement surface lives in
* tools-budget-advanced.test.ts).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
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: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
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);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
// Freezing the FX rate of a foreign currency is a real fetch to
// api.frankfurter.dev with a 10 s abort. Fail closed by default; the cases
// that assert a frozen rate install their own rate stub.
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
});
afterAll(() => {
vi.unstubAllGlobals();
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
function errorText(result: Awaited<ReturnType<McpHarness['client']['callTool']>>): string {
const text = (result.content as { type: string; text?: string }[]).find((c) => c.type === 'text');
return text?.text ?? '';
}
function tripWithTwo() {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, other.id);
return { user, other, trip };
}
function memberRows(itemId: number) {
return testDb.prepare('SELECT user_id, amount FROM budget_item_members WHERE budget_item_id = ? ORDER BY user_id')
.all(itemId) as { user_id: number; amount: number | null }[];
}
function itemRow(itemId: number) {
return testDb.prepare('SELECT total_price, currency, exchange_rate, persons, expense_date, place_id FROM budget_items WHERE id = ?')
.get(itemId) as {
total_price: number; currency: string | null; exchange_rate: number | null;
persons: number | null; expense_date: string | null; place_id: number | null;
};
}
function itemCount(tripId: number): number {
return (testDb.prepare('SELECT COUNT(*) AS count FROM budget_items WHERE trip_id = ?').get(tripId) as { count: number }).count;
}
function settlementRow(id: number) {
return testDb.prepare('SELECT amount, currency, exchange_rate FROM budget_settlements WHERE id = ?')
.get(id) as { amount: number; currency: string | null; exchange_rate: number | null };
}
// budget_settlements is not one of the tables the reset clears, and trip ids are
// reused, so a count is only ever compared against the count taken in the same test.
function settlementCount(tripId: number): number {
return (testDb.prepare('SELECT COUNT(*) AS count FROM budget_settlements WHERE trip_id = ?').get(tripId) as { count: number }).count;
}
/** Frankfurter's shape: one entry per quote, the base's own rate omitted. */
function stubRates(rates: Record<string, number>): void {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
text: async () => JSON.stringify(
Object.entries(rates).map(([quote, rate]) => ({ date: '2026-08-28', base: 'EUR', quote, rate })),
),
})));
}
// ---------------------------------------------------------------------------
// create_budget_item
// ---------------------------------------------------------------------------
describe('Tool: create_budget_item', () => {
it('creates a budget item with all fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Hotel Paris', category: 'Accommodation', total_price: 500, note: 'Prepaid' },
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('Hotel Paris');
expect(data.item.category).toBe('Accommodation');
expect(data.item.total_price).toBe(500);
expect(data.item.note).toBe('Prepaid');
});
});
it('defaults category to "Other" when not specified', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Misc', total_price: 10 },
});
const data = parseToolResult(result) as any;
expect(data.item.category).toBe('other');
});
});
it('broadcasts budget:created event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'create_budget_item', arguments: { tripId: trip.id, name: 'Taxi', total_price: 25 } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:created', expect.any(Object));
});
});
// Regression for #1244: a naive create must seed members so the client save-gate
// (participants.size > 0) passes — the entry must be saveable, not member-less.
it('defaults members to the trip owner when member_ids omitted (solo trip)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Dinner', total_price: 40 },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
expect(data.item.persons).toBe(1);
// saveable invariant: client requires participants.size > 0
expect(data.item.members.length).toBeGreaterThan(0);
});
});
it('defaults members to all trip members when member_ids omitted (multi-member)', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Group taxi', total_price: 60 },
});
const data = parseToolResult(result) as any;
const ids = data.item.members.map((m: any) => m.user_id).sort();
expect(ids).toEqual([user.id, member.id].sort());
expect(data.item.persons).toBe(2);
});
});
it('respects an explicit member_ids subset', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'My snack', total_price: 5, member_ids: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
});
});
it('treats an explicit empty member_ids as a planning-only entry (no split)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Estimate', total_price: 100, member_ids: [] },
});
const data = parseToolResult(result) as any;
expect(data.item.members).toEqual([]);
});
});
it('round-trips currency, expense_date, and payers', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Museum', total_price: 30, currency: 'EUR',
expense_date: '2026-07-01', payers: [{ user_id: user.id, amount: 30 }],
},
});
const data = parseToolResult(result) as any;
expect(data.item.currency).toBe('EUR');
expect(data.item.expense_date).toBe('2026-07-01');
expect(data.item.payers.map((p: any) => p.user_id)).toEqual([user.id]);
// total_price derives from payer sum
expect(data.item.total_price).toBe(30);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_budget_item', arguments: { tripId: trip.id, name: 'Hack', total_price: 0 } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_budget_item', arguments: { tripId: trip.id, name: 'X', total_price: 0 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_budget_item
// ---------------------------------------------------------------------------
describe('Tool: update_budget_item', () => {
it('updates budget item fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Old', category: 'Food', total_price: 50 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, name: 'New Name', total_price: 75 },
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('New Name');
expect(data.item.total_price).toBe(75);
expect(data.item.category).toBe('Food'); // preserved
});
});
it('broadcasts budget:updated event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:updated', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_budget_item
// ---------------------------------------------------------------------------
describe('Tool: delete_budget_item', () => {
it('deletes an existing budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(item.id)).toBeUndefined();
});
});
it('broadcasts budget:deleted event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:deleted', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: 99999 } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Resource: trek://trips/{tripId}/budget (moved from the legacy resources.ts
// registrar to the DI-discovered BudgetMcp — attached via the nest-mcp registry)
// ---------------------------------------------------------------------------
describe('Resource: trek://trips/{tripId}/budget', () => {
it('returns budget items for a trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Accommodation', total_price: 200 });
await withHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
const items = parseResourceResult(result) as { name: string }[];
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Hotel');
});
});
it('returns access denied for unauthorized trip', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
const data = parseResourceResult(result) as { error?: string };
expect(data.error).toBeTruthy();
});
});
});
// ---------------------------------------------------------------------------
// Uneven splits: members carries what each participant owes, the way the REST
// body has since the Costs rework. member_ids alone always meant an equal share.
// ---------------------------------------------------------------------------
describe('Tool: create_budget_item (uneven split)', () => {
it('persists what each member owes', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Dinner', total_price: 100,
members: [{ user_id: user.id, amount: 70 }, { user_id: other.id, amount: 30 }],
},
});
const data = parseToolResult(result) as any;
expect(memberRows(data.item.id)).toEqual([
{ user_id: user.id, amount: 70 },
{ user_id: other.id, amount: 30 },
]);
expect(itemRow(data.item.id).persons).toBe(2);
});
});
it('refuses a split that does not add up to the total', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Dinner', total_price: 100,
members: [{ user_id: user.id, amount: 60 }, { user_id: other.id, amount: 30 }],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('does not add up');
expect(itemCount(trip.id)).toBe(0);
});
});
it('measures the split against the payer sum, not the stated total', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Groceries', total_price: 100,
payers: [{ user_id: user.id, amount: 90 }],
members: [{ user_id: user.id, amount: 60 }, { user_id: other.id, amount: 30 }],
},
});
const data = parseToolResult(result) as any;
expect(itemRow(data.item.id).total_price).toBe(90);
expect(memberRows(data.item.id).map(m => m.amount)).toEqual([60, 30]);
});
});
it('refuses a split that matches the stated total but not what the payers paid', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Groceries', total_price: 100,
payers: [{ user_id: user.id, amount: 90 }],
members: [{ user_id: user.id, amount: 70 }, { user_id: other.id, amount: 30 }],
},
});
expect(result.isError).toBe(true);
expect(itemCount(trip.id)).toBe(0);
});
});
it('refuses a member who is not on the trip', async () => {
const { user, trip } = tripWithTwo();
const { user: stranger } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Dinner', total_price: 100,
members: [{ user_id: user.id, amount: 50 }, { user_id: stranger.id, amount: 50 }],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not on this trip');
expect(itemCount(trip.id)).toBe(0);
});
});
it('refuses members alongside member_ids', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Dinner', total_price: 100,
member_ids: [user.id, other.id],
members: [{ user_id: user.id, amount: 50 }, { user_id: other.id, amount: 50 }],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not both');
expect(itemCount(trip.id)).toBe(0);
});
});
});
describe('Tool: update_budget_item (uneven split)', () => {
function itemWithEqualSplit(tripId: number, userIds: number[]) {
const item = createBudgetItem(testDb, tripId, { name: 'Dinner', total_price: 100 });
const insert = testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
for (const id of userIds) insert.run(item.id, id);
testDb.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(userIds.length, item.id);
return item;
}
it('replaces the equal split with per-member amounts', async () => {
const { user, other, trip } = tripWithTwo();
const item = itemWithEqualSplit(trip.id, [user.id, other.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id, itemId: item.id,
members: [{ user_id: user.id, amount: 40 }, { user_id: other.id, amount: 60 }],
},
});
expect(result.isError).toBeFalsy();
expect(memberRows(item.id)).toEqual([
{ user_id: user.id, amount: 40 },
{ user_id: other.id, amount: 60 },
]);
expect(itemRow(item.id).persons).toBe(2);
});
});
it('measures the split against the stored total when the call does not restate it', async () => {
const { user, other, trip } = tripWithTwo();
const item = itemWithEqualSplit(trip.id, [user.id, other.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id, itemId: item.id,
members: [{ user_id: user.id, amount: 40 }, { user_id: other.id, amount: 50 }],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('100.00');
expect(memberRows(item.id)).toEqual([
{ user_id: user.id, amount: null },
{ user_id: other.id, amount: null },
]);
});
});
it('refuses the same member twice', async () => {
const { user, other, trip } = tripWithTwo();
const item = itemWithEqualSplit(trip.id, [user.id, other.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id, itemId: item.id,
members: [{ user_id: user.id, amount: 50 }, { user_id: user.id, amount: 50 }],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('twice');
expect(memberRows(item.id).map(m => m.amount)).toEqual([null, null]);
});
});
it('refuses members alongside member_ids', async () => {
const { user, other, trip } = tripWithTwo();
const item = itemWithEqualSplit(trip.id, [user.id, other.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id, itemId: item.id,
member_ids: [user.id],
members: [{ user_id: user.id, amount: 100 }],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not both');
expect(memberRows(item.id)).toHaveLength(2);
});
});
it('reports a missing item before it looks at the split', async () => {
const { user, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: 99999, members: [{ user_id: user.id, amount: 10 }] },
});
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('Budget item not found.');
});
});
});
// ---------------------------------------------------------------------------
// Currency and date on an existing expense
// ---------------------------------------------------------------------------
describe('Tool: update_budget_item (currency)', () => {
it('changes the currency and freezes the rate at entry time', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
stubRates({ USD: 1.1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, currency: 'USD' },
});
expect(result.isError).toBeFalsy();
const row = itemRow(item.id);
expect(row.currency).toBe('USD');
expect(row.exchange_rate).toBe(1.1);
});
});
it('clears the currency back to the trip currency', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
testDb.prepare('UPDATE budget_items SET currency = ?, exchange_rate = ? WHERE id = ?').run('USD', 1.1, item.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, currency: null },
});
expect(result.isError).toBeFalsy();
expect(itemRow(item.id).currency).toBeNull();
});
});
it('refuses a currency that is not a code', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, currency: 'United States Dollars' },
});
expect(result.isError).toBe(true);
expect(itemRow(item.id).currency).toBeNull();
});
});
});
describe('Tool: update_budget_item (expense date)', () => {
it('moves the expense to another date', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, expense_date: '2026-09-14' },
});
expect(result.isError).toBeFalsy();
expect(itemRow(item.id).expense_date).toBe('2026-09-14');
});
});
it('clears the expense date', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
testDb.prepare('UPDATE budget_items SET expense_date = ? WHERE id = ?').run('2026-09-14', item.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, expense_date: null },
});
expect(result.isError).toBeFalsy();
expect(itemRow(item.id).expense_date).toBeNull();
});
});
it('refuses a date that is not a string', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
testDb.prepare('UPDATE budget_items SET expense_date = ? WHERE id = ?').run('2026-09-14', item.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, expense_date: 20260915 },
});
expect(result.isError).toBe(true);
expect(itemRow(item.id).expense_date).toBe('2026-09-14');
});
});
});
// ---------------------------------------------------------------------------
// Expenses linked to a place (#1298), the place-side twin of reservation_id
// ---------------------------------------------------------------------------
describe('Budget tools: place link', () => {
it('create_budget_item links the expense to a place on the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Louvre tickets', total_price: 34, place_id: place.id },
});
const data = parseToolResult(result) as any;
expect(itemRow(data.item.id).place_id).toBe(place.id);
});
});
it('create_budget_item refuses a place from another trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const elsewhere = createTrip(testDb, user.id);
const place = createPlace(testDb, elsewhere.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Tickets', total_price: 34, place_id: place.id },
});
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('place_id does not belong to this trip.');
expect(itemCount(trip.id)).toBe(0);
});
});
it('create_budget_item_with_members links the expense to a place on the trip', async () => {
const { user, other, trip } = tripWithTwo();
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: { tripId: trip.id, name: 'Louvre tickets', total_price: 68, userIds: [user.id, other.id], place_id: place.id },
});
const data = parseToolResult(result) as any;
expect(itemRow(data.item.id).place_id).toBe(place.id);
});
});
it('create_budget_item_with_members refuses a place from another trip', async () => {
const { user, trip } = tripWithTwo();
const elsewhere = createTrip(testDb, user.id);
const place = createPlace(testDb, elsewhere.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: { tripId: trip.id, name: 'Tickets', total_price: 68, place_id: place.id },
});
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('place_id does not belong to this trip.');
expect(itemCount(trip.id)).toBe(0);
});
});
});
// ---------------------------------------------------------------------------
// Settle-up payments made in another currency (the #1445 freeze)
// ---------------------------------------------------------------------------
describe('Tool: create_settlement (currency)', () => {
it('records the payment in the currency it was made in and freezes its rate', async () => {
const { user, other, trip } = tripWithTwo();
stubRates({ USD: 1.1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20, currency: 'USD' },
});
const data = parseToolResult(result) as any;
const row = settlementRow(data.settlement.id);
expect(row.currency).toBe('USD');
expect(row.exchange_rate).toBe(1.1);
expect(row.amount).toBe(20);
});
});
it('books the payment in the trip currency when none is given', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20 },
});
const data = parseToolResult(result) as any;
const row = settlementRow(data.settlement.id);
expect(row.currency).toBeNull();
expect(row.exchange_rate).toBe(1);
});
});
it('refuses a currency that is not a code', async () => {
const { user, other, trip } = tripWithTwo();
const before = settlementCount(trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20, currency: 'United States Dollars' },
});
expect(result.isError).toBe(true);
expect(settlementCount(trip.id)).toBe(before);
});
});
});
describe('Tool: update_settlement (currency)', () => {
it('changes the currency the payment was made in and freezes its rate', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20 },
})) as any;
stubRates({ USD: 1.1 });
const result = await h.client.callTool({
name: 'update_settlement',
arguments: { tripId: trip.id, settlementId: created.settlement.id, from_user_id: other.id, to_user_id: user.id, amount: 20, currency: 'USD' },
});
expect(result.isError).toBeFalsy();
const row = settlementRow(created.settlement.id);
expect(row.currency).toBe('USD');
expect(row.exchange_rate).toBe(1.1);
});
});
it('clears the currency back to the trip currency', async () => {
const { user, other, trip } = tripWithTwo();
stubRates({ USD: 1.1 });
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20, currency: 'USD' },
})) as any;
const result = await h.client.callTool({
name: 'update_settlement',
arguments: { tripId: trip.id, settlementId: created.settlement.id, from_user_id: other.id, to_user_id: user.id, amount: 20, currency: null },
});
expect(result.isError).toBeFalsy();
expect(settlementRow(created.settlement.id).currency).toBeNull();
});
});
});
// ---------------------------------------------------------------------------
// The split has to reconcile against the total that is actually stored
//
// writeItemPayers drops a payer who is not on the trip and then derives
// total_price from what survived, so certifying a split against the payers as
// GIVEN would pass a check the stored row fails. An empty payer list is the
// same trap from the other side: it means "no payers", so the derived total is
// zero, not the previous one.
// ---------------------------------------------------------------------------
describe('Budget tools: a custom split cannot be certified against a total the row will not get', () => {
it('refuses a payer who is not on the trip rather than storing an unbalanced split', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Dinner', total_price: 100,
members: [{ user_id: owner.id, amount: 100 }],
payers: [{ user_id: stranger.id, amount: 50 }, { user_id: owner.id, amount: 50 }],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not on this trip');
expect(testDb.prepare('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 });
});
});
it('refuses an empty payer list under a non-zero split, because the stored total becomes zero', async () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, friend.id);
await withHarness(owner.id, async (h) => {
const created = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Taxi', total_price: 100, member_ids: [owner.id, friend.id] },
});
const { item } = parseToolResult(created) as { item: { id: number } };
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id, itemId: item.id,
payers: [],
members: [{ user_id: owner.id, amount: 60 }, { user_id: friend.id, amount: 40 }],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('does not add up');
// The item is untouched: the refusal happens before the write.
const row = testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(item.id) as any;
expect(row.total_price).toBe(100);
});
});
it('accepts an empty payer list when the split is empty too', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const created = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Placeholder', total_price: 50, member_ids: [] },
});
const { item } = parseToolResult(created) as { item: { id: number } };
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, payers: [], members: [] },
});
expect(result.isError).toBeFalsy();
const row = testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(item.id) as any;
expect(row.total_price).toBe(0);
});
});
it('still certifies a split against the payer sum when every payer is on the trip', async () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, friend.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id, name: 'Hotel', total_price: 999,
payers: [{ user_id: owner.id, amount: 70 }, { user_id: friend.id, amount: 30 }],
members: [{ user_id: owner.id, amount: 60 }, { user_id: friend.id, amount: 40 }],
},
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
// total_price came from the payers, not from the stated 999.
const row = testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(data.item.id) as any;
expect(row.total_price).toBe(100);
const shares = testDb.prepare('SELECT user_id, amount FROM budget_item_members WHERE budget_item_id = ? ORDER BY user_id').all(data.item.id) as any[];
expect(shares.map(s => s.amount)).toEqual([60, 40]);
});
});
});
// ---------------------------------------------------------------------------
// 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([]);
});
});
});