diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 55100ff95..6f5299a3b 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -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 { 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(); + 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(); 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 }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index ee5d1de3f..e06345246 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -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)