fix(trips): a copied trip keeps every expense total without naming people who are not on it

The copy has nobody on it but the copier, yet it took every payer and
split row along. Its balances then had the copier owing people who cannot
see the trip, and the first save of such an expense dropped them and
shrank its total.

An expense anyone else pays for or shares now comes across as a planning
entry with its total and no payers, split or receipt lines; one that is
the copier's alone is copied whole. Assignment participants and the
legacy paid_by_user_id keep only the copier for the same reason.

Refs #2604
This commit is contained in:
Maurice
2026-10-12 13:15:22 +02:00
parent af66bdd910
commit 64537a0224
2 changed files with 112 additions and 26 deletions
+32 -10
View File
@@ -674,9 +674,12 @@ export class TripsService {
* assignments, accommodations, reservations, budget, packing bags/items, day
* notes) into a new trip owned by `newOwnerId`.
* Cross-links are remapped to the copied rows (reservation↔budget item,
* reservation↔accommodation) and split data travels with the copy
* (budget_item_members/payers incl. paid flags, assignment_participants).
* Packing items and to-dos are reset to unchecked. Returns the new trip's ID.
* reservation↔accommodation). The copy has nobody on it but `newOwnerId`, so
* only their own split data travels with it (budget_item_members/payers incl.
* paid flags, assignment_participants). An expense anyone else pays for or
* shares comes across as a planning entry: same total, no payers, no split
* (#2604). Settlements are not copied. Packing items and to-dos are reset to
* unchecked. Returns the new trip's ID.
*/
async copy(sourceTripId: string | number, newOwnerId: number, title?: string): Promise<number> {
const src = await this.tripsRepo.findRaw(sourceTripId); // TP36
@@ -817,6 +820,7 @@ export class TripsService {
const oldParticipants = await this.assignmentParticipantsRepo.listForTrip(sourceTripId); // TP53
for (const ap of oldParticipants) {
if (ap.user_id !== newOwnerId) continue;
const newAssignmentId = assignmentMap.get(ap.assignment_id);
if (newAssignmentId) await this.assignmentParticipantsRepo.insertIgnore(newAssignmentId, [ap.user_id]); // TP54
}
@@ -877,32 +881,50 @@ export class TripsService {
reservationMap.set(r.id, newReservationId);
}
// TP62/TP64 — Plan 3e Task 2, converted. Read up front: which expenses
// involve someone the copy will not have decides how each one is copied.
const oldBudgetMembers = await this.budgetItemMembersRepo.listRawForTrip(sourceTripId);
const oldBudgetPayers = await this.budgetItemPayersRepo.listRawForTrip(sourceTripId);
// Carrying someone else's payer or split row over left the copy owing money
// to people who cannot see it, and the first save of the expense dropped them
// and shrank its total (#2604). Keeping only the copier's rows would leave the
// payers and shares no longer adding up to the total, so the whole split goes
// and the expense stays as a planning entry with its total.
const sharedWithOthers = new Set<number>();
for (const row of [...oldBudgetMembers, ...oldBudgetPayers]) {
if (row.user_id !== newOwnerId) sharedWithOthers.add(row.budget_item_id);
}
const hasSplitRows = new Set(oldBudgetMembers.map(bm => bm.budget_item_id));
// TP60 — Plan 3e Task 2, converted.
const oldBudget = await this.budgetItemsRepo.listAllForTrip(sourceTripId);
const budgetMap = new Map<number, number>();
for (const b of oldBudget) {
// TP61 — Plan 3e Task 2, converted (`insertCopy`, carries `paid_by_user_id` verbatim).
const planningOnly = sharedWithOthers.has(b.id);
// TP61 — Plan 3e Task 2, converted (`insertCopy`). The persons count of a
// split is its member count and goes with it; a typed one stays. A receipt
// split names who had which line, so it goes as well.
const newItemId = await this.budgetItemsRepo.insertCopy({
trip_id: newTripId, category: b.category, name: b.name, total_price: b.total_price, persons: b.persons, days: b.days,
trip_id: newTripId, category: b.category, name: b.name, total_price: b.total_price,
persons: planningOnly && hasSplitRows.has(b.id) ? null : b.persons, days: b.days,
note: b.note, sort_order: b.sort_order,
reservation_id: b.reservation_id ? (reservationMap.get(b.reservation_id) ?? null) : null,
currency: b.currency, exchange_rate: b.exchange_rate ?? 1, expense_date: b.expense_date,
ticket_json: b.ticket_json, paid_by_user_id: b.paid_by_user_id,
ticket_json: planningOnly ? null : b.ticket_json,
paid_by_user_id: b.paid_by_user_id === newOwnerId ? b.paid_by_user_id : null,
});
budgetMap.set(b.id, newItemId);
}
// TP62 — Plan 3e Task 2, converted.
const oldBudgetMembers = await this.budgetItemMembersRepo.listRawForTrip(sourceTripId);
for (const bm of oldBudgetMembers) {
if (sharedWithOthers.has(bm.budget_item_id)) continue;
const newItemId = budgetMap.get(bm.budget_item_id);
// TP63 — Plan 3e Task 2, converted.
if (newItemId) await this.budgetItemMembersRepo.insertIgnore({ budget_item_id: newItemId, user_id: bm.user_id, paid: bm.paid ?? 0, amount: bm.amount });
}
// TP64 — Plan 3e Task 2, converted.
const oldBudgetPayers = await this.budgetItemPayersRepo.listRawForTrip(sourceTripId);
for (const bp of oldBudgetPayers) {
if (sharedWithOthers.has(bp.budget_item_id)) continue;
const newItemId = budgetMap.get(bp.budget_item_id);
// TP65 — Plan 3e Task 2, converted.
if (newItemId) await this.budgetItemPayersRepo.insertIgnore({ budget_item_id: newItemId, user_id: bp.user_id, amount: bp.amount ?? 0 });
+80 -16
View File
@@ -1381,7 +1381,7 @@ describe('folded trip CRUD', () => {
expect(rows.find(r => r.name === "Member's meds")).toMatchObject({ is_private: 1, owner_id: member.id });
});
it('TRIP-SVC-059: copy remaps cross-links and carries splits/participants (smoke-test I-01)', async () => {
it("TRIP-SVC-059: copy remaps cross-links and keeps only the copier on splits and participants (smoke-test I-01)", async () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Linked', start_date: '2025-06-01', end_date: '2025-06-02' });
@@ -1415,14 +1415,11 @@ describe('folded trip CRUD', () => {
expect(Number(newRes.accommodation_id)).toBe(newAccom.id);
expect(newRes.url).toBe('https://example.test/booking');
// Splits carried over with per-member paid flags and amounts.
const members = await storedRows(BudgetItemMembers, { budgetItem: newItem.id }, { user: 'asc' }, ['user_id', 'paid', 'amount']) as any[];
expect(members).toEqual([
{ user_id: owner.id, paid: 1, amount: 120 },
{ user_id: friend.id, paid: 0, amount: 120 },
]);
const payers = await storedRows(BudgetItemPayers, { budgetItem: newItem.id }, undefined, ['user_id', 'amount']) as any[];
expect(payers).toEqual([{ user_id: owner.id, amount: 240 }]);
// The friend is not on the copy, so the shared expense comes across as a
// planning entry with its total and no split (#2604, see TRIP-SVC-094).
expect(newItem).toMatchObject({ total_price: 240, persons: null });
expect(await storedRows(BudgetItemMembers, { budgetItem: newItem.id }, undefined, ['user_id'])).toEqual([]);
expect(await storedRows(BudgetItemPayers, { budgetItem: newItem.id }, undefined, ['user_id'])).toEqual([]);
// Assignment participants copied onto the remapped assignment.
const newAssignment = (await getAssignments((await getDays(newTripId))[0].id))[0];
@@ -1433,6 +1430,74 @@ describe('folded trip CRUD', () => {
const todos = await storedRows(TodoItems, { trip: newTripId }, undefined, ['name', 'checked']) as any[];
expect(todos).toEqual([{ name: 'Book transfer', checked: 0 }]);
});
/**
* #2604: the copy has nobody on it but the copier, yet it used to take every
* payer and split row along. Balances in the copy then named people who could
* not see it, and the first save of such an expense dropped them and shrank
* its total. An expense anyone else pays for or shares now comes across as a
* planning entry with its total; one that is the copier's alone keeps it all.
*/
it('TRIP-SVC-094: copy keeps nobody outside the copy on its expenses and keeps every total (#2604)', async () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared costs', start_date: '2025-07-01', end_date: '2025-07-01' });
addTripMember(testDb, trip.id, friend.id);
const days = await getDays(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Harbour' });
const assignment = createDayAssignment(testDb, days[0].id, place.id);
await insertRow(await orm(), AssignmentParticipants, { assignment: assignment.id, user: owner.id });
await insertRow(await orm(), AssignmentParticipants, { assignment: assignment.id, user: friend.id });
const solo = Number(await insertRow(await orm(), BudgetItems, { trip: trip.id, category: 'Food', name: 'Solo lunch', total_price: 50, persons: 1, paidByUser: owner.id }));
await insertRow(await orm(), BudgetItemMembers, { budgetItem: solo, user: owner.id, paid: 1, amount: null });
await insertRow(await orm(), BudgetItemPayers, { budgetItem: solo, user: owner.id, amount: 50 });
const shared = Number(await insertRow(await orm(), BudgetItems, {
trip: trip.id, category: 'Transport', name: 'Rental car', total_price: 180, persons: 2, paidByUser: friend.id,
ticket_json: JSON.stringify({ items: [{ name: 'Car', price: '180', parts: [owner.id, friend.id] }] }),
}));
await insertRow(await orm(), BudgetItemMembers, { budgetItem: shared, user: owner.id, paid: 0, amount: null });
await insertRow(await orm(), BudgetItemMembers, { budgetItem: shared, user: friend.id, paid: 1, amount: null });
await insertRow(await orm(), BudgetItemPayers, { budgetItem: shared, user: owner.id, amount: 120 });
await insertRow(await orm(), BudgetItemPayers, { budgetItem: shared, user: friend.id, amount: 60 });
// Paid by the friend alone, with a typed head count and no split rows.
const theirs = Number(await insertRow(await orm(), BudgetItems, { trip: trip.id, category: 'Food', name: 'Seafood dinner', total_price: 90, persons: 4 }));
await insertRow(await orm(), BudgetItemPayers, { budgetItem: theirs, user: friend.id, amount: 90 });
const newTripId = await svc.copy(trip.id, owner.id, 'Shared costs copy');
const copied = await storedRows(BudgetItems, { trip: newTripId }, { name: 'asc' }, ['id', 'name', 'total_price', 'persons', 'paid_by_user_id', 'ticket_json']) as {
id: number; name: string; total_price: number; persons: number | null; paid_by_user_id: number | null; ticket_json: string | null;
}[];
const byName = (name: string) => copied.find(c => c.name === name)!;
expect(copied.map(c => [c.name, c.total_price])).toEqual([['Rental car', 180], ['Seafood dinner', 90], ['Solo lunch', 50]]);
// The copier's own expense is copied whole.
expect(byName('Solo lunch')).toMatchObject({ persons: 1, paid_by_user_id: owner.id });
expect(await storedRows(BudgetItemMembers, { budgetItem: byName('Solo lunch').id }, undefined, ['user_id', 'paid'])).toEqual([{ user_id: owner.id, paid: 1 }]);
expect(await storedRows(BudgetItemPayers, { budgetItem: byName('Solo lunch').id }, undefined, ['user_id', 'amount'])).toEqual([{ user_id: owner.id, amount: 50 }]);
// A shared one keeps its total as a planning entry: nobody pays, nobody owes.
expect(byName('Rental car')).toMatchObject({ persons: null, paid_by_user_id: null, ticket_json: null });
expect(await countRows(await orm(), BudgetItemMembers, { budgetItem: byName('Rental car').id })).toBe(0);
expect(await countRows(await orm(), BudgetItemPayers, { budgetItem: byName('Rental car').id })).toBe(0);
expect(byName('Seafood dinner')).toMatchObject({ persons: 4, paid_by_user_id: null });
expect(await countRows(await orm(), BudgetItemPayers, { budgetItem: byName('Seafood dinner').id })).toBe(0);
// Nothing in the copy points at the friend, and its ledger squares.
const newAssignment = (await getAssignments((await getDays(newTripId))[0].id))[0];
expect(await storedRows(AssignmentParticipants, { assignment: newAssignment.id }, undefined, ['user_id'])).toEqual([{ user_id: owner.id }]);
const settlement = await budgetSvc.calculateSettlement(newTripId);
expect(settlement.balances.map(b => b.user_id)).not.toContain(friend.id);
expect(Math.round(settlement.balances.reduce((a, b) => a + b.balance, 0) * 100)).toBe(0);
// The source keeps every row.
expect(await countRows(await orm(), BudgetItemPayers, { budgetItem: shared })).toBe(2);
expect(await countRows(await orm(), BudgetItemMembers, { budgetItem: shared })).toBe(2);
expect(await countRows(await orm(), AssignmentParticipants, { assignment: assignment.id })).toBe(2);
});
});
// ── Wrapper helpers (delegating members of the aggregate root) ────────────────
@@ -1848,7 +1913,8 @@ describe('copy — whole-trip parity (Task 8)', () => {
// assignment_participants
const newParticipants = await storedRows(AssignmentParticipants, { assignment: newAssignment.id }, undefined, ['user_id']) as { user_id: number }[];
expect(newParticipants.map(p => p.user_id).sort((a, b) => a - b)).toEqual([owner.id, member.id].sort((a, b) => a - b));
// Only the copier is on the copy (#2604).
expect(newParticipants.map(p => p.user_id)).toEqual([owner.id]);
// roadtrip_vias / roadtrip_day_tracks
const newVia = await storedFields(RoadtripVias, { day: newDays[0].id }, ['after_order_index', 'sequence', 'lat', 'lng']) as {
@@ -1920,12 +1986,10 @@ describe('copy — whole-trip parity (Task 8)', () => {
id: number; category: string; name: string; total_price: number; reservation_id: number | null; currency: string | null;
};
expect(newItem).toMatchObject({ category: 'Accommodation', name: 'Hotel', total_price: 300, reservation_id: newRes.id, currency: 'EUR' });
const newMembers = await storedRows(BudgetItemMembers, { budgetItem: newItem.id }, { user: 'asc' }, ['user_id', 'paid', 'amount']) as { user_id: number; paid: number; amount: number | null }[];
expect(newMembers).toEqual([owner.id, member.id].sort((a, b) => a - b).map((uid) =>
uid === owner.id ? { user_id: owner.id, paid: 1, amount: 150 } : { user_id: member.id, paid: 0, amount: 150 },
));
const newPayers = await storedRows(BudgetItemPayers, { budgetItem: newItem.id }, undefined, ['user_id', 'amount']) as { user_id: number; amount: number }[];
expect(newPayers).toEqual([{ user_id: owner.id, amount: 300 }]);
// The member is shared into this expense but not on the copy, so it comes
// across as a planning entry: same total, no split rows (#2604).
expect(await storedRows(BudgetItemMembers, { budgetItem: newItem.id }, undefined, ['user_id'])).toEqual([]);
expect(await storedRows(BudgetItemPayers, { budgetItem: newItem.id }, undefined, ['user_id'])).toEqual([]);
expect((await storedFields(BudgetCategoryOrder, { trip: newTripId, category: 'Accommodation' }, ['sort_order']) as { sort_order: number }).sort_order).toBe(1);
// packing_bags / packing_items (incl. the TP68 privacy filter — the copIER is the owner)