fix(trips): deleting a guest keeps the costs adding up instead of dropping their payments

A guest's payer rows and recorded payments hung on the users row by ON DELETE
CASCADE, so deleting the guest wiped money that had really moved: expenses
they paid were left without a payer, settled payments reopened, and a custom
or receipt split lost the guest's share so the balances no longer summed to
zero.

Deleting a guest who paid for an expense or is part of a recorded payment is
now refused with a 409 (REST and the delete_trip_guest MCP tool, same text),
and the share dialog says to change the payer or delete the payment first.
Otherwise the guest's share is handed on: an equal split re-splits as before,
a custom amount goes to the others by largest remainder in the currency's
smallest step (members without an amount get one, so the expense stays a
custom split), and a receipt drops the guest from its lines and is split
again like the cost editor does. A legacy paid_by_user_id is cleared as on
account deletion. Account deletion goes through the same re-split. The
confirmation text in all languages and the wiki say what really happens.

Refs #2604
This commit is contained in:
Maurice
2026-10-12 16:39:17 +02:00
parent 2149fd5315
commit 1b58acc78f
44 changed files with 663 additions and 44 deletions
@@ -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(<TripMembersModal {...defaultProps} />);
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 });
+5 -2
View File
@@ -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)
}