mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(budget): saving an expense keeps the payers and shares of people who left the trip
Removing someone from a trip leaves their payer and split rows on the expenses they were part of. Every save ran the ids the form sends 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 now stay allowed, as payer or as member, and can still be taken off it on purpose. A new expense and an id that never was on the expense are filtered as before (#1952). The paid ticks of the split carry over a form save instead of being reset. REST, the plugin RPC and the MCP split checks all read the same allowed set from the service. Refs #2604
This commit is contained in:
@@ -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<string | null> {
|
||||
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);
|
||||
|
||||
@@ -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<Set<number>> {
|
||||
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<Set<number>> {
|
||||
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<number>, 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<number>();
|
||||
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<number>();
|
||||
const existingPaid = new Map<number, number>();
|
||||
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<number, number> = {};
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user