mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -89,3 +89,20 @@ export const COLLAB_POLL_OPTIONS: JsonColumn<unknown[]> = {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -196,6 +196,16 @@ export class BudgetItemMembersRepository extends TrekRepository<BudgetItemMember
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** `UPDATE budget_item_members SET amount = ? WHERE budget_item_id = ? AND user_id = ?`. */
|
||||
async setAmount(budget_item_id: number, user_id: number, amount: number | null): Promise<void> {
|
||||
await this.kysely<BudgetItemMembersKyselyDB>()
|
||||
.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<void> {
|
||||
await this.kysely<BudgetItemMembersKyselyDB>()
|
||||
|
||||
@@ -94,6 +94,17 @@ export class BudgetItemPayersRepository extends TrekRepository<BudgetItemPayers>
|
||||
.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<number> {
|
||||
const row = await this.kysely<BudgetItemPayersKyselyDB>()
|
||||
.selectFrom('budget_item_payers')
|
||||
.select((eb) => eb.fn.countAll<number>().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<void> {
|
||||
await this.kysely<BudgetItemPayersKyselyDB>()
|
||||
|
||||
@@ -306,6 +306,11 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** `UPDATE budget_items SET ticket_json = ? WHERE id = ?`. */
|
||||
async setTicketJson(id: number, ticket_json: string | null): Promise<void> {
|
||||
await this.kysely<BudgetKyselyDB>().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<void> {
|
||||
await this.kysely<BudgetKyselyDB>()
|
||||
|
||||
@@ -179,6 +179,16 @@ export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlement
|
||||
await this.kysely<BudgetSettlementsKyselyDB>().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<number> {
|
||||
const row = await this.kysely<BudgetSettlementsKyselyDB>()
|
||||
.selectFrom('budget_settlements')
|
||||
.select((eb) => eb.fn.countAll<number>().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
|
||||
|
||||
@@ -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<number, number> | 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.';
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -298,18 +298,21 @@ export class TripMembersService {
|
||||
|
||||
async deleteGuest(tripId: string | number, guestUserId: number): Promise<boolean> {
|
||||
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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 = <T>(fn: () => Promise<T>) => 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TripMembersService>);
|
||||
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(() => {
|
||||
|
||||
@@ -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<Record<number, number | null>> {
|
||||
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<number> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,8 @@ const members: TranslationStrings = {
|
||||
'members.guestAddError': 'فشلت إضافة الضيف',
|
||||
'members.guestRenameError': 'فشلت إعادة تسمية الضيف',
|
||||
'members.guestRemoved': 'تمت إزالة الضيف',
|
||||
'members.confirmRemoveGuest': 'إزالة هذا الضيف؟ سيتم أيضًا إزالة تعييناته وحصص التكاليف الخاصة به.',
|
||||
'members.confirmRemoveGuest':
|
||||
'إزالة هذا الضيف؟ ستتم إزالة تعييناته، وتُوزَّع حصته من كل نفقة على الآخرين المشاركين فيها.',
|
||||
'members.guestHasPayments': 'دفع هذا الضيف نفقة أو هو طرف في دفعة مسجّلة. غيّر الدافع أو احذف الدفعة أولًا.',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,8 @@ const members: TranslationStrings = {
|
||||
'members.guestRenameError': 'Αποτυχία μετονομασίας επισκέπτη',
|
||||
'members.guestRemoved': 'Ο επισκέπτης αφαιρέθηκε',
|
||||
'members.confirmRemoveGuest':
|
||||
'Αφαίρεση αυτού του επισκέπτη; Θα αφαιρεθούν επίσης οι αναθέσεις και τα μερίδια εξόδων του.',
|
||||
'Αφαίρεση αυτού του επισκέπτη; Οι αναθέσεις του θα αφαιρεθούν και το μερίδιό του σε κάθε έξοδο θα μοιραστεί στους υπόλοιπους συμμετέχοντες.',
|
||||
'members.guestHasPayments':
|
||||
'Αυτός ο επισκέπτης πλήρωσε κάποιο έξοδο ή συμμετέχει σε καταγεγραμμένη πληρωμή. Αλλάξτε πρώτα τον πληρωτή ή διαγράψτε την πληρωμή.',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,6 +31,9 @@ const members: TranslationStrings = {
|
||||
'members.guestAddError': 'ゲストの追加に失敗しました',
|
||||
'members.guestRenameError': 'ゲストの名前変更に失敗しました',
|
||||
'members.guestRemoved': 'ゲストを削除しました',
|
||||
'members.confirmRemoveGuest': 'このゲストを削除しますか?割り当てと費用の負担分も削除されます。',
|
||||
'members.confirmRemoveGuest':
|
||||
'このゲストを削除しますか?割り当ては削除され、各費用の負担分は分担している他のメンバーに振り分けられます。',
|
||||
'members.guestHasPayments':
|
||||
'このゲストは費用を支払ったか、記録された支払いに含まれています。先に支払者を変更するか、支払いを削除してください。',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -31,6 +31,9 @@ const members: TranslationStrings = {
|
||||
'members.guestAddError': '게스트 추가 실패',
|
||||
'members.guestRenameError': '게스트 이름 변경 실패',
|
||||
'members.guestRemoved': '게스트가 제거되었습니다',
|
||||
'members.confirmRemoveGuest': '이 게스트를 제거할까요? 배정 및 비용 분담도 함께 제거됩니다.',
|
||||
'members.confirmRemoveGuest':
|
||||
'이 게스트를 제거할까요? 배정은 제거되고, 각 비용의 분담분은 함께 나누는 다른 사람들에게 나뉩니다.',
|
||||
'members.guestHasPayments':
|
||||
'이 게스트는 비용을 결제했거나 기록된 정산에 포함되어 있습니다. 먼저 결제자를 변경하거나 정산 내역을 삭제하세요.',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,9 @@ const members: TranslationStrings = {
|
||||
'members.guestAddError': 'Не удалось добавить гостя',
|
||||
'members.guestRenameError': 'Не удалось переименовать гостя',
|
||||
'members.guestRemoved': 'Гость удалён',
|
||||
'members.confirmRemoveGuest': 'Удалить этого гостя? Его назначения и доли расходов также будут удалены.',
|
||||
'members.confirmRemoveGuest':
|
||||
'Удалить этого гостя? Его назначения будут удалены, а его доля в каждом расходе будет распределена между остальными участниками.',
|
||||
'members.guestHasPayments':
|
||||
'Этот гость оплатил расход или участвует в записанном платеже. Сначала смените плательщика или удалите платёж.',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,6 +31,9 @@ const members: TranslationStrings = {
|
||||
'members.guestAddError': 'ไม่สามารถเพิ่มแขกได้',
|
||||
'members.guestRenameError': 'เปลี่ยนชื่อแขกไม่สำเร็จ',
|
||||
'members.guestRemoved': 'นำแขกออกแล้ว',
|
||||
'members.confirmRemoveGuest': 'ลบแขกรายนี้ใช่ไหม การมอบหมายและส่วนแบ่งต้นทุนของพวกเขาจะถูกลบออกด้วย',
|
||||
'members.confirmRemoveGuest':
|
||||
'ลบแขกรายนี้ใช่ไหม การมอบหมายของพวกเขาจะถูกลบ และส่วนแบ่งในแต่ละค่าใช้จ่ายจะถูกแบ่งให้ผู้ร่วมจ่ายคนอื่น',
|
||||
'members.guestHasPayments':
|
||||
'แขกรายนี้เป็นผู้จ่ายค่าใช้จ่ายหรือเกี่ยวข้องกับการชำระเงินที่บันทึกไว้ โปรดเปลี่ยนผู้จ่ายหรือลบการชำระเงินก่อน',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,9 @@ const members: TranslationStrings = {
|
||||
'members.guestAddError': 'Не вдалося додати гостя',
|
||||
'members.guestRenameError': 'Не вдалося перейменувати гостя',
|
||||
'members.guestRemoved': 'Гостя видалено',
|
||||
'members.confirmRemoveGuest': 'Видалити цього гостя? Його призначення та частки витрат також буде видалено.',
|
||||
'members.confirmRemoveGuest':
|
||||
'Видалити цього гостя? Його призначення буде видалено, а його частку в кожній витраті розподілено між іншими учасниками.',
|
||||
'members.guestHasPayments':
|
||||
'Цей гість оплатив витрату або є учасником записаного платежу. Спочатку змініть платника або видаліть платіж.',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,6 +31,7 @@ const members: TranslationStrings = {
|
||||
'members.guestAddError': '新增訪客失敗',
|
||||
'members.guestRenameError': '重新命名訪客失敗',
|
||||
'members.guestRemoved': '已移除訪客',
|
||||
'members.confirmRemoveGuest': '移除此訪客?其指派和費用分攤也將被移除。',
|
||||
'members.confirmRemoveGuest': '移除此訪客?其指派將被移除,其在每筆費用中的分攤將轉給其他參與者。',
|
||||
'members.guestHasPayments': '此訪客支付過費用或涉及已記錄的付款。請先變更付款人或刪除該付款。',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -31,6 +31,7 @@ const members: TranslationStrings = {
|
||||
'members.guestAddError': '添加访客失败',
|
||||
'members.guestRenameError': '重命名访客失败',
|
||||
'members.guestRemoved': '已移除访客',
|
||||
'members.confirmRemoveGuest': '移除此访客?其分配和费用分摊也将被移除。',
|
||||
'members.confirmRemoveGuest': '移除此访客?其分配将被移除,其在每笔费用中的分摊将转给其他参与者。',
|
||||
'members.guestHasPayments': '此访客支付过费用或涉及已记录的付款。请先更改付款人或删除该付款。',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user