diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index cad1603bf..197eb4629 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -1,9 +1,7 @@ import { ADDON_IDS } from '../../addons'; import { Places } from '../../db/entities/Places.entity'; -import { TripMembers } from '../../db/entities/TripMembers.entity'; import { Trips } from '../../db/entities/Trips.entity'; import type { PlacesRepository } from '../../db/repositories/Places.repository'; -import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository'; import type { TripsRepository } from '../../db/repositories/Trips.repository'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { @@ -102,9 +100,6 @@ export class BudgetMcp { private readonly uow: UnitOfWork, @InjectRepository(Places) private readonly places: PlacesRepository, @InjectRepository(Trips) private readonly trips: TripsRepository, - // Plan 4 Task 3 — DatabaseService.rosterUserIds inlined onto - // TripMembersRepository.rosterUserIds directly. - @InjectRepository(TripMembers) private readonly tripMembers: TripMembersRepository, ) {} /** @@ -125,10 +120,13 @@ export class BudgetMcp { /** * The total a custom split has to reconcile against: explicit payers derive it * (the write path overwrites total_price with their sum), so the stated total - * is not necessarily the figure that ends up in the row. + * is not necessarily the figure that ends up in the row. `itemId` is the + * expense being edited, null for a new one: its own payers and split members + * count as the write path counts them, even after leaving the trip (#2604). */ private async settledTotalCents( tripId: number, + itemId: number | null, payers: { user_id: number; amount: number }[] | undefined, total_price: number | undefined, fallbackCents: number, @@ -138,8 +136,8 @@ export class BudgetMcp { // here instead would certify a split against a figure the row never receives. // Negative payers count with their sign (#2176) — the write path stores them. if (payers !== undefined) { - const roster = await this.tripMembers.rosterUserIds(tripId); - return sumMinor(payers.filter((p) => p.amount !== 0 && roster.has(p.user_id)).map((p) => p.amount)); + const allowed = await this.budget.allowedParticipantIds(tripId, itemId); + return sumMinor(payers.filter((p) => p.amount !== 0 && allowed.has(p.user_id)).map((p) => p.amount)); } if (total_price !== undefined) return toMinor(total_price); return fallbackCents; @@ -152,16 +150,19 @@ export class BudgetMcp { * difference no payment can ever clear, so it is refused here rather than * stored and discovered on the balances screen. An off-roster or repeated * user_id is refused for the same reason: the write path drops both silently, - * which would unbalance a split that had just been checked. + * which would unbalance a split that had just been checked. Someone already on + * the expense being edited (`itemId`) is not a stranger, even once they have + * left the trip, because the write path keeps them (#2604). */ private async splitRefusal( tripId: number, + itemId: number | null, members: { user_id: number; amount: number }[], totalCents: number, payers?: { user_id: number; amount: number }[], ): Promise { - const roster = await this.tripMembers.rosterUserIds(tripId); - const strangers = members.filter((m) => !roster.has(m.user_id)).map((m) => m.user_id); + const allowed = await this.budget.allowedParticipantIds(tripId, itemId); + const strangers = members.filter((m) => !allowed.has(m.user_id)).map((m) => m.user_id); if (strangers.length > 0) { return `members contains user IDs that are not on this trip: ${strangers.join(', ')}. Resolve them with list_trip_members.`; } @@ -172,7 +173,7 @@ export class BudgetMcp { // certified here would not be the split stored. Refuse rather than let the // difference surface on the balances screen. if (payers) { - const payerStrangers = payers.filter((p) => p.amount !== 0 && !roster.has(p.user_id)).map((p) => p.user_id); + const payerStrangers = payers.filter((p) => p.amount !== 0 && !allowed.has(p.user_id)).map((p) => p.user_id); if (payerStrangers.length > 0) { return `payers contains user IDs that are not on this trip: ${payerStrangers.join(', ')}. Resolve them with list_trip_members.`; } @@ -271,8 +272,9 @@ export class BudgetMcp { if (members !== undefined) { const refusal = await this.splitRefusal( tripId, + null, members, - await this.settledTotalCents(tripId, payers, total_price, toMinor(total_price)), + await this.settledTotalCents(tripId, null, payers, total_price, toMinor(total_price)), payers, ); if (refusal) return errorResult(refusal); @@ -431,8 +433,9 @@ export class BudgetMcp { if (!existing) return errorResult('Budget item not found.'); const refusal = await this.splitRefusal( tripId, + itemId, members, - await this.settledTotalCents(tripId, payers, total_price, toMinor(existing.total_price)), + await this.settledTotalCents(tripId, itemId, payers, total_price, toMinor(existing.total_price)), payers, ); if (refusal) return errorResult(refusal); diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 47e54235e..587a3438d 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -266,27 +266,37 @@ export class BudgetService { } /** - * The subset of `userIds` that is actually on this trip. Used to be a plain - * existence check against `users`, which let any id on the instance become a - * payer or a split member and come back out through the read-back join with a - * name and an avatar attached. Off-roster ids drop silently, the way the - * packing and reservations assignee paths handle them. + * Who may be a payer or a split member of an expense: everyone on the trip, + * plus, for an existing expense, whoever already pays for it or shares it. + * Used to be a plain existence check against `users`, which let any id on the + * instance become a payer or a split member and come back out through the + * read-back join with a name and an avatar attached. Off-roster ids still drop + * silently, the way the packing and reservations assignee paths handle them. + * + * Someone removed from the trip keeps their rows on the expenses they were part + * of, and the edit form sends those ids back. Filtering them out on that save + * dropped their share and shrank total_price to what the rest had paid (#2604), + * so ids already on this expense stay allowed, as payer or as member, and can + * still be taken off it. A stranger who never was on it is refused as before. + * Read before anything is replaced, inside the caller's transaction. */ - private async rosterMemberIds(tripId: string | number, userIds: number[]): Promise> { - const unique = new Set(userIds); - if (unique.size === 0) return new Set(); - const roster = await this.tripMembersRepo.rosterUserIds(tripId); - return new Set([...unique].filter(id => roster.has(id))); + async allowedParticipantIds(tripId: string | number, itemId: number | null): Promise> { + const allowed = new Set(await this.tripMembersRepo.rosterUserIds(tripId)); + if (itemId !== null) { + for (const p of await this.budgetItemPayersRepo.listForItem(itemId)) allowed.add(p.user_id); + for (const m of await this.budgetItemMembersRepo.listUserPaid(itemId)) allowed.add(m.user_id); + } + return allowed; } /** * Replace the payer rows of an item and keep total_price = sum of payer amounts. * A negative amount is a real payer — the recipient of a refund (#2176) — and * is stored as such; only a zero (or NaN) amount says nothing and is dropped. + * `known` is {@link allowedParticipantIds}, read before the rows go. */ - private async writeItemPayers(itemId: number | string, tripId: string | number, payers: { user_id: number; amount: number }[]) { + private async writeItemPayers(itemId: number | string, known: ReadonlySet, payers: { user_id: number; amount: number }[]) { await this.budgetItemPayersRepo.deleteForItem(itemId as number); - const known = await this.rosterMemberIds(tripId, payers.map(p => p.user_id)); const accepted: number[] = []; for (const p of payers) { if (!p.amount || !known.has(p.user_id)) continue; @@ -623,10 +633,10 @@ export class BudgetService { const payerTotal = sumMoney((data.payers || []).filter(p => p.amount !== 0).map(p => p.amount)); const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0); - const knownMembers = data.members ? await this.rosterMemberIds(tripId, 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 ? await this.rosterMemberIds(tripId, data.member_ids) : null; - const memberIds = data.member_ids && knownIds ? data.member_ids.filter(uid => knownIds.has(uid)) : undefined; + // A new expense has nobody on it yet, so only the trip's own people qualify. + const known = data.payers || data.members || data.member_ids ? await this.allowedParticipantIds(tripId, null) : new Set(); + const members = data.members ? data.members.filter(m => known.has(m.user_id)) : undefined; + const memberIds = data.member_ids ? data.member_ids.filter(uid => known.has(uid)) : undefined; const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json); @@ -647,7 +657,7 @@ export class BudgetService { place_id: data.place_id != null ? data.place_id : null, }); - if (data.payers && data.payers.length > 0) await this.writeItemPayers(itemId, tripId, data.payers); + if (data.payers && data.payers.length > 0) await this.writeItemPayers(itemId, known, data.payers); if (members && members.length > 0) { for (const m of members) { await this.budgetItemMembersRepo.insertIgnore({ budget_item_id: itemId, user_id: m.user_id, paid: 0, amount: m.amount !== undefined && m.amount !== null ? m.amount : null }); @@ -727,6 +737,15 @@ export class BudgetService { const item = await this.budgetItemsRepo.findInTrip(id, tripId); if (!item) return null; + // Who may stay on or join this expense, and the paid ticks of its split, are + // read before the payer and member rows below are replaced. + const splitTouched = data.payers !== undefined || data.members !== undefined || data.member_ids !== undefined; + const known = splitTouched ? await this.allowedParticipantIds(tripId, id) : new Set(); + const existingPaid = new Map(); + if (data.members !== undefined || data.member_ids !== undefined) { + for (const e of await this.budgetItemMembersRepo.listUserPaid(id)) existingPaid.set(e.user_id, e.paid); + } + // An old client sending a receipt in `note` still lands in ticket_json, and // its note is left untouched rather than clobbered with the receipt blob. const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json); @@ -755,7 +774,7 @@ export class BudgetService { // Optional inline payer/member replacement (the edit modal saves all at once). if (data.payers !== undefined) { - await this.writeItemPayers(id, tripId, data.payers); + await this.writeItemPayers(id, known, 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. @@ -763,20 +782,20 @@ export class BudgetService { await this.budgetItemsRepo.setTotalPrice(id, data.total_price); } } + // The paid ticks carry over, as they do through updateMembers: the edit form + // saves the whole split every time, and it used to clear every tick with it. if (data.members !== undefined) { - const known = await this.rosterMemberIds(tripId, data.members.map(m => m.user_id)); const members = data.members.filter(m => known.has(m.user_id)); await this.budgetItemMembersRepo.deleteForItem(id); for (const m of members) { - await this.budgetItemMembersRepo.insertIgnore({ budget_item_id: id, user_id: m.user_id, paid: 0, amount: m.amount !== undefined && m.amount !== null ? m.amount : null }); + await this.budgetItemMembersRepo.insertIgnore({ budget_item_id: id, user_id: m.user_id, paid: existingPaid.get(m.user_id) || 0, amount: m.amount !== undefined && m.amount !== null ? m.amount : null }); } await this.budgetItemsRepo.setPersons(id, members.length || null); } else if (data.member_ids !== undefined) { - const known = await this.rosterMemberIds(tripId, data.member_ids); const memberIds = data.member_ids.filter(uid => known.has(uid)); await this.budgetItemMembersRepo.deleteForItem(id); for (const uid of memberIds) { - await this.budgetItemMembersRepo.insertIgnore({ budget_item_id: id, user_id: uid, paid: 0, amount: null }); + await this.budgetItemMembersRepo.insertIgnore({ budget_item_id: id, user_id: uid, paid: existingPaid.get(uid) || 0, amount: null }); } await this.budgetItemsRepo.setPersons(id, memberIds.length || null); } @@ -838,7 +857,7 @@ export class BudgetService { return await this.uow.transactional(async () => { const item = await this.budgetItemsRepo.existsInTrip(id, tripId); if (!item) return null; - await this.writeItemPayers(id, tripId, payers); + await this.writeItemPayers(id, await this.allowedParticipantIds(tripId, id), payers); const updated = this.toBudgetItem((await this.budgetItemsRepo.findById(id))!); updated.members = await this.loadItemMembers(id); updated.payers = await this.loadItemPayers(id); @@ -959,10 +978,10 @@ export class BudgetService { const existingPaid: Record = {}; const existing = await this.budgetItemMembersRepo.listUserPaid(id); for (const e of existing) existingPaid[e.user_id] = e.paid; + const known = await this.allowedParticipantIds(tripId, id); await this.budgetItemMembersRepo.deleteForItem(id); - const known = await this.rosterMemberIds(tripId, userIds); const memberIds = userIds.filter(uid => known.has(uid)); if (memberIds.length > 0) { for (const userId of memberIds) { diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index fd579f9ac..53afda57b 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -11,6 +11,7 @@ import { db } from '../../src/db/database'; import { BudgetItems } from '../../src/db/entities/BudgetItems.entity'; import { BudgetSettlements } from '../../src/db/entities/BudgetSettlements.entity'; import { Reservations } from '../../src/db/entities/Reservations.entity'; +import { TripMembers } from '../../src/db/entities/TripMembers.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; import { BudgetModule } from '../../src/nest/budget/budget.module'; import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; @@ -756,4 +757,40 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { expect(res.body.error).toContain('base_rate'); }); }); + + // #2604: removing someone from the trip leaves their rows on the expenses they + // were part of. Saving such an expense with the payload the form sends back used + // to drop them and shrink the total to what the rest had paid. + describe('someone who left the trip', () => { + it('PUT with the unchanged payers and split keeps them, the total and a balanced ledger; a stranger is still dropped', async () => { + await makeUser(orm, { id: 3, username: 'e2e-leaver', email: 'leaver@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { id: 4, username: 'e2e-stranger', email: 'stranger@example.test', role: 'user', password_version: 0 }); + const leftTripId = (await makeTrip(orm, 1, { title: 'E2E Left Trip', currency: 'EUR' })).id; + await addTripMember(orm, leftTripId, 2); + await addTripMember(orm, leftTripId, 3); + const payload = { + name: 'Rental car', + payers: [{ user_id: 1, amount: 120 }, { user_id: 3, amount: 60 }], + member_ids: [1, 2, 3], + }; + const created = await request(server).post(`/api/trips/${leftTripId}/budget`).set('Cookie', sessionCookie(1)).send(payload); + expect(created.status).toBe(201); + await deleteRows(orm, TripMembers, { trip: leftTripId, user: 3 }); + + const saved = await request(server) + .put(`/api/trips/${leftTripId}/budget/${created.body.item.id}`) + .set('Cookie', sessionCookie(1)) + .send({ ...payload, note: 'keys at the desk', member_ids: [1, 2, 3, 4] }); + expect(saved.status).toBe(200); + expect(saved.body.item.total_price).toBe(180); + expect(saved.body.item.payers.map((p: { user_id: number }) => p.user_id).sort()).toEqual([1, 3]); + expect(saved.body.item.members.map((m: { user_id: number }) => m.user_id).sort()).toEqual([1, 2, 3]); + + const settlement = await request(server).get(`/api/trips/${leftTripId}/budget/settlement`).set('Cookie', sessionCookie(1)); + expect(settlement.status).toBe(200); + const balances = settlement.body.balances as { user_id: number; balance: number }[]; + expect(Math.round(balances.reduce((a, b) => a + b.balance, 0) * 100)).toBe(0); + expect(balances.find(b => b.user_id === 3)?.balance).toBe(0); + }); + }); }); diff --git a/server/tests/unit/mcp/tools-budget.test.ts b/server/tests/unit/mcp/tools-budget.test.ts index bb58bdb33..8f76ada4e 100644 --- a/server/tests/unit/mcp/tools-budget.test.ts +++ b/server/tests/unit/mcp/tools-budget.test.ts @@ -11,6 +11,7 @@ import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; import { BudgetSettlements } from '../../../src/db/entities/BudgetSettlements.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; import { BudgetService } from '../../../src/nest/budget/budget.service'; import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache'; import { @@ -1280,6 +1281,65 @@ describe('Budget tools: a custom split cannot be certified against a total the r expect(shares.map((s) => s.amount)).toEqual([60, 40]); }); }); + + // #2604: someone removed from the trip keeps their rows on the expense, and an + // edit that sends them back must not be refused as if they were a stranger. + it('certifies an edit that keeps someone who has left the trip on the expense (#2604)', async () => { + const { user: owner } = createUser(testDb); + const { user: friend } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, friend.id); + await withHarness(owner.id, async (h) => { + const created = await h.client.callTool({ + name: 'create_budget_item', + arguments: { + tripId: trip.id, + name: 'Boat', + total_price: 100, + payers: [{ user_id: friend.id, amount: 100 }], + members: [ + { user_id: owner.id, amount: 60 }, + { user_id: friend.id, amount: 40 }, + ], + }, + }); + const { item } = parseToolResult(created) as { item: { id: number } }; + await deleteRows(orm, TripMembers, { trip: trip.id, user: friend.id }); + + const kept = await h.client.callTool({ + name: 'update_budget_item', + arguments: { + tripId: trip.id, + itemId: item.id, + note: 'paid at the pier', + payers: [{ user_id: friend.id, amount: 100 }], + members: [ + { user_id: owner.id, amount: 60 }, + { user_id: friend.id, amount: 40 }, + ], + }, + }); + expect(kept.isError).toBeFalsy(); + expect((await itemRow(item.id)).total_price).toBe(100); + expect((await memberRows(item.id)).map((s) => s.amount)).toEqual([60, 40]); + + const refused = await h.client.callTool({ + name: 'update_budget_item', + arguments: { + tripId: trip.id, + itemId: item.id, + payers: [{ user_id: friend.id, amount: 100 }], + members: [ + { user_id: owner.id, amount: 60 }, + { user_id: stranger.id, amount: 40 }, + ], + }, + }); + expect(refused.isError).toBe(true); + expect(errorText(refused)).toContain('not on this trip'); + }); + }); }); // --------------------------------------------------------------------------- diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index d22f40bf0..de299e403 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -167,7 +167,6 @@ beforeAll(async () => { await createTestUnitOfWork(testDb), await createTestPlacesRepo(testDb), await createTestTripsRepo(testDb), - await createTestTripMembersRepo(testDb), ); tripPromptsMcp = new TripPromptsMcp(tripsStub, readModelStub, promptPackingService, addonsStub); }); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 3e00d9e5a..327a8e940 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -2112,3 +2112,165 @@ describe('rows no rate can convert, and the rates that heal them', () => { } }); }); + +// ── People who left the trip keep their part of an expense (#2604) ─────────── +// Removing someone from the trip leaves their payer and split rows in place. +// Saving the expense used to run every id back through the roster filter, so an +// unchanged save dropped them, total_price shrank to what the rest had paid and +// a custom split stopped adding up. Ids already on the expense stay allowed; a +// stranger who never was on it is still dropped (#1952). + +describe('someone who left the trip keeps their part of an expense (#2604)', () => { + async function tripWithFormerMember() { + const { user: alice } = createUser(testDb, { username: 'alice' }); + const { user: bob } = createUser(testDb, { username: 'bob' }); + const { user: carol } = createUser(testDb, { username: 'carol' }); + const trip = createTrip(testDb, alice.id); + addTripMember(testDb, trip.id, bob.id); + addTripMember(testDb, trip.id, carol.id); + return { alice, bob, carol, trip }; + } + const balanceSum = (balances: { balance: number }[]) => Math.round(balances.reduce((a, b) => a + b.balance, 0) * 100); + + it('BUDGET-SVC-DB-087: an unchanged save keeps a former member as payer and split member', async () => { + const { alice, bob, carol, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Rental car', + payers: [{ user_id: alice.id, amount: 120 }, { user_id: bob.id, amount: 60 }], + member_ids: [alice.id, bob.id, carol.id], + }); + await membersSvc.removeMember(trip.id, bob.id); + + // What the expense form sends back: the ids it was seeded with. + const saved = await budget.updateBudgetItem(item.id, trip.id, { + note: 'keys at the desk', + payers: [{ user_id: alice.id, amount: 120 }, { user_id: bob.id, amount: 60 }], + member_ids: [alice.id, bob.id, carol.id], + }); + + expect(saved!.total_price).toBe(180); + expect(saved!.payers.map(p => p.user_id).sort()).toEqual([alice.id, bob.id].sort()); + expect(saved!.members.map(m => m.user_id).sort()).toEqual([alice.id, bob.id, carol.id].sort()); + expect(saved!.persons).toBe(3); + const settlement = await budget.calculateSettlement(trip.id); + expect(balanceSum(settlement.balances)).toBe(0); + }); + + it('BUDGET-SVC-DB-088: a custom split with a former member still adds up after a save', async () => { + const { alice, bob, carol, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Dinner', + payers: [{ user_id: bob.id, amount: 90 }], + members: [{ user_id: alice.id, amount: 30 }, { user_id: bob.id, amount: 30 }, { user_id: carol.id, amount: 30 }], + }); + await membersSvc.removeMember(trip.id, bob.id); + + const saved = await budget.updateBudgetItem(item.id, trip.id, { + payers: [{ user_id: bob.id, amount: 90 }], + members: [{ user_id: alice.id, amount: 30 }, { user_id: bob.id, amount: 30 }, { user_id: carol.id, amount: 30 }], + }); + + expect(saved!.total_price).toBe(90); + expect(saved!.members.reduce((a, m) => a + (m.amount ?? 0), 0)).toBe(90); + const settlement = await budget.calculateSettlement(trip.id); + expect(balanceSum(settlement.balances)).toBe(0); + expect(settlement.balances.find(b => b.user_id === bob.id)?.balance).toBe(60); + }); + + it('BUDGET-SVC-DB-089: a stranger who never was on the expense is still dropped', async () => { + const { alice, bob, trip } = await tripWithFormerMember(); + const { user: outsider } = createUser(testDb, { username: 'outsider' }); + const item = await budget.createBudgetItem(trip.id, { + name: 'Taxi', payers: [{ user_id: alice.id, amount: 40 }], member_ids: [alice.id, bob.id], + }); + + const saved = await budget.updateBudgetItem(item.id, trip.id, { + payers: [{ user_id: alice.id, amount: 40 }, { user_id: outsider.id, amount: 10 }], + member_ids: [alice.id, bob.id, outsider.id], + }); + + expect(saved!.payers.map(p => p.user_id)).toEqual([alice.id]); + expect(saved!.members.map(m => m.user_id)).not.toContain(outsider.id); + expect(saved!.total_price).toBe(40); + const viaMembers = await budget.updateMembers(item.id, trip.id, [alice.id, outsider.id]); + expect(viaMembers!.members.map(m => m.user_id)).toEqual([alice.id]); + }); + + it('BUDGET-SVC-DB-090: leaving a former member out of the save removes them', async () => { + const { alice, bob, carol, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Museum', payers: [{ user_id: alice.id, amount: 30 }], member_ids: [alice.id, bob.id, carol.id], + }); + await membersSvc.removeMember(trip.id, bob.id); + + const saved = await budget.updateBudgetItem(item.id, trip.id, { + payers: [{ user_id: alice.id, amount: 30 }], member_ids: [alice.id, carol.id], + }); + + expect(saved!.members.map(m => m.user_id).sort()).toEqual([alice.id, carol.id].sort()); + // Once gone from the expense, they cannot be put back while off the trip. + const again = await budget.updateBudgetItem(item.id, trip.id, { member_ids: [alice.id, bob.id, carol.id] }); + expect(again!.members.map(m => m.user_id)).not.toContain(bob.id); + }); + + it('BUDGET-SVC-DB-091: a former member who was only in the split can be recorded as the payer', async () => { + const { alice, bob, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Tickets', payers: [{ user_id: alice.id, amount: 50 }], member_ids: [alice.id, bob.id], + }); + await membersSvc.removeMember(trip.id, bob.id); + + const saved = await budget.updateBudgetItem(item.id, trip.id, { + payers: [{ user_id: bob.id, amount: 50 }], member_ids: [alice.id, bob.id], + }); + + expect(saved!.payers.map(p => p.user_id)).toEqual([bob.id]); + expect(saved!.total_price).toBe(50); + }); + + it('BUDGET-SVC-DB-092: the paid ticks survive a form save', async () => { + const { alice, bob, carol, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Hotel', payers: [{ user_id: alice.id, amount: 300 }], member_ids: [alice.id, bob.id, carol.id], + }); + await budget.toggleMemberPaid(item.id, trip.id, carol.id, true); + await budget.toggleMemberPaid(item.id, trip.id, bob.id, true); + await membersSvc.removeMember(trip.id, bob.id); + + await budget.updateBudgetItem(item.id, trip.id, { + name: 'Hotel Central', payers: [{ user_id: alice.id, amount: 300 }], member_ids: [alice.id, bob.id, carol.id], + }); + + expect(await paidFlag(item.id, carol.id)).toBe(1); + expect(await paidFlag(item.id, bob.id)).toBe(1); + expect(await paidFlag(item.id, alice.id)).toBe(0); + }); + + it('BUDGET-SVC-DB-093: the payer and member routes keep a former member too', async () => { + const { alice, bob, trip } = await tripWithFormerMember(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Ferry', payers: [{ user_id: alice.id, amount: 20 }, { user_id: bob.id, amount: 20 }], member_ids: [alice.id, bob.id], + }); + await membersSvc.removeMember(trip.id, bob.id); + + const payers = await budget.setItemPayers(item.id, trip.id, [{ user_id: alice.id, amount: 20 }, { user_id: bob.id, amount: 20 }]); + expect(payers!.total_price).toBe(40); + expect(payers!.payers.map(p => p.user_id).sort()).toEqual([alice.id, bob.id].sort()); + + const members = await budget.updateMembers(item.id, trip.id, [alice.id, bob.id]); + expect(members!.members.map(m => m.user_id).sort()).toEqual([alice.id, bob.id].sort()); + }); + + it('BUDGET-SVC-DB-094: a new expense still takes only people on the trip', async () => { + const { alice, bob, trip } = await tripWithFormerMember(); + await membersSvc.removeMember(trip.id, bob.id); + + const item = await budget.createBudgetItem(trip.id, { + name: 'Lunch', payers: [{ user_id: alice.id, amount: 20 }, { user_id: bob.id, amount: 20 }], member_ids: [alice.id, bob.id], + }) as { payers: { user_id: number }[]; members: { user_id: number }[]; total_price: number }; + + expect(item.payers.map(p => p.user_id)).toEqual([alice.id]); + expect(item.members.map(m => m.user_id)).toEqual([alice.id]); + expect(item.total_price).toBe(20); + }); +}); diff --git a/server/tests/unit/nest/demo-service-survivor-sites.test.ts b/server/tests/unit/nest/demo-service-survivor-sites.test.ts index f0cae8aa2..56a88938d 100644 --- a/server/tests/unit/nest/demo-service-survivor-sites.test.ts +++ b/server/tests/unit/nest/demo-service-survivor-sites.test.ts @@ -53,7 +53,7 @@ describe('demo refusal at the former survivor sites: exact refusal body per doma }); it('DEMO-SURVIVOR-004 (budget.mcp.ts): create_budget_item refuses byte-for-byte in demo mode', async () => { - const mcp = new BudgetMcp(stub, stub, stub, stub, stub, stub, stub, stub, stub, stub); + const mcp = new BudgetMcp(stub, stub, stub, stub, stub, stub, stub, stub, stub); const res = await callGatedTool( mcp, 'createBudgetItem',