diff --git a/client/src/components/Trips/TripMembersModal.test.tsx b/client/src/components/Trips/TripMembersModal.test.tsx index 829d285f4..0cf77354c 100644 --- a/client/src/components/Trips/TripMembersModal.test.tsx +++ b/client/src/components/Trips/TripMembersModal.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-060 +// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-061 import type { Mock } from 'vitest'; import { act, render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -989,7 +989,7 @@ describe('TripMembersModal', () => { const trash = await screen.findByRole('button', { name: 'Remove access' }); await user.click(trash); - expect(screen.getByText('Remove this guest? Their assignments and cost shares will be removed too.')).toBeInTheDocument(); + expect(screen.getByText('Remove this guest? Their assignments are removed and their share of each expense goes to the others in its split.')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(deletedId).toBeNull(); @@ -1015,6 +1015,23 @@ describe('TripMembersModal', () => { expect(screen.getByText('Grandma')).toBeInTheDocument(); }); + it('FE-COMP-MEMBERS-061: a guest who paid for something is kept, with the reason (#2604)', async () => { + const user = userEvent.setup(); + mockRoster([guestRow]); + server.use(http.delete('/api/trips/1/guests/:userId', () => HttpResponse.json({ error: 'refused' }, { status: 409 }))); + render(); + + await user.click(await screen.findByRole('button', { name: 'Remove access' })); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith( + 'This guest paid for an expense or is part of a recorded payment. Change the payer or delete the payment first.', + 'error', + undefined, + )); + expect(screen.getByText('Grandma')).toBeInTheDocument(); + }); + it('FE-COMP-MEMBERS-053: without member_manage a plain member sees no invite or guest controls', async () => { const stranger = buildUser({ id: 99, username: 'stranger' }); seedStore(useAuthStore, { user: stranger, isAuthenticated: true }); diff --git a/client/src/components/Trips/useTripShare.ts b/client/src/components/Trips/useTripShare.ts index 20fa2eeff..66304bc2e 100644 --- a/client/src/components/Trips/useTripShare.ts +++ b/client/src/components/Trips/useTripShare.ts @@ -279,8 +279,11 @@ export function useTripMembers({ isOpen, tripId, onClose, onMembersChanged }: { await loadMembers(true) await loadBudgetItems(tripId) toast.success(t('members.guestRemoved')) - } catch { - toast.error(t('members.removeError')) + } catch (err: unknown) { + // A guest who paid for something or is part of a recorded payment stays (#2604): + // deleting them would take that money off the books. + const refused = (err as { response?: { status?: number } } | null)?.response?.status === 409 + toast.error(t(refused ? 'members.guestHasPayments' : 'members.removeError')) } finally { setRemovingId(null) } diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index d9bfc5c19..faa7851a1 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -89,3 +89,20 @@ export const COLLAB_POLL_OPTIONS: JsonColumn = { schema: z.array(z.unknown()), fallback: () => [], }; + +/** + * An expense's itemized receipt (#1658): each line's price as typed and the user + * ids in on it. The client writes it; the server only reads it to take a departed + * guest off the lines, so every key it does not know is kept as it is. + */ +export const BUDGET_ITEM_TICKET: JsonColumn<{ items: { price?: string | number; parts?: number[] }[] } | null> = { + column: 'budget_items.ticket_json', + schema: z + .object({ + items: z.array( + z.looseObject({ price: z.union([z.string(), z.number()]).optional(), parts: z.array(z.number()).optional() }), + ), + }) + .loose(), + fallback: () => null, +}; diff --git a/server/src/db/repositories/BudgetItemMembers.repository.ts b/server/src/db/repositories/BudgetItemMembers.repository.ts index 9aac404c6..034f5171d 100644 --- a/server/src/db/repositories/BudgetItemMembers.repository.ts +++ b/server/src/db/repositories/BudgetItemMembers.repository.ts @@ -196,6 +196,16 @@ export class BudgetItemMembersRepository extends TrekRepository { + await this.kysely() + .updateTable('budget_item_members') + .set({ amount }) + .where('budget_item_id', '=', budget_item_id) + .where('user_id', '=', user_id) + .execute(); + } + /** BG35/BG38/BG59 — `DELETE FROM budget_item_members WHERE budget_item_id = ?` (replace-all before a re-insert). */ async deleteForItem(budget_item_id: number): Promise { await this.kysely() diff --git a/server/src/db/repositories/BudgetItemPayers.repository.ts b/server/src/db/repositories/BudgetItemPayers.repository.ts index b83e16a50..af51f407a 100644 --- a/server/src/db/repositories/BudgetItemPayers.repository.ts +++ b/server/src/db/repositories/BudgetItemPayers.repository.ts @@ -94,6 +94,17 @@ export class BudgetItemPayersRepository extends TrekRepository .execute(); } + /** How many expenses a user paid any part of, a refund's negative share included (#2176): `SELECT COUNT(*) FROM budget_item_payers WHERE user_id = ? AND amount <> 0`. */ + async countNonZeroForUser(user_id: number): Promise { + const row = await this.kysely() + .selectFrom('budget_item_payers') + .select((eb) => eb.fn.countAll().as('count')) + .where('user_id', '=', user_id) + .where('amount', '<>', 0) + .executeTakeFirst(); + return Number(row?.count ?? 0); + } + /** BG4 — `DELETE FROM budget_item_payers WHERE budget_item_id = ?` (replace-all before a re-insert). */ async deleteForItem(budget_item_id: number): Promise { await this.kysely() diff --git a/server/src/db/repositories/BudgetItems.repository.ts b/server/src/db/repositories/BudgetItems.repository.ts index cc4cb9463..4946d6e1a 100644 --- a/server/src/db/repositories/BudgetItems.repository.ts +++ b/server/src/db/repositories/BudgetItems.repository.ts @@ -306,6 +306,11 @@ export class BudgetItemsRepository extends TrekRepository { .execute(); } + /** `UPDATE budget_items SET ticket_json = ? WHERE id = ?`. */ + async setTicketJson(id: number, ticket_json: string | null): Promise { + await this.kysely().updateTable('budget_items').set({ ticket_json }).where('id', '=', id).execute(); + } + /** BG83 (`reorderItems`, looped) — `UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?`. */ async setSortOrder(id: number | string, trip_id: number | string, sort_order: number): Promise { await this.kysely() diff --git a/server/src/db/repositories/BudgetSettlements.repository.ts b/server/src/db/repositories/BudgetSettlements.repository.ts index 7d0739ec9..7cb821baf 100644 --- a/server/src/db/repositories/BudgetSettlements.repository.ts +++ b/server/src/db/repositories/BudgetSettlements.repository.ts @@ -179,6 +179,16 @@ export class BudgetSettlementsRepository extends TrekRepository().deleteFrom('budget_settlements').where('id', '=', id).execute(); } + /** How many recorded payments a user sent or received: `SELECT COUNT(*) FROM budget_settlements WHERE from_user_id = ? OR to_user_id = ?`. */ + async countForUser(user_id: number): Promise { + const row = await this.kysely() + .selectFrom('budget_settlements') + .select((eb) => eb.fn.countAll().as('count')) + .where((eb) => eb.or([eb('from_user_id', '=', user_id), eb('to_user_id', '=', user_id)])) + .executeTakeFirst(); + return Number(row?.count ?? 0); + } + /** * `UPDATE budget_settlements SET created_by_user_id = NULL WHERE * created_by_user_id = ?`. The column has no ON DELETE action, so deleting a diff --git a/server/src/nest/budget/budget-departure.helpers.ts b/server/src/nest/budget/budget-departure.helpers.ts new file mode 100644 index 000000000..0b20c76c6 --- /dev/null +++ b/server/src/nest/budget/budget-departure.helpers.ts @@ -0,0 +1,78 @@ +import { currencyDecimals, splitEqualShares, splitTicketLines, toMinor } from '@trek/shared'; + +/** A member row of one expense as the re-split reads it. */ +export interface DepartureMember { + user_id: number; + amount: number | null; +} + +/** The decoded `ticket_json` of an expense, every key the server does not read kept as it is. */ +export interface DepartureTicket { + items: { price?: string | number; parts?: number[] }[]; +} + +/** + * What an expense's split becomes when one of its members leaves for good (a + * deleted guest or account, #2604), so the shares still add up to what they did. + * + * An equal split needs nothing here: the members left divide the payer credit + * among themselves. A split by amount hands the leaver's amount to everyone + * left, largest remainder in the smallest step the currency is shown in, and + * gives each of them an amount of their own: a member without one pays nothing + * on a custom split, and leaving them at null would quietly turn the expense + * back into an equal one. A split by receipt takes the leaver off every line and + * splits the lines again the way the cost editor does, so the receipt and the + * amounts agree the next time it is opened; a line only the leaver was on is + * then carried by everyone left on the receipt. Should that not account for the + * same total (a receipt nobody else is on, or amounts edited past the receipt), + * the amount is handed on as for a custom split instead. + * + * `amounts` is null when no member amount changes, `ticket` when the receipt does not. + */ +export function shareAfterLeaving( + itemId: number, + members: readonly DepartureMember[], + leaverId: number, + currency: string, + ticket: DepartureTicket | null, +): { amounts: Record | null; ticket: DepartureTicket | null } { + const staying = members.filter((m) => m.user_id !== leaverId); + const leaver = members.find((m) => m.user_id === leaverId); + const onReceipt = ticket?.items.some((line) => line.parts?.includes(leaverId)) ?? false; + const nextTicket = + ticket && onReceipt + ? { + ...ticket, + items: ticket.items.map((line) => ({ ...line, parts: (line.parts ?? []).filter((id) => id !== leaverId) })), + } + : null; + if (!leaver || staying.length === 0 || !members.some((m) => m.amount !== null)) + return { amounts: null, ticket: nextTicket }; + + const before = members.reduce((sum, m) => sum + toMinor(m.amount ?? 0), 0); + if (nextTicket) { + const { shares, totalMinor } = splitTicketLines( + nextTicket.items.map((line) => ({ price: line.price ?? '', parts: line.parts })), + ); + const covered = staying.reduce((sum, m) => sum + (shares[m.user_id] ?? 0), 0); + if (totalMinor === before && covered === totalMinor) { + return { + amounts: Object.fromEntries(staying.map((m) => [m.user_id, (shares[m.user_id] ?? 0) / 100])), + ticket: nextTicket, + }; + } + } + + const leaving = toMinor(leaver.amount ?? 0); + const decimals = currencyDecimals(currency); + const step = decimals < 2 && leaving % 10 ** (2 - decimals) === 0 ? 10 ** (2 - decimals) : 1; + const extra = splitEqualShares(leaving / step, staying, itemId); + const amounts = Object.fromEntries( + staying.map((m) => [m.user_id, (toMinor(m.amount ?? 0) + (extra[m.user_id] ?? 0) * step) / 100]), + ); + return { amounts, ticket: nextTicket }; +} + +/** The refusal REST and MCP answer when a guest who moved money is to be deleted (#2604). */ +export const GUEST_HAS_PAYMENTS = + 'This guest paid for an expense or is part of a recorded payment. Change the payer or delete the payment first.'; diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 62b0f5627..83103254a 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -27,8 +27,10 @@ import { Trips } from '../../db/entities/Trips.entity'; import type { TripsRepository, TripAccess } from '../../db/repositories/Trips.repository'; import { TripMembers } from '../../db/entities/TripMembers.entity'; import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository'; -import { RESERVATION_METADATA } from '../../db/json-columns'; -import { decodeJson, encodeJson } from '../../utils/json-column'; +import { BUDGET_ITEM_TICKET, RESERVATION_METADATA } from '../../db/json-columns'; +import { decodeJson, decodeJsonResult, encodeJson } from '../../utils/json-column'; +import { DomainError } from '../common/domain-error'; +import { GUEST_HAS_PAYMENTS, shareAfterLeaving } from './budget-departure.helpers'; type Trip = TripAccess; @@ -1005,13 +1007,26 @@ export class BudgetService { }); } + /** + * Clears a guest's traces before the users row goes, refusing while money they moved is + * on the books: the cascade would take their payer rows and recorded payments with it (#2604). + */ + async releaseGuest(userId: number): Promise { + await this.uow.transactional(async () => { + const paid = await this.budgetItemPayersRepo.countNonZeroForUser(userId); + if (paid + (await this.budgetSettlementsRepo.countForUser(userId)) > 0) { + throw new DomainError(409, GUEST_HAS_PAYMENTS, { code: 'guest_has_payments' }); + } + await this.budgetItemsRepo.clearPaidByUser(userId); + await this.budgetSettlementsRepo.clearCreatedBy(userId); + await this.removeUserFromBudgetItems(userId); + }); + } + async removeUserFromBudgetItems(userId: number): Promise { await this.uow.transactional(async () => { const itemIds = await this.budgetItemMembersRepo.listItemIdsForUser(userId); - if (itemIds.length === 0) { - return; - } - + await Promise.all(itemIds.map(itemId => this.handOnShare(itemId, userId))); await this.budgetItemMembersRepo.deleteForUser(userId); for (const itemId of itemIds) { @@ -1021,6 +1036,19 @@ export class BudgetService { }); } + /** Hands a departing member's custom or receipt share to the rest of the split, see {@link shareAfterLeaving}. */ + private async handOnShare(itemId: number, userId: number): Promise { + const item = await this.budgetItemsRepo.findById(itemId); + if (!item) return; + const ticket = decodeJsonResult(BUDGET_ITEM_TICKET, item.ticket_json); + const currency = item.currency || (await this.tripsRepo.getCurrency(item.trip_id)) || 'EUR'; + const members = await this.budgetItemMembersRepo.listForItem(itemId); + const next = shareAfterLeaving(itemId, members, userId, currency, ticket.ok ? ticket.value : null); + if (next.ticket) await this.budgetItemsRepo.setTicketJson(itemId, encodeJson(BUDGET_ITEM_TICKET, next.ticket)); + const amounts = Object.entries(next.amounts ?? {}); + await Promise.all(amounts.map(([id, amount]) => this.budgetItemMembersRepo.setAmount(itemId, Number(id), amount))); + } + /** * R2-class fix (same as `FilesService`'s — flagged, mutation-tested): the * legacy trip-scoping guard (BG68) and the write (BG69) were two separate diff --git a/server/src/nest/trip-members/trip-members.service.ts b/server/src/nest/trip-members/trip-members.service.ts index 3853d7fe4..b224f7d15 100644 --- a/server/src/nest/trip-members/trip-members.service.ts +++ b/server/src/nest/trip-members/trip-members.service.ts @@ -298,18 +298,21 @@ export class TripMembersService { async deleteGuest(tripId: string | number, guestUserId: number): Promise { if (!(await this.guestOfTrip(tripId, guestUserId))) return false; - // The plugin erasure, the budget re-split and the user delete run in one + // The budget check and re-split, the plugin erasure and the user delete run in one // transaction, so a failure mid-flow can't leave the expense divisors re-derived, // or the guest's plugin rows erased, for a guest that still exists (or vice // versa). The plugin notification goes out after the commit. await this.uow.transactional(async () => { + // A guest who paid for something or is part of a recorded payment is refused with a + // 409 before anything is touched: the cascade would take that money off the books + // (#2604). Otherwise the expenses they were part of are re-split before the cascade + // takes their member rows away, the divisor and the custom amounts cannot follow a + // foreign key (#1553). + await this.budget.releaseGuest(guestUserId); // A guest is still a user id a plugin may hold data for, so erase that too (the // host-side per-user tables plus a durable own-db erasure per granted plugin), exactly // like a full account deletion; otherwise a deleted guest's plugin data lingers. await this.userCleanup.erasePluginUserData(guestUserId); - // Re-split the expenses they were part of before the cascade takes their member - // rows away — the divisor is denormalized and cannot follow a foreign key (#1553). - await this.budget.removeUserFromBudgetItems(guestUserId); // Deleting the guest's users row cascades its membership and every assignment join // (trip_members, budget/packing/assignment links) via the ON DELETE foreign keys. await this.usersRepo.deleteGuest(guestUserId); diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 32a9e0328..4f9e50305 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -610,7 +610,7 @@ export class TripsMcp { @Tool({ name: 'delete_trip_guest', description: - 'Remove a guest from a trip. This deletes the guest outright (they exist only for this trip) and re-splits any expenses they were part of. Only the trip owner can do this.', + 'Remove a guest from a trip. This deletes the guest outright (they exist only for this trip) and hands their share of any expense to the others in its split. Refused while the guest paid for an expense or is part of a recorded payment: change the payer or delete the payment first. Only the trip owner can do this.', inputSchema: { tripId: idSchema, guestId: idSchema.describe('User ID of the guest, from list_trip_members'), diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 4ee4c6992..e56d2ef55 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -22,12 +22,14 @@ */ import { db } from '../../src/db/database'; import { AuditLog } from '../../src/db/entities/AuditLog.entity'; +import { BudgetItemMembers } from '../../src/db/entities/BudgetItemMembers.entity'; import { Days } from '../../src/db/entities/Days.entity'; import { JourneyEntries } from '../../src/db/entities/JourneyEntries.entity'; import { Journeys } from '../../src/db/entities/Journeys.entity'; import { Settings } from '../../src/db/entities/Settings.entity'; import { TripMembers } from '../../src/db/entities/TripMembers.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; +import { Users } from '../../src/db/entities/Users.entity'; import { VacayEntries } from '../../src/db/entities/VacayEntries.entity'; import { VacayPlans } from '../../src/db/entities/VacayPlans.entity'; // TripsService itself is real since the trip fold — no tripService mock; the @@ -38,8 +40,10 @@ import { VacayPlans } from '../../src/db/entities/VacayPlans.entity'; // DI-injected BudgetService since the budget fold — stubbed via a container // spy in beforeAll (unrelated to this file's schema conversion). +import { GUEST_HAS_PAYMENTS } from '../../src/nest/budget/budget-departure.helpers'; import { BudgetService } from '../../src/nest/budget/budget.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { withRequestContext } from '../../src/nest/database/request-context'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { RealtimeService } from '../../src/nest/realtime/realtime.service'; @@ -53,6 +57,7 @@ import { makeUser } from '../helpers/factories/users'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { sessionCookie } from './harness'; +import { MikroORM } from '@mikro-orm/core'; import { Test } from '@nestjs/testing'; import { MAX_TRIP_DAYS } from '@trek/shared'; @@ -399,4 +404,51 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { expect(await findRow(orm, JourneyEntries, { type: 'skeleton' })).toBeNull(); expect((await findRow(orm, JourneyEntries, { id: filledId }))!.source_trip_id).toBeNull(); }); + + it('DELETE guest: 409 while the guest paid an expense, then 200 with their share handed on (#2604)', async () => { + const tripId = await seedTrip('T'); + const budget = app.get(BudgetService); + const inContext = (fn: () => Promise) => withRequestContext(app.get(MikroORM), fn); + const created = await request(server) + .post(`/api/trips/${tripId}/guests`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'FIX1012 G' }); + expect(created.status).toBe(201); + const guestId = created.body.member.id as number; + const paid = await inContext(() => + budget.createBudgetItem(tripId, { + name: 'Museum', + payers: [{ user_id: guestId, amount: 60 }], + member_ids: [1, guestId], + }), + ); + const shared = await inContext(() => + budget.createBudgetItem(tripId, { + name: 'Snacks', + payers: [{ user_id: 1, amount: 90 }], + members: [ + { user_id: 1, amount: 50 }, + { user_id: guestId, amount: 40 }, + ], + }), + ); + + const refused = await request(server) + .delete(`/api/trips/${tripId}/guests/${guestId}`) + .set('Cookie', sessionCookie(1)); + expect(refused.status).toBe(409); + expect(refused.body).toEqual({ error: GUEST_HAS_PAYMENTS }); + expect(await findRow(orm, Users, { id: guestId })).not.toBeNull(); + + // Once the payer is changed the guest goes, and the owner carries the whole snack bill. + await inContext(() => budget.setItemPayers(paid.id, tripId, [{ user_id: 1, amount: 60 }])); + const ok = await request(server).delete(`/api/trips/${tripId}/guests/${guestId}`).set('Cookie', sessionCookie(1)); + expect(ok.status).toBe(200); + expect(await findRow(orm, Users, { id: guestId })).toBeNull(); + expect( + (await findRows(orm, BudgetItemMembers, { budgetItem: shared.id })).map((m) => [m.user_id, m.amount]), + ).toEqual([[1, 90]]); + const { balances } = await inContext(() => budget.calculateSettlement(tripId)); + expect(balances.reduce((sum, b) => sum + Math.round(b.balance * 100), 0)).toBe(0); + }); }); diff --git a/server/tests/unit/mcp/tools-trip-members.test.ts b/server/tests/unit/mcp/tools-trip-members.test.ts index d4f1d77eb..17b966513 100644 --- a/server/tests/unit/mcp/tools-trip-members.test.ts +++ b/server/tests/unit/mcp/tools-trip-members.test.ts @@ -9,8 +9,10 @@ import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity'; import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; import { Users } from '../../../src/db/entities/Users.entity'; +import { GUEST_HAS_PAYMENTS } from '../../../src/nest/budget/budget-departure.helpers'; import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache'; import { createUser, createAdmin, createTrip, addTripMember } from '../../helpers/factories'; +import { addBudgetItemPayer, makeBudgetItem } from '../../helpers/factories/budget'; import { countRows, findRow } from '../../helpers/factories/rows'; import { setAppSetting } from '../../helpers/factories/settings'; import { makeShareToken } from '../../helpers/factories/trips'; @@ -841,6 +843,22 @@ describe('Tool: delete_trip_guest', () => { }); }); + it('refuses a guest who paid an expense, with the same text REST answers (#2604)', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const guestId = await makeGuest(h, trip.id, 'Anna'); + const item = await makeBudgetItem(orm, trip.id, { total_price: 60 }); + await addBudgetItemPayer(orm, item.id, guestId, 60); + broadcastMock.mockClear(); + const result = await h.client.callTool({ name: 'delete_trip_guest', arguments: { tripId: trip.id, guestId } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain(GUEST_HAS_PAYMENTS); + expect(await findRow(orm, Users, { id: guestId })).not.toBeNull(); + expect(broadcastMock).not.toHaveBeenCalledWith(trip.id, 'member:removed', expect.anything()); + }); + }); + it('will not delete a guest of another trip', async () => { const { user: owner } = createUser(testDb); const ownTrip = createTrip(testDb, owner.id); diff --git a/server/tests/unit/nest/trip-members.controller.test.ts b/server/tests/unit/nest/trip-members.controller.test.ts index 9c1bd526f..890574c99 100644 --- a/server/tests/unit/nest/trip-members.controller.test.ts +++ b/server/tests/unit/nest/trip-members.controller.test.ts @@ -1,4 +1,6 @@ import type { AuditService } from '../../../src/nest/audit/audit.service'; +import { GUEST_HAS_PAYMENTS } from '../../../src/nest/budget/budget-departure.helpers'; +import { DomainError } from '../../../src/nest/common/domain-error'; import { NotFoundError, ValidationError } from '../../../src/nest/common/domain-errors'; import { TripMembersController } from '../../../src/nest/trip-members/trip-members.controller'; import { TripMembersModule } from '../../../src/nest/trip-members/trip-members.module'; @@ -241,6 +243,18 @@ describe('TripMembersController', () => { expect(ok.broadcast).not.toHaveBeenCalled(); }); + it('passes the 409 of a guest who moved money on as it stands, the text the MCP tool answers too (#2604)', async () => { + const refused = svc({ + deleteGuest: vi + .fn() + .mockRejectedValue(new DomainError(409, GUEST_HAS_PAYMENTS, { code: 'guest_has_payments' })), + } as Partial); + expect(await thrown(() => tc(refused).deleteGuest(user, '9', '7'))).toEqual({ + status: 409, + body: { error: GUEST_HAS_PAYMENTS }, + }); + }); + it('maps a ValidationError from createGuest to 400', async () => { const ve = svc({ createGuest: vi.fn().mockImplementation(() => { diff --git a/server/tests/unit/nest/trip-members.guest-departure.test.ts b/server/tests/unit/nest/trip-members.guest-departure.test.ts new file mode 100644 index 000000000..31ea3ce4b --- /dev/null +++ b/server/tests/unit/nest/trip-members.guest-departure.test.ts @@ -0,0 +1,277 @@ +/** + * Deleting a guest leaves the trip's money adding up (#2604). GUEST-DEP-001+. + * + * Real in-memory SQLite over the container, so the ON DELETE cascades that used + * to take a guest's payer rows and recorded payments with them are the ones + * production runs. A guest who moved money is refused; one who only shared in + * expenses has their share handed to the others, by amount and by receipt line. + */ +import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity'; +import { BudgetItemPayers } from '../../../src/db/entities/BudgetItemPayers.entity'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import { BudgetSettlements } from '../../../src/db/entities/BudgetSettlements.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; +import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service'; +import { BudgetService } from '../../../src/nest/budget/budget.service'; +import { TripMembersModule } from '../../../src/nest/trip-members/trip-members.module'; +import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { countRows, findRow, findRows, updateRows } from '../../helpers/factories/rows'; +import { addTripMember, makeTrip } from '../../helpers/factories/trips'; +import { makeUser } from '../../helpers/factories/users'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { MikroORM } from '@mikro-orm/core'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let members: TripMembersService; +let budget: BudgetService; +const orm = () => t.get(MikroORM); + +beforeAll(async () => { + t = await createTestModule({ db, imports: [TripMembersModule] }); + members = t.get(TripMembersService); + budget = t.get(BudgetService); +}); + +afterAll(async () => { + await t.close(); + db.close(); +}); + +/** A trip with its owner, one more member and a guest. */ +async function tripWithGuest() { + const { user: owner } = await makeUser(orm()); + const { user: other } = await makeUser(orm()); + const trip = await makeTrip(orm(), owner.id); + await addTripMember(orm(), trip.id, other.id); + const guest = (await members.createGuest(trip.id, 'FIX1012 G', owner.id)).member; + return { owner, other, trip, guest }; +} + +async function amountsOf(itemId: number): Promise> { + const rows = await findRows(orm(), BudgetItemMembers, { budgetItem: itemId }); + return Object.fromEntries(rows.map((r) => [r.user_id, r.amount ?? null])); +} + +async function itemRow(id: number) { + const row = await findRow(orm(), BudgetItems, { id }); + if (!row) throw new Error(`no budget item ${id}`); + return row; +} + +/** The trip's balances added up, in cents: zero whenever every share has a payer behind it. */ +async function balanceCents(tripId: number): Promise { + const { balances } = await budget.calculateSettlement(tripId); + return Math.round(balances.reduce((sum, b) => sum + b.balance * 100, 0)); +} + +describe('a guest who moved money stays (#2604)', () => { + it('GUEST-DEP-001: refuses a guest who paid an expense with a 409, and changes nothing', async () => { + const { owner, trip, guest } = await tripWithGuest(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Museum', + payers: [{ user_id: guest.id, amount: 60 }], + member_ids: [owner.id, guest.id], + }); + + await expect(members.deleteGuest(trip.id, guest.id)).rejects.toMatchObject({ + status: 409, + code: 'guest_has_payments', + publicMessage: expect.stringContaining('Change the payer or delete the payment first'), + }); + + expect(await findRow(orm(), Users, { id: guest.id })).toBeTruthy(); + expect((await findRows(orm(), BudgetItemPayers, { budgetItem: item.id })).map((p) => p.user_id)).toEqual([ + guest.id, + ]); + expect( + Object.keys(await amountsOf(item.id)) + .map(Number) + .sort(), + ).toEqual([owner.id, guest.id].sort()); + }); + + it('GUEST-DEP-002: refuses a guest whose only payer row is a refund (a negative amount, #2176)', async () => { + const { owner, trip, guest } = await tripWithGuest(); + await budget.createBudgetItem(trip.id, { + name: 'Refund', + payers: [ + { user_id: owner.id, amount: 50 }, + { user_id: guest.id, amount: -10 }, + ], + member_ids: [owner.id], + }); + + await expect(members.deleteGuest(trip.id, guest.id)).rejects.toMatchObject({ status: 409 }); + }); + + it.each(['from', 'to'] as const)( + 'GUEST-DEP-003: refuses a guest on the %s side of a recorded payment', + async (side) => { + const { owner, trip, guest } = await tripWithGuest(); + const [from, to] = side === 'from' ? [guest.id, owner.id] : [owner.id, guest.id]; + await budget.insertSettlement(trip.id, { from_user_id: from, to_user_id: to, amount: 20 }, owner.id); + + await expect(members.deleteGuest(trip.id, guest.id)).rejects.toMatchObject({ status: 409 }); + expect(await countRows(orm(), BudgetSettlements, { trip: trip.id })).toBe(1); + }, + ); +}); + +describe('a deleted guest hands their share on (#2604)', () => { + it('GUEST-DEP-004: splits a custom amount across the others, the leftover cent included', async () => { + const { owner, other, trip, guest } = await tripWithGuest(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Snacks', + payers: [{ user_id: owner.id, amount: 90.01 }], + members: [ + { user_id: owner.id, amount: 30 }, + { user_id: other.id, amount: 20 }, + { user_id: guest.id, amount: 40.01 }, + ], + }); + + expect(await members.deleteGuest(trip.id, guest.id)).toBe(true); + + const amounts = await amountsOf(item.id); + expect(Object.keys(amounts).map(Number).sort()).toEqual([owner.id, other.id].sort()); + const added = [(amounts[owner.id] ?? 0) - 30, (amounts[other.id] ?? 0) - 20].map((v) => Math.round(v * 100)).sort(); + expect(added).toEqual([2000, 2001]); + expect(await balanceCents(trip.id)).toBe(0); + }); + + it('GUEST-DEP-005: gives members without an amount one, so the expense stays split by amount', async () => { + const { owner, other, trip, guest } = await tripWithGuest(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Mixed', + payers: [{ user_id: owner.id, amount: 40 }], + members: [ + { user_id: owner.id, amount: null }, + { user_id: other.id, amount: null }, + { user_id: guest.id, amount: 40 }, + ], + }); + + await members.deleteGuest(trip.id, guest.id); + + expect(await amountsOf(item.id)).toEqual({ [owner.id]: 20, [other.id]: 20 }); + expect(await balanceCents(trip.id)).toBe(0); + }); + + it('GUEST-DEP-006: hands on whole won in a currency without decimals', async () => { + const { owner, other, trip, guest } = await tripWithGuest(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Tteokbokki', + currency: 'KRW', + payers: [{ user_id: owner.id, amount: 30001 }], + members: [ + { user_id: owner.id, amount: 10000 }, + { user_id: other.id, amount: 10000 }, + { user_id: guest.id, amount: 10001 }, + ], + }); + + await members.deleteGuest(trip.id, guest.id); + + const amounts = await amountsOf(item.id); + expect([amounts[owner.id], amounts[other.id]].sort()).toEqual([15000, 15001]); + }); + + it('GUEST-DEP-007: takes the guest off the receipt and splits its lines again', async () => { + const { owner, other, trip, guest } = await tripWithGuest(); + const ticket = JSON.stringify({ + items: [ + { name: 'Pizza', price: '30', parts: [owner.id, guest.id] }, + { name: 'Wine', price: '10', parts: [guest.id] }, + { name: 'Salad', price: '20', parts: [other.id] }, + ], + }); + const item = await budget.createBudgetItem(trip.id, { + name: 'Dinner', + payers: [{ user_id: owner.id, amount: 60 }], + ticket_json: ticket, + members: [ + { user_id: owner.id, amount: 15 }, + { user_id: other.id, amount: 20 }, + { user_id: guest.id, amount: 25 }, + ], + }); + + await members.deleteGuest(trip.id, guest.id); + + // The pizza is the owner's alone now, and the wine nobody is ticked for any more + // is carried by everyone left on the receipt, as the cost editor splits it. + expect(await amountsOf(item.id)).toEqual({ [owner.id]: 35, [other.id]: 25 }); + const stored = JSON.parse((await itemRow(item.id)).ticket_json ?? '{}') as { + items: { name: string; parts: number[] }[]; + }; + expect(stored.items.map((l) => l.parts)).toEqual([[owner.id], [], [other.id]]); + expect(stored.items.map((l) => l.name)).toEqual(['Pizza', 'Wine', 'Salad']); + expect(await balanceCents(trip.id)).toBe(0); + }); + + it('GUEST-DEP-008: hands the amount on as for a custom split when nobody else is on the receipt', async () => { + const { owner, other, trip, guest } = await tripWithGuest(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Taxi', + payers: [{ user_id: owner.id, amount: 30 }], + ticket_json: JSON.stringify({ items: [{ name: 'Ride', price: '30', parts: [guest.id] }] }), + members: [ + { user_id: owner.id, amount: 0 }, + { user_id: other.id, amount: 0 }, + { user_id: guest.id, amount: 30 }, + ], + }); + + await members.deleteGuest(trip.id, guest.id); + + expect(await amountsOf(item.id)).toEqual({ [owner.id]: 15, [other.id]: 15 }); + expect(await balanceCents(trip.id)).toBe(0); + }); + + it('GUEST-DEP-009: re-splits an equal share as before, without writing amounts', async () => { + const { owner, other, trip, guest } = await tripWithGuest(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Bus', + payers: [{ user_id: owner.id, amount: 120 }], + member_ids: [owner.id, other.id, guest.id], + }); + + await members.deleteGuest(trip.id, guest.id); + + expect(await amountsOf(item.id)).toEqual({ [owner.id]: null, [other.id]: null }); + expect((await itemRow(item.id)).persons).toBe(2); + expect(await balanceCents(trip.id)).toBe(0); + }); + + it('GUEST-DEP-010: clears a legacy paid_by_user_id instead of failing on its foreign key', async () => { + const { owner, trip, guest } = await tripWithGuest(); + const item = await budget.createBudgetItem(trip.id, { name: 'Old', total_price: 10, member_ids: [owner.id] }); + await updateRows(orm(), BudgetItems, { id: item.id }, { paidByUser: guest.id }); + + expect(await members.deleteGuest(trip.id, guest.id)).toBe(true); + expect((await itemRow(item.id)).paid_by_user_id ?? null).toBeNull(); + }); +}); + +describe('a deleted account hands its share on the same way', () => { + it('GUEST-DEP-011: deleting a user keeps a custom split adding up', async () => { + const { owner, other, trip } = await tripWithGuest(); + const item = await budget.createBudgetItem(trip.id, { + name: 'Boat', + payers: [{ user_id: owner.id, amount: 100 }], + members: [ + { user_id: owner.id, amount: 70 }, + { user_id: other.id, amount: 30 }, + ], + }); + + await t.get(UserCleanupService).deleteUserCompletely(other.id); + + expect(await amountsOf(item.id)).toEqual({ [owner.id]: 100 }); + expect(await balanceCents(trip.id)).toBe(0); + }); +}); diff --git a/shared/src/i18n/ar/members.ts b/shared/src/i18n/ar/members.ts index 4e3f89534..2ebf931af 100644 --- a/shared/src/i18n/ar/members.ts +++ b/shared/src/i18n/ar/members.ts @@ -36,6 +36,8 @@ const members: TranslationStrings = { 'members.guestAddError': 'فشلت إضافة الضيف', 'members.guestRenameError': 'فشلت إعادة تسمية الضيف', 'members.guestRemoved': 'تمت إزالة الضيف', - 'members.confirmRemoveGuest': 'إزالة هذا الضيف؟ سيتم أيضًا إزالة تعييناته وحصص التكاليف الخاصة به.', + 'members.confirmRemoveGuest': + 'إزالة هذا الضيف؟ ستتم إزالة تعييناته، وتُوزَّع حصته من كل نفقة على الآخرين المشاركين فيها.', + 'members.guestHasPayments': 'دفع هذا الضيف نفقة أو هو طرف في دفعة مسجّلة. غيّر الدافع أو احذف الدفعة أولًا.', }; export default members; diff --git a/shared/src/i18n/az/members.ts b/shared/src/i18n/az/members.ts index b20f22a45..223c25628 100644 --- a/shared/src/i18n/az/members.ts +++ b/shared/src/i18n/az/members.ts @@ -38,7 +38,10 @@ const members: TranslationStrings = { 'members.guestAddError': 'Qonağı əlavə etmək mümkün olmadı', 'members.guestRenameError': 'Qonağın adını dəyişmək mümkün olmadı', 'members.guestRemoved': 'Qonaq çıxarıldı', - 'members.confirmRemoveGuest': 'Bu qonaq çıxarılsın? Ona təyin edilmiş tapşırıqlar və xərc payları da silinəcək.', + 'members.confirmRemoveGuest': + 'Bu qonaq çıxarılsın? Ona təyin edilmiş tapşırıqlar silinəcək, hər xərcdəki payı isə digər iştirakçılar arasında bölünəcək.', + 'members.guestHasPayments': + 'Bu qonaq bir xərci ödəyib və ya qeydə alınmış ödənişin tərəfidir. Əvvəlcə ödəyəni dəyişin və ya ödənişi silin.', }; export default members; diff --git a/shared/src/i18n/br/members.ts b/shared/src/i18n/br/members.ts index 269e99263..515099216 100644 --- a/shared/src/i18n/br/members.ts +++ b/shared/src/i18n/br/members.ts @@ -32,6 +32,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Falha ao adicionar convidado', 'members.guestRenameError': 'Falha ao renomear convidado', 'members.guestRemoved': 'Convidado removido', - 'members.confirmRemoveGuest': 'Remover este convidado? As atribuições e os custos dele também serão removidos.', + 'members.confirmRemoveGuest': + 'Remover este convidado? As atribuições dele serão removidas e a parte dele em cada despesa será dividida entre os demais participantes.', + 'members.guestHasPayments': + 'Este convidado pagou uma despesa ou faz parte de um pagamento registrado. Altere o pagador ou exclua o pagamento primeiro.', }; export default members; diff --git a/shared/src/i18n/ca/members.ts b/shared/src/i18n/ca/members.ts index ba94bea07..44ad5d474 100644 --- a/shared/src/i18n/ca/members.ts +++ b/shared/src/i18n/ca/members.ts @@ -35,6 +35,8 @@ const members: TranslationStrings = { 'members.guestRenameError': 'Error en canviar el nom del convidat', 'members.guestRemoved': 'Convidat eliminat', 'members.confirmRemoveGuest': - "Vols eliminar aquest convidat? Les seves assignacions i particions de despeses també s'eliminaran.", + "Vols eliminar aquest convidat? Les seves assignacions s'eliminaran i la seva part de cada despesa es repartirà entre la resta de participants.", + 'members.guestHasPayments': + "Aquest convidat ha pagat una despesa o forma part d'un pagament registrat. Canvia primer qui paga o elimina el pagament.", }; export default members; diff --git a/shared/src/i18n/cs/members.ts b/shared/src/i18n/cs/members.ts index 42283e1ad..eec0e65e1 100644 --- a/shared/src/i18n/cs/members.ts +++ b/shared/src/i18n/cs/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Nepodařilo se přidat hosta', 'members.guestRenameError': 'Nepodařilo se přejmenovat hosta', 'members.guestRemoved': 'Host odebrán', - 'members.confirmRemoveGuest': 'Odebrat tohoto hosta? Jeho přiřazení a podíly na nákladech budou také odebrány.', + 'members.confirmRemoveGuest': + 'Odebrat tohoto hosta? Jeho přiřazení budou odebrána a jeho podíl na každém výdaji se rozdělí mezi ostatní účastníky.', + 'members.guestHasPayments': + 'Tento host zaplatil výdaj nebo je součástí zaznamenané platby. Nejprve změňte plátce nebo platbu smažte.', }; export default members; diff --git a/shared/src/i18n/de/members.ts b/shared/src/i18n/de/members.ts index 3ebee7122..7715491f4 100644 --- a/shared/src/i18n/de/members.ts +++ b/shared/src/i18n/de/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Gast konnte nicht hinzugefügt werden', 'members.guestRenameError': 'Gast konnte nicht umbenannt werden', 'members.guestRemoved': 'Gast entfernt', - 'members.confirmRemoveGuest': 'Diesen Gast entfernen? Seine Zuweisungen und Kostenanteile werden ebenfalls entfernt.', + 'members.confirmRemoveGuest': + 'Diesen Gast entfernen? Seine Zuweisungen werden entfernt, sein Anteil an jeder Ausgabe geht an die anderen Beteiligten.', + 'members.guestHasPayments': + 'Dieser Gast hat eine Ausgabe bezahlt oder ist an einer erfassten Zahlung beteiligt. Ändere zuerst den Zahler oder lösche die Zahlung.', }; export default members; diff --git a/shared/src/i18n/en/members.ts b/shared/src/i18n/en/members.ts index 559863d2c..ccb260ac1 100644 --- a/shared/src/i18n/en/members.ts +++ b/shared/src/i18n/en/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Failed to add guest', 'members.guestRenameError': 'Failed to rename guest', 'members.guestRemoved': 'Guest removed', - 'members.confirmRemoveGuest': 'Remove this guest? Their assignments and cost shares will be removed too.', + 'members.confirmRemoveGuest': + 'Remove this guest? Their assignments are removed and their share of each expense goes to the others in its split.', + 'members.guestHasPayments': + 'This guest paid for an expense or is part of a recorded payment. Change the payer or delete the payment first.', }; export default members; diff --git a/shared/src/i18n/es/members.ts b/shared/src/i18n/es/members.ts index 8eeed119e..e7c7e2df3 100644 --- a/shared/src/i18n/es/members.ts +++ b/shared/src/i18n/es/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Error al añadir el invitado', 'members.guestRenameError': 'Error al renombrar el invitado', 'members.guestRemoved': 'Invitado eliminado', - 'members.confirmRemoveGuest': '¿Eliminar este invitado? También se eliminarán sus asignaciones y partes de gastos.', + 'members.confirmRemoveGuest': + '¿Eliminar este invitado? Se eliminarán sus asignaciones y su parte de cada gasto se repartirá entre los demás participantes.', + 'members.guestHasPayments': + 'Este invitado pagó un gasto o forma parte de un pago registrado. Cambia primero quién paga o elimina el pago.', }; export default members; diff --git a/shared/src/i18n/et/members.ts b/shared/src/i18n/et/members.ts index 205a7d1fa..608e8e0e1 100644 --- a/shared/src/i18n/et/members.ts +++ b/shared/src/i18n/et/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Külalise lisamine ebaõnnestus', 'members.guestRenameError': 'Külalise ümbernimetamine ebaõnnestus', 'members.guestRemoved': 'Külaline eemaldatud', - 'members.confirmRemoveGuest': 'Kas eemaldada see külaline? Eemaldatakse ka talle määratud ülesanded ja kuluosad.', + 'members.confirmRemoveGuest': + 'Kas eemaldada see külaline? Talle määratud ülesanded eemaldatakse ja tema osa igast kulust jagatakse teiste osalejate vahel.', + 'members.guestHasPayments': + 'See külaline on tasunud mõne kulu või on seotud registreeritud maksega. Muuda kõigepealt maksjat või kustuta makse.', }; export default members; diff --git a/shared/src/i18n/fr/members.ts b/shared/src/i18n/fr/members.ts index 13312f572..4151ca992 100644 --- a/shared/src/i18n/fr/members.ts +++ b/shared/src/i18n/fr/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': "Échec de l'ajout de l'invité", 'members.guestRenameError': "Échec du renommage de l'invité", 'members.guestRemoved': 'Invité supprimé', - 'members.confirmRemoveGuest': 'Supprimer cet invité ? Ses affectations et parts de dépenses seront aussi supprimées.', + 'members.confirmRemoveGuest': + 'Supprimer cet invité ? Ses affectations seront supprimées et sa part de chaque dépense sera répartie entre les autres participants.', + 'members.guestHasPayments': + "Cet invité a payé une dépense ou fait partie d'un paiement enregistré. Changez d'abord le payeur ou supprimez le paiement.", }; export default members; diff --git a/shared/src/i18n/gr/members.ts b/shared/src/i18n/gr/members.ts index 013966675..d19d28360 100644 --- a/shared/src/i18n/gr/members.ts +++ b/shared/src/i18n/gr/members.ts @@ -34,6 +34,8 @@ const members: TranslationStrings = { 'members.guestRenameError': 'Αποτυχία μετονομασίας επισκέπτη', 'members.guestRemoved': 'Ο επισκέπτης αφαιρέθηκε', 'members.confirmRemoveGuest': - 'Αφαίρεση αυτού του επισκέπτη; Θα αφαιρεθούν επίσης οι αναθέσεις και τα μερίδια εξόδων του.', + 'Αφαίρεση αυτού του επισκέπτη; Οι αναθέσεις του θα αφαιρεθούν και το μερίδιό του σε κάθε έξοδο θα μοιραστεί στους υπόλοιπους συμμετέχοντες.', + 'members.guestHasPayments': + 'Αυτός ο επισκέπτης πλήρωσε κάποιο έξοδο ή συμμετέχει σε καταγεγραμμένη πληρωμή. Αλλάξτε πρώτα τον πληρωτή ή διαγράψτε την πληρωμή.', }; export default members; diff --git a/shared/src/i18n/hu/members.ts b/shared/src/i18n/hu/members.ts index 24aa9ede7..e151f4dac 100644 --- a/shared/src/i18n/hu/members.ts +++ b/shared/src/i18n/hu/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Nem sikerült hozzáadni a vendéget', 'members.guestRenameError': 'Nem sikerült átnevezni a vendéget', 'members.guestRemoved': 'Vendég eltávolítva', - 'members.confirmRemoveGuest': 'Eltávolítja ezt a vendéget? A hozzárendelései és költségrészei is törlődnek.', + 'members.confirmRemoveGuest': + 'Eltávolítja ezt a vendéget? A hozzárendelései törlődnek, a költségekből rá eső rész pedig a többi résztvevő között oszlik meg.', + 'members.guestHasPayments': + 'Ez a vendég kifizetett egy költséget, vagy részese egy rögzített fizetésnek. Előbb módosítsa a fizetőt, vagy törölje a fizetést.', }; export default members; diff --git a/shared/src/i18n/id/members.ts b/shared/src/i18n/id/members.ts index 7921f4165..33561a91a 100644 --- a/shared/src/i18n/id/members.ts +++ b/shared/src/i18n/id/members.ts @@ -32,6 +32,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Gagal menambahkan tamu', 'members.guestRenameError': 'Gagal mengganti nama tamu', 'members.guestRemoved': 'Tamu dihapus', - 'members.confirmRemoveGuest': 'Hapus tamu ini? Penugasan dan bagian biayanya juga akan dihapus.', + 'members.confirmRemoveGuest': + 'Hapus tamu ini? Penugasannya akan dihapus dan bagiannya di setiap pengeluaran dibagi ke peserta lainnya.', + 'members.guestHasPayments': + 'Tamu ini telah membayar pengeluaran atau terlibat dalam pembayaran yang tercatat. Ubah pembayar atau hapus pembayaran terlebih dahulu.', }; export default members; diff --git a/shared/src/i18n/it/members.ts b/shared/src/i18n/it/members.ts index 8f90f8b58..a2d4c87d7 100644 --- a/shared/src/i18n/it/members.ts +++ b/shared/src/i18n/it/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': "Impossibile aggiungere l'ospite", 'members.guestRenameError': "Impossibile rinominare l'ospite", 'members.guestRemoved': 'Ospite rimosso', - 'members.confirmRemoveGuest': 'Rimuovere questo ospite? Verranno rimosse anche le sue assegnazioni e quote di spesa.', + 'members.confirmRemoveGuest': + 'Rimuovere questo ospite? Le sue assegnazioni verranno rimosse e la sua quota di ogni spesa sarà ripartita tra gli altri partecipanti.', + 'members.guestHasPayments': + 'Questo ospite ha pagato una spesa o fa parte di un pagamento registrato. Cambia prima chi paga o elimina il pagamento.', }; export default members; diff --git a/shared/src/i18n/ja/members.ts b/shared/src/i18n/ja/members.ts index d83383a80..e4f79408a 100644 --- a/shared/src/i18n/ja/members.ts +++ b/shared/src/i18n/ja/members.ts @@ -31,6 +31,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'ゲストの追加に失敗しました', 'members.guestRenameError': 'ゲストの名前変更に失敗しました', 'members.guestRemoved': 'ゲストを削除しました', - 'members.confirmRemoveGuest': 'このゲストを削除しますか?割り当てと費用の負担分も削除されます。', + 'members.confirmRemoveGuest': + 'このゲストを削除しますか?割り当ては削除され、各費用の負担分は分担している他のメンバーに振り分けられます。', + 'members.guestHasPayments': + 'このゲストは費用を支払ったか、記録された支払いに含まれています。先に支払者を変更するか、支払いを削除してください。', }; export default members; diff --git a/shared/src/i18n/ko/members.ts b/shared/src/i18n/ko/members.ts index fc8c7119f..7101fd760 100644 --- a/shared/src/i18n/ko/members.ts +++ b/shared/src/i18n/ko/members.ts @@ -31,6 +31,9 @@ const members: TranslationStrings = { 'members.guestAddError': '게스트 추가 실패', 'members.guestRenameError': '게스트 이름 변경 실패', 'members.guestRemoved': '게스트가 제거되었습니다', - 'members.confirmRemoveGuest': '이 게스트를 제거할까요? 배정 및 비용 분담도 함께 제거됩니다.', + 'members.confirmRemoveGuest': + '이 게스트를 제거할까요? 배정은 제거되고, 각 비용의 분담분은 함께 나누는 다른 사람들에게 나뉩니다.', + 'members.guestHasPayments': + '이 게스트는 비용을 결제했거나 기록된 정산에 포함되어 있습니다. 먼저 결제자를 변경하거나 정산 내역을 삭제하세요.', }; export default members; diff --git a/shared/src/i18n/nl/members.ts b/shared/src/i18n/nl/members.ts index b2eaefdff..d683f7516 100644 --- a/shared/src/i18n/nl/members.ts +++ b/shared/src/i18n/nl/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Kan gast niet toevoegen', 'members.guestRenameError': 'Kan gast niet hernoemen', 'members.guestRemoved': 'Gast verwijderd', - 'members.confirmRemoveGuest': 'Deze gast verwijderen? Hun toewijzingen en kostenaandelen worden ook verwijderd.', + 'members.confirmRemoveGuest': + 'Deze gast verwijderen? Hun toewijzingen worden verwijderd en hun aandeel in elke uitgave wordt verdeeld over de andere deelnemers.', + 'members.guestHasPayments': + 'Deze gast heeft een uitgave betaald of maakt deel uit van een geregistreerde betaling. Wijzig eerst de betaler of verwijder de betaling.', }; export default members; diff --git a/shared/src/i18n/pl/members.ts b/shared/src/i18n/pl/members.ts index 0ebe5f278..4a5c30e51 100644 --- a/shared/src/i18n/pl/members.ts +++ b/shared/src/i18n/pl/members.ts @@ -34,6 +34,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Nie udało się dodać gościa', 'members.guestRenameError': 'Nie udało się zmienić nazwy gościa', 'members.guestRemoved': 'Usunięto gościa', - 'members.confirmRemoveGuest': 'Usunąć tego gościa? Jego przypisania i udziały w kosztach również zostaną usunięte.', + 'members.confirmRemoveGuest': + 'Usunąć tego gościa? Jego przypisania zostaną usunięte, a jego udział w każdym wydatku zostanie podzielony między pozostałych uczestników.', + 'members.guestHasPayments': + 'Ten gość zapłacił za wydatek lub jest stroną zarejestrowanej płatności. Najpierw zmień płatnika lub usuń płatność.', }; export default members; diff --git a/shared/src/i18n/ru/members.ts b/shared/src/i18n/ru/members.ts index 58d53e0b5..99cf4a88d 100644 --- a/shared/src/i18n/ru/members.ts +++ b/shared/src/i18n/ru/members.ts @@ -34,6 +34,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Не удалось добавить гостя', 'members.guestRenameError': 'Не удалось переименовать гостя', 'members.guestRemoved': 'Гость удалён', - 'members.confirmRemoveGuest': 'Удалить этого гостя? Его назначения и доли расходов также будут удалены.', + 'members.confirmRemoveGuest': + 'Удалить этого гостя? Его назначения будут удалены, а его доля в каждом расходе будет распределена между остальными участниками.', + 'members.guestHasPayments': + 'Этот гость оплатил расход или участвует в записанном платеже. Сначала смените плательщика или удалите платёж.', }; export default members; diff --git a/shared/src/i18n/sk/members.ts b/shared/src/i18n/sk/members.ts index a84f9e9bf..03434238d 100644 --- a/shared/src/i18n/sk/members.ts +++ b/shared/src/i18n/sk/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Nepodarilo sa pridať hosťa', 'members.guestRenameError': 'Nepodarilo sa premenovať hosťa', 'members.guestRemoved': 'Hosť odobraný', - 'members.confirmRemoveGuest': 'Odobrať tohto hosťa? Jeho priradenia a podiely na nákladoch budú tiež odobrané.', + 'members.confirmRemoveGuest': + 'Odobrať tohto hosťa? Jeho priradenia budú odobrané a jeho podiel na každom výdavku sa rozdelí medzi ostatných účastníkov.', + 'members.guestHasPayments': + 'Tento hosť zaplatil výdavok alebo je súčasťou zaznamenanej platby. Najprv zmeňte platiteľa alebo platbu odstráňte.', }; export default members; diff --git a/shared/src/i18n/sv/members.ts b/shared/src/i18n/sv/members.ts index 55215a4af..cdf50bc6f 100644 --- a/shared/src/i18n/sv/members.ts +++ b/shared/src/i18n/sv/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Det gick inte att lägga till gästen', 'members.guestRenameError': 'Det gick inte att byta namn på gästen', 'members.guestRemoved': 'Gäst borttagen', - 'members.confirmRemoveGuest': 'Ta bort den här gästen? Deras tilldelningar och kostnadsandelar tas också bort.', + 'members.confirmRemoveGuest': + 'Ta bort den här gästen? Deras tilldelningar tas bort och deras andel av varje utgift fördelas på de andra deltagarna.', + 'members.guestHasPayments': + 'Den här gästen har betalat en utgift eller ingår i en registrerad betalning. Byt betalare eller ta bort betalningen först.', }; export default members; diff --git a/shared/src/i18n/th/members.ts b/shared/src/i18n/th/members.ts index 44533f54f..c9ba1d4ff 100644 --- a/shared/src/i18n/th/members.ts +++ b/shared/src/i18n/th/members.ts @@ -31,6 +31,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'ไม่สามารถเพิ่มแขกได้', 'members.guestRenameError': 'เปลี่ยนชื่อแขกไม่สำเร็จ', 'members.guestRemoved': 'นำแขกออกแล้ว', - 'members.confirmRemoveGuest': 'ลบแขกรายนี้ใช่ไหม การมอบหมายและส่วนแบ่งต้นทุนของพวกเขาจะถูกลบออกด้วย', + 'members.confirmRemoveGuest': + 'ลบแขกรายนี้ใช่ไหม การมอบหมายของพวกเขาจะถูกลบ และส่วนแบ่งในแต่ละค่าใช้จ่ายจะถูกแบ่งให้ผู้ร่วมจ่ายคนอื่น', + 'members.guestHasPayments': + 'แขกรายนี้เป็นผู้จ่ายค่าใช้จ่ายหรือเกี่ยวข้องกับการชำระเงินที่บันทึกไว้ โปรดเปลี่ยนผู้จ่ายหรือลบการชำระเงินก่อน', }; export default members; diff --git a/shared/src/i18n/tr/members.ts b/shared/src/i18n/tr/members.ts index a62b89a44..0b0b4a1a5 100644 --- a/shared/src/i18n/tr/members.ts +++ b/shared/src/i18n/tr/members.ts @@ -33,6 +33,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Misafir eklenemedi', 'members.guestRenameError': 'Misafir yeniden adlandırılamadı', 'members.guestRemoved': 'Misafir kaldırıldı', - 'members.confirmRemoveGuest': 'Bu misafir kaldırılsın mı? Atamaları ve masraf payları da kaldırılacak.', + 'members.confirmRemoveGuest': + 'Bu misafir kaldırılsın mı? Atamaları kaldırılacak ve her masraftaki payı diğer katılımcılar arasında paylaştırılacak.', + 'members.guestHasPayments': + 'Bu misafir bir masrafı ödedi veya kayıtlı bir ödemenin tarafı. Önce ödeyeni değiştirin veya ödemeyi silin.', }; export default members; diff --git a/shared/src/i18n/uk/members.ts b/shared/src/i18n/uk/members.ts index b112672fd..526ac458b 100644 --- a/shared/src/i18n/uk/members.ts +++ b/shared/src/i18n/uk/members.ts @@ -35,6 +35,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Не вдалося додати гостя', 'members.guestRenameError': 'Не вдалося перейменувати гостя', 'members.guestRemoved': 'Гостя видалено', - 'members.confirmRemoveGuest': 'Видалити цього гостя? Його призначення та частки витрат також буде видалено.', + 'members.confirmRemoveGuest': + 'Видалити цього гостя? Його призначення буде видалено, а його частку в кожній витраті розподілено між іншими учасниками.', + 'members.guestHasPayments': + 'Цей гість оплатив витрату або є учасником записаного платежу. Спочатку змініть платника або видаліть платіж.', }; export default members; diff --git a/shared/src/i18n/vi/members.ts b/shared/src/i18n/vi/members.ts index 8fe04da0f..df6c850e9 100644 --- a/shared/src/i18n/vi/members.ts +++ b/shared/src/i18n/vi/members.ts @@ -32,6 +32,9 @@ const members: TranslationStrings = { 'members.guestAddError': 'Không thể thêm khách', 'members.guestRenameError': 'Không thể đổi tên khách', 'members.guestRemoved': 'Đã xóa khách', - 'members.confirmRemoveGuest': 'Xóa khách này? Các phân công và phần chi phí của họ cũng sẽ bị xóa.', + 'members.confirmRemoveGuest': + 'Xóa khách này? Các phân công của họ sẽ bị xóa và phần của họ trong mỗi khoản chi sẽ được chia cho những người còn lại.', + 'members.guestHasPayments': + 'Khách này đã thanh toán một khoản chi hoặc có liên quan đến một khoản thanh toán đã ghi nhận. Hãy đổi người trả hoặc xóa khoản thanh toán trước.', }; export default members; diff --git a/shared/src/i18n/zh-TW/members.ts b/shared/src/i18n/zh-TW/members.ts index 271da1454..94ca45542 100644 --- a/shared/src/i18n/zh-TW/members.ts +++ b/shared/src/i18n/zh-TW/members.ts @@ -31,6 +31,7 @@ const members: TranslationStrings = { 'members.guestAddError': '新增訪客失敗', 'members.guestRenameError': '重新命名訪客失敗', 'members.guestRemoved': '已移除訪客', - 'members.confirmRemoveGuest': '移除此訪客?其指派和費用分攤也將被移除。', + 'members.confirmRemoveGuest': '移除此訪客?其指派將被移除,其在每筆費用中的分攤將轉給其他參與者。', + 'members.guestHasPayments': '此訪客支付過費用或涉及已記錄的付款。請先變更付款人或刪除該付款。', }; export default members; diff --git a/shared/src/i18n/zh/members.ts b/shared/src/i18n/zh/members.ts index 289438066..41b98753d 100644 --- a/shared/src/i18n/zh/members.ts +++ b/shared/src/i18n/zh/members.ts @@ -31,6 +31,7 @@ const members: TranslationStrings = { 'members.guestAddError': '添加访客失败', 'members.guestRenameError': '重命名访客失败', 'members.guestRemoved': '已移除访客', - 'members.confirmRemoveGuest': '移除此访客?其分配和费用分摊也将被移除。', + 'members.confirmRemoveGuest': '移除此访客?其分配将被移除,其在每笔费用中的分摊将转给其他参与者。', + 'members.guestHasPayments': '此访客支付过费用或涉及已记录的付款。请先更改付款人或删除该付款。', }; export default members; diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 15aee92ab..b0feb1dbd 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -43,7 +43,7 @@ Requires `trips:read` or `trips:write` scope. | `leave_trip` | Leave a trip you were invited to, giving up your own access. Use this rather than `remove_trip_member` when the user means themselves. The owner cannot leave their own trip. Requires `trips:write`. | | `create_trip_guest` | Add a travelling companion who has no TREK account. Assignable to budget splits, packing and day participants; never signs in, never emailed. Owner only. | | `rename_trip_guest` | Rename a guest on a trip. Owner only. | -| `delete_trip_guest` | Delete a guest and re-split the expenses they were part of. Owner only. | +| `delete_trip_guest` | Delete a guest and hand their share of each expense to the others in its split. Refused while the guest paid for an expense or is part of a recorded payment. Owner only. | | `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items reset to unchecked. | | `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text. | | `get_share_link` | Get the current public share link for a trip and its permission flags. Requires `trips:share`. | diff --git a/wiki/Trip-Members-and-Sharing.md b/wiki/Trip-Members-and-Sharing.md index 27610c343..bafcf6729 100644 --- a/wiki/Trip-Members-and-Sharing.md +++ b/wiki/Trip-Members-and-Sharing.md @@ -76,7 +76,9 @@ These limits are enforced on the server, not just hidden in the UI. In the owner's Guests section, each guest row has a **Rename** (pencil) and a **Remove access** (trash) button. Rename turns the name into a field: Enter saves, Escape leaves it as it was. Removing a guest is **destructive and cascading**, so TREK asks in a dialog before it deletes, and removes the guest only on **Delete**: -> Remove this guest? Their assignments and cost shares will be removed too. +> Remove this guest? Their assignments are removed and their share of each expense goes to the others in its split. + +A share split by amount is handed to the others in equal parts, and a receipt split line by line is split again without the guest. A guest who paid for an expense, or who is part of a recorded payment, cannot be removed: TREK says so and keeps the guest, so the money stays on the books. Change the payer or delete the payment first. There is no limit on the number of guests per trip.