mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(mcp): uneven expense splits, and the fields update was missing
Historical costs are rarely split evenly, and every budget tool took only a list of member ids, which the service stores as a null amount, meaning an equal share. members[] carries what each person owes instead. A custom split has to reconcile before it is persisted, so the tool refuses one whose shares do not add up in whole cents. Two ways round that check needed closing with it: the write path drops a payer who is not on the trip and then derives the total from what survived, and an empty payer list means a total of zero rather than the previous one. Both would have certified a split the stored row does not satisfy. Also on update, which had none of them: currency (so the FX freeze is no longer a no-op), expense_date, and a settlement currency, plus place_id on create so a museum ticket can point at the museum.
This commit is contained in:
@@ -26,11 +26,30 @@ const payersSchema = z.array(z.object({
|
||||
amount: z.number().nonnegative(),
|
||||
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
|
||||
|
||||
/** Reusable Zod shape for an unequal split: what each participant owes. */
|
||||
const splitMembersSchema = z.array(z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
amount: z.number().nonnegative(),
|
||||
})).describe('Unequal split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Ask the user; do not guess.');
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
/** Money is compared in whole cents, never in floats (the budget money rule). */
|
||||
function toCents(amount: number): number {
|
||||
return Math.round(amount * 100);
|
||||
}
|
||||
|
||||
function sumCents(amounts: number[]): number {
|
||||
return amounts.reduce((sum, a) => sum + toCents(a), 0);
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget MCP surface — ported from the legacy registrars: the eleven tools
|
||||
* from src/mcp/tools/budget.ts and the trek://trips/{tripId}/budget,
|
||||
@@ -83,20 +102,88 @@ export class BudgetMcp {
|
||||
return Array.from(new Set([ownerId, ...this.membership.listMemberUserIds(tripId)]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The total a custom split has to reconcile against: explicit payers derive it
|
||||
* (the write path overwrites total_price with their sum), so the stated total
|
||||
* is not necessarily the figure that ends up in the row.
|
||||
*/
|
||||
private settledTotalCents(
|
||||
tripId: number,
|
||||
payers: { user_id: number; amount: number }[] | undefined,
|
||||
total_price: number | undefined,
|
||||
fallbackCents: number,
|
||||
): number {
|
||||
// Once payers are sent at all, the write path derives the total from them and
|
||||
// an empty list therefore means zero, not "no opinion". Reading total_price
|
||||
// here instead would certify a split against a figure the row never receives.
|
||||
if (payers !== undefined) {
|
||||
const roster = this.db.rosterUserIds(tripId);
|
||||
return sumCents(payers.filter(p => p.amount > 0 && roster.has(p.user_id)).map(p => p.amount));
|
||||
}
|
||||
if (total_price !== undefined) return toCents(total_price);
|
||||
return fallbackCents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasons to refuse a custom split, or null when it is safe to persist.
|
||||
*
|
||||
* A split whose shares do not add up to the total hands the settlement a
|
||||
* difference no payment can ever clear, so it is refused here rather than
|
||||
* stored and discovered on the balances screen. An off-roster or repeated
|
||||
* user_id is refused for the same reason: the write path drops both silently,
|
||||
* which would unbalance a split that had just been checked.
|
||||
*/
|
||||
private splitRefusal(
|
||||
tripId: number,
|
||||
members: { user_id: number; amount: number }[],
|
||||
totalCents: number,
|
||||
payers?: { user_id: number; amount: number }[],
|
||||
): string | null {
|
||||
const roster = this.db.rosterUserIds(tripId);
|
||||
const strangers = members.filter(m => !roster.has(m.user_id)).map(m => m.user_id);
|
||||
if (strangers.length > 0) {
|
||||
return `members contains user IDs that are not on this trip: ${strangers.join(', ')}. Resolve them with list_trip_members.`;
|
||||
}
|
||||
if (new Set(members.map(m => m.user_id)).size !== members.length) {
|
||||
return 'members lists the same user twice. Give each participant one amount.';
|
||||
}
|
||||
// A payer the write path will drop takes the total down with it, so the split
|
||||
// certified here would not be the split stored. Refuse rather than let the
|
||||
// difference surface on the balances screen.
|
||||
if (payers) {
|
||||
const payerStrangers = payers.filter(p => p.amount > 0 && !roster.has(p.user_id)).map(p => p.user_id);
|
||||
if (payerStrangers.length > 0) {
|
||||
return `payers contains user IDs that are not on this trip: ${payerStrangers.join(', ')}. Resolve them with list_trip_members.`;
|
||||
}
|
||||
}
|
||||
const splitCents = sumCents(members.map(m => m.amount));
|
||||
if (splitCents !== totalCents) {
|
||||
return `The split does not add up: the member amounts total ${formatCents(splitCents)} but the expense total is ${formatCents(totalCents)}. Adjust the amounts so they sum to the expense total.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The sibling tools' foreign-id rule: a linked id must live on the same trip. */
|
||||
private placeOnTrip(tripId: number, placeId: number): boolean {
|
||||
return !!this.db.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
|
||||
}
|
||||
|
||||
// --- BUDGET ---
|
||||
|
||||
@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). 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.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
total_price: z.number().nonnegative(),
|
||||
currency: z.string().max(10).nullable().optional().describe('ISO currency code (e.g. "EUR"); defaults to the trip currency'),
|
||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense. Omit to split across all trip members (owner + members); pass [] for no split.'),
|
||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense equally. Omit to split across all trip members (owner + members); pass [] for no split.'),
|
||||
members: splitMembersSchema.optional().describe('Uneven split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Use this instead of member_ids, never alongside it.'),
|
||||
payers: payersSchema.optional().describe('Who paid how much, in the expense currency. When given, total_price is derived from the sum. Ask the user; do not guess.'),
|
||||
expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD'),
|
||||
place_id: z.number().int().positive().optional().describe('Place on this trip the expense belongs to (the museum ticket for that museum), linking it in the planner'),
|
||||
note: z.string().max(500).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
@@ -104,17 +191,26 @@ export class BudgetMcp {
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async createBudgetItem(
|
||||
{ tripId, name, category, total_price, currency, member_ids, payers, expense_date, note }: {
|
||||
{ tripId, name, category, total_price, currency, member_ids, members, payers, expense_date, place_id, note }: {
|
||||
tripId: number; name: string; category?: string; total_price: number; currency?: string | null;
|
||||
member_ids?: number[]; payers?: { user_id: number; amount: number }[]; expense_date?: string | null; note?: string;
|
||||
member_ids?: number[]; members?: { user_id: number; amount: number }[];
|
||||
payers?: { user_id: number; amount: number }[]; expense_date?: string | null; place_id?: number; note?: string;
|
||||
},
|
||||
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();
|
||||
const members = this.resolveMemberIds(tripId, member_ids);
|
||||
const itemData = { category, name, total_price, currency, member_ids: members, payers, expense_date, note };
|
||||
if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.');
|
||||
if (place_id != null && !this.placeOnTrip(tripId, place_id)) return errorResult('place_id does not belong to this trip.');
|
||||
if (members !== undefined) {
|
||||
const refusal = this.splitRefusal(tripId, members, this.settledTotalCents(tripId, payers, total_price, toCents(total_price)), payers);
|
||||
if (refusal) return errorResult(refusal);
|
||||
}
|
||||
// The split participants are the members of an uneven split; the equal-split
|
||||
// list still carries them so the row's `persons` count comes out the same.
|
||||
const splitIds = members ? members.map(m => m.user_id) : this.resolveMemberIds(tripId, member_ids);
|
||||
const itemData = { category, name, total_price, currency, member_ids: splitIds, members, payers, expense_date, place_id, note };
|
||||
// Freeze the live FX rate at entry time so a settled position isn't re-opened
|
||||
// when live rates drift (#1445) — same as the REST create path.
|
||||
await this.budget.freezeForeignRate(tripId, itemData);
|
||||
@@ -148,17 +244,20 @@ export class BudgetMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_budget_item',
|
||||
description: 'Update an existing budget/expense item in a trip. You can also re-split it via member_ids 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.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
total_price: z.number().nonnegative().optional(),
|
||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense; replaces the current split. Omit to leave unchanged, pass [] for no split.'),
|
||||
currency: z.string().max(10).nullable().optional().describe('ISO currency code the expense is in (e.g. "USD"); null puts it back in the trip currency. Changing it re-freezes the FX rate at today\'s rate.'),
|
||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense equally; replaces the current split. Omit to leave unchanged, pass [] for no split.'),
|
||||
members: splitMembersSchema.optional().describe('Uneven split: what each participant owes, in the expense currency; replaces the current split. The amounts must add up to the expense total. Use this instead of member_ids, never alongside it.'),
|
||||
payers: payersSchema.optional().describe('Replaces who paid how much, in the expense currency. Omit to leave unchanged. Ask the user; do not guess.'),
|
||||
persons: z.number().int().positive().nullable().optional(),
|
||||
days: z.number().int().positive().nullable().optional(),
|
||||
expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD; null clears it. Omit to leave unchanged.'),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
@@ -166,18 +265,29 @@ export class BudgetMcp {
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async updateBudgetItem(
|
||||
{ tripId, itemId, name, category, total_price, member_ids, payers, persons, days, note }: {
|
||||
tripId: number; itemId: number; name?: string; category?: string; total_price?: number;
|
||||
member_ids?: number[]; payers?: { user_id: number; amount: number }[]; persons?: number | null; days?: number | null; note?: string | null;
|
||||
{ tripId, itemId, name, category, total_price, currency, member_ids, members, payers, persons, days, expense_date, note }: {
|
||||
tripId: number; itemId: number; name?: string; category?: string; total_price?: number; currency?: string | null;
|
||||
member_ids?: number[]; members?: { user_id: number; amount: number }[];
|
||||
payers?: { user_id: number; amount: number }[]; persons?: number | null; days?: number | null;
|
||||
expense_date?: string | null; note?: string | null;
|
||||
},
|
||||
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();
|
||||
// Freeze-then-write composite (no-op while the schema has no currency input,
|
||||
// but keeps REST and MCP on one code path for the #1445 freeze).
|
||||
const item = await this.budget.update(itemId, tripId, { name, category, total_price, member_ids, payers, persons, days, note });
|
||||
if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.');
|
||||
if (members !== undefined) {
|
||||
// An edit that leaves the total alone still has to reconcile against it, so
|
||||
// the stored figure stands in when the call does not restate one.
|
||||
const existing = this.budget.getBudgetItem(itemId, tripId);
|
||||
if (!existing) return errorResult('Budget item not found.');
|
||||
const refusal = this.splitRefusal(tripId, members, this.settledTotalCents(tripId, payers, total_price, toCents(existing.total_price)), payers);
|
||||
if (refusal) return errorResult(refusal);
|
||||
}
|
||||
// Freeze-then-write composite: a currency change re-freezes the rate at entry
|
||||
// time (#1445) on the same code path the REST update uses.
|
||||
const item = await this.budget.update(itemId, tripId, { name, category, total_price, currency, member_ids, members, payers, persons, days, expense_date, note });
|
||||
if (!item) return errorResult('Budget item not found.');
|
||||
this.guards.safeBroadcast(tripId, 'budget:updated', { item });
|
||||
return ok({ item });
|
||||
@@ -187,7 +297,7 @@ export class BudgetMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_budget_item_with_members',
|
||||
description: 'Create a budget/expense item and set the trip members splitting it in one atomic operation. If userIds is omitted, the cost is split across all trip members; pass an explicit list to split among a subset, or an empty array for a planning-only entry with no split. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist — if it already exists, use set_budget_item_members directly.',
|
||||
description: 'Create a budget/expense item and set the trip members splitting it equally in one atomic operation. If userIds is omitted, the cost is split across all trip members; pass an explicit list to split among a subset, or an empty array for a planning-only entry with no split. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist; if it already exists, use set_budget_item_members directly. For an uneven split, a foreign currency or a date, use create_budget_item instead.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -195,25 +305,27 @@ export class BudgetMcp {
|
||||
total_price: z.number().nonnegative(),
|
||||
note: z.string().max(500).optional(),
|
||||
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split'),
|
||||
place_id: z.number().int().positive().optional().describe('Place on this trip the expense belongs to (the museum ticket for that museum), linking it in the planner'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async createBudgetItemWithMembers(
|
||||
{ tripId, name, category, total_price, note, userIds }: {
|
||||
tripId: number; name: string; category?: string; total_price: number; note?: string; userIds?: number[];
|
||||
{ tripId, name, category, total_price, note, userIds, place_id }: {
|
||||
tripId: number; name: string; category?: string; total_price: number; note?: string; userIds?: number[]; place_id?: 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();
|
||||
if (place_id != null && !this.placeOnTrip(tripId, place_id)) return errorResult('place_id does not belong to this trip.');
|
||||
// Omitted userIds → default to the whole trip, matching create_budget_item.
|
||||
const members = (userIds && userIds.length > 0) ? userIds : this.resolveMemberIds(tripId, undefined);
|
||||
try {
|
||||
const item = this.db.transaction(() => {
|
||||
const created = this.budget.createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
|
||||
const created = this.budget.createBudgetItem(tripId, { category, name, total_price, note, member_ids: members, place_id });
|
||||
return this.budget.getBudgetItem(created.id, tripId)!;
|
||||
});
|
||||
this.guards.safeBroadcast(tripId, 'budget:created', { item });
|
||||
@@ -309,27 +421,28 @@ export class BudgetMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_settlement',
|
||||
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount (in the trip's base currency) to settle shared expenses. 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.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
|
||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
||||
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
|
||||
amount: z.number().positive().describe('Amount paid, in `currency`'),
|
||||
currency: z.string().max(10).nullable().optional().describe("ISO currency code the payment was made in (e.g. \"USD\"); defaults to the trip currency. Its FX rate is frozen now, so the transfer keeps cancelling its expense when live rates drift."),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async createSettlement(
|
||||
{ tripId, from_user_id, to_user_id, amount }: { tripId: number; from_user_id: number; to_user_id: number; amount: number },
|
||||
{ tripId, from_user_id, to_user_id, amount, currency }: { tripId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null },
|
||||
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();
|
||||
// Freeze-then-write composite, same as the REST path (no-op while the
|
||||
// schema has no currency input — see the #1445 note in the class doc).
|
||||
const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount }, ctx.userId);
|
||||
// Freeze-then-write composite, same as the REST path: the rate for the display
|
||||
// currency is frozen at entry time (#1445).
|
||||
const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency }, ctx.userId);
|
||||
if (!settlement) return errorResult('Settlement not found.');
|
||||
this.guards.safeBroadcast(tripId, 'budget:settlement-created', { settlement });
|
||||
return ok({ settlement });
|
||||
@@ -337,27 +450,29 @@ export class BudgetMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_settlement',
|
||||
description: 'Update a recorded settle-up payment (who paid, who received, and the amount).',
|
||||
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.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
settlementId: z.number().int().positive(),
|
||||
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
|
||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
||||
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
|
||||
amount: z.number().positive().describe('Amount paid, in `currency`'),
|
||||
currency: z.string().max(10).nullable().optional().describe('ISO currency code the payment was made in (e.g. "USD"); null puts it back in the trip currency. Omit to leave it as recorded, which also keeps the rate frozen at settle time.'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async updateSettlement(
|
||||
{ tripId, settlementId, from_user_id, to_user_id, amount }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number },
|
||||
{ tripId, settlementId, from_user_id, to_user_id, amount, currency }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null },
|
||||
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();
|
||||
// Freeze-then-write composite, same as the REST path.
|
||||
const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount });
|
||||
// Freeze-then-write composite, same as the REST path: an edit that leaves the
|
||||
// currency alone keeps the rate frozen at settle time.
|
||||
const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency });
|
||||
if (!settlement) return errorResult('Settlement not found.');
|
||||
this.guards.safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
|
||||
return ok({ settlement });
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Unit tests for MCP budget tools: create_budget_item, update_budget_item, delete_budget_item.
|
||||
* 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';
|
||||
|
||||
@@ -35,7 +38,7 @@ 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, addTripMember } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createBudgetItem, createPlace, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -47,9 +50,14 @@ 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();
|
||||
});
|
||||
|
||||
@@ -58,6 +66,57 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,3 +395,561 @@ describe('Resource: trek://trips/{tripId}/budget', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user