From 7f34dadf7018c808fe2161eefb597b76399592fa Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 27 Jul 2026 23:48:05 +0200 Subject: [PATCH] chore: move budget backend to proper DI --- server/src/mcp/resources.ts | 42 +- server/src/mcp/tools.ts | 4 +- server/src/mcp/tools/budget.ts | 319 ------- server/src/mcp/tools/transports.ts | 2 +- server/src/mcp/tools/trips.ts | 2 +- server/src/nest/README.md | 26 +- .../booking-import/booking-import.module.ts | 3 +- .../booking-import/booking-import.service.ts | 7 +- server/src/nest/budget/budget.bridge.ts | 44 + server/src/nest/budget/budget.mcp.ts | 448 ++++++++++ server/src/nest/budget/budget.module.ts | 9 +- server/src/nest/budget/budget.service.ts | 834 ++++++++++++++++-- .../src/nest/budget/exchange-rates.bridge.ts | 24 - .../src/nest/budget/exchange-rates.service.ts | 4 +- server/src/nest/plugins/DI-MIGRATION.md | 9 +- .../plugins/host/plugin-host-deps.factory.ts | 5 +- .../nest/reservations/reservations.bridge.ts | 8 +- .../src/nest/reservations/reservations.mcp.ts | 5 +- .../nest/reservations/reservations.module.ts | 4 +- .../nest/reservations/reservations.service.ts | 13 +- server/src/nest/trips/trips.module.ts | 3 +- server/src/nest/trips/trips.service.ts | 7 +- server/src/services/budgetService.ts | 755 ---------------- server/src/services/tripService.ts | 2 +- server/src/services/userCleanupService.ts | 2 +- server/tests/e2e/budget.e2e.test.ts | 132 ++- server/tests/e2e/reservations.e2e.test.ts | 11 +- server/tests/e2e/trips.e2e.test.ts | 6 +- server/tests/helpers/mcp-test-controllers.ts | 8 +- server/tests/helpers/plugin-host.ts | 5 +- server/tests/unit/mcp/resources.test.ts | 38 +- server/tests/unit/mcp/tools-budget.test.ts | 34 +- .../budget.service.calc.test.ts} | 89 +- .../budget.service.db.test.ts} | 154 +++- server/tests/unit/nest/budget.service.test.ts | 159 ++-- .../unit/nest/exchange-rates.service.test.ts | 17 +- .../unit/nest/reservations.service.test.ts | 10 +- server/tests/unit/nest/trips.service.test.ts | 13 +- .../plugins/plugin-host-deps.factory.test.ts | 13 +- 39 files changed, 1780 insertions(+), 1490 deletions(-) delete mode 100644 server/src/mcp/tools/budget.ts create mode 100644 server/src/nest/budget/budget.bridge.ts create mode 100644 server/src/nest/budget/budget.mcp.ts delete mode 100644 server/src/nest/budget/exchange-rates.bridge.ts delete mode 100644 server/src/services/budgetService.ts rename server/tests/unit/{services/budgetService.test.ts => nest/budget.service.calc.test.ts} (85%) rename server/tests/unit/{services/budgetServiceDb.test.ts => nest/budget.service.db.test.ts} (64%) diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index 86e8bd23b..ab7f2faa0 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -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). diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index b0cfff544..1f1beaf4c 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -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). diff --git a/server/src/mcp/tools/budget.ts b/server/src/mcp/tools/budget.ts deleted file mode 100644 index 7c4a4da4f..000000000 --- a/server/src/mcp/tools/budget.ts +++ /dev/null @@ -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) -} diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts index 3772cf847..e7c571ced 100644 --- a/server/src/mcp/tools/transports.ts +++ b/server/src/mcp/tools/transports.ts @@ -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 { diff --git a/server/src/mcp/tools/trips.ts b/server/src/mcp/tools/trips.ts index 8886fbc97..8fbf986de 100644 --- a/server/src/mcp/tools/trips.ts +++ b/server/src/mcp/tools/trips.ts @@ -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, diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 821d42765..f854c8654 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -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 diff --git a/server/src/nest/booking-import/booking-import.module.ts b/server/src/nest/booking-import/booking-import.module.ts index 0e1746558..f4547bc57 100644 --- a/server/src/nest/booking-import/booking-import.module.ts +++ b/server/src/nest/booking-import/booking-import.module.ts @@ -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], }) diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index 8ac95535b..d5cebccbb 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -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( diff --git a/server/src/nest/budget/budget.bridge.ts b/server/src/nest/budget/budget.bridge.ts new file mode 100644 index 000000000..026475481 --- /dev/null +++ b/server/src/nest/budget/budget.bridge.ts @@ -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 { + return budget.rebaseTripCurrency(tripId, newCurrency); +} + +export function linkBudgetItemToReservation( + tripId: string | number, + reservationId: number, + data: Parameters[2], +) { + return budget.linkBudgetItemToReservation(tripId, reservationId, data); +} diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts new file mode 100644 index 000000000..8a33da04c --- /dev/null +++ b/server/src/nest/budget/budget.mcp.ts @@ -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), + }], + }; + } +} diff --git a/server/src/nest/budget/budget.module.ts b/server/src/nest/budget/budget.module.ts index d9d006f7f..0d4c8abb8 100644 --- a/server/src/nest/budget/budget.module.ts +++ b/server/src/nest/budget/budget.module.ts @@ -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 {} diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 6559a68ad..a7dd4bb0e 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -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>; +type Trip = NonNullable>; + +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(` + 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(` + 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 { + 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(` + 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 = {}; + + if (itemIds.length > 0) { + const allMembers = this.db.all(` + 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 = {}; + if (itemIds.length > 0) { + const allPayers = this.db.all(` + 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 { + 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 { + 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('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('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('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('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 = {}; + 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('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(` + 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 { + 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 = {}; + 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 | 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('SELECT * FROM budget_items WHERE trip_id = ?', tripId); + const allMembers = this.db.all(` + 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(` + 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 = {}; + 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(` + 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[1]) { - await svc.freezeForeignRate(tripId, data); - return svc.createBudgetItem(tripId, data); + async create(tripId: string, data: Parameters[1]) { + await this.freezeForeignRate(tripId, data); + return this.createBudgetItem(tripId, data); } - async update(id: string, tripId: string, data: Parameters[2]) { - await svc.freezeForeignRate(tripId, data, id); - return svc.updateBudgetItem(id, tripId, data); + async update(id: string, tripId: string, data: Parameters[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); diff --git a/server/src/nest/budget/exchange-rates.bridge.ts b/server/src/nest/budget/exchange-rates.bridge.ts deleted file mode 100644 index 3ce43510f..000000000 --- a/server/src/nest/budget/exchange-rates.bridge.ts +++ /dev/null @@ -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); -} diff --git a/server/src/nest/budget/exchange-rates.service.ts b/server/src/nest/budget/exchange-rates.service.ts index 4b905a3d8..bd4626963 100644 --- a/server/src/nest/budget/exchange-rates.service.ts +++ b/server/src/nest/budget/exchange-rates.service.ts @@ -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 diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md index 277fedf99..04b6b05be 100644 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ b/server/src/nest/plugins/DI-MIGRATION.md @@ -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 diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index 02e3a99b3..0aff1da89 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -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 diff --git a/server/src/nest/reservations/reservations.bridge.ts b/server/src/nest/reservations/reservations.bridge.ts index b902fb600..99fc48456 100644 --- a/server/src/nest/reservations/reservations.bridge.ts +++ b/server/src/nest/reservations/reservations.bridge.ts @@ -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); diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 7ac01c264..7a8fd3c78 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -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, diff --git a/server/src/nest/reservations/reservations.module.ts b/server/src/nest/reservations/reservations.module.ts index 10f728eeb..1b7811402 100644 --- a/server/src/nest/reservations/reservations.module.ts +++ b/server/src/nest/reservations/reservations.module.ts @@ -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). diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 7591fb792..afc94fed1 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -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>; @@ -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); diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index 14439357b..629f8f859 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -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], }) diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 5fb720889..49643fb28 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -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), diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts deleted file mode 100644 index 935eceead..000000000 --- a/server/src/services/budgetService.ts +++ /dev/null @@ -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 { - 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 = {}; - - 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 = {}; - 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 { - 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 { - 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 = {}; - 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 { - 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 = {}; - 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 | 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 = {}; - 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)); - })(); -} diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index 380ea28e7..23e5dba5e 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -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'; diff --git a/server/src/services/userCleanupService.ts b/server/src/services/userCleanupService.ts index 1bcffdd5a..10d50016f 100644 --- a/server/src/services/userCleanupService.ts +++ b/server/src/services/userCleanupService.ts @@ -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: diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 6651e4d26..b66e0cccd 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -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>; + 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' }); }); diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 62dfa73ad..9b0ba0025 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -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'; diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 1e9c1b631..e400c0034 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -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 }); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index c76d4c440..14548e1f9 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -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), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 8246cf489..2251bde30 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -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), diff --git a/server/tests/unit/mcp/resources.test.ts b/server/tests/unit/mcp/resources.test.ts index bb29d7344..f10d9b8d7 100644 --- a/server/tests/unit/mcp/resources.test.ts +++ b/server/tests/unit/mcp/resources.test.ts @@ -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. diff --git a/server/tests/unit/mcp/tools-budget.test.ts b/server/tests/unit/mcp/tools-budget.test.ts index 19ff0683f..3d361f887 100644 --- a/server/tests/unit/mcp/tools-budget.test.ts +++ b/server/tests/unit/mcp/tools-budget.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(); + }); + }); +}); diff --git a/server/tests/unit/services/budgetService.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts similarity index 85% rename from server/tests/unit/services/budgetService.test.ts rename to server/tests/unit/nest/budget.service.calc.test.ts index 99f6647b5..8100f3659 100644 --- a/server/tests/unit/services/budgetService.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -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); diff --git a/server/tests/unit/services/budgetServiceDb.test.ts b/server/tests/unit/nest/budget.service.db.test.ts similarity index 64% rename from server/tests/unit/services/budgetServiceDb.test.ts rename to server/tests/unit/nest/budget.service.db.test.ts index 8ac97530d..5cf07d0e8 100644 --- a/server/tests/unit/services/budgetServiceDb.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -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> = { - 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>, +})); +// 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); + }); +}); diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index abc638f4e..3d6beebb7 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -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', () => { diff --git a/server/tests/unit/nest/exchange-rates.service.test.ts b/server/tests/unit/nest/exchange-rates.service.test.ts index 906420840..5571c9f54 100644 --- a/server/tests/unit/nest/exchange-rates.service.test.ts +++ b/server/tests/unit/nest/exchange-rates.service.test.ts @@ -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); }); }); diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index 327dc3bfc..512c1d316 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -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(() => { diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 820414f82..d10a8d34f 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -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)', () => { diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index cb212e1fd..42eff69e7 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -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 }]),