chore: move budget backend to proper DI

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