mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move budget backend to proper DI
This commit is contained in:
@@ -2,7 +2,6 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
|
||||
import { canAccessTrip } from '../db/database';
|
||||
import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService';
|
||||
import { listPlaces } from '../services/placeService';
|
||||
import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../services/budgetService';
|
||||
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
|
||||
import { getNotifications } from '../services/inAppNotifications';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
@@ -87,18 +86,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
}
|
||||
);
|
||||
|
||||
// Budget items
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
|
||||
'trip-budget',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
|
||||
{ description: 'Budget and expense items for a trip', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const items = listBudgetItems(id);
|
||||
return jsonContent(uri.href, items);
|
||||
}
|
||||
);
|
||||
// The trip-budget resource moved to the DI-discovered
|
||||
// src/nest/budget/budget.mcp.ts (@ResourceTemplate).
|
||||
|
||||
// The trip-packing resource moved to the DI-discovered
|
||||
// src/nest/packing/packing.mcp.ts (@ResourceTemplate).
|
||||
@@ -161,31 +150,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
}
|
||||
);
|
||||
|
||||
// Budget per-person summary
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
|
||||
'trip-budget-per-person',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }),
|
||||
{ description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const summary = getPerPersonSummary(id);
|
||||
return jsonContent(uri.href, summary);
|
||||
}
|
||||
);
|
||||
|
||||
// Budget settlement
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
|
||||
'trip-budget-settlement',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }),
|
||||
{ description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const settlement = calculateSettlement(id);
|
||||
return jsonContent(uri.href, settlement);
|
||||
}
|
||||
);
|
||||
// The trip-budget-per-person and trip-budget-settlement resources moved to
|
||||
// the DI-discovered src/nest/budget/budget.mcp.ts (@ResourceTemplate).
|
||||
|
||||
// The trip-packing-bags resource moved to the DI-discovered
|
||||
// src/nest/packing/packing.mcp.ts (@ResourceTemplate).
|
||||
|
||||
@@ -5,7 +5,6 @@ import { registerNotificationTools } from './tools/notifications';
|
||||
import { registerAtlasTools } from './tools/atlas';
|
||||
import { registerPlaceTools } from './tools/places';
|
||||
import { registerCollectionTools } from './tools/collections';
|
||||
import { registerBudgetTools } from './tools/budget';
|
||||
import { registerTripTools } from './tools/trips';
|
||||
import { registerTransportTools } from './tools/transports';
|
||||
import { registerTransitTools } from './tools/transit';
|
||||
@@ -19,7 +18,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
|
||||
registerCollectionTools(server, userId, scopes);
|
||||
|
||||
registerBudgetTools(server, userId, scopes);
|
||||
// The budget tools moved to the DI-discovered src/nest/budget/budget.mcp.ts
|
||||
// (@McpController, attached via the nest-mcp registry below).
|
||||
|
||||
// The packing tools moved to the DI-discovered src/nest/packing/packing.mcp.ts
|
||||
// (@McpController, attached via the nest-mcp registry below).
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
||||
updateMembers as updateBudgetMembers,
|
||||
toggleMemberPaid, getBudgetItem, freezeForeignRate,
|
||||
calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement,
|
||||
} from '../../services/budgetService';
|
||||
import { getRates } from '../../nest/budget/exchange-rates.bridge';
|
||||
import { getTripOwner, listMembers } from '../../services/tripService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||
} from './_shared';
|
||||
import { canRead, canWrite } from '../scopes';
|
||||
|
||||
/** Reusable Zod shape for the per-payer amounts on a budget item. */
|
||||
const payersSchema = z.array(z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
amount: z.number().nonnegative(),
|
||||
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/**
|
||||
* Resolve the equal-split participants for a new budget item. When member_ids is
|
||||
* omitted, default to the whole trip (owner + all members), deduped — reproducing
|
||||
* the client's own create flow (CostsPanel seeds participants from all members).
|
||||
* An explicit empty array means "planning-only, no split" and is passed through.
|
||||
*/
|
||||
function resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
|
||||
if (member_ids !== undefined) return member_ids;
|
||||
const owner = getTripOwner(tripId);
|
||||
if (!owner) return undefined;
|
||||
const { members } = listMembers(tripId, owner.user_id);
|
||||
return Array.from(new Set([owner.user_id, ...members.map(m => m.id)]));
|
||||
}
|
||||
|
||||
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
const R = canRead(scopes, 'budget');
|
||||
const W = canWrite(scopes, 'budget');
|
||||
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||
// --- BUDGET ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'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.',
|
||||
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.'),
|
||||
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'),
|
||||
note: z.string().max(500).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, category, total_price, currency, member_ids, payers, expense_date, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const members = resolveMemberIds(tripId, member_ids);
|
||||
const itemData = { category, name, total_price, currency, member_ids: members, payers, expense_date, 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 freezeForeignRate(tripId, itemData);
|
||||
const item = createBudgetItem(tripId, itemData);
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_budget_item',
|
||||
{
|
||||
description: 'Delete a budget item from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deleteBudgetItem(itemId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- BUDGET (update) ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'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.',
|
||||
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.'),
|
||||
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(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, name, category, total_price, member_ids, payers, persons, days, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, member_ids, payers, persons, days, note });
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
// --- BUDGET ADVANCED ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'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.',
|
||||
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(),
|
||||
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'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, category, total_price, note, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
// Omitted userIds → default to the whole trip, matching create_budget_item.
|
||||
const members = (userIds && userIds.length > 0) ? userIds : resolveMemberIds(tripId, undefined);
|
||||
try {
|
||||
const item = db.transaction(() => {
|
||||
const created = createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
|
||||
return getBudgetItem(created.id, tripId)!;
|
||||
})();
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
if (members && members.length > 0) safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||
return ok({ item });
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'set_budget_item_members',
|
||||
{
|
||||
description: 'Set which trip members are splitting a budget item (replaces current member list). Ask the user which members share the expense; resolve user IDs with list_trip_members.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
userIds: z.array(z.number().int().positive()).describe('User IDs splitting this item; empty array clears all'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const result = updateBudgetMembers(itemId, tripId, userIds);
|
||||
if (!result) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
const item = getBudgetItem(itemId, tripId);
|
||||
safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'toggle_budget_member_paid',
|
||||
{
|
||||
description: 'Mark or unmark a member as having paid their share of a budget item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
memberId: z.number().int().positive().describe('User ID of the member'),
|
||||
paid: z.boolean(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, memberId, paid }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const member = toggleMemberPaid(itemId, tripId, memberId, paid);
|
||||
safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, member });
|
||||
return ok({ member });
|
||||
}
|
||||
);
|
||||
|
||||
// --- SETTLEMENTS (settle-up payments between members) ---
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_settlement_summary',
|
||||
{
|
||||
description: "See each member's net balance and the suggested payments to settle shared expenses. Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId, base }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as { currency?: string } | undefined;
|
||||
const tripCurrency = trip?.currency || 'EUR';
|
||||
const effectiveBase = (base || tripCurrency).toUpperCase();
|
||||
const rates = await getRates(effectiveBase);
|
||||
const summary = calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
return ok({ summary });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_settlements',
|
||||
{
|
||||
description: 'List the recorded settle-up payments for a trip (who paid whom, how much, when).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
return ok({ settlements: listSettlements(tripId) });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'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.",
|
||||
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"),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, from_user_id, to_user_id, amount }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const settlement = createSettlement(tripId, { from_user_id, to_user_id, amount }, userId);
|
||||
safeBroadcast(tripId, 'budget:settlement-created', { settlement });
|
||||
return ok({ settlement });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_settlement',
|
||||
{
|
||||
description: 'Update a recorded settle-up payment (who paid, who received, and the amount).',
|
||||
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"),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, settlementId, from_user_id, to_user_id, amount }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const settlement = updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount });
|
||||
if (!settlement) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
|
||||
return ok({ settlement });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_settlement',
|
||||
{
|
||||
description: 'Delete a recorded settle-up payment. This is the undo for create_settlement and restores the affected balances.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
settlementId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, settlementId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const deleted = deleteSettlement(settlementId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
} // isAddonEnabled(BUDGET)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createReservation, deleteReservation, getReservation, updateReservation,
|
||||
type EndpointInput,
|
||||
} from '../../nest/reservations/reservations.bridge';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { linkBudgetItemToReservation } from '../../nest/budget/budget.bridge';
|
||||
import { getDay } from '../../nest/days/days.bridge';
|
||||
import { findByIata } from '../../services/airportService';
|
||||
import {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { countMessages, listPolls } from '../../nest/collab/collab.bridge';
|
||||
import {
|
||||
listItems as listTodoItems,
|
||||
} from '../../nest/todo/todo.bridge';
|
||||
import { rebaseTripCurrency } from '../../services/budgetService';
|
||||
import { rebaseTripCurrency } from '../../nest/budget/budget.bridge';
|
||||
import {
|
||||
safeBroadcast, MAX_MCP_TRIP_DAYS,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
|
||||
@@ -187,15 +187,25 @@ for real against an `audit_log` table in their temp DBs).
|
||||
The exchangeRateService fold followed (the pure-infra FX helper — Frankfurter
|
||||
fetch + module-scoped 6h cache, no SQL, no controller, no MCP registrar of its
|
||||
own — folded into `nest/budget/` as a dep-free `ExchangeRatesService`, injected
|
||||
by `BudgetService` and `PluginHostDepsFactory` (its 17th constructor dep), with
|
||||
an `exchange-rates.bridge.ts` for the legacy `services/budgetService.ts` FX
|
||||
seams and the `mcp/tools/budget.ts` registrar; the rate **cache stays
|
||||
module-scoped** like the permissions cache, so the bridge instance and the
|
||||
container singleton share one cached upstream feed).
|
||||
by `BudgetService` and `PluginHostDepsFactory` (its 17th constructor dep); the
|
||||
rate **cache stays module-scoped** like the permissions cache, so any
|
||||
out-of-container instance and the container singleton share one cached
|
||||
upstream feed).
|
||||
budgetService followed (the 755-line Wave-4 money core folded into the wrapper
|
||||
`BudgetService`: items/members/payers CRUD, the FX freeze + rebase paths and
|
||||
the settlement maths with `splitEqualShares` gone private; the freeze-then-write
|
||||
composites kept their wrapper names while the raw settlement writes became
|
||||
`insertSettlement`/`applySettlementUpdate` so the MCP paths keep skipping the
|
||||
freeze; the 11-tool `mcp/tools/budget.ts` registrar + 3 `resources.ts` budget
|
||||
resources moved to `budget.mcp.ts`; TripsService, ReservationsService (+ its
|
||||
MCP class) and BookingImportService inject `BudgetService`; a 4-export
|
||||
`budget.bridge.ts` serves the legacy tripService/userCleanupService and the
|
||||
trips/transports registrars; `exchange-rates.bridge.ts` was deleted with its
|
||||
last consumers, and the controller adopted `budget.dto.ts` — all nine
|
||||
allow-list entries removed).
|
||||
Repeat these steps per
|
||||
service (next up: **budgetService** — frontier-ready now that its
|
||||
exchangeRateService dependency is folded, per the dependency-honest order in
|
||||
`migration-graph.md`). This is a
|
||||
service (next up: **tripService** — the budget fold was its unblock, per the
|
||||
dependency-honest order in `migration-graph.md`). This is a
|
||||
**pure relocation** — byte-identical
|
||||
SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a
|
||||
bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it
|
||||
|
||||
@@ -7,9 +7,10 @@ import { FeaturesController } from './features.controller';
|
||||
import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
import { ReservationsModule } from '../reservations/reservations.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
|
||||
@Module({
|
||||
imports: [LlmParseModule, ReservationsModule, PermissionsModule],
|
||||
imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule],
|
||||
controllers: [BookingImportController, FeaturesController],
|
||||
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import { createBudgetItem, freezeForeignRate } from '../../services/budgetService';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { searchNominatim } from '../../services/mapsService';
|
||||
@@ -25,6 +25,7 @@ export class BookingImportService {
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly reservations: ReservationsService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly budget: BudgetService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -251,8 +252,8 @@ export class BookingImportService {
|
||||
};
|
||||
// Freeze the live FX rate for a foreign-currency booking price so a
|
||||
// settled position isn't re-opened when live rates drift (#1445).
|
||||
await freezeForeignRate(tripId, budgetData);
|
||||
const budgetItem = createBudgetItem(tripId, budgetData);
|
||||
await this.budget.freezeForeignRate(tripId, budgetData);
|
||||
const budgetItem = this.budget.createBudgetItem(tripId, budgetData);
|
||||
broadcast(tripId, 'budget:created', { item: budgetItem }, socketId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the budget domain — for code running OUTSIDE the
|
||||
* Nest container (the legacy services/tripService.ts and
|
||||
* services/userCleanupService.ts, and the legacy update_trip / create_transport
|
||||
* registrars in src/mcp/tools/trips.ts and src/mcp/tools/transports.ts; the
|
||||
* budget MCP tools and resources moved to the DI-discovered budget.mcp.ts, and
|
||||
* the plugin RPC host injects BudgetService via PluginHostDepsFactory).
|
||||
* Exports only the legacy services/budgetService names still consumed outside
|
||||
* the container, 1:1, so repointing a consumer is an import-path-only diff.
|
||||
* Inside the container, inject BudgetService instead. Delete this file when
|
||||
* tripService/userCleanupService and the legacy MCP registrars migrate.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton, and ExchangeRatesService keeps its FX
|
||||
* cache module-scoped on purpose so this instance and the DI singleton share
|
||||
* one cache.
|
||||
*/
|
||||
const budget = new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService());
|
||||
|
||||
export function listBudgetItems(tripId: string | number) {
|
||||
return budget.listBudgetItems(tripId);
|
||||
}
|
||||
|
||||
export function removeUserFromBudgetItems(userId: number): void {
|
||||
budget.removeUserFromBudgetItems(userId);
|
||||
}
|
||||
|
||||
export function rebaseTripCurrency(tripId: string | number, newCurrency: string | null | undefined): Promise<void> {
|
||||
return budget.rebaseTripCurrency(tripId, newCurrency);
|
||||
}
|
||||
|
||||
export function linkBudgetItemToReservation(
|
||||
tripId: string | number,
|
||||
reservationId: number,
|
||||
data: Parameters<BudgetService['linkBudgetItemToReservation']>[2],
|
||||
) {
|
||||
return budget.linkBudgetItemToReservation(tripId, reservationId, data);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import {
|
||||
McpController, Tool, ResourceTemplate, type McpContext,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, errorResult, ok,
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { getTripOwner, listMembers } from '../../services/tripService';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
|
||||
/** Legacy registrar gate: the whole budget surface rides the budget addon. */
|
||||
const budgetAddonOn = () => isAddonEnabled(ADDON_IDS.BUDGET);
|
||||
|
||||
/** Reusable Zod shape for the per-payer amounts on a budget item. */
|
||||
const payersSchema = z.array(z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
amount: z.number().nonnegative(),
|
||||
})).describe('Who actually paid, and how much each paid, in the expense currency. 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the equal-split participants for a new budget item. When member_ids is
|
||||
* omitted, default to the whole trip (owner + all members), deduped — reproducing
|
||||
* the client's own create flow (CostsPanel seeds participants from all members).
|
||||
* An explicit empty array means "planning-only, no split" and is passed through.
|
||||
*/
|
||||
function resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
|
||||
if (member_ids !== undefined) return member_ids;
|
||||
const owner = getTripOwner(tripId);
|
||||
if (!owner) return undefined;
|
||||
const { members } = listMembers(tripId, owner.user_id);
|
||||
return Array.from(new Set([owner.user_id, ...members.map(m => m.id)]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget MCP surface — ported 1:1 from the legacy registrars: the eleven tools
|
||||
* from src/mcp/tools/budget.ts and the trek://trips/{tripId}/budget,
|
||||
* …/budget/per-person and …/budget/settlement resources from
|
||||
* src/mcp/resources.ts (identical names, descriptions, schemas, annotations,
|
||||
* error/payload shapes and broadcasts — including the MCP-specific
|
||||
* 'budget:members-updated' { item } and 'budget:member-paid-updated'
|
||||
* { itemId, member } payloads, and the settlement writes that do not freeze an
|
||||
* FX rate). The registration-time gates map to `when` (the whole-registrar
|
||||
* budget-addon check) plus the declarative budget read/write access markers
|
||||
* (the legacy `if (R)` / `if (W)` checks, resolved by trekMcpAccessPolicy).
|
||||
*/
|
||||
@McpController()
|
||||
export class BudgetMcp {
|
||||
constructor(
|
||||
private readonly budget: BudgetService,
|
||||
private readonly exchangeRates: ExchangeRatesService,
|
||||
private readonly db: DatabaseService,
|
||||
) {}
|
||||
|
||||
// --- 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.',
|
||||
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.'),
|
||||
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'),
|
||||
note: z.string().max(500).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async createBudgetItem(
|
||||
{ tripId, name, category, total_price, currency, member_ids, payers, expense_date, 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;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const members = resolveMemberIds(tripId, member_ids);
|
||||
const itemData = { category, name, total_price, currency, member_ids: members, payers, expense_date, 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);
|
||||
const item = this.budget.createBudgetItem(tripId, itemData);
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_budget_item',
|
||||
description: 'Delete a budget item from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async deleteBudgetItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const deleted = this.budget.deleteBudgetItem(itemId, tripId);
|
||||
if (!deleted) return errorResult('Budget item not found.');
|
||||
safeBroadcast(tripId, 'budget:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
// --- BUDGET (update) ---
|
||||
|
||||
@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.',
|
||||
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.'),
|
||||
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(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: budgetAddonOn,
|
||||
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;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const item = this.budget.updateBudgetItem(itemId, tripId, { name, category, total_price, member_ids, payers, persons, days, note });
|
||||
if (!item) return errorResult('Budget item not found.');
|
||||
safeBroadcast(tripId, 'budget:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
|
||||
// --- BUDGET ADVANCED ---
|
||||
|
||||
@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.',
|
||||
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(),
|
||||
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'),
|
||||
},
|
||||
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[];
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
// Omitted userIds → default to the whole trip, matching create_budget_item.
|
||||
const members = (userIds && userIds.length > 0) ? userIds : resolveMemberIds(tripId, undefined);
|
||||
try {
|
||||
const item = this.db.transaction(() => {
|
||||
const created = this.budget.createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
|
||||
return this.budget.getBudgetItem(created.id, tripId)!;
|
||||
});
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
if (members && members.length > 0) safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||
return ok({ item });
|
||||
} catch {
|
||||
return errorResult('Failed to create budget item.');
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_budget_item_members',
|
||||
description: 'Set which trip members are splitting a budget item (replaces current member list). Ask the user which members share the expense; resolve user IDs with list_trip_members.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
userIds: z.array(z.number().int().positive()).describe('User IDs splitting this item; empty array clears all'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async setBudgetItemMembers({ tripId, itemId, userIds }: { tripId: number; itemId: number; userIds: number[] }, ctx: McpContext) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const result = this.budget.updateMembers(itemId, tripId, userIds);
|
||||
if (!result) return errorResult('Budget item not found.');
|
||||
const item = this.budget.getBudgetItem(itemId, tripId);
|
||||
safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'toggle_budget_member_paid',
|
||||
description: 'Mark or unmark a member as having paid their share of a budget item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
memberId: z.number().int().positive().describe('User ID of the member'),
|
||||
paid: z.boolean(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async toggleBudgetMemberPaid({ tripId, itemId, memberId, paid }: { tripId: number; itemId: number; memberId: number; paid: boolean }, ctx: McpContext) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const member = this.budget.toggleMemberPaid(itemId, tripId, memberId, paid);
|
||||
safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, member });
|
||||
return ok({ member });
|
||||
}
|
||||
|
||||
// --- SETTLEMENTS (settle-up payments between members) ---
|
||||
|
||||
@Tool({
|
||||
name: 'get_settlement_summary',
|
||||
description: "See each member's net balance and the suggested payments to settle shared expenses. Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'read' },
|
||||
})
|
||||
async getSettlementSummary({ tripId, base }: { tripId: number; base?: string }, ctx: McpContext) {
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
const tripCurrency = trip?.currency || 'EUR';
|
||||
const effectiveBase = (base || tripCurrency).toUpperCase();
|
||||
const rates = await this.exchangeRates.getRates(effectiveBase);
|
||||
const summary = this.budget.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
return ok({ summary });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_settlements',
|
||||
description: 'List the recorded settle-up payments for a trip (who paid whom, how much, when).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'read' },
|
||||
})
|
||||
async listSettlements({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
return ok({ settlements: this.budget.listSettlements(tripId) });
|
||||
}
|
||||
|
||||
@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.",
|
||||
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"),
|
||||
},
|
||||
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 },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const settlement = this.budget.insertSettlement(tripId, { from_user_id, to_user_id, amount }, ctx.userId);
|
||||
safeBroadcast(tripId, 'budget:settlement-created', { settlement });
|
||||
return ok({ settlement });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_settlement',
|
||||
description: 'Update a recorded settle-up payment (who paid, who received, and the amount).',
|
||||
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"),
|
||||
},
|
||||
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 },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const settlement = this.budget.applySettlementUpdate(settlementId, tripId, { from_user_id, to_user_id, amount });
|
||||
if (!settlement) return errorResult('Settlement not found.');
|
||||
safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
|
||||
return ok({ settlement });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_settlement',
|
||||
description: 'Delete a recorded settle-up payment. This is the undo for create_settlement and restores the affected balances.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
settlementId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async deleteSettlement({ tripId, settlementId }: { tripId: number; settlementId: number }, ctx: McpContext) {
|
||||
if (isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const deleted = this.budget.deleteSettlement(settlementId, tripId);
|
||||
if (!deleted) return errorResult('Settlement not found.');
|
||||
safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
// --- RESOURCES ---
|
||||
|
||||
@ResourceTemplate({
|
||||
name: 'trip-budget',
|
||||
uriTemplate: 'trek://trips/{tripId}/budget',
|
||||
description: 'Budget and expense items for a trip',
|
||||
mimeType: 'application/json',
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'read' },
|
||||
})
|
||||
async tripBudgetResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !this.budget.verifyTripAccess(id, ctx.userId)) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: 'Trip not found or access denied' }),
|
||||
}],
|
||||
};
|
||||
}
|
||||
const items = this.budget.listBudgetItems(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(items, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
@ResourceTemplate({
|
||||
name: 'trip-budget-per-person',
|
||||
uriTemplate: 'trek://trips/{tripId}/budget/per-person',
|
||||
description: 'Per-person budget summary for a trip (total spent per member, split breakdown)',
|
||||
mimeType: 'application/json',
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'read' },
|
||||
})
|
||||
async tripBudgetPerPersonResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !this.budget.verifyTripAccess(id, ctx.userId)) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: 'Trip not found or access denied' }),
|
||||
}],
|
||||
};
|
||||
}
|
||||
const summary = this.budget.getPerPersonSummary(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(summary, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
@ResourceTemplate({
|
||||
name: 'trip-budget-settlement',
|
||||
uriTemplate: 'trek://trips/{tripId}/budget/settlement',
|
||||
description: 'Suggested settlement transactions to balance who owes whom',
|
||||
mimeType: 'application/json',
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'read' },
|
||||
})
|
||||
async tripBudgetSettlementResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !this.budget.verifyTripAccess(id, ctx.userId)) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: 'Trip not found or access denied' }),
|
||||
}],
|
||||
};
|
||||
}
|
||||
const settlement = this.budget.calculateSettlement(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(settlement, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BudgetController } from './budget.controller';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { BudgetMcp } from './budget.mcp';
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
|
||||
/** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule. */
|
||||
/** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule.
|
||||
* BudgetMcp carries the decorator-registered MCP tools + resources. */
|
||||
@Module({
|
||||
imports: [PermissionsModule],
|
||||
controllers: [BudgetController],
|
||||
providers: [BudgetService, ExchangeRatesService],
|
||||
// For in-container consumers (PluginHostDepsFactory).
|
||||
providers: [BudgetService, ExchangeRatesService, BudgetMcp],
|
||||
// For in-container consumers (PluginHostDepsFactory, TripsService,
|
||||
// ReservationsService, BookingImportService).
|
||||
exports: [BudgetService, ExchangeRatesService],
|
||||
})
|
||||
export class BudgetModule {}
|
||||
|
||||
@@ -2,31 +2,41 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/budgetService';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { avatarUrl } from '../../services/avatarUrl';
|
||||
import type { User, BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../types';
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
type Trip = NonNullable<ReturnType<typeof verifyTripAccess>>;
|
||||
|
||||
type SettlementRow = {
|
||||
id: number; trip_id: string; from_user_id: number; to_user_id: number;
|
||||
amount: number; currency: string | null; exchange_rate: number | null;
|
||||
created_at: string; created_by_user_id: number | null;
|
||||
from_username: string; from_avatar: string | null;
|
||||
to_username: string; to_avatar: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing budget service. Trip-access, the
|
||||
* 'budget_edit' permission, the SQL, settlement maths and the WebSocket
|
||||
* broadcasts all reuse the legacy code unchanged.
|
||||
* Budget domain service — owns the budget SQL (moved 1:1 from the legacy
|
||||
* services/budgetService.ts: identical statements, the `||` falsy-coercion
|
||||
* defaults, the COALESCE / CASE WHEN sentinel conventions on update and the
|
||||
* post-write re-selects). Trip access, the 'budget_edit' permission and the
|
||||
* WebSocket broadcast keep their legacy call paths. Non-Nest consumers (the
|
||||
* legacy tripService/userCleanupService and the legacy MCP trips/transports
|
||||
* registrars) go through budget.bridge.ts instead of importing this class
|
||||
* directly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BudgetService {
|
||||
constructor(
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly db: DatabaseService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly exchangeRates: ExchangeRatesService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return verifyTripAccess(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
@@ -37,76 +47,797 @@ export class BudgetService {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private loadItemMembers(itemId: number | string) {
|
||||
const rows = this.db.all<BudgetItemMember>(`
|
||||
SELECT bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id = ?
|
||||
`, itemId);
|
||||
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
||||
}
|
||||
|
||||
private loadItemPayers(itemId: number | string) {
|
||||
const rows = this.db.all<BudgetItemPayer>(`
|
||||
SELECT bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id = ?
|
||||
`, itemId);
|
||||
return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) }));
|
||||
}
|
||||
|
||||
private knownUserIds(userIds: number[]): Set<number> {
|
||||
const unique = [...new Set(userIds)];
|
||||
if (unique.length === 0) return new Set();
|
||||
const rows = this.db.all<{ id: number }>(
|
||||
`SELECT id FROM users WHERE id IN (${unique.map(() => '?').join(',')})`,
|
||||
...unique,
|
||||
);
|
||||
return new Set(rows.map(r => r.id));
|
||||
}
|
||||
|
||||
/** Replace the payer rows of an item and keep total_price = sum of payer amounts. */
|
||||
private writeItemPayers(itemId: number | string, payers: { user_id: number; amount: number }[]) {
|
||||
this.db.run('DELETE FROM budget_item_payers WHERE budget_item_id = ?', itemId);
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)');
|
||||
const known = this.knownUserIds(payers.map(p => p.user_id));
|
||||
let total = 0;
|
||||
for (const p of payers) {
|
||||
if (!(p.amount > 0) || !known.has(p.user_id)) continue;
|
||||
insert.run(itemId, p.user_id, p.amount);
|
||||
total += p.amount;
|
||||
}
|
||||
this.db.run('UPDATE budget_items SET total_price = ? WHERE id = ?', total, itemId);
|
||||
return total;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
listBudgetItems(tripId: string | number) {
|
||||
const items = this.db.all<BudgetItem>(`
|
||||
SELECT bi.* FROM budget_items bi
|
||||
LEFT JOIN budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = bi.category
|
||||
WHERE bi.trip_id = ?
|
||||
ORDER BY COALESCE(bco.sort_order, 999999) ASC, bi.sort_order ASC
|
||||
`, tripId);
|
||||
|
||||
const itemIds = items.map(i => i.id);
|
||||
const membersByItem: Record<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};
|
||||
|
||||
if (itemIds.length > 0) {
|
||||
const allMembers = this.db.all<BudgetItemMember & { budget_item_id: number }>(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
||||
`, ...itemIds);
|
||||
|
||||
for (const m of allMembers) {
|
||||
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
|
||||
membersByItem[m.budget_item_id].push({
|
||||
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m), amount: m.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {};
|
||||
if (itemIds.length > 0) {
|
||||
const allPayers = this.db.all<BudgetItemPayer & { budget_item_id: number }>(`
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
||||
`, ...itemIds);
|
||||
|
||||
for (const p of allPayers) {
|
||||
if (!payersByItem[p.budget_item_id]) payersByItem[p.budget_item_id] = [];
|
||||
payersByItem[p.budget_item_id].push({
|
||||
user_id: p.user_id, amount: p.amount, username: p.username, avatar_url: avatarUrl(p),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
item.members = membersByItem[item.id] || [];
|
||||
item.payers = payersByItem[item.id] || [];
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the live FX rate at entry time into `exchange_rate` so a settled position
|
||||
* isn't re-opened when live rates drift later (#1335 / #1445). The stored rate is
|
||||
* "units of the item/display currency per 1 trip currency" — the settlement
|
||||
* converts with it via `amount / rate`.
|
||||
*
|
||||
* Only freezes for a foreign currency with no explicit rate; degrades to live
|
||||
* rates if the fetch fails. On update it (re)freezes only when the currency
|
||||
* changes (checked against `budget_items`), so an unrelated edit never moves
|
||||
* money. Callers must invoke this *before* the (synchronous) DB write — the raw
|
||||
* create/update stay sync because better-sqlite3 transactions can't await.
|
||||
*/
|
||||
async freezeForeignRate(
|
||||
tripId: string | number,
|
||||
data: { currency?: string | null; exchange_rate?: number },
|
||||
existingItemId?: string | number,
|
||||
existingCurrency?: string | null,
|
||||
): Promise<void> {
|
||||
if (data.exchange_rate != null) return; // an explicit rate from the caller wins
|
||||
const cur = (data.currency || '').toUpperCase();
|
||||
if (!cur) return; // currency not being set in this request
|
||||
// Skip the re-freeze when the currency isn't actually changing, so an unrelated
|
||||
// edit never moves money. Items resolve the prior currency from budget_items; a
|
||||
// settlement lives in a different table, so its caller passes it in directly.
|
||||
let prior: string | undefined;
|
||||
if (existingCurrency !== undefined) {
|
||||
prior = (existingCurrency || '').toUpperCase();
|
||||
} else if (existingItemId != null) {
|
||||
const existing = this.db.get<{ currency?: string }>('SELECT currency FROM budget_items WHERE id = ?', existingItemId);
|
||||
if (existing) prior = (existing.currency || '').toUpperCase();
|
||||
}
|
||||
if (prior !== undefined && prior === cur) return; // currency unchanged
|
||||
const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
const tripCur = (trip?.currency || 'EUR').toUpperCase();
|
||||
if (cur === tripCur) return; // same as the trip currency → no conversion to freeze
|
||||
const rates = await this.exchangeRates.getRates(tripCur);
|
||||
const r = rates?.[cur];
|
||||
if (r && r > 0) data.exchange_rate = r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor a trip's money when its base currency changes (#1543).
|
||||
*
|
||||
* Every frozen `exchange_rate` is "units of the row's currency per 1 *trip*
|
||||
* currency", and `currency = NULL` means "the trip's own currency" — both are
|
||||
* relative to the trip currency, so swapping it out from under them silently
|
||||
* corrupts the settlement: NULL rows redenominate (9 000 RUB becomes 9 000 EUR)
|
||||
* and frozen rates keep pointing at the old base, which is exactly the mismatch
|
||||
* that inflated #1543 by ~27x.
|
||||
*
|
||||
* So, before the switch: pin the implicit rows to the outgoing currency (their
|
||||
* amounts really were in it) and re-freeze every row against the incoming one.
|
||||
* No stored amount is rewritten — each expense keeps the figure the user typed,
|
||||
* in the currency they typed it in, and its real-world value is preserved.
|
||||
*
|
||||
* Place prices follow the same NULL = "the trip's own currency" convention (that
|
||||
* is how the PDF export and the place chips read them), so they are pinned too —
|
||||
* otherwise a €15 museum on a trip switched to JPY starts reading as ¥15. They
|
||||
* carry no frozen rate, so pinning the currency is all they need.
|
||||
*
|
||||
* Must run *before* the (synchronous) trip update, while the old currency is
|
||||
* still in `trips`, and is a no-op when the currency isn't actually changing.
|
||||
*/
|
||||
async rebaseTripCurrency(
|
||||
tripId: string | number,
|
||||
newCurrency: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const next = (newCurrency || '').toUpperCase();
|
||||
if (!next) return;
|
||||
const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
if (!trip) return;
|
||||
const prev = (trip.currency || 'EUR').toUpperCase();
|
||||
if (prev === next) return;
|
||||
|
||||
const rates = await this.exchangeRates.getRates(next);
|
||||
// A row already denominated in the new base needs no conversion (rate 1). When no
|
||||
// live rate is available we also store 1 rather than a stale one: rate 1 means "not
|
||||
// frozen", so the settlement falls back to live rates instead of trusting a figure
|
||||
// anchored to a currency this trip no longer uses.
|
||||
const rateFor = (cur: string): number => {
|
||||
if (cur === next) return 1;
|
||||
const r = rates?.[cur];
|
||||
return r && r > 0 ? r : 1;
|
||||
};
|
||||
|
||||
const rebase = (table: 'budget_items' | 'budget_settlements') => {
|
||||
this.db.run(`UPDATE ${table} SET currency = ? WHERE trip_id = ? AND (currency IS NULL OR currency = '')`, prev, tripId);
|
||||
const rows = this.db.all<{ cur: string }>(
|
||||
`SELECT DISTINCT currency AS cur FROM ${table} WHERE trip_id = ? AND currency IS NOT NULL`,
|
||||
tripId,
|
||||
);
|
||||
for (const { cur } of rows) {
|
||||
this.db.run(`UPDATE ${table} SET exchange_rate = ? WHERE trip_id = ? AND currency = ?`, rateFor(cur.toUpperCase()), tripId, cur);
|
||||
}
|
||||
};
|
||||
|
||||
// Only priced places have anything to denominate; a currency on a free place would
|
||||
// just be noise. `updated_at` doubles as the optimistic-concurrency token (#1135),
|
||||
// so bumping it stops a client holding the pre-switch row from writing the pin away.
|
||||
const pinPlaces = () => {
|
||||
this.db.run(`
|
||||
UPDATE places SET currency = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE trip_id = ? AND price IS NOT NULL AND (currency IS NULL OR currency = '')
|
||||
`, prev, tripId);
|
||||
};
|
||||
|
||||
this.db.transaction(() => { rebase('budget_items'); rebase('budget_settlements'); pinPlaces(); });
|
||||
}
|
||||
|
||||
createBudgetItem(
|
||||
tripId: string | number,
|
||||
data: {
|
||||
category?: string; name: string; total_price?: number;
|
||||
currency?: string | null; exchange_rate?: number;
|
||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||
members?: { user_id: number; amount?: number | null }[];
|
||||
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
||||
reservation_id?: number | null;
|
||||
},
|
||||
) {
|
||||
const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?', tripId)!;
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const cat = data.category || 'other';
|
||||
|
||||
// Ensure category has a sort_order entry
|
||||
const catExists = this.db.get('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?', tripId, cat);
|
||||
if (!catExists) {
|
||||
const maxCatOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?', tripId);
|
||||
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
|
||||
this.db.run('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)', tripId, cat, catOrder);
|
||||
}
|
||||
|
||||
// total_price is derived from explicit payers when given; otherwise the caller
|
||||
// value (planning entries, or a bill no one has paid yet).
|
||||
const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0);
|
||||
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
|
||||
|
||||
const knownMembers = data.members ? this.knownUserIds(data.members.map(m => m.user_id)) : null;
|
||||
const members = data.members && knownMembers ? data.members.filter(m => knownMembers.has(m.user_id)) : undefined;
|
||||
const knownIds = data.member_ids ? this.knownUserIds(data.member_ids) : null;
|
||||
const memberIds = data.member_ids && knownIds ? data.member_ids.filter(uid => knownIds.has(uid)) : undefined;
|
||||
|
||||
const result = this.db.run(
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId,
|
||||
cat,
|
||||
data.name,
|
||||
total,
|
||||
data.currency || null,
|
||||
data.exchange_rate != null ? data.exchange_rate : 1,
|
||||
memberIds ? memberIds.length : (data.persons != null ? data.persons : null),
|
||||
data.days !== undefined && data.days !== null ? data.days : null,
|
||||
data.note || null,
|
||||
sortOrder,
|
||||
data.expense_date || null,
|
||||
data.reservation_id != null ? data.reservation_id : null,
|
||||
);
|
||||
|
||||
const itemId = result.lastInsertRowid as number;
|
||||
if (data.payers && data.payers.length > 0) this.writeItemPayers(itemId, data.payers);
|
||||
if (members && members.length > 0) {
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
|
||||
for (const m of members) insert.run(itemId, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
|
||||
} else if (memberIds && memberIds.length > 0) {
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
|
||||
for (const uid of memberIds) insert.run(itemId, uid);
|
||||
}
|
||||
|
||||
const item = this.db.get<BudgetItem>('SELECT * FROM budget_items WHERE id = ?', itemId)!;
|
||||
item.members = this.loadItemMembers(itemId);
|
||||
item.payers = this.loadItemPayers(itemId);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Fetch a single budget item hydrated with its members and payers, scoped to the trip. */
|
||||
getBudgetItem(id: string | number, tripId: string | number): BudgetItem | null {
|
||||
const item = this.db.get<BudgetItem>('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!item) return null;
|
||||
item.members = this.loadItemMembers(id);
|
||||
item.payers = this.loadItemPayers(id);
|
||||
return item;
|
||||
}
|
||||
|
||||
linkBudgetItemToReservation(
|
||||
tripId: string | number,
|
||||
reservationId: number,
|
||||
data: { name: string; category?: string; total_price: number },
|
||||
) {
|
||||
const item = this.createBudgetItem(tripId, data) as BudgetItem & { reservation_id?: number | null };
|
||||
this.db.run('UPDATE budget_items SET reservation_id = ? WHERE id = ?', reservationId, item.id);
|
||||
item.reservation_id = reservationId;
|
||||
return item;
|
||||
}
|
||||
|
||||
updateBudgetItem(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: {
|
||||
category?: string; name?: string; total_price?: number;
|
||||
currency?: string | null; exchange_rate?: number;
|
||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||
members?: { user_id: number; amount?: number | null }[];
|
||||
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
|
||||
},
|
||||
) {
|
||||
const item = this.db.get('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!item) return null;
|
||||
|
||||
this.db.run(`
|
||||
UPDATE budget_items SET
|
||||
category = COALESCE(?, category),
|
||||
name = COALESCE(?, name),
|
||||
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
|
||||
currency = CASE WHEN ? THEN ? ELSE currency END,
|
||||
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END,
|
||||
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
||||
days = CASE WHEN ? THEN ? ELSE days END,
|
||||
note = CASE WHEN ? THEN ? ELSE note END,
|
||||
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END,
|
||||
expense_date = CASE WHEN ? THEN ? ELSE expense_date END
|
||||
WHERE id = ?
|
||||
`,
|
||||
data.category || null,
|
||||
data.name || null,
|
||||
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
|
||||
data.currency !== undefined ? 1 : 0, data.currency !== undefined ? (data.currency || null) : null,
|
||||
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
|
||||
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
|
||||
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
|
||||
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
|
||||
data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0,
|
||||
data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null,
|
||||
id,
|
||||
);
|
||||
|
||||
// Optional inline payer/member replacement (the edit modal saves all at once).
|
||||
if (data.payers !== undefined) {
|
||||
this.writeItemPayers(id, data.payers);
|
||||
// writeItemPayers derives total_price from the payer sum (0 for no payers).
|
||||
// A "recorded total, nobody assigned" expense clears payers but still carries
|
||||
// an explicit total_price — re-apply it so it isn't clobbered to 0.
|
||||
if (data.payers.length === 0 && data.total_price !== undefined) {
|
||||
this.db.run('UPDATE budget_items SET total_price = ? WHERE id = ?', data.total_price, id);
|
||||
}
|
||||
}
|
||||
if (data.members !== undefined) {
|
||||
const known = this.knownUserIds(data.members.map(m => m.user_id));
|
||||
const members = data.members.filter(m => known.has(m.user_id));
|
||||
this.db.run('DELETE FROM budget_item_members WHERE budget_item_id = ?', id);
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
|
||||
for (const m of members) insert.run(id, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
|
||||
this.db.run('UPDATE budget_items SET persons = ? WHERE id = ?', members.length || null, id);
|
||||
} else if (data.member_ids !== undefined) {
|
||||
const known = this.knownUserIds(data.member_ids);
|
||||
const memberIds = data.member_ids.filter(uid => known.has(uid));
|
||||
this.db.run('DELETE FROM budget_item_members WHERE budget_item_id = ?', id);
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
|
||||
for (const uid of memberIds) insert.run(id, uid);
|
||||
this.db.run('UPDATE budget_items SET persons = ? WHERE id = ?', memberIds.length || null, id);
|
||||
}
|
||||
|
||||
// If category changed, update category order table
|
||||
if (data.category) {
|
||||
const catExists = this.db.get('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?', tripId, data.category);
|
||||
if (!catExists) {
|
||||
const maxCatOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?', tripId);
|
||||
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
|
||||
this.db.run('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)', tripId, data.category, catOrder);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = this.db.get<BudgetItem>('SELECT * FROM budget_items WHERE id = ?', id)!;
|
||||
updated.members = this.loadItemMembers(id);
|
||||
updated.payers = this.loadItemPayers(id);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Payers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) {
|
||||
const item = this.db.get('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!item) return null;
|
||||
this.writeItemPayers(id, payers);
|
||||
const updated = this.db.get<BudgetItem>('SELECT * FROM budget_items WHERE id = ?', id)!;
|
||||
updated.members = this.loadItemMembers(id);
|
||||
updated.payers = this.loadItemPayers(id);
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteBudgetItem(id: string | number, tripId: string | number): boolean {
|
||||
const item = this.db.get('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!item) return false;
|
||||
this.db.run('DELETE FROM budget_items WHERE id = ?', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Members
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
updateMembers(id: string | number, tripId: string | number, userIds: number[]) {
|
||||
const item = this.db.get('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!item) return null;
|
||||
|
||||
const existingPaid: Record<number, number> = {};
|
||||
const existing = this.db.all<{ user_id: number; paid: number }>('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?', id);
|
||||
for (const e of existing) existingPaid[e.user_id] = e.paid;
|
||||
|
||||
this.db.run('DELETE FROM budget_item_members WHERE budget_item_id = ?', id);
|
||||
|
||||
const known = this.knownUserIds(userIds);
|
||||
const memberIds = userIds.filter(uid => known.has(uid));
|
||||
if (memberIds.length > 0) {
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
|
||||
for (const userId of memberIds) insert.run(id, userId, existingPaid[userId] || 0);
|
||||
this.db.run('UPDATE budget_items SET persons = ? WHERE id = ?', memberIds.length, id);
|
||||
} else {
|
||||
this.db.run('UPDATE budget_items SET persons = NULL WHERE id = ?', id);
|
||||
}
|
||||
|
||||
const members = this.loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
||||
const updated = this.db.get<BudgetItem>('SELECT * FROM budget_items WHERE id = ?', id)!;
|
||||
return { members, item: updated };
|
||||
}
|
||||
|
||||
removeUserFromBudgetItems(userId: number): void {
|
||||
const itemIds = this.db.all<{ budget_item_id: number }>(
|
||||
'SELECT DISTINCT budget_item_id FROM budget_item_members WHERE user_id = ?',
|
||||
userId,
|
||||
).map(r => r.budget_item_id);
|
||||
if (itemIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.run('DELETE FROM budget_item_members WHERE user_id = ?', userId);
|
||||
|
||||
const remaining = this.db.prepare('SELECT COUNT(*) AS count FROM budget_item_members WHERE budget_item_id = ?');
|
||||
const setPersons = this.db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?');
|
||||
for (const itemId of itemIds) {
|
||||
const { count } = remaining.get(itemId) as { count: number };
|
||||
setPersons.run(count || null, itemId);
|
||||
}
|
||||
}
|
||||
|
||||
toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) {
|
||||
// Resolve the item within the caller's trip before updating.
|
||||
const item = this.db.get('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!item) return null;
|
||||
|
||||
this.db.run('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?', paid ? 1 : 0, id, userId);
|
||||
|
||||
const member = this.db.get<BudgetItemMember>(`
|
||||
SELECT bm.user_id, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id = ? AND bm.user_id = ?
|
||||
`, id, userId);
|
||||
|
||||
return member ? { ...member, avatar_url: avatarUrl(member) } : null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Per-person summary
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getPerPersonSummary(tripId: string | number) {
|
||||
const summary = this.db.all<{ user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }>(`
|
||||
SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar,
|
||||
SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned,
|
||||
SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid,
|
||||
COUNT(bi.id) as items_count
|
||||
FROM budget_item_members bm
|
||||
JOIN budget_items bi ON bm.budget_item_id = bi.id
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bi.trip_id = ?
|
||||
GROUP BY bm.user_id
|
||||
`, tripId);
|
||||
|
||||
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
|
||||
}
|
||||
|
||||
private splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
|
||||
const n = members.length;
|
||||
if (n === 0) return {};
|
||||
|
||||
const totalCents = Math.round(total * 100);
|
||||
const baseCents = Math.floor(totalCents / n);
|
||||
const remainder = totalCents % n;
|
||||
|
||||
const shares: Record<number, number> = {};
|
||||
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id);
|
||||
const startIndex = itemId % n;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const member = sortedMembers[i];
|
||||
const hasExtraCent = ((i - startIndex + n) % n) < remainder;
|
||||
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100;
|
||||
}
|
||||
|
||||
return shares;
|
||||
}
|
||||
|
||||
calculateSettlement(
|
||||
tripId: string | number,
|
||||
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string } = {},
|
||||
) {
|
||||
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
|
||||
const tripCurrency = (opts.tripCurrency || base).toUpperCase();
|
||||
const rates = opts.rates ?? null;
|
||||
// Net the whole settlement in the trip's canonical currency and convert the final
|
||||
// totals to the display currency once, instead of netting in the (moving) display
|
||||
// currency. Otherwise per-expense rounding shifts as live FX drifts and the greedy
|
||||
// debt-simplifier reshuffles it into phantom third-party micro-flows (#1382). When
|
||||
// the display currency IS the trip currency (the common case) every conversion below
|
||||
// is the identity, so behaviour is unchanged.
|
||||
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
|
||||
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
|
||||
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
|
||||
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
|
||||
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate;
|
||||
// Legacy rows without a frozen rate: convert via base with live rates.
|
||||
if (!rates) return amount;
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
return amount;
|
||||
};
|
||||
// trip-currency → display currency, applied once to the final netted totals.
|
||||
const toDisplay = (v: number): number =>
|
||||
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v / rates[tripCurrency] : v);
|
||||
// A recorded settle-up amount is entered in whatever display currency the payer
|
||||
// was viewing. New rows capture that currency and the rate frozen at settle time
|
||||
// (#1445), so a settled position stays balanced when live rates drift — mirroring
|
||||
// toTrip for expenses. Legacy rows (currency = NULL) have no frozen rate, so fall
|
||||
// back to the old behaviour: assume they were entered in the current display base
|
||||
// and convert with live rates.
|
||||
const settleToTrip = (amount: number, sCurrency?: string | null, sRate?: number | null): number => {
|
||||
if (sCurrency) {
|
||||
const cur = sCurrency.toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
if (sRate != null && sRate > 0 && sRate !== 1) return amount / sRate;
|
||||
// Frozen currency but no usable rate (fetch failed at settle time): live fallback.
|
||||
if (rates) {
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
return base === tripCurrency ? amount : (rates && rates[tripCurrency] > 0 ? amount * rates[tripCurrency] : amount);
|
||||
};
|
||||
|
||||
const items = this.db.all<BudgetItem>('SELECT * FROM budget_items WHERE trip_id = ?', tripId);
|
||||
const allMembers = this.db.all<BudgetItemMember & { budget_item_id: number }>(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
`, tripId);
|
||||
const allPayers = this.db.all<BudgetItemPayer & { budget_item_id: number }>(`
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
`, tripId);
|
||||
|
||||
// Net balance per user, in the requested base currency: positive = is owed
|
||||
// money, negative = owes money. Each expense's amounts are converted from their
|
||||
// own currency to the base with live rates, so mixed-currency trips net correctly.
|
||||
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
||||
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
|
||||
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), balance: 0 };
|
||||
return balances[id];
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
||||
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
||||
|
||||
// Payers are credited what they actually paid (converted to trip currency with
|
||||
// the item's stored exchange rate)…
|
||||
for (const p of payers) ensure(p.user_id, p).balance += toTrip(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
|
||||
// …and each split participant owes their share — a custom per-member amount
|
||||
// when one is set, otherwise an equal share of the expense total.
|
||||
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
|
||||
const equalShares = !hasCustomSplit ? this.splitEqualShares(item.total_price, members, item.id) : {};
|
||||
for (const m of members) {
|
||||
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
|
||||
? toTrip(m.amount, item.currency, item.exchange_rate)
|
||||
: toTrip(equalShares[m.user_id] || 0, item.currency, item.exchange_rate);
|
||||
ensure(m.user_id, m).balance -= memberShare;
|
||||
}
|
||||
}
|
||||
|
||||
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
||||
// the receiver's credit shrinks, so the corresponding flow disappears. A transfer
|
||||
// counts even when neither user has an expense-derived balance yet — a manual
|
||||
// payment, or one left behind after its expense was deleted, then correctly
|
||||
// surfaces as an amount still to square up instead of silently vanishing.
|
||||
const settlements = this.listSettlements(tripId);
|
||||
const ensureSettled = (id: number, username: string | undefined, avatar_url: string | null | undefined) => {
|
||||
if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, balance: 0 };
|
||||
return balances[id];
|
||||
};
|
||||
for (const s of settlements) {
|
||||
const inTrip = settleToTrip(s.amount, s.currency, s.exchange_rate);
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += inTrip;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= inTrip;
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
|
||||
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
|
||||
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
|
||||
|
||||
// Sort by amount descending for efficient matching
|
||||
debtors.sort((a, b) => b.amount - a.amount);
|
||||
creditors.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
|
||||
|
||||
let di = 0, ci = 0;
|
||||
while (di < debtors.length && ci < creditors.length) {
|
||||
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
|
||||
if (transfer > 0.01) {
|
||||
flows.push({
|
||||
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
|
||||
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
|
||||
amount: Math.round(toDisplay(transfer) * 100) / 100,
|
||||
});
|
||||
}
|
||||
debtors[di].amount -= transfer;
|
||||
creditors[ci].amount -= transfer;
|
||||
if (debtors[di].amount < 0.01) di++;
|
||||
if (creditors[ci].amount < 0.01) ci++;
|
||||
}
|
||||
|
||||
return {
|
||||
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(toDisplay(b.balance) * 100) / 100 })),
|
||||
flows,
|
||||
settlements,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Settlements (persisted settle-up transfers — history + undo)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
listSettlements(tripId: string | number) {
|
||||
const rows = this.db.all<SettlementRow>(`
|
||||
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.created_by_user_id,
|
||||
fu.username AS from_username, fu.avatar AS from_avatar,
|
||||
tu.username AS to_username, tu.avatar AS to_avatar
|
||||
FROM budget_settlements s
|
||||
JOIN users fu ON s.from_user_id = fu.id
|
||||
JOIN users tu ON s.to_user_id = tu.id
|
||||
WHERE s.trip_id = ?
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
`, tripId);
|
||||
return rows.map(r => ({
|
||||
id: r.id, trip_id: r.trip_id,
|
||||
from_user_id: r.from_user_id, to_user_id: r.to_user_id,
|
||||
amount: r.amount, currency: r.currency ?? null, exchange_rate: r.exchange_rate ?? 1,
|
||||
created_at: r.created_at, created_by_user_id: r.created_by_user_id,
|
||||
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
|
||||
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Raw settlement insert (no FX freeze) — the REST path wraps it in createSettlement. */
|
||||
insertSettlement(
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
|
||||
createdByUserId?: number,
|
||||
) {
|
||||
const result = this.db.run(
|
||||
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
|
||||
data.currency ? data.currency.toUpperCase() : null,
|
||||
data.exchange_rate != null ? data.exchange_rate : 1,
|
||||
createdByUserId ?? null,
|
||||
);
|
||||
return this.listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
||||
}
|
||||
|
||||
/** Raw settlement update (no FX freeze) — the REST path wraps it in updateSettlement. */
|
||||
applySettlementUpdate(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
|
||||
) {
|
||||
const row = this.db.get('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!row) return null;
|
||||
this.db.run(`
|
||||
UPDATE budget_settlements SET
|
||||
from_user_id = ?, to_user_id = ?, amount = ?,
|
||||
currency = CASE WHEN ? THEN ? ELSE currency END,
|
||||
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END
|
||||
WHERE id = ?
|
||||
`,
|
||||
data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
|
||||
data.currency !== undefined ? 1 : 0, data.currency ? data.currency.toUpperCase() : null,
|
||||
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
|
||||
id,
|
||||
);
|
||||
return this.listSettlements(tripId).find(s => s.id === Number(id)) || null;
|
||||
}
|
||||
|
||||
deleteSettlement(id: string | number, tripId: string | number): boolean {
|
||||
const row = this.db.get('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!row) return false;
|
||||
this.db.run('DELETE FROM budget_settlements WHERE id = ?', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Controller-facing surface (unchanged from the pre-fold wrapper)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
list(tripId: string) {
|
||||
return svc.listBudgetItems(tripId);
|
||||
return this.listBudgetItems(tripId);
|
||||
}
|
||||
|
||||
perPersonSummary(tripId: string) {
|
||||
return svc.getPerPersonSummary(tripId);
|
||||
return this.getPerPersonSummary(tripId);
|
||||
}
|
||||
|
||||
async settlement(tripId: string, base: string | undefined, tripCurrency: string) {
|
||||
const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase();
|
||||
const rates = await this.exchangeRates.getRates(effectiveBase);
|
||||
return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
}
|
||||
|
||||
async create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) {
|
||||
await svc.freezeForeignRate(tripId, data);
|
||||
return svc.createBudgetItem(tripId, data);
|
||||
async create(tripId: string, data: Parameters<BudgetService['createBudgetItem']>[1]) {
|
||||
await this.freezeForeignRate(tripId, data);
|
||||
return this.createBudgetItem(tripId, data);
|
||||
}
|
||||
|
||||
async update(id: string, tripId: string, data: Parameters<typeof svc.updateBudgetItem>[2]) {
|
||||
await svc.freezeForeignRate(tripId, data, id);
|
||||
return svc.updateBudgetItem(id, tripId, data);
|
||||
async update(id: string, tripId: string, data: Parameters<BudgetService['updateBudgetItem']>[2]) {
|
||||
await this.freezeForeignRate(tripId, data, id);
|
||||
return this.updateBudgetItem(id, tripId, data);
|
||||
}
|
||||
|
||||
remove(id: string, tripId: string): boolean {
|
||||
return svc.deleteBudgetItem(id, tripId);
|
||||
}
|
||||
|
||||
updateMembers(id: string, tripId: string, userIds: number[]) {
|
||||
return svc.updateMembers(id, tripId, userIds);
|
||||
}
|
||||
|
||||
toggleMemberPaid(id: string, tripId: string, userId: string, paid: boolean) {
|
||||
return svc.toggleMemberPaid(id, tripId, userId, paid);
|
||||
return this.deleteBudgetItem(id, tripId);
|
||||
}
|
||||
|
||||
setPayers(id: string, tripId: string, payers: { user_id: number; amount: number }[]) {
|
||||
return svc.setItemPayers(id, tripId, payers);
|
||||
}
|
||||
|
||||
listSettlements(tripId: string) {
|
||||
return svc.listSettlements(tripId);
|
||||
return this.setItemPayers(id, tripId, payers);
|
||||
}
|
||||
|
||||
async createSettlement(tripId: string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }, userId: number) {
|
||||
// Freeze the FX rate for the display currency the amount was entered in so the
|
||||
// transfer keeps cancelling its expense when live rates drift (#1445).
|
||||
await svc.freezeForeignRate(tripId, data);
|
||||
return svc.createSettlement(tripId, data, userId);
|
||||
await this.freezeForeignRate(tripId, data);
|
||||
return this.insertSettlement(tripId, data, userId);
|
||||
}
|
||||
|
||||
async updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }) {
|
||||
// Pass the settlement's stored currency so an edit that doesn't change it keeps
|
||||
// the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a
|
||||
// settled position on an unrelated edit.
|
||||
const existing = svc.listSettlements(tripId).find((s) => s.id === Number(id));
|
||||
await svc.freezeForeignRate(tripId, data, undefined, existing?.currency ?? null);
|
||||
return svc.updateSettlement(id, tripId, data);
|
||||
}
|
||||
|
||||
deleteSettlement(id: string, tripId: string): boolean {
|
||||
return svc.deleteSettlement(id, tripId);
|
||||
const existing = this.listSettlements(tripId).find((s) => s.id === Number(id));
|
||||
await this.freezeForeignRate(tripId, data, undefined, existing?.currency ?? null);
|
||||
return this.applySettlementUpdate(id, tripId, data);
|
||||
}
|
||||
|
||||
reorderItems(tripId: string, orderedIds: number[]): void {
|
||||
svc.reorderBudgetItems(tripId, orderedIds);
|
||||
const update = this.db.prepare('UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
|
||||
this.db.transaction(() => {
|
||||
orderedIds.forEach((id, index) => update.run(index, id, tripId));
|
||||
});
|
||||
}
|
||||
|
||||
reorderCategories(tripId: string, orderedCategories: string[]): void {
|
||||
svc.reorderBudgetCategories(tripId, orderedCategories);
|
||||
const upsert = this.db.prepare(
|
||||
'INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?) ON CONFLICT(trip_id, category) DO UPDATE SET sort_order = excluded.sort_order'
|
||||
);
|
||||
this.db.transaction(() => {
|
||||
orderedCategories.forEach((cat, index) => upsert.run(tripId, cat, index));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,14 +847,15 @@ export class BudgetService {
|
||||
*/
|
||||
syncReservationPrice(tripId: string, reservationId: number, totalPrice: number, socketId: string | undefined): void {
|
||||
try {
|
||||
const reservation = this.db.prepare(
|
||||
const reservation = this.db.get<{ id: number; metadata: string | null }>(
|
||||
'SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?',
|
||||
).get(reservationId, tripId) as { id: number; metadata: string | null } | undefined;
|
||||
reservationId, tripId,
|
||||
);
|
||||
if (!reservation) return;
|
||||
const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
meta.price = String(totalPrice);
|
||||
this.db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id);
|
||||
const updatedRes = this.db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id);
|
||||
this.db.run('UPDATE reservations SET metadata = ? WHERE id = ?', JSON.stringify(meta), reservation.id);
|
||||
const updatedRes = this.db.get('SELECT * FROM reservations WHERE id = ?', reservation.id);
|
||||
broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, socketId);
|
||||
} catch (err) {
|
||||
console.error('[budget] Failed to sync price to reservation:', err);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the exchange-rates fold — for code running OUTSIDE
|
||||
* the Nest container (the legacy `services/budgetService.ts` FX-freeze/rebase
|
||||
* seams and the legacy `get_settlement_summary` registrar in
|
||||
* src/mcp/tools/budget.ts; the plugin RPC host injects ExchangeRatesService via
|
||||
* PluginHostDepsFactory). Exports only the legacy services/exchangeRateService
|
||||
* names still consumed outside the container (getRates — the dead
|
||||
* convertWithRates export was dropped in the post-fold quirk fixes), so
|
||||
* repointing a consumer is an import-path-only diff. Inside the container,
|
||||
* inject ExchangeRatesService instead.
|
||||
* Delete this file when the budgetService migration lands.
|
||||
*
|
||||
* Module-level construction is safe: the service has no dependencies and no
|
||||
* side effects at construction; its rate cache is module-scoped in
|
||||
* exchange-rates.service.ts, so this instance and the DI singleton share one
|
||||
* cached upstream feed.
|
||||
*/
|
||||
const fx = new ExchangeRatesService();
|
||||
|
||||
export function getRates(base: string) {
|
||||
return fx.getRates(base);
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import { Injectable } from '@nestjs/common';
|
||||
* Moved from the legacy `services/exchangeRateService.ts` (the budget-domain
|
||||
* fold): identical cache/coalescing behavior, `||` falsy-coercion defaults and
|
||||
* null degradation. The cache and inflight maps are deliberately MODULE-scoped,
|
||||
* not instance fields — the DI singleton and the exchange-rates.bridge instance
|
||||
* wrap the same upstream feed, so they must share one cache (same reasoning as
|
||||
* not instance fields — the DI singleton and the budget.bridge instance wrap
|
||||
* the same upstream feed, so they must share one cache (same reasoning as
|
||||
* the permissions.service.ts cache).
|
||||
*
|
||||
* Post-migration fixes on top of the relocated legacy behavior: the outbound
|
||||
|
||||
@@ -116,7 +116,9 @@ Wave-2 pair — its `checkPermission` import became the injected
|
||||
`PermissionsService` (the auditLog half never touched the factory: the plugin
|
||||
host's audit trail is the separate `plugin-audit.ts`); exchangeRateService
|
||||
swapped in 2026-07 with the budget-domain fold — its `getRates` import became
|
||||
the injected `ExchangeRatesService`; the remaining factory
|
||||
the injected `ExchangeRatesService`; budgetService swapped in 2026-07 with the
|
||||
budget migration — its `listBudgetItems` import became the already-injected
|
||||
`BudgetService`, so no constructor change at all; the remaining factory
|
||||
imports are all Wave-4+ domains: trips, places, journeys, atlas, collections).
|
||||
|
||||
### Test impact (as landed)
|
||||
@@ -126,7 +128,10 @@ imports are all Wave-4+ domains: trips, places, journeys, atlas, collections).
|
||||
- `tests/unit/plugins/plugin-host-deps.factory.test.ts` (was
|
||||
`create-rpc-host.test.ts`) — the six DI-domain path mocks became constructor
|
||||
stubs (sixteen stubs as of the 2026-07 exchange-rates fold); the ~22
|
||||
legacy-service path mocks remain; a file-local shim keeps the
|
||||
legacy-service path mocks shrank by one with the 2026-07 budget migration
|
||||
(the budgetService satisfy-the-import mock died with the legacy file — the
|
||||
`budgetStub` gained `listBudgetItems` for the two swapped closures); a
|
||||
file-local shim keeps the
|
||||
historical `createRealRpcHost(id, granted)` call sites and supplies a default
|
||||
no-op router.
|
||||
- Bridge delegation tests (TAG-SVC-016..020, CAT-SVC-016) — deleted with the
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { readEnv } from '../../../app-config';
|
||||
import { broadcast, broadcastToUser } from '../../../websocket';
|
||||
import { listBudgetItems } from '../../../services/budgetService';
|
||||
import { isUpdateConflict } from '../../../services/conflictResult';
|
||||
import { getWeather } from '../../../services/weatherService';
|
||||
import { BLOCKED_EXTENSIONS, filesDir } from '../../files/files.constants';
|
||||
@@ -426,12 +425,12 @@ export class PluginHostDepsFactory {
|
||||
const r = this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').run(id, name);
|
||||
return { cancelled: r.changes > 0 };
|
||||
},
|
||||
listCostsForTrip: (tripId) => listBudgetItems(tripId),
|
||||
listCostsForTrip: (tripId) => this.budget.listBudgetItems(tripId),
|
||||
// Cross-trip: every accessible trip's budget items (membership predicate is
|
||||
// baked into listTrips). Reuses the hydrated list so members/payers come too.
|
||||
listCostsForUser: (userId) => {
|
||||
const trips = listTrips(userId, null) as Array<{ id: number }>;
|
||||
return trips.flatMap((t) => listBudgetItems(t.id));
|
||||
return trips.flatMap((t) => this.budget.listBudgetItems(t.id));
|
||||
},
|
||||
// Reuses BudgetService.create (frozen FX + members/payers), then broadcasts
|
||||
// the same 'budget:created' event the controller emits so the web app updates
|
||||
|
||||
@@ -2,6 +2,8 @@ import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { ExchangeRatesService } from '../budget/exchange-rates.service';
|
||||
|
||||
export type { EndpointInput, ReservationEndpoint, ReservationTraveler } from './reservations.service';
|
||||
|
||||
@@ -19,7 +21,11 @@ export type { EndpointInput, ReservationEndpoint, ReservationTraveler } from './
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton.
|
||||
*/
|
||||
const reservations = new ReservationsService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)));
|
||||
const reservations = new ReservationsService(
|
||||
new DatabaseService(db),
|
||||
new PermissionsService(new DatabaseService(db)),
|
||||
new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService()),
|
||||
);
|
||||
|
||||
export function listReservations(tripId: string | number) {
|
||||
return reservations.list(tripId);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { placeExists, getAssignmentForTrip } from '../assignments/assignments.bridge';
|
||||
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
@@ -31,6 +31,7 @@ export class ReservationsMcp {
|
||||
constructor(
|
||||
private readonly reservations: ReservationsService,
|
||||
private readonly days: DaysService,
|
||||
private readonly budget: BudgetService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
@@ -100,7 +101,7 @@ export class ReservationsMcp {
|
||||
}
|
||||
|
||||
if (price != null && price > 0) {
|
||||
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
const item = this.budget.linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: budget_category || type,
|
||||
total_price: price,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
import { DaysModule } from '../days/days.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { ReservationsController } from './reservations.controller';
|
||||
@@ -16,7 +17,8 @@ import { UpcomingReservationsController } from './upcoming-reservations.controll
|
||||
*/
|
||||
@Module({
|
||||
// DaysModule: AccommodationsService + ReservationsMcp inject DaysService.
|
||||
imports: [DaysModule, PermissionsModule],
|
||||
// BudgetModule: ReservationsService + ReservationsMcp inject BudgetService (budget-sync seam).
|
||||
imports: [DaysModule, PermissionsModule, BudgetModule],
|
||||
controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController],
|
||||
providers: [ReservationsService, AccommodationsService, ReservationsMcp],
|
||||
// For in-container consumers (PluginHostDepsFactory, TripsService, BookingImportService).
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { avatarUrl } from '../../services/avatarUrl';
|
||||
import type { Reservation, User } from '../../types';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { typeToCostCategory } from '@trek/shared';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof verifyTripAccess>>;
|
||||
@@ -132,6 +132,7 @@ export class ReservationsService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly budget: BudgetService,
|
||||
) {}
|
||||
|
||||
verifyTripAccess(tripId: string | number, userId: number) {
|
||||
@@ -739,7 +740,7 @@ export class ReservationsService {
|
||||
syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
||||
if (!entry || !(Number(entry.total_price) > 0)) return;
|
||||
try {
|
||||
const item = linkBudgetItemToReservation(tripId, reservationId, {
|
||||
const item = this.budget.linkBudgetItemToReservation(tripId, reservationId, {
|
||||
name: title,
|
||||
category: entry.category || type || 'Other',
|
||||
total_price: entry.total_price!,
|
||||
@@ -761,7 +762,7 @@ export class ReservationsService {
|
||||
const oldCat = typeToCostCategory(currentType);
|
||||
const newCat = typeToCostCategory(type);
|
||||
if (oldCat !== newCat && linked.category === oldCat) {
|
||||
const updated = updateBudgetItem(linked.id, tripId, { category: newCat });
|
||||
const updated = this.budget.updateBudgetItem(linked.id, tripId, { category: newCat });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
}
|
||||
}
|
||||
@@ -776,7 +777,7 @@ export class ReservationsService {
|
||||
// Explicit clear (total_price 0/empty) — drop the linked item.
|
||||
const linked = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);
|
||||
if (linked) {
|
||||
deleteBudgetItem(linked.id, tripId);
|
||||
this.budget.deleteBudgetItem(linked.id, tripId);
|
||||
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
|
||||
}
|
||||
return;
|
||||
@@ -787,10 +788,10 @@ export class ReservationsService {
|
||||
const category = entry.category || type || currentType || 'Other';
|
||||
const existing = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);
|
||||
if (existing) {
|
||||
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
const updated = this.budget.updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
} else {
|
||||
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
const item = this.budget.createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
this.db.run('UPDATE budget_items SET reservation_id = ? WHERE id = ?', id, item.id);
|
||||
item.reservation_id = Number(id);
|
||||
broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
import { DaysModule } from '../days/days.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { PackingModule } from '../packing/packing.module';
|
||||
@@ -12,7 +13,7 @@ import { TripsService } from './trips.service';
|
||||
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
|
||||
* not capture the nested sub-domain mounts (collab, files, ...). */
|
||||
@Module({
|
||||
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule],
|
||||
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule],
|
||||
controllers: [TripsController],
|
||||
providers: [TripsService],
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { listPlaces } from '../../services/placeService';
|
||||
import { DaysService } from '../days/days.service';
|
||||
import { PackingService } from '../packing/packing.service';
|
||||
import { TodoService } from '../todo/todo.service';
|
||||
import { listBudgetItems, rebaseTripCurrency } from '../../services/budgetService';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashService';
|
||||
@@ -30,6 +30,7 @@ export class TripsService {
|
||||
private readonly reservations: ReservationsService,
|
||||
private readonly days: DaysService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly budget: BudgetService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -76,7 +77,7 @@ export class TripsService {
|
||||
// Re-anchor the budget while the outgoing currency is still on the trip row,
|
||||
// otherwise the frozen FX rates and the currency-less expenses that inherit the
|
||||
// trip's base are left pointing at a currency that no longer exists (#1543).
|
||||
await rebaseTripCurrency(tripId, body.currency);
|
||||
await this.budget.rebaseTripCurrency(tripId, body.currency);
|
||||
return tripSvc.updateTrip(tripId, userId, body, role);
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ export class TripsService {
|
||||
// (#858) never land in this viewer's offline cache.
|
||||
packingItems: this.packing.listItems(tripId, viewerId),
|
||||
todoItems: this.todo.listItems(tripId),
|
||||
budgetItems: listBudgetItems(tripId),
|
||||
budgetItems: this.budget.listBudgetItems(tripId),
|
||||
reservations: this.reservations.list(tripId),
|
||||
files: this.files.listFiles(tripId, false),
|
||||
accommodations: this.days.listAccommodations(tripId),
|
||||
|
||||
@@ -1,755 +0,0 @@
|
||||
import { db } from '../db/database';
|
||||
import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
import { getRates } from '../nest/budget/exchange-rates.bridge';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { avatarUrl };
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
function loadItemMembers(itemId: number | string) {
|
||||
const rows = db.prepare(`
|
||||
SELECT bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id = ?
|
||||
`).all(itemId) as BudgetItemMember[];
|
||||
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
||||
}
|
||||
|
||||
function loadItemPayers(itemId: number | string) {
|
||||
const rows = db.prepare(`
|
||||
SELECT bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id = ?
|
||||
`).all(itemId) as BudgetItemPayer[];
|
||||
return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) }));
|
||||
}
|
||||
|
||||
function knownUserIds(userIds: number[]): Set<number> {
|
||||
const unique = [...new Set(userIds)];
|
||||
if (unique.length === 0) return new Set();
|
||||
const rows = db.prepare(
|
||||
`SELECT id FROM users WHERE id IN (${unique.map(() => '?').join(',')})`
|
||||
).all(...unique) as { id: number }[];
|
||||
return new Set(rows.map(r => r.id));
|
||||
}
|
||||
|
||||
/** Replace the payer rows of an item and keep total_price = sum of payer amounts. */
|
||||
function writeItemPayers(itemId: number | string, payers: { user_id: number; amount: number }[]) {
|
||||
db.prepare('DELETE FROM budget_item_payers WHERE budget_item_id = ?').run(itemId);
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)');
|
||||
const known = knownUserIds(payers.map(p => p.user_id));
|
||||
let total = 0;
|
||||
for (const p of payers) {
|
||||
if (!(p.amount > 0) || !known.has(p.user_id)) continue;
|
||||
insert.run(itemId, p.user_id, p.amount);
|
||||
total += p.amount;
|
||||
}
|
||||
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(total, itemId);
|
||||
return total;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listBudgetItems(tripId: string | number) {
|
||||
const items = db.prepare(`
|
||||
SELECT bi.* FROM budget_items bi
|
||||
LEFT JOIN budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = bi.category
|
||||
WHERE bi.trip_id = ?
|
||||
ORDER BY COALESCE(bco.sort_order, 999999) ASC, bi.sort_order ASC
|
||||
`).all(tripId) as BudgetItem[];
|
||||
|
||||
const itemIds = items.map(i => i.id);
|
||||
const membersByItem: Record<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};
|
||||
|
||||
if (itemIds.length > 0) {
|
||||
const allMembers = db.prepare(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
||||
`).all(...itemIds) as (BudgetItemMember & { budget_item_id: number })[];
|
||||
|
||||
for (const m of allMembers) {
|
||||
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
|
||||
membersByItem[m.budget_item_id].push({
|
||||
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m), amount: m.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {};
|
||||
if (itemIds.length > 0) {
|
||||
const allPayers = db.prepare(`
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
||||
`).all(...itemIds) as (BudgetItemPayer & { budget_item_id: number })[];
|
||||
|
||||
for (const p of allPayers) {
|
||||
if (!payersByItem[p.budget_item_id]) payersByItem[p.budget_item_id] = [];
|
||||
payersByItem[p.budget_item_id].push({
|
||||
user_id: p.user_id, amount: p.amount, username: p.username, avatar_url: avatarUrl(p),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
item.members = membersByItem[item.id] || [];
|
||||
item.payers = payersByItem[item.id] || [];
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the live FX rate at entry time into `exchange_rate` so a settled position
|
||||
* isn't re-opened when live rates drift later (#1335 / #1445). The stored rate is
|
||||
* "units of the item/display currency per 1 trip currency" — the settlement
|
||||
* converts with it via `amount / rate`.
|
||||
*
|
||||
* Only freezes for a foreign currency with no explicit rate; degrades to live
|
||||
* rates if the fetch fails. On update it (re)freezes only when the currency
|
||||
* changes (checked against `budget_items`), so an unrelated edit never moves
|
||||
* money. Callers must invoke this *before* the (synchronous) DB write — the raw
|
||||
* create/update stay sync because better-sqlite3 transactions can't await.
|
||||
*/
|
||||
export async function freezeForeignRate(
|
||||
tripId: string | number,
|
||||
data: { currency?: string | null; exchange_rate?: number },
|
||||
existingItemId?: string | number,
|
||||
existingCurrency?: string | null,
|
||||
): Promise<void> {
|
||||
if (data.exchange_rate != null) return; // an explicit rate from the caller wins
|
||||
const cur = (data.currency || '').toUpperCase();
|
||||
if (!cur) return; // currency not being set in this request
|
||||
// Skip the re-freeze when the currency isn't actually changing, so an unrelated
|
||||
// edit never moves money. Items resolve the prior currency from budget_items; a
|
||||
// settlement lives in a different table, so its caller passes it in directly.
|
||||
let prior: string | undefined;
|
||||
if (existingCurrency !== undefined) {
|
||||
prior = (existingCurrency || '').toUpperCase();
|
||||
} else if (existingItemId != null) {
|
||||
const existing = db.prepare('SELECT currency FROM budget_items WHERE id = ?')
|
||||
.get(existingItemId) as { currency?: string } | undefined;
|
||||
if (existing) prior = (existing.currency || '').toUpperCase();
|
||||
}
|
||||
if (prior !== undefined && prior === cur) return; // currency unchanged
|
||||
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?')
|
||||
.get(tripId) as { currency?: string } | undefined;
|
||||
const tripCur = (trip?.currency || 'EUR').toUpperCase();
|
||||
if (cur === tripCur) return; // same as the trip currency → no conversion to freeze
|
||||
const rates = await getRates(tripCur);
|
||||
const r = rates?.[cur];
|
||||
if (r && r > 0) data.exchange_rate = r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor a trip's money when its base currency changes (#1543).
|
||||
*
|
||||
* Every frozen `exchange_rate` is "units of the row's currency per 1 *trip*
|
||||
* currency", and `currency = NULL` means "the trip's own currency" — both are
|
||||
* relative to the trip currency, so swapping it out from under them silently
|
||||
* corrupts the settlement: NULL rows redenominate (9 000 RUB becomes 9 000 EUR)
|
||||
* and frozen rates keep pointing at the old base, which is exactly the mismatch
|
||||
* that inflated #1543 by ~27x.
|
||||
*
|
||||
* So, before the switch: pin the implicit rows to the outgoing currency (their
|
||||
* amounts really were in it) and re-freeze every row against the incoming one.
|
||||
* No stored amount is rewritten — each expense keeps the figure the user typed,
|
||||
* in the currency they typed it in, and its real-world value is preserved.
|
||||
*
|
||||
* Place prices follow the same NULL = "the trip's own currency" convention (that
|
||||
* is how the PDF export and the place chips read them), so they are pinned too —
|
||||
* otherwise a €15 museum on a trip switched to JPY starts reading as ¥15. They
|
||||
* carry no frozen rate, so pinning the currency is all they need.
|
||||
*
|
||||
* Must run *before* the (synchronous) trip update, while the old currency is
|
||||
* still in `trips`, and is a no-op when the currency isn't actually changing.
|
||||
*/
|
||||
export async function rebaseTripCurrency(
|
||||
tripId: string | number,
|
||||
newCurrency: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const next = (newCurrency || '').toUpperCase();
|
||||
if (!next) return;
|
||||
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?')
|
||||
.get(tripId) as { currency?: string } | undefined;
|
||||
if (!trip) return;
|
||||
const prev = (trip.currency || 'EUR').toUpperCase();
|
||||
if (prev === next) return;
|
||||
|
||||
const rates = await getRates(next);
|
||||
// A row already denominated in the new base needs no conversion (rate 1). When no
|
||||
// live rate is available we also store 1 rather than a stale one: rate 1 means "not
|
||||
// frozen", so the settlement falls back to live rates instead of trusting a figure
|
||||
// anchored to a currency this trip no longer uses.
|
||||
const rateFor = (cur: string): number => {
|
||||
if (cur === next) return 1;
|
||||
const r = rates?.[cur];
|
||||
return r && r > 0 ? r : 1;
|
||||
};
|
||||
|
||||
const rebase = (table: 'budget_items' | 'budget_settlements') => {
|
||||
db.prepare(`UPDATE ${table} SET currency = ? WHERE trip_id = ? AND (currency IS NULL OR currency = '')`)
|
||||
.run(prev, tripId);
|
||||
const rows = db.prepare(`SELECT DISTINCT currency AS cur FROM ${table} WHERE trip_id = ? AND currency IS NOT NULL`)
|
||||
.all(tripId) as { cur: string }[];
|
||||
for (const { cur } of rows) {
|
||||
db.prepare(`UPDATE ${table} SET exchange_rate = ? WHERE trip_id = ? AND currency = ?`)
|
||||
.run(rateFor(cur.toUpperCase()), tripId, cur);
|
||||
}
|
||||
};
|
||||
|
||||
// Only priced places have anything to denominate; a currency on a free place would
|
||||
// just be noise. `updated_at` doubles as the optimistic-concurrency token (#1135),
|
||||
// so bumping it stops a client holding the pre-switch row from writing the pin away.
|
||||
const pinPlaces = () => {
|
||||
db.prepare(`
|
||||
UPDATE places SET currency = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE trip_id = ? AND price IS NOT NULL AND (currency IS NULL OR currency = '')
|
||||
`).run(prev, tripId);
|
||||
};
|
||||
|
||||
db.transaction(() => { rebase('budget_items'); rebase('budget_settlements'); pinPlaces(); })();
|
||||
}
|
||||
|
||||
export function createBudgetItem(
|
||||
tripId: string | number,
|
||||
data: {
|
||||
category?: string; name: string; total_price?: number;
|
||||
currency?: string | null; exchange_rate?: number;
|
||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||
members?: { user_id: number; amount?: number | null }[];
|
||||
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
||||
reservation_id?: number | null;
|
||||
},
|
||||
) {
|
||||
const maxOrder = db.prepare(
|
||||
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
|
||||
).get(tripId) as { max: number | null };
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const cat = data.category || 'other';
|
||||
|
||||
// Ensure category has a sort_order entry
|
||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
|
||||
if (!catExists) {
|
||||
const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
|
||||
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
|
||||
}
|
||||
|
||||
// total_price is derived from explicit payers when given; otherwise the caller
|
||||
// value (planning entries, or a bill no one has paid yet).
|
||||
const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0);
|
||||
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
|
||||
|
||||
const knownMembers = data.members ? knownUserIds(data.members.map(m => m.user_id)) : null;
|
||||
const members = data.members && knownMembers ? data.members.filter(m => knownMembers.has(m.user_id)) : undefined;
|
||||
const knownIds = data.member_ids ? knownUserIds(data.member_ids) : null;
|
||||
const memberIds = data.member_ids && knownIds ? data.member_ids.filter(uid => knownIds.has(uid)) : undefined;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
cat,
|
||||
data.name,
|
||||
total,
|
||||
data.currency || null,
|
||||
data.exchange_rate != null ? data.exchange_rate : 1,
|
||||
memberIds ? memberIds.length : (data.persons != null ? data.persons : null),
|
||||
data.days !== undefined && data.days !== null ? data.days : null,
|
||||
data.note || null,
|
||||
sortOrder,
|
||||
data.expense_date || null,
|
||||
data.reservation_id != null ? data.reservation_id : null,
|
||||
);
|
||||
|
||||
const itemId = result.lastInsertRowid as number;
|
||||
if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
|
||||
if (members && members.length > 0) {
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
|
||||
for (const m of members) insert.run(itemId, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
|
||||
} else if (memberIds && memberIds.length > 0) {
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
|
||||
for (const uid of memberIds) insert.run(itemId, uid);
|
||||
}
|
||||
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId) as BudgetItem;
|
||||
item.members = loadItemMembers(itemId);
|
||||
item.payers = loadItemPayers(itemId);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Fetch a single budget item hydrated with its members and payers, scoped to the trip. */
|
||||
export function getBudgetItem(id: string | number, tripId: string | number): BudgetItem | null {
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId) as BudgetItem | undefined;
|
||||
if (!item) return null;
|
||||
item.members = loadItemMembers(id);
|
||||
item.payers = loadItemPayers(id);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function linkBudgetItemToReservation(
|
||||
tripId: string | number,
|
||||
reservationId: number,
|
||||
data: { name: string; category?: string; total_price: number },
|
||||
) {
|
||||
const item = createBudgetItem(tripId, data) as BudgetItem & { reservation_id?: number | null };
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id);
|
||||
item.reservation_id = reservationId;
|
||||
return item;
|
||||
}
|
||||
|
||||
export function updateBudgetItem(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: {
|
||||
category?: string; name?: string; total_price?: number;
|
||||
currency?: string | null; exchange_rate?: number;
|
||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||
members?: { user_id: number; amount?: number | null }[];
|
||||
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
|
||||
},
|
||||
) {
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE budget_items SET
|
||||
category = COALESCE(?, category),
|
||||
name = COALESCE(?, name),
|
||||
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
|
||||
currency = CASE WHEN ? THEN ? ELSE currency END,
|
||||
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END,
|
||||
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
||||
days = CASE WHEN ? THEN ? ELSE days END,
|
||||
note = CASE WHEN ? THEN ? ELSE note END,
|
||||
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END,
|
||||
expense_date = CASE WHEN ? THEN ? ELSE expense_date END
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
data.category || null,
|
||||
data.name || null,
|
||||
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
|
||||
data.currency !== undefined ? 1 : 0, data.currency !== undefined ? (data.currency || null) : null,
|
||||
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
|
||||
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
|
||||
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
|
||||
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
|
||||
data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0,
|
||||
data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null,
|
||||
id,
|
||||
);
|
||||
|
||||
// Optional inline payer/member replacement (the edit modal saves all at once).
|
||||
if (data.payers !== undefined) {
|
||||
writeItemPayers(id, data.payers);
|
||||
// writeItemPayers derives total_price from the payer sum (0 for no payers).
|
||||
// A "recorded total, nobody assigned" expense clears payers but still carries
|
||||
// an explicit total_price — re-apply it so it isn't clobbered to 0.
|
||||
if (data.payers.length === 0 && data.total_price !== undefined) {
|
||||
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(data.total_price, id);
|
||||
}
|
||||
}
|
||||
if (data.members !== undefined) {
|
||||
const known = knownUserIds(data.members.map(m => m.user_id));
|
||||
const members = data.members.filter(m => known.has(m.user_id));
|
||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
|
||||
for (const m of members) insert.run(id, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
|
||||
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(members.length || null, id);
|
||||
} else if (data.member_ids !== undefined) {
|
||||
const known = knownUserIds(data.member_ids);
|
||||
const memberIds = data.member_ids.filter(uid => known.has(uid));
|
||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
|
||||
for (const uid of memberIds) insert.run(id, uid);
|
||||
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(memberIds.length || null, id);
|
||||
}
|
||||
|
||||
// If category changed, update category order table
|
||||
if (data.category) {
|
||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
|
||||
if (!catExists) {
|
||||
const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
|
||||
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, data.category, catOrder);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
|
||||
updated.members = loadItemMembers(id);
|
||||
updated.payers = loadItemPayers(id);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) {
|
||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return null;
|
||||
writeItemPayers(id, payers);
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
|
||||
updated.members = loadItemMembers(id);
|
||||
updated.payers = loadItemPayers(id);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteBudgetItem(id: string | number, tripId: string | number): boolean {
|
||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return false;
|
||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function updateMembers(id: string | number, tripId: string | number, userIds: number[]) {
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return null;
|
||||
|
||||
const existingPaid: Record<number, number> = {};
|
||||
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id) as { user_id: number; paid: number }[];
|
||||
for (const e of existing) existingPaid[e.user_id] = e.paid;
|
||||
|
||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||
|
||||
const known = knownUserIds(userIds);
|
||||
const memberIds = userIds.filter(uid => known.has(uid));
|
||||
if (memberIds.length > 0) {
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
|
||||
for (const userId of memberIds) insert.run(id, userId, existingPaid[userId] || 0);
|
||||
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(memberIds.length, id);
|
||||
} else {
|
||||
db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
|
||||
return { members, item: updated };
|
||||
}
|
||||
|
||||
export function removeUserFromBudgetItems(userId: number): void {
|
||||
const itemIds = (db.prepare('SELECT DISTINCT budget_item_id FROM budget_item_members WHERE user_id = ?')
|
||||
.all(userId) as { budget_item_id: number }[]).map(r => r.budget_item_id);
|
||||
if (itemIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM budget_item_members WHERE user_id = ?').run(userId);
|
||||
|
||||
const remaining = db.prepare('SELECT COUNT(*) AS count FROM budget_item_members WHERE budget_item_id = ?');
|
||||
const setPersons = db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?');
|
||||
for (const itemId of itemIds) {
|
||||
const { count } = remaining.get(itemId) as { count: number };
|
||||
setPersons.run(count || null, itemId);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) {
|
||||
// Resolve the item within the caller's trip before updating.
|
||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return null;
|
||||
|
||||
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
|
||||
.run(paid ? 1 : 0, id, userId);
|
||||
|
||||
const member = db.prepare(`
|
||||
SELECT bm.user_id, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id = ? AND bm.user_id = ?
|
||||
`).get(id, userId) as BudgetItemMember | undefined;
|
||||
|
||||
return member ? { ...member, avatar_url: avatarUrl(member) } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-person summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getPerPersonSummary(tripId: string | number) {
|
||||
const summary = db.prepare(`
|
||||
SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar,
|
||||
SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned,
|
||||
SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid,
|
||||
COUNT(bi.id) as items_count
|
||||
FROM budget_item_members bm
|
||||
JOIN budget_items bi ON bm.budget_item_id = bi.id
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bi.trip_id = ?
|
||||
GROUP BY bm.user_id
|
||||
`).all(tripId) as { user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }[];
|
||||
|
||||
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
|
||||
}
|
||||
|
||||
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
|
||||
const n = members.length;
|
||||
if (n === 0) return {};
|
||||
|
||||
const totalCents = Math.round(total * 100);
|
||||
const baseCents = Math.floor(totalCents / n);
|
||||
const remainder = totalCents % n;
|
||||
|
||||
const shares: Record<number, number> = {};
|
||||
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id);
|
||||
const startIndex = itemId % n;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const member = sortedMembers[i];
|
||||
const hasExtraCent = ((i - startIndex + n) % n) < remainder;
|
||||
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100;
|
||||
}
|
||||
|
||||
return shares;
|
||||
}
|
||||
|
||||
export function calculateSettlement(
|
||||
tripId: string | number,
|
||||
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string } = {},
|
||||
) {
|
||||
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
|
||||
const tripCurrency = (opts.tripCurrency || base).toUpperCase();
|
||||
const rates = opts.rates ?? null;
|
||||
// Net the whole settlement in the trip's canonical currency and convert the final
|
||||
// totals to the display currency once, instead of netting in the (moving) display
|
||||
// currency. Otherwise per-expense rounding shifts as live FX drifts and the greedy
|
||||
// debt-simplifier reshuffles it into phantom third-party micro-flows (#1382). When
|
||||
// the display currency IS the trip currency (the common case) every conversion below
|
||||
// is the identity, so behaviour is unchanged.
|
||||
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
|
||||
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
|
||||
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
|
||||
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
|
||||
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate;
|
||||
// Legacy rows without a frozen rate: convert via base with live rates.
|
||||
if (!rates) return amount;
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
return amount;
|
||||
};
|
||||
// trip-currency → display currency, applied once to the final netted totals.
|
||||
const toDisplay = (v: number): number =>
|
||||
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v / rates[tripCurrency] : v);
|
||||
// A recorded settle-up amount is entered in whatever display currency the payer
|
||||
// was viewing. New rows capture that currency and the rate frozen at settle time
|
||||
// (#1445), so a settled position stays balanced when live rates drift — mirroring
|
||||
// toTrip for expenses. Legacy rows (currency = NULL) have no frozen rate, so fall
|
||||
// back to the old behaviour: assume they were entered in the current display base
|
||||
// and convert with live rates.
|
||||
const settleToTrip = (amount: number, sCurrency?: string | null, sRate?: number | null): number => {
|
||||
if (sCurrency) {
|
||||
const cur = sCurrency.toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
if (sRate != null && sRate > 0 && sRate !== 1) return amount / sRate;
|
||||
// Frozen currency but no usable rate (fetch failed at settle time): live fallback.
|
||||
if (rates) {
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
return base === tripCurrency ? amount : (rates && rates[tripCurrency] > 0 ? amount * rates[tripCurrency] : amount);
|
||||
};
|
||||
|
||||
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
||||
const allMembers = db.prepare(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
||||
const allPayers = db.prepare(`
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
`).all(tripId) as (BudgetItemPayer & { budget_item_id: number })[];
|
||||
|
||||
// Net balance per user, in the requested base currency: positive = is owed
|
||||
// money, negative = owes money. Each expense's amounts are converted from their
|
||||
// own currency to the base with live rates, so mixed-currency trips net correctly.
|
||||
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
||||
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
|
||||
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), balance: 0 };
|
||||
return balances[id];
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
||||
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
||||
|
||||
// Payers are credited what they actually paid (converted to trip currency with
|
||||
// the item's stored exchange rate)…
|
||||
for (const p of payers) ensure(p.user_id, p).balance += toTrip(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
|
||||
// …and each split participant owes their share — a custom per-member amount
|
||||
// when one is set, otherwise an equal share of the expense total.
|
||||
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
|
||||
const equalShares = !hasCustomSplit ? splitEqualShares(item.total_price, members, item.id) : {};
|
||||
for (const m of members) {
|
||||
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
|
||||
? toTrip(m.amount, item.currency, item.exchange_rate)
|
||||
: toTrip(equalShares[m.user_id] || 0, item.currency, item.exchange_rate);
|
||||
ensure(m.user_id, m).balance -= memberShare;
|
||||
}
|
||||
}
|
||||
|
||||
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
||||
// the receiver's credit shrinks, so the corresponding flow disappears. A transfer
|
||||
// counts even when neither user has an expense-derived balance yet — a manual
|
||||
// payment, or one left behind after its expense was deleted, then correctly
|
||||
// surfaces as an amount still to square up instead of silently vanishing.
|
||||
const settlements = listSettlements(tripId);
|
||||
const ensureSettled = (id: number, username: string | undefined, avatar_url: string | null | undefined) => {
|
||||
if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, balance: 0 };
|
||||
return balances[id];
|
||||
};
|
||||
for (const s of settlements) {
|
||||
const inTrip = settleToTrip(s.amount, s.currency, s.exchange_rate);
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += inTrip;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= inTrip;
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
|
||||
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
|
||||
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
|
||||
|
||||
// Sort by amount descending for efficient matching
|
||||
debtors.sort((a, b) => b.amount - a.amount);
|
||||
creditors.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
|
||||
|
||||
let di = 0, ci = 0;
|
||||
while (di < debtors.length && ci < creditors.length) {
|
||||
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
|
||||
if (transfer > 0.01) {
|
||||
flows.push({
|
||||
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
|
||||
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
|
||||
amount: Math.round(toDisplay(transfer) * 100) / 100,
|
||||
});
|
||||
}
|
||||
debtors[di].amount -= transfer;
|
||||
creditors[ci].amount -= transfer;
|
||||
if (debtors[di].amount < 0.01) di++;
|
||||
if (creditors[ci].amount < 0.01) ci++;
|
||||
}
|
||||
|
||||
return {
|
||||
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(toDisplay(b.balance) * 100) / 100 })),
|
||||
flows,
|
||||
settlements,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settlements (persisted settle-up transfers — history + undo)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listSettlements(tripId: string | number) {
|
||||
const rows = db.prepare(`
|
||||
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.created_by_user_id,
|
||||
fu.username AS from_username, fu.avatar AS from_avatar,
|
||||
tu.username AS to_username, tu.avatar AS to_avatar
|
||||
FROM budget_settlements s
|
||||
JOIN users fu ON s.from_user_id = fu.id
|
||||
JOIN users tu ON s.to_user_id = tu.id
|
||||
WHERE s.trip_id = ?
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
`).all(tripId) as any[];
|
||||
return rows.map(r => ({
|
||||
id: r.id, trip_id: r.trip_id,
|
||||
from_user_id: r.from_user_id, to_user_id: r.to_user_id,
|
||||
amount: r.amount, currency: r.currency ?? null, exchange_rate: r.exchange_rate ?? 1,
|
||||
created_at: r.created_at, created_by_user_id: r.created_by_user_id,
|
||||
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
|
||||
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createSettlement(
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
|
||||
createdByUserId?: number,
|
||||
) {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
|
||||
data.currency ? data.currency.toUpperCase() : null,
|
||||
data.exchange_rate != null ? data.exchange_rate : 1,
|
||||
createdByUserId ?? null,
|
||||
);
|
||||
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
||||
}
|
||||
|
||||
export function updateSettlement(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
|
||||
) {
|
||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!row) return null;
|
||||
db.prepare(`
|
||||
UPDATE budget_settlements SET
|
||||
from_user_id = ?, to_user_id = ?, amount = ?,
|
||||
currency = CASE WHEN ? THEN ? ELSE currency END,
|
||||
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
|
||||
data.currency !== undefined ? 1 : 0, data.currency ? data.currency.toUpperCase() : null,
|
||||
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
|
||||
id,
|
||||
);
|
||||
return listSettlements(tripId).find(s => s.id === Number(id)) || null;
|
||||
}
|
||||
|
||||
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
|
||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!row) return false;
|
||||
db.prepare('DELETE FROM budget_settlements WHERE id = ?').run(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function reorderBudgetItems(tripId: string | number, orderedIds: number[]) {
|
||||
const update = db.prepare('UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
|
||||
db.transaction(() => {
|
||||
orderedIds.forEach((id, index) => update.run(index, id, tripId));
|
||||
})();
|
||||
}
|
||||
|
||||
export function reorderBudgetCategories(tripId: string | number, orderedCategories: string[]) {
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?) ON CONFLICT(trip_id, category) DO UPDATE SET sort_order = excluded.sort_order'
|
||||
);
|
||||
db.transaction(() => {
|
||||
orderedCategories.forEach((cat, index) => upsert.run(tripId, cat, index));
|
||||
})();
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { erasePluginUserData } from './userCleanupService';
|
||||
import { emitUserDeleted } from '../plugin-user-lifecycle';
|
||||
import { Trip, User } from '../types';
|
||||
import { listDays, listAccommodations, addDays, resyncAccommodationDays, restampReservationDates } from '../nest/days/days.bridge';
|
||||
import { listBudgetItems, removeUserFromBudgetItems } from './budgetService';
|
||||
import { listBudgetItems, removeUserFromBudgetItems } from '../nest/budget/budget.bridge';
|
||||
import { listItems as listPackingItems } from '../nest/packing/packing.bridge';
|
||||
import { listReservations, loadEndpointsByTrip, resyncReservationDays } from '../nest/reservations/reservations.bridge';
|
||||
import { listNotes as listCollabNotes } from '../nest/collab/collab.bridge';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import { db } from '../db/database';
|
||||
import { pluginsDataRoot } from '../nest/plugins/paths';
|
||||
import { removeUserFromBudgetItems } from './budgetService';
|
||||
import { removeUserFromBudgetItems } from '../nest/budget/budget.bridge';
|
||||
|
||||
/**
|
||||
* Erase a user's PLUGIN-held data on account deletion. Two parts:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Budget module e2e — exercises the migrated /api/trips/:tripId/budget endpoints
|
||||
* through the real JwtAuthGuard against a temp SQLite db. budgetService, the
|
||||
* permission check and the WebSocket broadcast are mocked.
|
||||
* through the real JwtAuthGuard against a temp SQLite db carrying the full real
|
||||
* schema (createTables + runMigrations), so the folded BudgetService runs its
|
||||
* real SQL. Only the db singleton (trip access) and the WebSocket broadcast are
|
||||
* mocked; the permission check is a spy on the container's PermissionsService.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -9,19 +11,26 @@ import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import { sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec('PRAGMA foreign_keys = ON');
|
||||
return { db: tmp };
|
||||
});
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
canAccessTrip,
|
||||
getPlaceWithTags: () => null,
|
||||
isOwner: () => false,
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
@@ -30,43 +39,46 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi
|
||||
// PermissionsService singleton (created in beforeAll, after build()).
|
||||
let checkPermission: MockInstance;
|
||||
|
||||
const { svc } = vi.hoisted(() => ({
|
||||
svc: {
|
||||
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(), freezeForeignRate: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
|
||||
setItemPayers: vi.fn(), listSettlements: vi.fn(() => []), createSettlement: vi.fn(), updateSettlement: vi.fn(), deleteSettlement: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/budgetService', () => svc);
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { BudgetModule } from '../../src/nest/budget/budget.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
describe('Budget e2e (real auth guard + temp SQLite)', () => {
|
||||
describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let tripId: number;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, BudgetModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
createTables(db);
|
||||
runMigrations(db);
|
||||
// The temp db carries the real schema (password_hash NOT NULL), so seed the
|
||||
// auth users directly instead of via the trimmed-DDL seedUser helper.
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (2, 'e2e-peer', 'peer@example.test', 'x', 'user', 0)",
|
||||
).run();
|
||||
tripId = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'E2E Trip', 'EUR')").run().lastInsertRowid);
|
||||
app = await build();
|
||||
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
||||
server = app.getHttpServer();
|
||||
svc.listBudgetItems.mockReturnValue([{ id: 1, name: 'Hotel' }]);
|
||||
svc.createBudgetItem.mockReturnValue({ id: 9, name: 'Hotel' });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
|
||||
canAccessTrip.mockReturnValue({ id: tripId, user_id: 1, currency: 'EUR' });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
@@ -75,52 +87,84 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => {
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/trips/5/budget');
|
||||
const res = await request(server).get(`/api/trips/${tripId}/budget`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list for an accessible trip', async () => {
|
||||
const res = await request(server).get('/api/trips/5/budget').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ items: [{ id: 1, name: 'Hotel' }] });
|
||||
});
|
||||
|
||||
it('404 when the trip is not accessible', async () => {
|
||||
svc.verifyTripAccess.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/budget').set('Cookie', sessionCookie(1));
|
||||
canAccessTrip.mockReturnValue(undefined);
|
||||
const res = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 on create with permission', async () => {
|
||||
const res = await request(server).post('/api/trips/5/budget').set('Cookie', sessionCookie(1)).send({ name: 'Hotel' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ item: { id: 9, name: 'Hotel' } });
|
||||
it('201 on create with permission, then 200 list returns the stored row', async () => {
|
||||
const created = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Hotel', total_price: 200 });
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.item).toMatchObject({ name: 'Hotel', total_price: 200, category: 'other', members: [], payers: [] });
|
||||
|
||||
const row = db.prepare('SELECT name, total_price FROM budget_items WHERE id = ?').get(created.body.item.id);
|
||||
expect(row).toEqual({ name: 'Hotel', total_price: 200 });
|
||||
|
||||
const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.items.map((i: { id: number }) => i.id)).toContain(created.body.item.id);
|
||||
});
|
||||
|
||||
it('403 on create without permission', async () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const res = await request(server).post('/api/trips/5/budget').set('Cookie', sessionCookie(1)).send({ name: 'Hotel' });
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Hotel' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission' });
|
||||
});
|
||||
|
||||
it('400 on member update with a non-array user_ids', async () => {
|
||||
const res = await request(server).put('/api/trips/5/budget/9/members').set('Cookie', sessionCookie(1)).send({ user_ids: 'no' });
|
||||
it('400 on member update with a non-array user_ids (Zod pipe envelope)', async () => {
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/${tripId}/budget/9/members`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ user_ids: 'no' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'user_ids must be an array' });
|
||||
expect(res.body.error).toContain('user_ids');
|
||||
});
|
||||
|
||||
it('200 on settlement update with permission', async () => {
|
||||
svc.updateSettlement.mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
it('400 on create without a name (Zod pipe envelope)', async () => {
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ total_price: 10 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.toLowerCase()).toContain('name');
|
||||
});
|
||||
|
||||
it('200 on settlement update with permission, persisting the new amount', async () => {
|
||||
const created = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget/settlements`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ from_user_id: 2, to_user_id: 1, amount: 10 });
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/${tripId}/budget/settlements/${created.body.settlement.id}`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
||||
expect(res.body.settlement).toMatchObject({ id: created.body.settlement.id, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
|
||||
const row = db.prepare('SELECT amount FROM budget_settlements WHERE id = ?').get(created.body.settlement.id);
|
||||
expect(row).toEqual({ amount: 15 });
|
||||
});
|
||||
|
||||
it('404 on settlement update when it does not exist', async () => {
|
||||
svc.updateSettlement.mockReturnValue(null);
|
||||
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/${tripId}/budget/settlements/424242`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Settlement not found' });
|
||||
});
|
||||
|
||||
@@ -45,15 +45,8 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi
|
||||
// PermissionsService singleton (created in beforeAll, after build()).
|
||||
let checkPermission: MockInstance;
|
||||
|
||||
const { budget } = vi.hoisted(() => ({
|
||||
budget: {
|
||||
createBudgetItem: vi.fn(),
|
||||
updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(),
|
||||
linkBudgetItemToReservation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/budgetService', () => budget);
|
||||
// The budget-sync seam runs the real injected BudgetService (BudgetModule is
|
||||
// imported by ReservationsModule since the budget fold) over the same temp db.
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
|
||||
@@ -103,8 +103,10 @@ vi.mock('../../src/services/tripService', () => tripSvc);
|
||||
// bundle()'s days + accommodations now run DaysService's real SQL (DI-injected,
|
||||
// no mock) — the days/places/day_accommodations/reservations DDL above serves them.
|
||||
vi.mock('../../src/services/placeService', () => ({ listPlaces: () => [] }));
|
||||
vi.mock('../../src/services/budgetService', () => ({ listBudgetItems: () => [] }));
|
||||
// bundle()'s budget items come from the DI-injected BudgetService since the
|
||||
// budget fold — stubbed via a container spy in beforeAll (no budget DDL here).
|
||||
|
||||
import { BudgetService } from '../../src/nest/budget/budget.service';
|
||||
import { TripsModule } from '../../src/nest/trips/trips.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
@@ -125,6 +127,8 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
||||
vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockReturnValue([]);
|
||||
vi.spyOn(app.get(BudgetService), 'rebaseTripCurrency').mockResolvedValue();
|
||||
server = app.getHttpServer();
|
||||
tripSvc.listTrips.mockReturnValue([{ id: 1, title: 'T' }]);
|
||||
tripSvc.createTrip.mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
||||
|
||||
@@ -3,6 +3,9 @@ import { db } from '../../src/db/database';
|
||||
import { trekMcpAccessPolicy } from '../../src/mcp/nest-mcp-policy';
|
||||
import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp';
|
||||
import { AssignmentsService } from '../../src/nest/assignments/assignments.service';
|
||||
import { BudgetMcp } from '../../src/nest/budget/budget.mcp';
|
||||
import { BudgetService } from '../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
|
||||
import { CategoriesMcp } from '../../src/nest/categories/categories.mcp';
|
||||
import { CategoriesService } from '../../src/nest/categories/categories.service';
|
||||
import { CollabMcp } from '../../src/nest/collab/collab.mcp';
|
||||
@@ -35,13 +38,16 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
const dbService = new DatabaseService(db);
|
||||
const permissionsService = new PermissionsService(dbService);
|
||||
const daysService = new DaysService(dbService, permissionsService);
|
||||
const exchangeRatesService = new ExchangeRatesService();
|
||||
const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService);
|
||||
return createTestRegistry(
|
||||
[
|
||||
new TagsMcp(new TagsService(dbService)),
|
||||
new CategoriesMcp(new CategoriesService(dbService)),
|
||||
new TodoMcp(new TodoService(dbService, permissionsService)),
|
||||
new PackingMcp(new PackingService(dbService, permissionsService)),
|
||||
new ReservationsMcp(new ReservationsService(dbService, permissionsService), daysService),
|
||||
new BudgetMcp(budgetService, exchangeRatesService, dbService),
|
||||
new ReservationsMcp(new ReservationsService(dbService, permissionsService, budgetService), daysService, budgetService),
|
||||
new DayNotesMcp(new DayNotesService(dbService, permissionsService)),
|
||||
new DaysMcp(daysService, dbService),
|
||||
new AssignmentsMcp(new AssignmentsService(dbService, permissionsService), daysService),
|
||||
|
||||
@@ -30,9 +30,10 @@ import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFactory {
|
||||
const permissions = new PermissionsService(dbs);
|
||||
const exchangeRates = new ExchangeRatesService();
|
||||
const budget = new BudgetService(dbs, permissions, exchangeRates);
|
||||
return new PluginHostDepsFactory(
|
||||
new BudgetService(dbs, permissions, exchangeRates),
|
||||
new ReservationsService(dbs, permissions),
|
||||
budget,
|
||||
new ReservationsService(dbs, permissions, budget),
|
||||
new TagsService(dbs),
|
||||
new CategoriesService(dbs),
|
||||
new TodoService(dbs, permissions),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Unit tests for MCP resources (resources.ts).
|
||||
* Tests all 7 legacy-registrar resources via InMemoryTransport + Client
|
||||
* (trek://categories moved to the DI-discovered CategoriesMcp — see
|
||||
* Tests all 6 legacy-registrar resources via InMemoryTransport + Client
|
||||
* (trek://trips/{tripId}/budget, .../budget/per-person and
|
||||
* .../budget/settlement moved to the DI-discovered BudgetMcp — see
|
||||
* tools-budget.test.ts and tools-budget-advanced.test.ts;
|
||||
* trek://categories moved to the DI-discovered CategoriesMcp — see
|
||||
* tools-categories.test.ts; trek://trips/{tripId}/todos moved to TodoMcp —
|
||||
* see tools-todos.test.ts; trek://trips/{tripId}/packing and .../packing/bags
|
||||
* moved to PackingMcp — see tools-packing.test.ts;
|
||||
@@ -48,7 +51,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace, addTripMember, createBudgetItem, createReservation, createCollabNote, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createPlace, addTripMember, createReservation, createCollabNote, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -186,32 +189,9 @@ describe('Resource: trek://trips/{tripId}/places', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
|
||||
const items = parseResourceResult(result) as any[];
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
// The trek://trips/{tripId}/budget resource moved to the DI-discovered
|
||||
// src/nest/budget/budget.mcp.ts — its cases live in tools-budget.test.ts (the
|
||||
// registry attaches via registerTools, which this withTools:false harness skips).
|
||||
|
||||
// The trek://trips/{tripId}/packing and .../packing/bags resources moved to the
|
||||
// DI-discovered PackingMcp — see tools-packing.test.ts.
|
||||
|
||||
@@ -36,7 +36,7 @@ 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 { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -304,3 +304,35 @@ describe('Tool: delete_budget_item', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+57
-32
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* Settlement-math and FX-freeze unit tests for BudgetService over a prepare-stub
|
||||
* DB mock. Moved 1:1 from the legacy tests/unit/services/budgetService.test.ts
|
||||
* (same cases, incl. the #1335/#1445/#1426 pins); the SUT is now the folded
|
||||
* BudgetService with a constructor-injected ExchangeRatesService stub (was a
|
||||
* path mock of the deleted exchange-rates.bridge), and the raw settlement
|
||||
* update is exercised as applySettlementUpdate (the no-freeze write the REST
|
||||
* updateSettlement wraps).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── DB mock setup ────────────────────────────────────────────────────────────
|
||||
@@ -11,17 +20,33 @@ const mockDb = vi.hoisted(() => {
|
||||
run: vi.fn(),
|
||||
})),
|
||||
},
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: vi.fn(() => true),
|
||||
isOwner: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => mockDb);
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
const mockRates = vi.hoisted(() => ({ getRates: vi.fn() }));
|
||||
vi.mock('../../../src/nest/budget/exchange-rates.bridge', () => mockRates);
|
||||
const mockRates = { getRates: vi.fn() };
|
||||
|
||||
import { calculateSettlement, updateSettlement, freezeForeignRate } from '../../../src/services/budgetService';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const permissionsStub = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService;
|
||||
|
||||
const budget = new BudgetService(
|
||||
new DatabaseService(mockDb.db as unknown as Database.Database),
|
||||
permissionsStub,
|
||||
mockRates as unknown as ExchangeRatesService,
|
||||
);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
// Who actually paid is recorded as explicit payers (budget_item_payers); members
|
||||
@@ -85,14 +110,14 @@ beforeEach(() => {
|
||||
describe('calculateSettlement', () => {
|
||||
it('returns empty balances and flows when trip has no items', () => {
|
||||
setupDb([], [], []);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
expect(result.balances).toEqual([]);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no flows when there are items but no members', () => {
|
||||
setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -102,7 +127,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -113,7 +138,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(alice.balance).toBe(50);
|
||||
@@ -131,7 +156,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 90, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
const carol = result.balances.find(b => b.user_id === 3)!;
|
||||
@@ -148,7 +173,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
for (const b of result.balances) {
|
||||
expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
|
||||
}
|
||||
@@ -162,7 +187,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const flow = result.flows[0];
|
||||
expect(flow.from.username).toBe('bob'); // debtor
|
||||
expect(flow.to.username).toBe('alice'); // creditor
|
||||
@@ -175,7 +200,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 10, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
for (const b of result.balances) {
|
||||
const str = b.balance.toString();
|
||||
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
||||
@@ -200,7 +225,7 @@ describe('calculateSettlement', () => {
|
||||
],
|
||||
[makePayer(1, 1, 100, 'alice'), makePayer(2, 2, 60, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(alice.balance).toBe(20);
|
||||
@@ -219,7 +244,7 @@ describe('calculateSettlement', () => {
|
||||
}
|
||||
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
||||
});
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(bob.balance).toBe(30);
|
||||
@@ -238,7 +263,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 110, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
|
||||
const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
// 110 / 1.1 = 100 EUR; Bob owes half = 50 (frozen). With the live 1.2 it would be ~45.83.
|
||||
expect(bob.balance).toBeCloseTo(-50, 2);
|
||||
@@ -250,7 +275,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 120, 'alice')],
|
||||
);
|
||||
const result = calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
|
||||
const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
// 120 / 1.2 (live) = 100 EUR; Bob owes 50 — unchanged behaviour for pre-#1335 rows.
|
||||
expect(bob.balance).toBeCloseTo(-50, 2);
|
||||
@@ -267,7 +292,7 @@ describe('calculateSettlement', () => {
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
[makeSettlementRow(1, 2, 1, 62.5, 'USD', 1.25)],
|
||||
);
|
||||
const result = calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } });
|
||||
const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } });
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(bob.balance).toBeCloseTo(0, 2); // settled — no residual re-opens
|
||||
});
|
||||
@@ -282,7 +307,7 @@ describe('calculateSettlement', () => {
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
[makeSettlementRow(1, 2, 1, 62.5, null, 1)],
|
||||
);
|
||||
const result = calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } });
|
||||
const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: 0.5 } });
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
// settleToTrip(62.5) = 62.5 * 0.5 = 31.25 EUR; balance -50 + 31.25 = -18.75 EUR → reopens.
|
||||
expect(Math.abs(bob.balance)).toBeGreaterThan(1);
|
||||
@@ -300,7 +325,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 45, 'alice'), makePayer(1, 2, 45, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance;
|
||||
|
||||
expect(balance(1)).toBeCloseTo(15, 2);
|
||||
@@ -316,7 +341,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 70, 'alice'), makePayer(1, 2, 30, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance;
|
||||
|
||||
expect(balance(1)).toBeCloseTo(20, 2);
|
||||
@@ -340,7 +365,7 @@ describe('calculateSettlement', () => {
|
||||
makePayer(2, 2, 25, 'bob'), makePayer(2, 3, 30, 'carol'),
|
||||
],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const sum = result.balances.reduce((a, b) => a + b.balance, 0);
|
||||
|
||||
expect(sum).toBeCloseTo(0, 2);
|
||||
@@ -354,7 +379,7 @@ describe('calculateSettlement', () => {
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 33.34, 'alice'), makePayer(1, 2, 33.34, 'bob'), makePayer(1, 3, 33.33, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const result = budget.calculateSettlement(1);
|
||||
const sum = result.balances.reduce((a, b) => a + b.balance, 0);
|
||||
|
||||
expect(sum).toBeCloseTo(0, 2);
|
||||
@@ -375,7 +400,7 @@ describe('freezeForeignRate', () => {
|
||||
});
|
||||
mockRates.getRates.mockResolvedValue({ EUR: 1, USD: 1.25 });
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'usd' };
|
||||
await freezeForeignRate(1, data);
|
||||
await budget.freezeForeignRate(1, data);
|
||||
expect(mockRates.getRates).toHaveBeenCalledWith('EUR');
|
||||
expect(data.exchange_rate).toBe(1.25);
|
||||
});
|
||||
@@ -384,14 +409,14 @@ describe('freezeForeignRate', () => {
|
||||
mockDb.db.prepare.mockImplementation((sql: string) =>
|
||||
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'EUR' };
|
||||
await freezeForeignRate(1, data);
|
||||
await budget.freezeForeignRate(1, data);
|
||||
expect(mockRates.getRates).not.toHaveBeenCalled();
|
||||
expect(data.exchange_rate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects an explicit exchange_rate from the caller', async () => {
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD', exchange_rate: 2 };
|
||||
await freezeForeignRate(1, data);
|
||||
await budget.freezeForeignRate(1, data);
|
||||
expect(mockRates.getRates).not.toHaveBeenCalled();
|
||||
expect(data.exchange_rate).toBe(2);
|
||||
});
|
||||
@@ -401,7 +426,7 @@ describe('freezeForeignRate', () => {
|
||||
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
|
||||
mockRates.getRates.mockResolvedValue(null);
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
||||
await freezeForeignRate(1, data);
|
||||
await budget.freezeForeignRate(1, data);
|
||||
expect(data.exchange_rate).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -412,7 +437,7 @@ describe('freezeForeignRate', () => {
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
||||
await freezeForeignRate(1, data, 9);
|
||||
await budget.freezeForeignRate(1, data, 9);
|
||||
expect(mockRates.getRates).not.toHaveBeenCalled();
|
||||
expect(data.exchange_rate).toBeUndefined();
|
||||
});
|
||||
@@ -422,7 +447,7 @@ describe('freezeForeignRate', () => {
|
||||
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
||||
// the settlement already holds USD — pass it as existingCurrency → keep the frozen rate
|
||||
await freezeForeignRate(1, data, undefined, 'USD');
|
||||
await budget.freezeForeignRate(1, data, undefined, 'USD');
|
||||
expect(mockRates.getRates).not.toHaveBeenCalled();
|
||||
expect(data.exchange_rate).toBeUndefined();
|
||||
});
|
||||
@@ -432,14 +457,14 @@ describe('freezeForeignRate', () => {
|
||||
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
|
||||
mockRates.getRates.mockResolvedValue({ EUR: 1, USD: 1.25 });
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
||||
await freezeForeignRate(1, data, undefined, 'GBP'); // was GBP → now USD → re-freeze
|
||||
await budget.freezeForeignRate(1, data, undefined, 'GBP'); // was GBP → now USD → re-freeze
|
||||
expect(data.exchange_rate).toBe(1.25);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateSettlement ──────────────────────────────────────────────────────────
|
||||
// ── applySettlementUpdate (the raw no-freeze write behind updateSettlement) ──
|
||||
|
||||
describe('updateSettlement', () => {
|
||||
describe('applySettlementUpdate', () => {
|
||||
it('returns null when the settlement is not in the trip', () => {
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT id FROM budget_settlements')) {
|
||||
@@ -447,7 +472,7 @@ describe('updateSettlement', () => {
|
||||
}
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
expect(updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull();
|
||||
expect(budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull();
|
||||
});
|
||||
|
||||
it('updates the row (rounded to cents) and returns the refreshed settlement', () => {
|
||||
@@ -467,7 +492,7 @@ describe('updateSettlement', () => {
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
|
||||
const res = updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
|
||||
const res = budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
|
||||
// from, to, rounded amount, currency-flag(0)/value(null), rate-flag(null)/value(1), id.
|
||||
// No currency/exchange_rate passed → both CASE guards keep the existing columns.
|
||||
expect(run).toHaveBeenCalledWith(2, 1, 10.13, 0, null, null, 1, 7);
|
||||
+114
-40
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* DB-backed unit tests for budgetService trip-scoping (BUDGET-SVC-DB-001+).
|
||||
* DB-backed unit tests for BudgetService trip-scoping (BUDGET-SVC-DB-001+).
|
||||
* Uses a real in-memory SQLite DB so the SQL WHERE clauses are exercised.
|
||||
* BUDGET-SVC-DB-001 through 014 moved 1:1 from the legacy
|
||||
* tests/unit/services/budgetServiceDb.test.ts; 015–018 pin the budget.bridge
|
||||
* delegation (the bridge instance runs over the same mocked db Proxy).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -27,23 +30,43 @@ vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
// Frozen snapshot of the rates in play in #1543 (rates[X] = units of X per 1 base).
|
||||
const RATES: Record<string, Record<string, number>> = {
|
||||
RUB: { RUB: 1, USD: 0.013042, EUR: 0.011412 },
|
||||
EUR: { EUR: 1, USD: 1.1429, RUB: 87.63 },
|
||||
};
|
||||
vi.mock('../../../src/nest/budget/exchange-rates.bridge', () => ({
|
||||
getRates: vi.fn(async (base: string) => RATES[base.toUpperCase()] ?? null),
|
||||
const { RATES } = vi.hoisted(() => ({
|
||||
RATES: {
|
||||
RUB: { RUB: 1, USD: 0.013042, EUR: 0.011412 },
|
||||
EUR: { EUR: 1, USD: 1.1429, RUB: 87.63 },
|
||||
} as Record<string, Record<string, number>>,
|
||||
}));
|
||||
// Constructor-injected since the fold; the class is mocked at the module path so
|
||||
// the budget.bridge instance (which news up its own ExchangeRatesService) sees
|
||||
// the same deterministic rates as the SUT.
|
||||
vi.mock('../../../src/nest/budget/exchange-rates.service', () => ({
|
||||
ExchangeRatesService: class {
|
||||
async getRates(base: string) {
|
||||
return RATES[base.toUpperCase()] ?? null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { createBudgetItem, updateBudgetItem, updateMembers, toggleMemberPaid, calculateSettlement, rebaseTripCurrency } from '../../../src/services/budgetService';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import * as bridge from '../../../src/nest/budget/budget.bridge';
|
||||
import { createGuest, deleteGuest } from '../../../src/services/tripService';
|
||||
|
||||
const budget = new BudgetService(
|
||||
new DatabaseService(testDb),
|
||||
new PermissionsService(new DatabaseService(testDb)),
|
||||
new ExchangeRatesService(),
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
@@ -77,7 +100,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guests = ['G1', 'G2', 'G3'].map(n => createGuest(trip.id, n, owner.id).member);
|
||||
const item = createBudgetItem(trip.id, {
|
||||
const item = budget.createBudgetItem(trip.id, {
|
||||
name: 'Dinner', total_price: 400,
|
||||
member_ids: [owner.id, ...guests.map(g => g.id)],
|
||||
});
|
||||
@@ -97,7 +120,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = createGuest(trip.id, 'G1', owner.id).member;
|
||||
// No member rows — `persons` is just a number someone typed.
|
||||
const item = createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 });
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 });
|
||||
|
||||
deleteGuest(trip.id, guest.id);
|
||||
|
||||
@@ -108,7 +131,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = createGuest(trip.id, 'G1', owner.id).member;
|
||||
const item = createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] });
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] });
|
||||
|
||||
deleteGuest(trip.id, guest.id);
|
||||
|
||||
@@ -120,12 +143,12 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = createGuest(trip.id, 'G1', owner.id).member;
|
||||
const item = createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] });
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] });
|
||||
|
||||
deleteGuest(trip.id, guest.id);
|
||||
|
||||
// A client that loaded before the deletion still sends the guest back (#1553).
|
||||
const updated = updateBudgetItem(item.id, trip.id, { member_ids: [owner.id, guest.id] });
|
||||
const updated = budget.updateBudgetItem(item.id, trip.id, { member_ids: [owner.id, guest.id] });
|
||||
|
||||
expect(updated!.members.map(m => m.user_id)).toEqual([owner.id]);
|
||||
expect(personsOf(item.id)).toBe(1);
|
||||
@@ -135,10 +158,10 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = createGuest(trip.id, 'G1', owner.id).member;
|
||||
const item = createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] });
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] });
|
||||
|
||||
deleteGuest(trip.id, guest.id);
|
||||
const result = updateMembers(item.id, trip.id, [owner.id, guest.id]);
|
||||
const result = budget.updateMembers(item.id, trip.id, [owner.id, guest.id]);
|
||||
|
||||
expect(result!.members.map(m => m.user_id)).toEqual([owner.id]);
|
||||
expect(personsOf(item.id)).toBe(1);
|
||||
@@ -149,10 +172,10 @@ describe('toggleMemberPaid trip-scoping', () => {
|
||||
it('BUDGET-SVC-DB-001: toggles paid for an item that belongs to the given trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip A' });
|
||||
const item = createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 });
|
||||
updateMembers(item.id, trip.id, [user.id]);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 });
|
||||
budget.updateMembers(item.id, trip.id, [user.id]);
|
||||
|
||||
const member = toggleMemberPaid(item.id, trip.id, user.id, true);
|
||||
const member = budget.toggleMemberPaid(item.id, trip.id, user.id, true);
|
||||
|
||||
expect(member).not.toBeNull();
|
||||
expect(paidFlag(item.id, user.id)).toBe(1);
|
||||
@@ -162,11 +185,11 @@ describe('toggleMemberPaid trip-scoping', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tripA = createTrip(testDb, user.id, { title: 'Trip A' });
|
||||
const tripB = createTrip(testDb, user.id, { title: 'Trip B' });
|
||||
const itemB = createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 });
|
||||
updateMembers(itemB.id, tripB.id, [user.id]);
|
||||
const itemB = budget.createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 });
|
||||
budget.updateMembers(itemB.id, tripB.id, [user.id]);
|
||||
|
||||
// Caller passes a trip they can access (A) but the item lives in trip B.
|
||||
const member = toggleMemberPaid(itemB.id, tripA.id, user.id, true);
|
||||
const member = budget.toggleMemberPaid(itemB.id, tripA.id, user.id, true);
|
||||
|
||||
expect(member).toBeNull();
|
||||
expect(paidFlag(itemB.id, user.id)).toBe(0); // unchanged
|
||||
@@ -180,7 +203,7 @@ describe('calculateSettlement custom splits', () => {
|
||||
const trip = createTrip(testDb, alice.id, { title: 'Trip' });
|
||||
|
||||
// 100 total, custom split: Alice owes 90, Bob owes 10. Alice paid the whole bill.
|
||||
createBudgetItem(trip.id, {
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'Dinner',
|
||||
payers: [{ user_id: alice.id, amount: 100 }],
|
||||
members: [
|
||||
@@ -189,7 +212,7 @@ describe('calculateSettlement custom splits', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = calculateSettlement(trip.id);
|
||||
const result = budget.calculateSettlement(trip.id);
|
||||
|
||||
// Alice paid 100 but owes 90 → net +10 (creditor); Bob owes 10 → net -10 (debtor).
|
||||
// With the equal-split bug both owe 50, so the flow would be 50 instead of 10.
|
||||
@@ -214,9 +237,9 @@ function seedIssue1543Trip(tripCurrency: string) {
|
||||
|
||||
// 9 000 ₽ nobody has paid yet, 9 000 ₽ paid by me, and $100 paid by me. The USD row
|
||||
// carries the rate frozen at entry time: units of USD per 1 RUB.
|
||||
createBudgetItem(trip.id, { name: 'Проезд обратно', total_price: 9000, currency: 'RUB', members });
|
||||
createBudgetItem(trip.id, { name: 'Проезд туда', currency: 'RUB', payers: [{ user_id: me.id, amount: 9000 }], members });
|
||||
createBudgetItem(trip.id, {
|
||||
budget.createBudgetItem(trip.id, { name: 'Проезд обратно', total_price: 9000, currency: 'RUB', members });
|
||||
budget.createBudgetItem(trip.id, { name: 'Проезд туда', currency: 'RUB', payers: [{ user_id: me.id, amount: 9000 }], members });
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'test', currency: 'USD', exchange_rate: 0.013042,
|
||||
payers: [{ user_id: me.id, amount: 100 }], members,
|
||||
});
|
||||
@@ -227,7 +250,7 @@ describe('calculateSettlement with a foreign-currency expense (#1543)', () => {
|
||||
it('BUDGET-SVC-DB-004: nets in the trip currency instead of inflating the foreign share ~27x', () => {
|
||||
const { trip, me, danil, serega } = seedIssue1543Trip('RUB');
|
||||
|
||||
const result = calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
|
||||
const result = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
|
||||
const balanceOf = (id: number) => result.balances.find(b => b.user_id === id)!.balance;
|
||||
|
||||
// Total spend is 9 000 + 9 000 + $100 (≈7 668 ₽), so each of the three owes a third
|
||||
@@ -248,7 +271,7 @@ describe('calculateSettlement with a foreign-currency expense (#1543)', () => {
|
||||
const { trip, danil } = seedIssue1543Trip('RUB');
|
||||
|
||||
// Same trip, viewed in EUR: every balance is the RUB one converted once, at the end.
|
||||
const inEur = calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'RUB', rates: RATES.EUR });
|
||||
const inEur = budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'RUB', rates: RATES.EUR });
|
||||
const danilEur = inEur.balances.find(b => b.user_id === danil.id)!.balance;
|
||||
|
||||
const shareRub = (18000 + 100 / RATES.RUB.USD) / 3;
|
||||
@@ -269,11 +292,11 @@ describe('rebaseTripCurrency', () => {
|
||||
|
||||
// An expense that inherits the trip's base (currency NULL), one booked in USD, and
|
||||
// one already in the incoming currency.
|
||||
const implicit = createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members }) as { id: number };
|
||||
const usd = createBudgetItem(trip.id, { name: 'USD', total_price: 100, currency: 'USD', exchange_rate: 1.1429, members }) as { id: number };
|
||||
const rub = createBudgetItem(trip.id, { name: 'RUB', total_price: 9000, currency: 'RUB', exchange_rate: 87.63, members }) as { id: number };
|
||||
const implicit = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members }) as { id: number };
|
||||
const usd = budget.createBudgetItem(trip.id, { name: 'USD', total_price: 100, currency: 'USD', exchange_rate: 1.1429, members }) as { id: number };
|
||||
const rub = budget.createBudgetItem(trip.id, { name: 'RUB', total_price: 9000, currency: 'RUB', exchange_rate: 87.63, members }) as { id: number };
|
||||
|
||||
await rebaseTripCurrency(trip.id, 'RUB');
|
||||
await budget.rebaseTripCurrency(trip.id, 'RUB');
|
||||
|
||||
// The implicit row really held euros, so it is stamped EUR rather than silently
|
||||
// becoming 100 ₽, and every rate is re-anchored to the new base.
|
||||
@@ -290,15 +313,15 @@ describe('rebaseTripCurrency', () => {
|
||||
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
||||
const members = [{ user_id: alice.id }, { user_id: bob.id }];
|
||||
|
||||
createBudgetItem(trip.id, { name: 'Hotel', payers: [{ user_id: alice.id, amount: 100 }], members });
|
||||
createBudgetItem(trip.id, { name: 'Dinner', currency: 'USD', exchange_rate: 1.1429, payers: [{ user_id: bob.id, amount: 60 }], members });
|
||||
budget.createBudgetItem(trip.id, { name: 'Hotel', payers: [{ user_id: alice.id, amount: 100 }], members });
|
||||
budget.createBudgetItem(trip.id, { name: 'Dinner', currency: 'USD', exchange_rate: 1.1429, payers: [{ user_id: bob.id, amount: 60 }], members });
|
||||
|
||||
const before = calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'EUR', rates: RATES.EUR });
|
||||
const before = budget.calculateSettlement(trip.id, { base: 'EUR', tripCurrency: 'EUR', rates: RATES.EUR });
|
||||
|
||||
await rebaseTripCurrency(trip.id, 'RUB');
|
||||
await budget.rebaseTripCurrency(trip.id, 'RUB');
|
||||
testDb.prepare("UPDATE trips SET currency = 'RUB' WHERE id = ?").run(trip.id);
|
||||
|
||||
const after = calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
|
||||
const after = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
|
||||
|
||||
for (const b of before.balances) {
|
||||
const rub = after.balances.find(x => x.user_id === b.user_id)!.balance;
|
||||
@@ -322,7 +345,7 @@ describe('rebaseTripCurrency', () => {
|
||||
const jpy = priced(1500, 'JPY');
|
||||
const free = priced(null, null);
|
||||
|
||||
await rebaseTripCurrency(trip.id, 'JPY');
|
||||
await budget.rebaseTripCurrency(trip.id, 'JPY');
|
||||
|
||||
const placeRow = (id: number) =>
|
||||
testDb.prepare('SELECT price, currency FROM places WHERE id = ?')
|
||||
@@ -340,10 +363,61 @@ describe('rebaseTripCurrency', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
||||
const item = createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }) as { id: number };
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }) as { id: number };
|
||||
|
||||
await rebaseTripCurrency(trip.id, 'EUR');
|
||||
await budget.rebaseTripCurrency(trip.id, 'EUR');
|
||||
|
||||
expect(itemRow(item.id)).toEqual({ currency: null, exchange_rate: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('budget.bridge delegation', () => {
|
||||
it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list through the bridge', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100, member_ids: [user.id] });
|
||||
|
||||
const items = bridge.listBudgetItems(trip.id);
|
||||
|
||||
expect(items.map(i => i.id)).toEqual([item.id]);
|
||||
expect(items[0].members.map(m => m.user_id)).toEqual([user.id]);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-016: removeUserFromBudgetItems re-derives persons through the bridge', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb, { username: 'other' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, other.id] });
|
||||
|
||||
bridge.removeUserFromBudgetItems(other.id);
|
||||
|
||||
const row = testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null };
|
||||
expect(row.persons).toBe(1);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-017: rebaseTripCurrency pins the implicit currency through the bridge', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
||||
const item = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] });
|
||||
|
||||
await bridge.rebaseTripCurrency(trip.id, 'RUB');
|
||||
|
||||
const row = testDb.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(item.id) as { currency: string | null; exchange_rate: number };
|
||||
expect(row).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR });
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-018: linkBudgetItemToReservation stamps the reservation id through the bridge', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservationId = Number(testDb
|
||||
.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')")
|
||||
.run(trip.id).lastInsertRowid);
|
||||
|
||||
const item = bridge.linkBudgetItemToReservation(trip.id, reservationId, { name: 'Flight', total_price: 200 });
|
||||
|
||||
expect(item.reservation_id).toBe(reservationId);
|
||||
const row = testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(item.id) as { reservation_id: number | null };
|
||||
expect(row.reservation_id).toBe(reservationId);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,27 @@
|
||||
/**
|
||||
* Seam tests for the folded BudgetService: the permission/broadcast edges, the
|
||||
* controller-facing composite methods (create/update/settlement wrappers that
|
||||
* freeze the FX rate before the raw write — incl. the #1445 stored-currency
|
||||
* thread-through) and the wrapper-only syncReservationPrice SQL. The SQL-heavy
|
||||
* paths themselves are covered by budget.service.db.test.ts (real :memory: DB)
|
||||
* and budget.service.calc.test.ts (settlement math over a prepare-stub mock).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the data + side-effect dependencies the wrapper reaches into directly.
|
||||
// Mock the data + side-effect dependencies the service reaches into directly.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: dbMock,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
canAccessTrip,
|
||||
getPlaceWithTags: () => null,
|
||||
isOwner: () => false,
|
||||
}));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
@@ -16,35 +32,11 @@ vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
const checkPermission = vi.fn(() => true);
|
||||
const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
||||
|
||||
// Constructor-injected stub since the exchange-rates fold (was a path mock of
|
||||
// the deleted services/exchangeRateService).
|
||||
// Constructor-injected ExchangeRatesService stub (as in the pre-fold wrapper).
|
||||
import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
const getRates = vi.fn();
|
||||
const exchangeRatesStub = { getRates } as unknown as ExchangeRatesService;
|
||||
|
||||
const { budget } = vi.hoisted(() => ({
|
||||
budget: {
|
||||
verifyTripAccess: vi.fn(),
|
||||
listBudgetItems: vi.fn(),
|
||||
getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(),
|
||||
freezeForeignRate: vi.fn(),
|
||||
createBudgetItem: vi.fn(),
|
||||
updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(),
|
||||
updateMembers: vi.fn(),
|
||||
toggleMemberPaid: vi.fn(),
|
||||
setItemPayers: vi.fn(),
|
||||
listSettlements: vi.fn(() => []),
|
||||
createSettlement: vi.fn(),
|
||||
updateSettlement: vi.fn(),
|
||||
deleteSettlement: vi.fn(),
|
||||
reorderBudgetItems: vi.fn(),
|
||||
reorderBudgetCategories: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/budgetService', () => budget);
|
||||
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
|
||||
function svc() {
|
||||
@@ -57,10 +49,10 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('BudgetService', () => {
|
||||
it('verifyTripAccess delegates to the legacy service', () => {
|
||||
budget.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 });
|
||||
it('verifyTripAccess resolves through canAccessTrip (services/tripAccess)', () => {
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 2 });
|
||||
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
expect(budget.verifyTripAccess).toHaveBeenCalledWith('5', 2);
|
||||
expect(canAccessTrip).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
|
||||
it('canEdit forwards the ownership flag when the user owns the trip', () => {
|
||||
@@ -80,70 +72,91 @@ describe('BudgetService', () => {
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
||||
});
|
||||
|
||||
it('list / perPersonSummary delegate', () => {
|
||||
budget.listBudgetItems.mockReturnValue([{ id: 1 }]);
|
||||
expect(svc().list('5')).toEqual([{ id: 1 }]);
|
||||
budget.getPerPersonSummary.mockReturnValue([{ userId: 1 }]);
|
||||
expect(svc().perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
it('list / perPersonSummary resolve through the folded SQL methods', () => {
|
||||
const s = svc();
|
||||
const listSpy = vi.spyOn(s, 'listBudgetItems').mockReturnValue([{ id: 1 }] as never);
|
||||
expect(s.list('5')).toEqual([{ id: 1 }]);
|
||||
expect(listSpy).toHaveBeenCalledWith('5');
|
||||
const summarySpy = vi.spyOn(s, 'getPerPersonSummary').mockReturnValue([{ userId: 1 }] as never);
|
||||
expect(s.perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
expect(summarySpy).toHaveBeenCalledWith('5');
|
||||
});
|
||||
|
||||
describe('settlement', () => {
|
||||
it('upper-cases the explicit base and forwards the rates', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue({ USD: 1.1 });
|
||||
budget.calculateSettlement.mockReturnValue({ transfers: [] });
|
||||
await svc().settlement('5', 'usd', 'EUR');
|
||||
await s.settlement('5', 'usd', 'EUR');
|
||||
expect(getRates).toHaveBeenCalledWith('USD');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
});
|
||||
|
||||
it('falls back to the trip currency when no base is given', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, 'gbp');
|
||||
await s.settlement('5', undefined, 'gbp');
|
||||
expect(getRates).toHaveBeenCalledWith('GBP');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
||||
});
|
||||
|
||||
it('falls back to EUR when neither base nor trip currency is present', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, '');
|
||||
await s.settlement('5', undefined, '');
|
||||
expect(getRates).toHaveBeenCalledWith('EUR');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
||||
});
|
||||
});
|
||||
|
||||
it('create / update / remove / members / paid / payers delegate', async () => {
|
||||
await svc().create('5', { name: 'Hotel' } as never);
|
||||
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
||||
await svc().update('9', '5', { name: 'X' });
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' });
|
||||
svc().remove('9', '5');
|
||||
expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5');
|
||||
svc().updateMembers('9', '5', [2, 3]);
|
||||
expect(budget.updateMembers).toHaveBeenCalledWith('9', '5', [2, 3]);
|
||||
svc().toggleMemberPaid('9', '5', '2', true);
|
||||
expect(budget.toggleMemberPaid).toHaveBeenCalledWith('9', '5', '2', true);
|
||||
svc().setPayers('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
expect(budget.setItemPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
it('create / update freeze the FX rate before the raw write', async () => {
|
||||
const s = svc();
|
||||
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
|
||||
const createSpy = vi.spyOn(s, 'createBudgetItem').mockReturnValue({ id: 1 } as never);
|
||||
await s.create('5', { name: 'Hotel' });
|
||||
expect(freezeSpy).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
||||
expect(createSpy).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
||||
|
||||
const updateSpy = vi.spyOn(s, 'updateBudgetItem').mockReturnValue({ id: 9 } as never);
|
||||
await s.update('9', '5', { name: 'X' });
|
||||
// the item id is threaded through so an unchanged-currency edit keeps the frozen rate
|
||||
expect(freezeSpy).toHaveBeenCalledWith('5', { name: 'X' }, '9');
|
||||
expect(updateSpy).toHaveBeenCalledWith('9', '5', { name: 'X' });
|
||||
});
|
||||
|
||||
it('settlement ledger + reorder delegate', async () => {
|
||||
svc().listSettlements('5');
|
||||
expect(budget.listSettlements).toHaveBeenCalledWith('5');
|
||||
// createSettlement freezes the FX rate (await) before delegating.
|
||||
await svc().createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
expect(budget.freezeForeignRate).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 });
|
||||
expect(budget.createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
budget.listSettlements.mockReturnValue([{ id: 7, currency: 'USD' }] as never);
|
||||
await svc().updateSettlement('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
|
||||
// the settlement's stored currency is threaded through so an unchanged-currency edit keeps the frozen rate (#1445)
|
||||
expect(budget.freezeForeignRate).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' }, undefined, 'USD');
|
||||
expect(budget.updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
|
||||
svc().deleteSettlement('7', '5');
|
||||
expect(budget.deleteSettlement).toHaveBeenCalledWith('7', '5');
|
||||
svc().reorderItems('5', [3, 1]);
|
||||
expect(budget.reorderBudgetItems).toHaveBeenCalledWith('5', [3, 1]);
|
||||
svc().reorderCategories('5', ['food', 'fun']);
|
||||
expect(budget.reorderBudgetCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
|
||||
it('remove / setPayers resolve through the folded SQL methods', () => {
|
||||
const s = svc();
|
||||
const deleteSpy = vi.spyOn(s, 'deleteBudgetItem').mockReturnValue(true);
|
||||
expect(s.remove('9', '5')).toBe(true);
|
||||
expect(deleteSpy).toHaveBeenCalledWith('9', '5');
|
||||
|
||||
const payersSpy = vi.spyOn(s, 'setItemPayers').mockReturnValue({ id: 9 } as never);
|
||||
s.setPayers('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
expect(payersSpy).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
});
|
||||
|
||||
describe('settlement ledger wrappers (#1445 freeze-then-write)', () => {
|
||||
it('createSettlement freezes the FX rate (await) before the raw insert', async () => {
|
||||
const s = svc();
|
||||
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
|
||||
const insertSpy = vi.spyOn(s, 'insertSettlement').mockReturnValue({ id: 7 } as never);
|
||||
await s.createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
expect(freezeSpy).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 });
|
||||
expect(insertSpy).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
});
|
||||
|
||||
it('updateSettlement threads the stored currency through the freeze', async () => {
|
||||
const s = svc();
|
||||
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
|
||||
vi.spyOn(s, 'listSettlements').mockReturnValue([{ id: 7, currency: 'USD' }] as never);
|
||||
const applySpy = vi.spyOn(s, 'applySettlementUpdate').mockReturnValue({ id: 7 } as never);
|
||||
await s.updateSettlement('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
|
||||
// the settlement's stored currency is threaded through so an unchanged-currency edit keeps the frozen rate (#1445)
|
||||
expect(freezeSpy).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' }, undefined, 'USD');
|
||||
expect(applySpy).toHaveBeenCalledWith('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncReservationPrice', () => {
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* dedicated tests; the budget-domain fold moved it inside the src/nest/**
|
||||
* coverage gate. 001–012 pin the fetch/cache behavior (including the parity
|
||||
* quirks kept on purpose: `|| 'EUR'` falsy coercion, stale-cache fallback, the
|
||||
* `>1 keys` failure heuristic); 019 pins the exchange-rates.bridge delegation
|
||||
* and the module-scoped cache shared between the DI instance and the bridge
|
||||
* instance; 021–022 pin the post-fold quirk fixes (AbortSignal timeout,
|
||||
* `>1 keys` failure heuristic); 019 pins the module-scoped cache shared across
|
||||
* instances (the DI singleton and the budget.bridge instance wrap one feed —
|
||||
* originally pinned via the exchange-rates.bridge, deleted with the budget
|
||||
* fold); 021–022 pin the post-fold quirk fixes (AbortSignal timeout,
|
||||
* response-size cap, logged failures). 013–018 and 020 covered the dead
|
||||
* convertWithRates export and were removed with it in the quirk fixes.
|
||||
*
|
||||
@@ -15,7 +16,6 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { getRates as bridgeGetRates } from '../../../src/nest/budget/exchange-rates.bridge';
|
||||
|
||||
const TTL_MS = 6 * 60 * 60 * 1000; // mirrors the service's 6h TTL
|
||||
|
||||
@@ -139,13 +139,14 @@ describe('ExchangeRatesService.getRates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchange-rates.bridge', () => {
|
||||
it('FX-SVC-019: getRates delegates and shares the module-scoped cache with the DI instance', async () => {
|
||||
describe('cross-instance cache sharing', () => {
|
||||
it('FX-SVC-019: a second instance (the budget.bridge shape) serves the module-scoped cache', async () => {
|
||||
// Prime the cache through the DI-style instance…
|
||||
const primed = await svc.getRates('AUD');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
// …then the bridge (its own instance) must serve the same cached feed.
|
||||
expect(await bridgeGetRates('AUD')).toBe(primed);
|
||||
// …then a separately-constructed instance (as budget.bridge news up) must
|
||||
// serve the same cached feed instead of refetching.
|
||||
expect(await new ExchangeRatesService().getRates('AUD')).toBe(primed);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,10 +37,9 @@ vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
const checkPermission = vi.fn(() => true);
|
||||
const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
||||
|
||||
const { budget } = vi.hoisted(() => ({
|
||||
budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../../src/services/budgetService', () => budget);
|
||||
// Constructor-injected since the budget fold (was a path mock of the deleted
|
||||
// services/budgetService).
|
||||
const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() };
|
||||
|
||||
const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } }));
|
||||
vi.mock('../../../src/services/notificationService', () => notif);
|
||||
@@ -52,9 +51,10 @@ import { createUser, createTrip, createReservation, createBudgetItem, createPlac
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import type { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import * as bridge from '../../../src/nest/reservations/reservations.bridge';
|
||||
|
||||
const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub);
|
||||
const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub, budget as unknown as BudgetService);
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -27,17 +27,18 @@ const { tripSvc } = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock('../../../src/services/tripService', () => tripSvc);
|
||||
vi.mock('../../../src/services/placeService', () => ({ listPlaces: () => [] }));
|
||||
vi.mock('../../../src/services/budgetService', () => ({
|
||||
listBudgetItems: () => [],
|
||||
rebaseTripCurrency: vi.fn(async () => {}),
|
||||
}));
|
||||
import { TripsService } from '../../../src/nest/trips/trips.service';
|
||||
import type { TodoService } from '../../../src/nest/todo/todo.service';
|
||||
import type { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
import type { FilesService } from '../../../src/nest/files/files.service';
|
||||
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import type { DaysService } from '../../../src/nest/days/days.service';
|
||||
import { rebaseTripCurrency } from '../../../src/services/budgetService';
|
||||
import type { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
|
||||
// Constructor-injected since the budget fold (was a path mock of the deleted
|
||||
// services/budgetService).
|
||||
const rebaseTripCurrency = vi.fn(async () => {});
|
||||
const budgetStub = { listBudgetItems: () => [], rebaseTripCurrency } as unknown as BudgetService;
|
||||
|
||||
const todoStub = { listItems: () => [] } as unknown as TodoService;
|
||||
const packingListItems = vi.fn(() => []);
|
||||
@@ -45,7 +46,7 @@ const packingStub = { listItems: packingListItems } as unknown as PackingService
|
||||
const filesStub = { listFiles: () => [] } as unknown as FilesService;
|
||||
const reservationsStub = { list: () => [] } as unknown as ReservationsService;
|
||||
const daysStub = { list: () => ({ days: [1] }), listAccommodations: () => [] } as unknown as DaysService;
|
||||
function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub, permissionsStub); }
|
||||
function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub, permissionsStub, budgetStub); }
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => {
|
||||
|
||||
@@ -56,6 +56,9 @@ const budgetStub = {
|
||||
return id === '404' ? null : { id: Number(id), trip_id: Number(tid), ...input };
|
||||
},
|
||||
remove(id: string, _tid: string) { return id !== '404'; },
|
||||
// The listCostsForTrip/listCostsForUser closures inject this since the budget
|
||||
// fold (was the legacy listBudgetItems path import).
|
||||
listBudgetItems: vi.fn(() => []),
|
||||
} as unknown as BudgetService;
|
||||
|
||||
// Edit permission — flip per test to exercise the gates (constructor-injected
|
||||
@@ -124,16 +127,6 @@ const assignmentsStub = {
|
||||
placeExists: vi.fn((placeId: number) => placeId === 7),
|
||||
getAssignmentForTrip: vi.fn((id: number) => (id === 99 ? undefined : { id, day_id: 3 })),
|
||||
} as unknown as AssignmentsService;
|
||||
// The extra named exports satisfy the real reservations.service.ts module (now
|
||||
// loaded un-mocked as a factory constructor dep); its instance is stubbed, so
|
||||
// these are never called.
|
||||
vi.mock('../../../src/services/budgetService', () => ({
|
||||
listBudgetItems: vi.fn(() => []),
|
||||
createBudgetItem: vi.fn(),
|
||||
updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(),
|
||||
linkBudgetItemToReservation: vi.fn(),
|
||||
}));
|
||||
// Packing is a constructor-injected stub (same behaviors as the old path mock).
|
||||
const packingStub = {
|
||||
listItems: vi.fn((tid: number | string, userId?: number) => [{ id: 1, trip_id: tid, name: 'Socks', _uid: userId }]),
|
||||
|
||||
Reference in New Issue
Block a user