From a70eea5bf2fdcbc10af84f30672ab3d37d8972e1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 09:58:17 +0100 Subject: [PATCH] feat(costs): let a trip keep its costs as totals only (#1714) Travellers who track what a trip costs but never split it saw an Unfinished warning on every expense and a settle-up they had no use for. A trip now has a costs mode (appended migration, trips.costs_mode, split by default); totals leaves payers, shares and settling up out of Costs on desktop and phone through the same gate as a trip of one, and saves new expenses without a payer. PUT /api/trips/:id and update_trip take it on budget_edit. The split chips move into their own component. --- client/scripts/feature-pairs.json | 6 + client/scripts/size-baseline.json | 2 +- .../src/components/Budget/CostsModeCard.tsx | 51 +++++++++ .../Budget/CostsPanel.totals.test.tsx | 104 ++++++++++++++++++ client/src/components/Budget/CostsPanel.tsx | 43 +------- .../src/components/Budget/CostsSplitChips.tsx | 81 ++++++++++++++ .../src/components/Budget/costsModel.test.ts | 23 ++-- client/src/components/Budget/costsModel.ts | 13 ++- .../components/Budget/useCostsLedger.test.ts | 12 +- .../src/components/Budget/useCostsLedger.ts | 9 +- .../components/Budget/useCostsMode.test.ts | 56 ++++++++++ client/src/components/Budget/useCostsMode.ts | 36 ++++++ .../components/Budget/useExpenseForm.test.ts | 36 +++++- .../src/components/Budget/useExpenseForm.ts | 15 ++- .../mobile/screens/trip/tabs/MCostsTab.tsx | 24 +++- .../tests/unit/mobile/trip/MCostsTab.test.tsx | 17 ++- server/scripts/size-baseline.json | 2 +- server/src/db/entities/Trips.entity.ts | 2 + server/src/db/kysely/Trips.table.ts | 1 + ..._trip_can_keep_its_costs_as_totals_only.ts | 24 ++++ .../src/db/repositories/Trips.repository.ts | 22 ++-- server/src/nest/trips/trips.helpers.ts | 11 +- server/src/nest/trips/trips.mcp.ts | 21 +++- server/src/nest/trips/trips.service.ts | 24 ++-- server/tests/e2e/trips.e2e.test.ts | 20 ++++ server/tests/unit/db/legacy-baseline.test.ts | 1 + .../db/repositories/Trips.repository.test.ts | 23 +++- server/tests/unit/mcp/tools-trips.test.ts | 31 ++++++ .../tests/unit/nest/trips.controller.test.ts | 19 ++++ server/tests/unit/nest/trips.helpers.test.ts | 18 ++- shared/src/i18n/ar/budget.ts | 3 + shared/src/i18n/az/budget.ts | 3 + shared/src/i18n/br/budget.ts | 3 + shared/src/i18n/ca/budget.ts | 3 + shared/src/i18n/cs/budget.ts | 3 + shared/src/i18n/de/budget.ts | 4 + shared/src/i18n/en/budget.ts | 3 + shared/src/i18n/es/budget.ts | 4 + shared/src/i18n/et/budget.ts | 3 + shared/src/i18n/fr/budget.ts | 4 + shared/src/i18n/gr/budget.ts | 3 + shared/src/i18n/he/budget.ts | 3 + shared/src/i18n/hu/budget.ts | 4 + shared/src/i18n/id/budget.ts | 4 + shared/src/i18n/it/budget.ts | 3 + shared/src/i18n/ja/budget.ts | 3 + shared/src/i18n/ko/budget.ts | 3 + shared/src/i18n/nl/budget.ts | 4 + shared/src/i18n/pl/budget.ts | 4 + shared/src/i18n/ru/budget.ts | 4 + shared/src/i18n/sk/budget.ts | 3 + shared/src/i18n/sv/budget.ts | 3 + shared/src/i18n/th/budget.ts | 3 + shared/src/i18n/tr/budget.ts | 3 + shared/src/i18n/uk/budget.ts | 3 + shared/src/i18n/vi/budget.ts | 4 + shared/src/i18n/zh-TW/budget.ts | 3 + shared/src/i18n/zh/budget.ts | 3 + shared/src/trip/trip.schema.spec.ts | 18 +++ shared/src/trip/trip.schema.ts | 14 +++ wiki/Budget-Tracking.md | 8 ++ wiki/MCP-Tools-and-Resources.md | 2 +- 62 files changed, 778 insertions(+), 104 deletions(-) create mode 100644 client/src/components/Budget/CostsModeCard.tsx create mode 100644 client/src/components/Budget/CostsPanel.totals.test.tsx create mode 100644 client/src/components/Budget/CostsSplitChips.tsx create mode 100644 client/src/components/Budget/useCostsMode.test.ts create mode 100644 client/src/components/Budget/useCostsMode.ts create mode 100644 server/src/db/migrations/Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only.ts diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 1cac200af..ab047accc 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -287,6 +287,12 @@ "desktop": ["src/components/Budget/CostsPanel.tsx"], "mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"] }, + { + "feature": "Costs/budget splitting or totals only: useCostsMode", + "hooks": ["src/components/Budget/useCostsMode.ts"], + "desktop": ["src/components/Budget/CostsModeCard.tsx"], + "mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"] + }, { "feature": "Costs/budget settle-up payment: useSettlementForm", "hooks": ["src/components/Budget/useSettlementForm.ts"], diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index a0273ec96..6a9224c9a 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -4,7 +4,7 @@ "src/components/Admin/AdminPluginsPanel.test.tsx": 2704, "src/components/Admin/AdminPluginsPanel.tsx": 1266, "src/components/Budget/CostsPanel.test.tsx": 2730, - "src/components/Budget/CostsPanel.tsx": 1668, + "src/components/Budget/CostsPanel.tsx": 1633, "src/components/Map/MapView.tsx": 1200, "src/components/Map/MapViewGL.test.tsx": 2919, "src/components/Map/MapViewGL.tsx": 2126, diff --git a/client/src/components/Budget/CostsModeCard.tsx b/client/src/components/Budget/CostsModeCard.tsx new file mode 100644 index 000000000..6ddc41b4e --- /dev/null +++ b/client/src/components/Budget/CostsModeCard.tsx @@ -0,0 +1,51 @@ +import { useId } from 'react'; + +import { useTranslation } from '../../i18n'; +import type { Trip } from '../../types'; +import ToggleSwitch from '../Settings/ToggleSwitch'; +import { fs } from '../shared/DialogShell'; +import { useCostsMode } from './useCostsMode'; + +/** + * The switch between splitting a trip's costs and keeping the totals only + * (#1714), at the foot of the Costs sidebar. Nothing is shown to someone who may + * not change it, or on a trip of one. + */ +export function CostsModeCard({ + trip, + memberCount, + canEdit, + className, +}: { + trip: Trip | null | undefined; + memberCount: number; + canEdit: boolean; + className: string; +}) { + const { t } = useTranslation(); + const hintId = useId(); + const { splitting, setSplitting, saving, offered } = useCostsMode(trip, memberCount, canEdit); + if (!offered) return null; + + return ( +
+
+
+
+ {t('costs.mode.label')} +
+

+ {splitting ? t('costs.mode.hintSplit') : t('costs.mode.hintTotals')} +

+
+ void setSplitting(!splitting)} + label={t('costs.mode.label')} + describedBy={hintId} + disabled={saving} + /> +
+
+ ); +} diff --git a/client/src/components/Budget/CostsPanel.totals.test.tsx b/client/src/components/Budget/CostsPanel.totals.test.tsx new file mode 100644 index 000000000..37fc4e24a --- /dev/null +++ b/client/src/components/Budget/CostsPanel.totals.test.tsx @@ -0,0 +1,104 @@ +// FE-COSTS-TOTALS-001 to FE-COSTS-TOTALS-003: a trip that keeps its costs as totals only (#1714) +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildBudgetItem, buildTrip, buildUser } from '../../../tests/helpers/factories'; +import { server } from '../../../tests/helpers/msw/server'; +import { render, screen } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { useTripStore, type TripStoreState } from '../../store/tripStore'; +import type { BudgetItem } from '../../types'; +import CostsPanel from './CostsPanel'; + +const tripMembers = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: null }, +]; + +// Paid by Bob and shared by both: a split trip owes him half. +const dinner = { + ...buildBudgetItem({ trip_id: 1 }), + id: 801, + name: 'Dinner', + category: 'food', + total_price: 60, + expense_date: '2025-06-15', + payers: [{ user_id: 2, amount: 60 }], + members: [ + { user_id: 1, username: 'alice' }, + { user_id: 2, username: 'bob' }, + ], +} as unknown as BudgetItem; + +let updateTrip: ReturnType; + +function mount(costsMode: 'split' | 'totals') { + updateTrip = vi.fn(async () => buildTrip({ id: 1 })); + useTripStore.setState({ + trip: buildTrip({ id: 1, currency: 'EUR', costs_mode: costsMode }), + updateTrip, + } as unknown as Partial); + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [dinner] })), + http.get('/api/trips/1/budget/settlement', () => + HttpResponse.json({ + balances: [], + flows: [{ from: { user_id: 1, username: 'alice' }, to: { user_id: 2, username: 'bob' }, amount: 30 }], + settlements: [], + }) + ) + ); + return render(); +} + +beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }); + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }); +}); + +describe('CostsPanel keeping totals only (#1714)', () => { + it('FE-COSTS-TOTALS-001: what is owed, the payers and the shares stay out, the totals stay', async () => { + mount('totals'); + await screen.findByText('Dinner'); + expect(screen.getByRole('region', { name: 'Total trip spend' })).toHaveTextContent('1 expense'); + for (const name of ['You owe', 'Settle up', 'Balances', 'Final budget']) { + expect(screen.queryByRole('region', { name })).toBeNull(); + } + expect(screen.queryByText(/you lent|you borrowed/)).toBeNull(); + expect(screen.queryAllByTestId('split-chip')).toHaveLength(0); + expect(screen.getByRole('region', { name: 'By category' })).toBeInTheDocument(); + }); + + it('FE-COSTS-TOTALS-002: the switch at the foot of the sidebar turns splitting back on', async () => { + const user = userEvent.setup(); + mount('totals'); + await screen.findByText('Dinner'); + const toggle = screen.getByRole('button', { name: 'Split costs between travelers' }); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText(/^Totals only:/)).toBeInTheDocument(); + await user.click(toggle); + expect(updateTrip).toHaveBeenCalledWith(1, { costs_mode: 'split' }); + }); + + it('FE-COSTS-TOTALS-003: a split trip shows the switch on, and a new expense asks for no payer or split once off', async () => { + const user = userEvent.setup(); + const split = mount('split'); + expect(await screen.findByRole('region', { name: 'Settle up' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Split costs between travelers' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + split.unmount(); + + mount('totals'); + await screen.findByText('Dinner'); + await user.click(screen.getByRole('button', { name: 'Add expense' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.queryByText('Who paid?')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Equally' })).toBeNull(); + }); +}); diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index cffd543f4..85bc052d0 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -29,6 +29,8 @@ import { ReceiptPreviewModal } from './ReceiptPreviewModal' import { ExpenseReceiptsSection } from './ExpenseReceiptsSection' import { useExpenseDeleteConfirm } from './useExpenseDeleteConfirm' import { FlowPills } from './FlowPills' +import { CostsSplitChips } from './CostsSplitChips' +import { CostsModeCard } from './CostsModeCard' import type { BudgetParticipantFinal, ReceiptLine } from '@trek/shared' import type { BudgetItem, BudgetItemReceipt } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' @@ -43,9 +45,6 @@ import CostsToolbar, { type CostsView } from './CostsToolbar' import CostsTable, { CostsTableSummary } from './CostsTable' import SummaryCard from './CostsSummaryCard' -/** Split chips a row shows before the rest fold into "+N" (#1763). */ -const SPLIT_CHIP_MAX = 6 - interface CostsPanelProps { tripId: number tripMembers?: TripMember[] @@ -381,6 +380,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{CategoryBreakdown()}
)} + )} @@ -608,6 +608,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.byCategory')}
{CategoryBreakdown()} + ) } @@ -738,7 +739,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ))} )} - {splitting && SplitChips({ e })} + {splitting && } + personName={personName} shareOf={shareOf} fmt={v => fmt(v)} />} {/* The note, marked by a rule in the category colour instead of a box, so @@ -762,39 +764,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ) } - // Who an expense is split between and each one's part (#1763), so checking that - // the right people are in no longer means opening every expense. Quieter than - // the payer chips above, and capped so a large group cannot crowd the row. - function SplitChips({ e }: { e: BudgetItem }) { - const members = e.members || [] - if (members.length === 0) return null - const shown = members.length > SPLIT_CHIP_MAX ? members.slice(0, SPLIT_CHIP_MAX - 1) : members - const rest = members.slice(shown.length) - const chip = 'inline-flex items-center gap-[5px] rounded-full bg-surface-tertiary text-content-secondary' - return ( -
- {t('costs.split')} - {shown.map(m => { - const share = fmt(shareOf(e, m.user_id)) - return ( - - - - {share} - {m.paid ? : null} - - - ) - })} - {rest.length > 0 && ( - t('costs.splitChipLabel', { name: personName(m.user_id), amount: fmt(shareOf(e, m.user_id)) })).join(', ')}> - +{rest.length} - - )} -
- ) - } - // A row's note as a pill that opens in place; an empty one keeps the column. function NoteCell({ note, noteKey, color }: { note: string; noteKey: string; color: string }) { if (!note) return diff --git a/client/src/components/Budget/CostsSplitChips.tsx b/client/src/components/Budget/CostsSplitChips.tsx new file mode 100644 index 000000000..17d18774f --- /dev/null +++ b/client/src/components/Budget/CostsSplitChips.tsx @@ -0,0 +1,81 @@ +import { Check } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { useTranslation } from '../../i18n'; +import type { BudgetItem } from '../../types'; +import { Tooltip } from '../shared/Tooltip'; + +/** Split chips a row shows before the rest fold into "+N" (#1763). */ +const SPLIT_CHIP_MAX = 6; +const CHIP = 'inline-flex items-center gap-[5px] rounded-full bg-surface-tertiary text-content-secondary'; + +/** + * Who an expense is split between and each one's part (#1763), so checking that + * the right people are in no longer means opening every expense. Quieter than + * the payer chips above it in the ledger row, and capped so a large group + * cannot crowd the row. + */ +export function CostsSplitChips({ + e, + avatar, + personName, + shareOf, + fmt, +}: { + e: BudgetItem; + /** A member's avatar at the given size, drawn the way the rest of the ledger draws it. */ + avatar: (userId: number, size: number) => ReactNode; + personName: (id: number) => string; + /** A member's part of the expense in the display currency. */ + shareOf: (e: BudgetItem, userId: number) => number; + fmt: (amount: number) => string; +}) { + const { t } = useTranslation(); + const members = e.members || []; + if (members.length === 0) return null; + const shown = members.length > SPLIT_CHIP_MAX ? members.slice(0, SPLIT_CHIP_MAX - 1) : members; + const rest = members.slice(shown.length); + const label = (userId: number) => + t('costs.splitChipLabel', { name: personName(userId), amount: fmt(shareOf(e, userId)) }); + return ( +
+ + {t('costs.split')} + + {shown.map((m) => ( + + + {avatar(m.user_id, 16)} + {fmt(shareOf(e, m.user_id))} + {m.paid ? ( + + ) : null} + + + ))} + {rest.length > 0 && ( + label(m.user_id)).join(', ')}> + + +{rest.length} + + + )} +
+ ); +} diff --git a/client/src/components/Budget/costsModel.test.ts b/client/src/components/Budget/costsModel.test.ts index 2d9f3851a..36fa76019 100644 --- a/client/src/components/Budget/costsModel.test.ts +++ b/client/src/components/Budget/costsModel.test.ts @@ -18,7 +18,7 @@ import { type CostsSettlement, } from './costsModel'; -// FE-BUDGET-COSTSMODEL-001 to FE-BUDGET-COSTSMODEL-013 +// FE-BUDGET-COSTSMODEL-001 to FE-BUDGET-COSTSMODEL-014 // The pieces both ledgers share since the desktop panel runs on this model too; // the rest of the model is pinned in tests/unit/mobile/trip/costsModel.test.ts. @@ -148,20 +148,20 @@ describe('costsModel: the currency picker options', () => { }); }); -describe('costsModel: when Costs shows who paid and the shares (#2689)', () => { +describe('costsModel: when Costs shows who paid and the shares (#2689, #1714)', () => { const settled = { flows: [], balances: [] }; it('FE-BUDGET-COSTSMODEL-012: a trip of one has nothing to split, unless the ledger still names someone else', () => { const mine = expense({ payers: [payer(1, 20)], members: [member(1)] }); - expect(showsSplitting(2, [], null, 1)).toBe(true); - expect(showsSplitting(1, [mine], settled, 1)).toBe(false); - expect(showsSplitting(0, [], null, 1)).toBe(false); + expect(showsSplitting('split', 2, [], null, 1)).toBe(true); + expect(showsSplitting(undefined, 1, [mine], settled, 1)).toBe(false); + expect(showsSplitting('split', 0, [], null, 1)).toBe(false); // Someone who left: named on an expense, owed money, or still in a transfer. - expect(showsSplitting(1, [expense({ members: [member(1), member(9)] })], settled, 1)).toBe(true); + expect(showsSplitting('split', 1, [expense({ members: [member(1), member(9)] })], settled, 1)).toBe(true); const owed = { flows: [], balances: [{ user_id: 9, username: 'u9', avatar_url: null, balance: 5 }] }; - expect(showsSplitting(1, [mine], owed, 1)).toBe(true); + expect(showsSplitting('split', 1, [mine], owed, 1)).toBe(true); const flow = { from: { user_id: 9, username: 'u9' }, to: { user_id: 1, username: 'u1' }, amount: 5 }; - expect(showsSplitting(1, [mine], { flows: [flow], balances: [] }, 1)).toBe(true); + expect(showsSplitting('split', 1, [mine], { flows: [flow], balances: [] }, 1)).toBe(true); }); it('FE-BUDGET-COSTSMODEL-013: a split is plain with one payer, equal shares, no receipt lines and only current members', () => { @@ -172,4 +172,11 @@ describe('costsModel: when Costs shows who paid and the shares (#2689)', () => { expect(isPlainSplit(expense({ ticket_json: '{"items":[]}', members: [member(1)] }), [1])).toBe(false); expect(isPlainSplit(expense({ payers: [payer(9, 20)], members: [] }), [1])).toBe(false); }); + + it('FE-BUDGET-COSTSMODEL-014: a trip that keeps totals only splits nothing, however many share it (#1714)', () => { + const shared = expense({ payers: [payer(1, 20)], members: [member(1), member(2)] }); + const flow = { from: { user_id: 2, username: 'u2' }, to: { user_id: 1, username: 'u1' }, amount: 10 }; + expect(showsSplitting('totals', 3, [shared], { flows: [flow], balances: [] }, 1)).toBe(false); + expect(showsSplitting('split', 3, [shared], { flows: [flow], balances: [] }, 1)).toBe(true); + }); }); diff --git a/client/src/components/Budget/costsModel.ts b/client/src/components/Budget/costsModel.ts index 5fc4a875f..710393618 100644 --- a/client/src/components/Budget/costsModel.ts +++ b/client/src/components/Budget/costsModel.ts @@ -1,4 +1,4 @@ -import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared'; +import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory, TripCostsMode } from '@trek/shared'; import { readDateFormat } from '../../hooks/useDateFormat'; import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates'; import type { BudgetItem } from '../../types'; @@ -174,17 +174,20 @@ export interface CostsSettlementResponse { /** * Whether Costs shows the splitting side: who paid, the shares, the balances - * and settling up. A trip of one has nothing to split, since every share is the - * whole amount and nobody can owe anybody (#2689), so it is left out there, - * unless the ledger still names someone else: a traveller who left the trip - * with money open, or members that have not loaded yet. + * and settling up. A trip that keeps its costs as totals only leaves it out + * (#1714), whatever is stored. A trip of one has nothing to split, since every + * share is the whole amount and nobody can owe anybody (#2689), so it is left + * out there too, unless the ledger still names someone else: a traveller who + * left the trip with money open, or members that have not loaded yet. */ export function showsSplitting( + costsMode: TripCostsMode | undefined, memberCount: number, items: BudgetItem[], settlement: Pick | null, me: number ): boolean { + if (costsMode === 'totals') return false; if (memberCount > 1) return true; const namesOthers = items.some((e) => [...(e.payers || []), ...(e.members || [])].some((p) => p.user_id !== me)); const moneyOpen = (settlement?.balances || []).some((b) => Math.abs(b.balance) >= 0.005); diff --git a/client/src/components/Budget/useCostsLedger.test.ts b/client/src/components/Budget/useCostsLedger.test.ts index 3bfa07b2b..e91a742dd 100644 --- a/client/src/components/Budget/useCostsLedger.test.ts +++ b/client/src/components/Budget/useCostsLedger.test.ts @@ -16,7 +16,7 @@ import type { CostsSettlementResponse } from './costsModel'; import { useCostsLedger } from './useCostsLedger'; import { useFreezeMissingRates } from './useFreezeMissingRates'; -// FE-BUDGET-LEDGER-001 to FE-BUDGET-LEDGER-014 +// FE-BUDGET-LEDGER-001 to FE-BUDGET-LEDGER-015 const fx = vi.hoisted(() => ({ // One stable converter, the way useExchangeRates hands out a memoised one: USD is half a euro. @@ -263,6 +263,16 @@ describe('useCostsLedger: a trip of one (#2689)', () => { }); }); +describe('useCostsLedger: a trip that keeps totals only (#1714)', () => { + it('FE-BUDGET-LEDGER-015: nothing is split, though the trip has several members and money open', async () => { + const { result } = setup({ trip: buildTrip({ id: 7, currency: 'EUR', costs_mode: 'totals' }) }); + await waitFor(() => expect(result.current.settlement).toEqual(SETTLEMENT)); + expect(result.current.splitting).toBe(false); + expect(result.current.filteredSettlements).toEqual([]); + expect(result.current.filtered).toHaveLength(2); + }); +}); + describe('useCostsLedger: writes', () => { it('FE-BUDGET-LEDGER-009: deleting an expense reads the settlement again, a failure toasts', async () => { const { result } = setup(); diff --git a/client/src/components/Budget/useCostsLedger.ts b/client/src/components/Budget/useCostsLedger.ts index 7bf453029..fcfa02d02 100644 --- a/client/src/components/Budget/useCostsLedger.ts +++ b/client/src/components/Budget/useCostsLedger.ts @@ -53,8 +53,9 @@ interface Toaster { * breakdown, the CSV export and the ledger writes. Each surface lays it out * and keeps its own dialogs or sheets. * - * `splitting` says whether the ledger has anything to split at all (#2689); - * without it the owner filter and the recorded payments are left out too. + * `splitting` says whether the ledger splits anything at all: not on a trip + * that keeps totals only (#1714) or on a trip of one (#2689). Without it the + * owner filter and the recorded payments are left out too. * * Where the two differ today, the surface says which way: the desktop also * reads the settlement when the trip changes (`settlementOnTripChange`), leaves @@ -187,8 +188,8 @@ export function useCostsLedger({ const shareOf = skipUnfinishedShares ? settledShareOf : myShareOf; const flows = useMemo(() => settlement?.flows || [], [settlement]); const splitting = useMemo( - () => showsSplitting(memberCount, budgetItems, settlement, me), - [memberCount, budgetItems, settlement, me] + () => showsSplitting(trip?.costs_mode, memberCount, budgetItems, settlement, me), + [trip?.costs_mode, memberCount, budgetItems, settlement, me] ); // The owner filter is hidden while nothing is split, so it cannot hold the list back. const activeSegment = splitting ? segment : 'all'; diff --git a/client/src/components/Budget/useCostsMode.test.ts b/client/src/components/Budget/useCostsMode.test.ts new file mode 100644 index 000000000..de6577900 --- /dev/null +++ b/client/src/components/Budget/useCostsMode.test.ts @@ -0,0 +1,56 @@ +// FE-BUDGET-COSTSMODE-001 to FE-BUDGET-COSTSMODE-003 +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildTrip } from '../../../tests/helpers/factories'; +import { resetAllStores } from '../../../tests/helpers/store'; +import { useTripStore, type TripStoreState } from '../../store/tripStore'; +import { useCostsMode } from './useCostsMode'; + +let updateTrip: ReturnType; +let addToast: ReturnType; + +const trip = (costsMode?: 'split' | 'totals') => buildTrip({ id: 7, ...(costsMode ? { costs_mode: costsMode } : {}) }); + +beforeEach(() => { + resetAllStores(); + updateTrip = vi.fn(async () => buildTrip({ id: 7 })); + useTripStore.setState({ updateTrip } as unknown as Partial); + addToast = vi.fn(); + (window as unknown as { __addToast: unknown }).__addToast = addToast; +}); + +afterEach(() => { + delete (window as unknown as { __addToast?: unknown }).__addToast; +}); + +describe('useCostsMode (#1714)', () => { + it('FE-BUDGET-COSTSMODE-001: a trip splits its costs until it says otherwise, and keeps totals only when it does', () => { + expect(renderHook(() => useCostsMode(trip(), 2, true)).result.current.splitting).toBe(true); + expect(renderHook(() => useCostsMode(trip('split'), 2, true)).result.current.splitting).toBe(true); + expect(renderHook(() => useCostsMode(trip('totals'), 2, true)).result.current.splitting).toBe(false); + }); + + it('FE-BUDGET-COSTSMODE-002: the switch is offered to whoever may edit the costs, on a trip of more than one', () => { + expect(renderHook(() => useCostsMode(trip(), 2, true)).result.current.offered).toBe(true); + expect(renderHook(() => useCostsMode(trip(), 1, true)).result.current.offered).toBe(false); + expect(renderHook(() => useCostsMode(trip(), 3, false)).result.current.offered).toBe(false); + }); + + it('FE-BUDGET-COSTSMODE-003: switching saves the mode on the trip, and a failure says so', async () => { + const { result } = renderHook(() => useCostsMode(trip(), 2, true)); + await act(async () => { + await result.current.setSplitting(false); + }); + expect(updateTrip).toHaveBeenCalledWith(7, { costs_mode: 'totals' }); + expect(result.current.saving).toBe(false); + + updateTrip.mockRejectedValueOnce(new Error('offline')); + await act(async () => { + await result.current.setSplitting(true); + }); + expect(updateTrip).toHaveBeenLastCalledWith(7, { costs_mode: 'split' }); + expect(addToast.mock.calls.map((c) => c[1])).toContain('error'); + expect(result.current.saving).toBe(false); + }); +}); diff --git a/client/src/components/Budget/useCostsMode.ts b/client/src/components/Budget/useCostsMode.ts new file mode 100644 index 000000000..d75f1cb01 --- /dev/null +++ b/client/src/components/Budget/useCostsMode.ts @@ -0,0 +1,36 @@ +import { useState } from 'react'; + +import { useTranslation } from '../../i18n'; +import { useTripStore } from '../../store/tripStore'; +import type { Trip } from '../../types'; +import { useToast } from '../shared/Toast'; + +/** + * Whether a trip splits its costs between the travellers or keeps the totals + * only (#1714), and the switch between the two, for the desktop Costs panel and + * the phone tab alike. The mode is the trip's own (`trips.costs_mode`), so every + * member sees the same Costs; it is saved through the trip, as `budget_edit`. + * + * The switch is offered to whoever may edit the costs, and only on a trip with + * more than one member: a trip of one has nothing to split either way (#2689). + */ +export function useCostsMode(trip: Trip | null | undefined, memberCount: number, canEdit: boolean) { + const { t } = useTranslation(); + const toast = useToast(); + const updateTrip = useTripStore((s) => s.updateTrip); + const [saving, setSaving] = useState(false); + + const setSplitting = async (on: boolean) => { + if (!trip) return; + setSaving(true); + try { + await updateTrip(trip.id, { costs_mode: on ? 'split' : 'totals' }); + } catch { + toast.error(t('common.unknownError')); + } finally { + setSaving(false); + } + }; + + return { splitting: trip?.costs_mode !== 'totals', setSplitting, saving, offered: canEdit && memberCount > 1 }; +} diff --git a/client/src/components/Budget/useExpenseForm.test.ts b/client/src/components/Budget/useExpenseForm.test.ts index a2b595fc3..f81eee6d0 100644 --- a/client/src/components/Budget/useExpenseForm.test.ts +++ b/client/src/components/Budget/useExpenseForm.test.ts @@ -21,7 +21,7 @@ vi.mock('./receiptUploads', () => ({ ), })); -// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-027 +// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-028 const PEOPLE: TripMember[] = [ { id: 1, username: 'alice', avatar_url: null }, @@ -516,6 +516,40 @@ describe('useExpenseForm: saving', () => { ); }); + it('FE-BUDGET-EXPFORM-028: on a totals-only trip a new expense names nobody, an edit keeps who it named (#1714)', async () => { + useTripStore.setState({ + trip: buildTrip({ id: 7, currency: 'EUR', costs_mode: 'totals' }), + } as Partial); + const created = setup(); + expect(created.result.current.showSplit).toBe(false); + fill(created.result, 'Museum', '30'); + await save(created.result); + expect(addBudgetItem).toHaveBeenCalledWith( + 7, + expect.objectContaining({ payers: [], members: [], member_ids: [], total_price: 30 }) + ); + + const editing = buildBudgetItem({ + id: 9, + name: 'Dinner', + total_price: 40, + payers: [{ user_id: 2, amount: 40 }], + members: [ + { user_id: 1, paid: 0, username: 'alice', amount: null }, + { user_id: 2, paid: 0, username: 'bob', amount: null }, + ], + } as Partial); + const edited = setup({ editing }); + expect(edited.result.current.showSplit).toBe(false); + act(() => edited.result.current.onTotalChange('50')); + await save(edited.result); + expect(updateBudgetItem).toHaveBeenCalledWith( + 7, + 9, + expect.objectContaining({ payers: [{ user_id: 2, amount: 50 }], member_ids: [1, 2], total_price: 50 }) + ); + }); + it('FE-BUDGET-EXPFORM-017: a ticket split sends everyone a line names, ticked or not (#1382)', async () => { const { result } = setup(); act(() => { diff --git a/client/src/components/Budget/useExpenseForm.ts b/client/src/components/Budget/useExpenseForm.ts index f8490e631..d6980b7a3 100644 --- a/client/src/components/Budget/useExpenseForm.ts +++ b/client/src/components/Budget/useExpenseForm.ts @@ -73,6 +73,7 @@ export function useExpenseForm({ const addBudgetItem = useTripStore((s) => s.addBudgetItem); const updateBudgetItem = useTripStore((s) => s.updateBudgetItem); const tripFiles = useTripStore((s) => s.files); + const totalsOnly = useTripStore((s) => s.trip?.costs_mode) === 'totals'; const sym = (c: string) => SYMBOLS[c] || c + ' '; const nameOf = (p: TripMember) => (p.id === me ? t('costs.you') : p.username); // A saved expense without a currency opens in the trip's own (#2525). @@ -103,8 +104,11 @@ export function useExpenseForm({ if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''; return seed.total; }); + // A new expense on a totals-only trip (#1714) names nobody: no payer, and no + // split that would turn into debts if the trip ever splits its costs again. + const unsplit = totalsOnly && !editing; const [participants, setParticipants] = useState>(() => - editing ? new Set((editing.members || []).map((m) => m.user_id)) : new Set(people.map((p) => p.id)) + editing ? new Set((editing.members || []).map((m) => m.user_id)) : new Set(unsplit ? [] : people.map((p) => p.id)) ); // Payer state. An expense can be fronted by several people, each with their own @@ -119,7 +123,7 @@ export function useExpenseForm({ const [payerId, setPayerId] = useState(() => { const existingPayer = initialPayers[0]; if (existingPayer) return existingPayer.user_id; - return editing ? 0 : me; + return editing || unsplit ? 0 : me; }); const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1); const [payerIds, setPayerIds] = useState>(() => new Set(initialPayers.map((p) => p.user_id))); @@ -132,15 +136,16 @@ export function useExpenseForm({ // the others absorb the remainder. const [pinnedPayers, setPinnedPayers] = useState>(() => new Set(initialPayers.map((p) => p.user_id))); - // A trip of one has nothing to split (#2689): who paid and the split stay out of - // sight, unless the expense being edited carries more than that one person. + // Who paid and the split stay out of sight on a trip that keeps totals only + // (#1714) and on a trip of one, which has nothing to split (#2689), unless the + // expense being edited carries more than that, so nothing on it is hidden. const [plainSplit] = useState(() => isPlainSplit( editing, people.map((p) => p.id) ) ); - const showSplit = people.length > 1 || !plainSplit; + const showSplit = (!totalsOnly && people.length > 1) || !plainSplit; const [splitMode, setSplitMode] = useState(() => { if (hasTicketSplit(editing)) return 'ticket'; diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 16d6956c4..2d44991b2 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react' import { AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp, Layers, Pencil, Plus, RotateCcw, StickyNote, Trash2, Receipt, @@ -12,6 +12,8 @@ import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' import { useCostsLedger } from '../../../../components/Budget/useCostsLedger' import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks' +import { useCostsMode } from '../../../../components/Budget/useCostsMode' +import ToggleSwitch from '../../../../components/Settings/ToggleSwitch' import { finalBudgetFor, finalBudgetSources, formerParticipantRows, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import MConfirmSheet from '../../settings/MConfirmSheet' @@ -51,6 +53,9 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { deleteExpense, undoSettlement: handleUndoSettlement, } = useCostsLedger({ tripId, trip, budgetItems, memberCount: tripMembers.length, actions: planner.tripActions, canEdit, t, toast }) + // Split between the travellers or totals only (#1714), switched at the foot of the summary. + const costsMode = useCostsMode(trip, tripMembers.length, canEdit) + const modeHintId = useId() const [catOpen, setCatOpen] = useState(false) const [dayOpen, setDayOpen] = useState(false) const [settleOpen, setSettleOpen] = useState(true) @@ -328,6 +333,23 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { )} + {costsMode.offered && ( +
+
+
{t('costs.mode.label')}
+

+ {costsMode.splitting ? t('costs.mode.hintSplit') : t('costs.mode.hintTotals')} +

+
+ void costsMode.setSplitting(!costsMode.splitting)} + label={t('costs.mode.label')} + describedBy={modeHintId} + disabled={costsMode.saving} + /> +
+ )} ) diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 7cd664e40..b9780c512 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -18,7 +18,7 @@ import { server } from '../../../helpers/msw/server' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { setSizeClass } from '../../../helpers/sizeClass' -// FE-MOB-COSTT-001 to FE-MOB-COSTT-056 +// FE-MOB-COSTT-001 to FE-MOB-COSTT-057 // The add/edit expense sheet is the shared desktop-sized form; the panel only // owns when it opens and what happens on save, so it is stubbed here. @@ -982,4 +982,19 @@ describe('MCostsTab', () => { // The souvenirs nobody paid for are an expense like any other here. expect(screen.getByText('Souvenirs')).toBeInTheDocument() }) + + it('FE-MOB-COSTT-057: a trip that keeps totals only leaves out what is owed, and its switch turns splitting back on (#1714)', async () => { + const updateTrip = vi.fn(async () => TRIP) + useTripStore.setState({ updateTrip }) + render() + await waitFor(() => expect(settlementBases).toEqual(['USD'])) + for (const key of ['costs.youOwe', 'costs.settleUp', 'costs.finalBudget', 'costs.filter.mine', 'costs.unfinished']) { + expect(screen.queryByText(key)).not.toBeInTheDocument() + } + const toggle = screen.getByRole('button', { name: 'costs.mode.label' }) + expect(toggle).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByText('costs.mode.hintTotals')).toBeInTheDocument() + fireEvent.click(toggle) + await waitFor(() => expect(updateTrip).toHaveBeenCalledWith(7, { costs_mode: 'split' })) + }) }) diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index 8ce89647a..bc019645b 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -3,7 +3,7 @@ "scripts/generate-entities.ts": 2259, "src/db/repositories/Places.repository.ts": 1437, "src/db/repositories/Reservations.repository.ts": 1559, - "src/db/repositories/Trips.repository.ts": 1121, + "src/db/repositories/Trips.repository.ts": 1120, "src/db/repositories/Users.repository.ts": 1445, "src/nest/atlas/atlas.service.ts": 1177, "src/nest/auth/auth.service.ts": 1143, diff --git a/server/src/db/entities/Trips.entity.ts b/server/src/db/entities/Trips.entity.ts index 2a2aacc35..93ff06b90 100644 --- a/server/src/db/entities/Trips.entity.ts +++ b/server/src/db/entities/Trips.entity.ts @@ -58,6 +58,7 @@ export class Trips { created_at?: string | null; updated_at?: string | null; reminder_sent_for?: string | null; + costs_mode: string & Opt = 'split'; assistant_attachments_collection = new Collection(this); assistant_threads_collection = new Collection(this); budget_category_order_collection = new Collection(this); @@ -124,6 +125,7 @@ export const TripsSchema = defineEntity({ created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`).index('idx_trips_created_at'), updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), reminder_sent_for: p.text().nullable(), + costs_mode: p.text().default('split'), assistant_attachments_collection: () => p.oneToMany(AssistantAttachments).mappedBy('trip').hidden(), assistant_threads_collection: () => p.oneToMany(AssistantThreads).mappedBy('trip').hidden(), budget_category_order_collection: () => p.oneToMany(BudgetCategoryOrder).mappedBy('trip').hidden(), diff --git a/server/src/db/kysely/Trips.table.ts b/server/src/db/kysely/Trips.table.ts index b1b3323a1..9eb90fcec 100644 --- a/server/src/db/kysely/Trips.table.ts +++ b/server/src/db/kysely/Trips.table.ts @@ -17,4 +17,5 @@ export interface TripsTable { created_at: InsertOptional; updated_at: InsertOptional; reminder_sent_for: InsertOptional; + costs_mode: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only.ts b/server/src/db/migrations/Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only.ts new file mode 100644 index 000000000..192e5c39f --- /dev/null +++ b/server/src/db/migrations/Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * A trip can keep its costs as totals only (#1714): who paid, the shares and + * settling up are left out of Costs, for travellers who track what a trip costs + * but never split it. + * + * `costs_mode` is 'split' (what every trip did so far) or 'totals'. 'split' as + * the default keeps this from being a breaking change: SQLite fills every + * existing row with it on the ALTER, so every trip looks as it did before. + */ +export class Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only extends Migration { + override name = 'Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trips', + 'costs_mode', + `costs_mode TEXT NOT NULL DEFAULT 'split' CHECK (costs_mode IN ('split', 'totals'))`, + ); + } +} diff --git a/server/src/db/repositories/Trips.repository.ts b/server/src/db/repositories/Trips.repository.ts index cba9187ec..ca0b562b5 100644 --- a/server/src/db/repositories/Trips.repository.ts +++ b/server/src/db/repositories/Trips.repository.ts @@ -36,6 +36,7 @@ export interface TripRawRow { created_at: string | null; updated_at: string | null; reminder_sent_for: string | null; + costs_mode: string; } const _tripRawRowKeys: AssertRowKeys = true; @@ -544,7 +545,7 @@ export class TripsRepository extends TrekRepository { /** * TP37 (`trips.service.ts::copy`'s INSERT) — `INSERT INTO trips (user_id, * title, description, start_date, end_date, currency, cover_image, - * is_archived, reminder_days) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`. A + * is_archived, reminder_days, costs_mode) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`. A * DIFFERENT column set from `insertTrip` (TP18, `create`'s own INSERT): * this one also writes `cover_image` (verbatim from the source row) and * `is_archived` as a hard-coded literal `0` — a copy is never archived, @@ -563,6 +564,7 @@ export class TripsRepository extends TrekRepository { currency: string | null; cover_image: string | null; reminder_days: number; + costs_mode: string; }): Promise { return await this.insert({ user: input.user_id, @@ -574,13 +576,14 @@ export class TripsRepository extends TrekRepository { cover_image: input.cover_image, is_archived: 0, reminder_days: input.reminder_days, + costs_mode: input.costs_mode, }); } /** * TP25 (`trips.service.ts::updateTrip`) — `UPDATE trips SET title=?, * description=?, start_date=?, end_date=?, currency=?, is_archived=?, - * cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP WHERE + * cover_image=?, reminder_days=?, costs_mode=?, updated_at=CURRENT_TIMESTAMP WHERE * id=?`. The `||`/`!== undefined` pre-image fallbacks are the SERVICE's * own (R5/§18.6 — this write stays OUTSIDE the days-regeneration * transaction, unchanged); this takes the final, already-decided values @@ -597,16 +600,11 @@ export class TripsRepository extends TrekRepository { * here would tempt a caller to paper over that with its own `?? 0`, the * exact regression the review found. */ - async updateTripRow(id: number, data: { - title: string; - description: string | null; - start_date: string | null; - end_date: string | null; - currency: string; - is_archived: number | null; - cover_image: string | null; - reminder_days: number; - }): Promise { + async updateTripRow( + id: number, + data: Pick + & { currency: string; reminder_days: number }, + ): Promise { const platform = this.getEntityManager().getPlatform(); await this.nativeUpdate({ id }, { ...data, updated_at: currentTimestamp(platform) }); } diff --git a/server/src/nest/trips/trips.helpers.ts b/server/src/nest/trips/trips.helpers.ts index c0b6603b9..96b347cb4 100644 --- a/server/src/nest/trips/trips.helpers.ts +++ b/server/src/nest/trips/trips.helpers.ts @@ -40,9 +40,11 @@ const TRIP_EDIT_FIELDS = ['title', 'description', 'start_date', 'end_date', 'cur /** * The permissions a trip update needs, in the order PUT /api/trips/:id checks * them, each with the refusal that route answers. Archiving and the cover have - * their own action; any other field present is an edit. update_trip runs the - * same list, so an MCP caller cannot archive a trip or swap its cover on the - * strength of trip_edit alone. + * their own action, and so does the costs mode (#1714): it decides what Costs + * shows, so whoever may edit the costs sets it, as against the trip's own + * details. Any other field present is an edit. update_trip runs the same list, + * so an MCP caller cannot archive a trip or swap its cover on the strength of + * trip_edit alone. */ export function tripUpdatePermissions(body: object): { action: PermissionKey; refusal: string }[] { const sent = (field: string) => (body as Record)[field] !== undefined; @@ -53,6 +55,9 @@ export function tripUpdatePermissions(body: object): { action: PermissionKey; re if (sent('cover_image')) { needed.push({ action: 'trip_cover_upload', refusal: 'No permission to change cover image' }); } + if (sent('costs_mode')) { + needed.push({ action: 'budget_edit', refusal: 'No permission to change how this trip keeps its costs' }); + } if (TRIP_EDIT_FIELDS.some(sent)) needed.push({ action: 'trip_edit', refusal: 'No permission to edit this trip' }); return needed; } diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 14255ea9d..bc0cbcbab 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -29,7 +29,7 @@ import { TripReadModelService } from '../trip-read-model/trip-read-model.service import { TripWritesService, type TripListWriter } from './trip-writes.service'; import { tripUpdatePermissions } from './trips.helpers'; import { TripsService, NotFoundError, ValidationError } from './trips.service'; -import { idSchema, MAX_TRIP_DAYS } from '@trek/shared'; +import { idSchema, MAX_TRIP_DAYS, tripUpdateRequestSchema, type TripCostsMode } from '@trek/shared'; import { z } from 'zod'; @@ -251,6 +251,9 @@ export class TripsMcp { .describe( 'When changing dates: keep_bookings (default) keeps dated reservations/accommodations on their dates while day plans move; shift_all moves the whole itinerary, bookings included', ), + costs_mode: tripUpdateRequestSchema.shape.costs_mode.describe( + 'How the trip keeps its costs: split (who paid and who shares each expense, with settling up) or totals (what the trip costs, nothing split). Who paid and the shares stay stored either way. Needs the budget_edit permission rather than trip_edit', + ), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, @@ -269,6 +272,7 @@ export class TripsMcp { day_count, reminder_days, date_shift_mode, + costs_mode, }: { tripId: number; title?: string; @@ -282,6 +286,7 @@ export class TripsMcp { day_count?: number; reminder_days?: number; date_shift_mode?: 'keep_bookings' | 'shift_all'; + costs_mode?: TripCostsMode; }, ctx: McpContext, ) { @@ -293,8 +298,18 @@ export class TripsMcp { const dates: { start_date?: string | null; end_date?: string | null } = clear_dates ? { start_date: null, end_date: null } : { start_date, end_date }; - const fields = { title, description, ...dates, currency, is_archived, cover_image, day_count, reminder_days }; - // The rights PUT /api/trips/:id asks for, field by field: archiving and the cover have their own. + const fields = { + title, + description, + ...dates, + currency, + is_archived, + cover_image, + day_count, + reminder_days, + costs_mode, + }; + // The rights PUT /api/trips/:id asks for, field by field: archiving, the cover and the costs mode have their own. const granted = await Promise.all( tripUpdatePermissions(fields).map(({ action }) => this.guards.hasTripPermission(action, tripId, ctx.userId)), ); diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 2422ca3e4..cd02c30be 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -34,6 +34,7 @@ import { type DayGridRemoval, type TrekWsPayload, type TrekWsTripEventName, + type TripUpdateRequest, } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; @@ -62,20 +63,9 @@ interface CreateTripData { day_count?: number; } -// Nullable where the wire contract (tripUpdateRequestSchema) is nullable — the -// legacy route accepted arbitrary JSON, so null always reached these fields. -interface UpdateTripData { - title?: string; - description?: string | null; - start_date?: string | null; - end_date?: string | null; - currency?: string; - is_archived?: boolean | number; - cover_image?: string | null; - reminder_days?: number; - day_count?: number; - date_shift_mode?: 'keep_bookings' | 'shift_all'; -} +// The wire contract of PUT /api/trips/:id (tripUpdateRequestSchema), which +// update_trip hands on as well; nullable where it is, as null always arrived. +type UpdateTripData = TripUpdateRequest; export interface UpdateTripResult { updatedTrip: any; @@ -475,7 +465,7 @@ export class TripsService { const trip = await this.tripsRepo.findRaw(tripId) as (Trip & { reminder_days?: number }) | null; // TP24 if (!trip) throw new NotFoundError('Trip not found'); - const { title, description, currency, is_archived, cover_image, reminder_days } = data; + const { title, description, currency, is_archived, cover_image, reminder_days, costs_mode } = data; const { newStart, newEnd, dayCount, regenerate } = this.resolveRange(trip, data); const newTitle = title || trip.title; @@ -483,6 +473,7 @@ export class TripsService { const newCurrency = currency || trip.currency; const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived; const newCover = cover_image !== undefined ? cover_image : trip.cover_image; + const newCostsMode = costs_mode ?? trip.costs_mode; const oldReminder = (trip as any).reminder_days ?? 3; const newReminder = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : oldReminder) @@ -510,6 +501,7 @@ export class TripsService { is_archived: newArchived, cover_image: newCover ?? null, reminder_days: newReminder, + costs_mode: newCostsMode, }); if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date) @@ -545,6 +537,7 @@ export class TripsService { if (newEnd !== trip.end_date) changes.end_date = newEnd; if (newReminder !== oldReminder) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`; if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived; + if (newCostsMode !== trip.costs_mode) changes.costs_mode = newCostsMode; const isAdminEdit = userRole === 'admin' && trip.user_id !== userId; let ownerEmail: string | undefined; @@ -659,6 +652,7 @@ export class TripsService { currency: src.currency, cover_image: src.cover_image, reminder_days: src.reminder_days ?? 3, + costs_mode: src.costs_mode, }); const oldDays = await this.daysRepo.listByTrip(Number(sourceTripId)); // TP38 diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index e56d2ef55..442b6d387 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -243,6 +243,26 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { expect(await dayIds(week.body.trip.id)).toEqual(kept); }); + it('200 update keeps the costs as totals only, the trip as it was otherwise; 400 on another mode (#1714)', async () => { + const trip = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'Lisbon' }); + expect(trip.body.trip.costs_mode).toBe('split'); + const res = await request(server) + .put(`/api/trips/${trip.body.trip.id}`) + .set('Cookie', sessionCookie(1)) + .send({ costs_mode: 'totals' }); + expect(res.status).toBe(200); + expect(res.body.trip).toMatchObject({ title: 'Lisbon', costs_mode: 'totals' }); + const audit = await findRow(orm, AuditLog, { action: 'trip.update' }); + expect(JSON.parse(audit!.details!)).toMatchObject({ costs_mode: 'totals' }); + + const bogus = await request(server) + .put(`/api/trips/${trip.body.trip.id}`) + .set('Cookie', sessionCookie(1)) + .send({ costs_mode: 'shares' }); + expect(bogus.status).toBe(400); + expect((await findRow(orm, Trips, { id: trip.body.trip.id }))!.costs_mode).toBe('totals'); + }); + it('200 update with a later start moves the leave days of the owner along, off the blocked weekend, and tells the plan (#2604)', async () => { // 2030-11-25 is a Monday; the plan blocks Saturday and Sunday by default. const trip = await request(server) diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index 7dc5c389a..a0c735b84 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -106,6 +106,7 @@ const UNNUMBERED = [ 'Migration20200101046360_a_new_install_starts_with_more_addons_on', 'Migration20200101046370_a_deleted_chat_message_keeps_no_text', 'Migration20200101046380_ireland_gained_its_counties', + 'Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/repositories/Trips.repository.test.ts b/server/tests/unit/db/repositories/Trips.repository.test.ts index 887dbed7a..754b86aaf 100644 --- a/server/tests/unit/db/repositories/Trips.repository.test.ts +++ b/server/tests/unit/db/repositories/Trips.repository.test.ts @@ -560,6 +560,7 @@ describe('TripsRepository.findForViewer / listForUser / activeTrip (Plan 3c Task is_archived: 0, cover_image: null, reminder_days: 3, + costs_mode: 'split', }); // Seen INSIDE the open transaction — the write and the read share // one connection. @@ -609,6 +610,7 @@ describe('TripsRepository.insertTrip / updateTripRow / setCoverImage / deleteByI is_archived: 1, cover_image: 'c.png', reminder_days: 7, + costs_mode: 'totals', }); const row = await tripRow(trip.id); expect(row).toMatchObject({ @@ -620,6 +622,7 @@ describe('TripsRepository.insertTrip / updateTripRow / setCoverImage / deleteByI is_archived: 1, cover_image: 'c.png', reminder_days: 7, + costs_mode: 'totals', }); // CURRENT_TIMESTAMP has one-second resolution — a same-second before/after // comparison is flaky, not a real assertion; the column being non-null and @@ -647,7 +650,7 @@ describe('TripsRepository.insertTrip / updateTripRow / setCoverImage / deleteByI // ── Plan 3c Task 8 (`TripsService.copy`, TP37) — additive ─────────────────── describe('TripsRepository.insertTripCopy (TP37)', () => { - it('TRIPREPO-039: writes the 8-column copy set, is_archived hard-coded to 0 regardless of the caller', async () => { + it('TRIPREPO-039: writes the 9-column copy set, is_archived hard-coded to 0 regardless of the caller', async () => { const { user: owner } = createUser(testDb); const { user: newOwner } = createUser(testDb); const newId = await trips.insertTripCopy({ @@ -659,9 +662,20 @@ describe('TripsRepository.insertTripCopy (TP37)', () => { currency: 'USD', cover_image: 'c.png', reminder_days: 7, + costs_mode: 'totals', }); - const { user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days } = - await tripRow(newId); + const { + user_id, + title, + description, + start_date, + end_date, + currency, + cover_image, + is_archived, + reminder_days, + costs_mode, + } = await tripRow(newId); const row = { user_id, title, @@ -672,6 +686,7 @@ describe('TripsRepository.insertTripCopy (TP37)', () => { cover_image, is_archived, reminder_days, + costs_mode, }; expect(row).toEqual({ user_id: newOwner.id, @@ -683,6 +698,7 @@ describe('TripsRepository.insertTripCopy (TP37)', () => { cover_image: 'c.png', is_archived: 0, reminder_days: 7, + costs_mode: 'totals', }); // The source trip (never touched) proves is_archived: 0 is a literal, not a copied value. await setTrip(owner.id, { is_archived: 1 }); @@ -701,6 +717,7 @@ describe('TripsRepository.insertTripCopy (TP37)', () => { currency: null, cover_image: null, reminder_days: 3, + costs_mode: 'split', }); expect((await tripRow(newId)).is_archived).toBe(0); }); diff --git a/server/tests/unit/mcp/tools-trips.test.ts b/server/tests/unit/mcp/tools-trips.test.ts index 09c8c9b4b..942efb440 100644 --- a/server/tests/unit/mcp/tools-trips.test.ts +++ b/server/tests/unit/mcp/tools-trips.test.ts @@ -1179,4 +1179,35 @@ describe('Trip writes, as REST makes them', () => { }); expect(await tripRow(trip.id)).toMatchObject({ title: 'After', is_archived: 1 }); }); + + it('MCP-TRIPS-005: update_trip sets the costs mode on budget_edit, as the route does (#1714)', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + expect((await tripRow(trip.id)).costs_mode).toBe('split'); + // A member may edit the costs by default, though not the trip's own details. + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_trip', + arguments: { tripId: trip.id, costs_mode: 'totals' }, + }); + expect(result.isError).toBeFalsy(); + expect((parseToolResult(result) as { trip: { costs_mode: string } }).trip.costs_mode).toBe('totals'); + }); + await setPermission('budget_edit', 'trip_owner'); + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_trip', + arguments: { tripId: trip.id, costs_mode: 'split' }, + }); + expect(result.isError).toBe(true); + const bogus = await h.client.callTool({ + name: 'update_trip', + arguments: { tripId: trip.id, costs_mode: 'shares' }, + }); + expect(bogus.isError).toBe(true); + }); + expect((await tripRow(trip.id)).costs_mode).toBe('totals'); + }); }); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index c10adc826..05a20cf42 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -385,6 +385,25 @@ describe('TripsController (parity with the legacy /api/trips route)', async () = }); }); + it('the costs mode needs budget_edit and nothing else (#1714)', async () => { + const noBudget = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'budget_edit') }); + expect(await thrownAsync(() => tc(noBudget).update(user, '9', { costs_mode: 'totals' }, req))).toEqual({ + status: 403, + body: { error: 'No permission to change how this trip keeps its costs' }, + }); + const update = vi.fn().mockReturnValue({ + updatedTrip: { id: 9, costs_mode: 'totals' }, + changes: { costs_mode: 'totals' }, + newTitle: 'b', + newReminder: 0, + oldReminder: 0, + }); + const onlyBudget = svc({ update, can: vi.fn().mockImplementation((a: string) => a === 'budget_edit') }); + expect(await tc(onlyBudget).update(user, '9', { costs_mode: 'totals' }, req)).toEqual({ + trip: { id: 9, costs_mode: 'totals' }, + }); + }); + it('403 on an edit field without trip_edit', async () => { const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_edit') }); expect(await thrownAsync(() => tc(s).update(user, '9', { title: 'b' }, req))).toEqual({ diff --git a/server/tests/unit/nest/trips.helpers.test.ts b/server/tests/unit/nest/trips.helpers.test.ts index b54cd4310..b52d3a4df 100644 --- a/server/tests/unit/nest/trips.helpers.test.ts +++ b/server/tests/unit/nest/trips.helpers.test.ts @@ -1,8 +1,9 @@ /** * The pure helpers of the trips domain: the date range a trip may span and the - * feed token a trip row loses on its way out. TRIP-HELPERS-001 to 003. + * feed token a trip row loses on its way out, and the rights a trip update + * needs. TRIP-HELPERS-001 to 004. */ -import { assertTripSpan, withoutFeedToken } from '../../../src/nest/trips/trips.helpers'; +import { assertTripSpan, tripUpdatePermissions, withoutFeedToken } from '../../../src/nest/trips/trips.helpers'; import { MAX_TRIP_DAYS, addIsoDays } from '@trek/shared'; import { describe, expect, it } from 'vitest'; @@ -32,3 +33,16 @@ describe('withoutFeedToken', () => { expect(withoutFeedToken(42)).toBe(42); }); }); + +describe('tripUpdatePermissions', () => { + it('TRIP-HELPERS-004: the costs mode asks for budget_edit, not trip_edit (#1714)', () => { + expect(tripUpdatePermissions({ costs_mode: 'totals' }).map((p) => p.action)).toEqual(['budget_edit']); + expect(tripUpdatePermissions({ costs_mode: 'split', title: 'Japan' }).map((p) => p.action)).toEqual([ + 'budget_edit', + 'trip_edit', + ]); + expect(tripUpdatePermissions({ title: 'Japan', costs_mode: undefined }).map((p) => p.action)).toEqual([ + 'trip_edit', + ]); + }); +}); diff --git a/shared/src/i18n/ar/budget.ts b/shared/src/i18n/ar/budget.ts index c47da2231..104407782 100644 --- a/shared/src/i18n/ar/budget.ts +++ b/shared/src/i18n/ar/budget.ts @@ -202,6 +202,9 @@ const budget: TranslationStrings = { 'أعطِ كل شخص عددًا من الحصص، مثل عدد الليالي التي أقامها؛ ويُقسَّم المبلغ الإجمالي بهذه النسبة.', 'costs.splitSharesOf': 'حصص {name}', 'costs.splitSharesNone': 'أعطِ حصة لشخص واحد على الأقل', + 'costs.mode.label': 'تقسيم التكاليف بين المسافرين', + 'costs.mode.hintSplit': 'من دفع ومن يتشارك كل مصروف، والتسوية في النهاية.', + 'costs.mode.hintTotals': 'الإجماليات فقط: بلا دافعين ولا حصص ولا تسوية. ما قُسِّم من قبل يبقى محفوظًا.', 'costs.splitHint.ticket': 'اكتب ما ورد في الإيصال وحدّد من أخذ كل عنصر.', 'costs.filter.allCategories': 'كل الفئات', 'costs.filter.allDays': 'كل الأيام', diff --git a/shared/src/i18n/az/budget.ts b/shared/src/i18n/az/budget.ts index a12c453a2..764a3584b 100644 --- a/shared/src/i18n/az/budget.ts +++ b/shared/src/i18n/az/budget.ts @@ -164,6 +164,9 @@ const budget: TranslationStrings = { 'Hər kəsə pay sayı verin, məsələn qaldığı gecələrin sayı; ümumi məbləğ bu nisbətdə bölünür.', 'costs.splitSharesOf': '{name} üçün paylar', 'costs.splitSharesNone': 'Ən azı bir nəfərə pay verin', + 'costs.mode.label': 'Xərcləri səyahətçilər arasında bölmək', + 'costs.mode.hintSplit': 'Hər xərci kimin ödədiyi və kimlərin bölüşdüyü, sonda isə hesablaşma.', + 'costs.mode.hintTotals': 'Yalnız yekunlar: ödəyən, pay və hesablaşma yoxdur. Əvvəl bölünənlər saxlanılır.', 'costs.splitHint.ticket': 'Qəbzdə olanları sadalayın və hər elementi kimin götürdüyünü işarələyin.', 'costs.pickSomeone': 'Bölüşdürmək üçün ən azı bir nəfər seçin.', 'costs.splitSummary': '{count} nəfər arasında bölünüb · hərəsinə {amount}', diff --git a/shared/src/i18n/br/budget.ts b/shared/src/i18n/br/budget.ts index 30c5284f6..eef660fc9 100644 --- a/shared/src/i18n/br/budget.ts +++ b/shared/src/i18n/br/budget.ts @@ -174,6 +174,9 @@ const budget: TranslationStrings = { 'Dê a cada pessoa um número de cotas, como as noites em que ficou; o total é dividido nessa proporção.', 'costs.splitSharesOf': 'Cotas de {name}', 'costs.splitSharesNone': 'Dê uma cota a pelo menos uma pessoa', + 'costs.mode.label': 'Dividir os custos entre os viajantes', + 'costs.mode.hintSplit': 'Quem pagou e quem divide cada despesa, e o acerto no final.', + 'costs.mode.hintTotals': 'Só os totais: sem pagadores, partes ou acerto. O que foi dividido continua salvo.', 'costs.splitHint.ticket': 'Liste o que estava no comprovante e marque quem consumiu cada item.', 'costs.filter.allCategories': 'Todas as categorias', 'costs.filter.allDays': 'Todos os dias', diff --git a/shared/src/i18n/ca/budget.ts b/shared/src/i18n/ca/budget.ts index f191dc21a..3d807cca6 100644 --- a/shared/src/i18n/ca/budget.ts +++ b/shared/src/i18n/ca/budget.ts @@ -139,6 +139,9 @@ const budget: TranslationStrings = { 'Dona a cada persona un nombre de parts, com ara les nits que s’hi ha allotjat; el total es reparteix en aquesta proporció.', 'costs.splitSharesOf': 'Parts de {name}', 'costs.splitSharesNone': 'Dona una part a almenys una persona', + 'costs.mode.label': 'Repartir les despeses entre els viatgers', + 'costs.mode.hintSplit': 'Qui ha pagat i qui comparteix cada despesa, i el saldo final.', + 'costs.mode.hintTotals': 'Només els totals: sense pagadors, parts ni saldo. El que s’havia repartit es manté desat.', 'costs.splitHint.ticket': 'Llista el que hi havia al rebut i marca qui ha pres cada article.', 'costs.pickSomeone': 'Tria almenys una persona amb qui dividir-ho.', 'costs.splitSummary': 'Dividit entre {count} persones · {amount} cadascú', diff --git a/shared/src/i18n/cs/budget.ts b/shared/src/i18n/cs/budget.ts index e5aeb8b09..b9af75465 100644 --- a/shared/src/i18n/cs/budget.ts +++ b/shared/src/i18n/cs/budget.ts @@ -181,6 +181,9 @@ const budget: TranslationStrings = { 'Dejte každé osobě počet podílů, třeba podle počtu nocí; celková částka se rozdělí v tomto poměru.', 'costs.splitSharesOf': 'Podíly pro {name}', 'costs.splitSharesNone': 'Dejte podíl alespoň jedné osobě', + 'costs.mode.label': 'Dělit náklady mezi cestující', + 'costs.mode.hintSplit': 'Kdo platil a kdo se na každém výdaji podílí, a vyrovnání na konci.', + 'costs.mode.hintTotals': 'Jen součty: bez plátců, podílů a vyrovnání. Co už bylo rozděleno, zůstává uložené.', 'costs.splitHint.ticket': 'Vypište, co bylo na účtence, a zaškrtněte, kdo měl kterou položku.', 'costs.filter.allCategories': 'Všechny kategorie', 'costs.filter.allDays': 'Všechny dny', diff --git a/shared/src/i18n/de/budget.ts b/shared/src/i18n/de/budget.ts index b485a16eb..50dc5c1f7 100644 --- a/shared/src/i18n/de/budget.ts +++ b/shared/src/i18n/de/budget.ts @@ -178,6 +178,10 @@ const budget: TranslationStrings = { 'Gib jeder Person eine Zahl von Anteilen, etwa die Nächte, die sie geblieben ist; der Betrag wird in diesem Verhältnis aufgeteilt.', 'costs.splitSharesOf': 'Anteile von {name}', 'costs.splitSharesNone': 'Gib mindestens einer Person einen Anteil', + 'costs.mode.label': 'Kosten unter den Reisenden aufteilen', + 'costs.mode.hintSplit': 'Wer bezahlt hat und wer sich jede Ausgabe teilt, und am Ende das Ausgleichen.', + 'costs.mode.hintTotals': + 'Nur Summen: keine Zahler, Anteile oder Ausgleich. Was schon aufgeteilt war, bleibt gespeichert.', 'costs.splitHint.ticket': 'Liste auf, was auf dem Beleg steht, und hak ab, wer was hatte.', 'costs.ticketItemName': 'Artikelname', 'costs.ticketSplitting': 'Aufteilen auf:', diff --git a/shared/src/i18n/en/budget.ts b/shared/src/i18n/en/budget.ts index f627c5b9c..d11c17091 100644 --- a/shared/src/i18n/en/budget.ts +++ b/shared/src/i18n/en/budget.ts @@ -148,6 +148,9 @@ const budget: TranslationStrings = { 'Give each person a number of shares, such as the nights they stayed; the total is split in that ratio.', 'costs.splitSharesOf': 'Shares for {name}', 'costs.splitSharesNone': 'Give at least one person a share', + 'costs.mode.label': 'Split costs between travelers', + 'costs.mode.hintSplit': 'Who paid and who shares each expense, and settling up at the end.', + 'costs.mode.hintTotals': 'Totals only: no payers, shares or settling up. What was split stays saved.', 'costs.splitHint.ticket': 'List what was on the receipt and tick who had each item.', 'costs.pickSomeone': 'Pick at least one person to split with.', 'costs.splitSummary': 'Split {count} ways · {amount} each', diff --git a/shared/src/i18n/es/budget.ts b/shared/src/i18n/es/budget.ts index 265493535..8e602d7aa 100644 --- a/shared/src/i18n/es/budget.ts +++ b/shared/src/i18n/es/budget.ts @@ -174,6 +174,10 @@ const budget: TranslationStrings = { 'Da a cada persona un número de partes, como las noches que se alojó; el total se reparte en esa proporción.', 'costs.splitSharesOf': 'Partes de {name}', 'costs.splitSharesNone': 'Da una parte al menos a una persona', + 'costs.mode.label': 'Repartir los gastos entre los viajeros', + 'costs.mode.hintSplit': 'Quién pagó y quién comparte cada gasto, y el ajuste de cuentas al final.', + 'costs.mode.hintTotals': + 'Solo totales: sin pagadores, partes ni ajuste de cuentas. Lo que ya estaba repartido se conserva.', 'costs.splitHint.ticket': 'Enumera lo que había en el recibo y marca quién tomó cada artículo.', 'costs.filter.allCategories': 'Todas las categorías', 'costs.filter.allDays': 'Todos los días', diff --git a/shared/src/i18n/et/budget.ts b/shared/src/i18n/et/budget.ts index b69c6a3e5..2753a6420 100644 --- a/shared/src/i18n/et/budget.ts +++ b/shared/src/i18n/et/budget.ts @@ -148,6 +148,9 @@ const budget: TranslationStrings = { 'Anna igale inimesele osade arv, näiteks ööbitud ööde arv; summa jagatakse selles vahekorras.', 'costs.splitSharesOf': '{name} osad', 'costs.splitSharesNone': 'Anna vähemalt ühele inimesele osa', + 'costs.mode.label': 'Jaga kulud reisijate vahel', + 'costs.mode.hintSplit': 'Kes maksis ja kes iga kulu jagab, ning lõpus arveldamine.', + 'costs.mode.hintTotals': 'Ainult summad: maksjaid, osasid ega arveldamist pole. Varem jagatu jääb salvestatuks.', 'costs.splitHint.ticket': 'Loetle, mis tšekil oli, ja märgi iga kirje juurde, kellele see kuulus.', 'costs.pickSomeone': 'Vali jagamiseks vähemalt üks inimene.', 'costs.splitSummary': 'Jaotatud {count} osaks · igaühele {amount}', diff --git a/shared/src/i18n/fr/budget.ts b/shared/src/i18n/fr/budget.ts index bcefbd517..3cdff30e1 100644 --- a/shared/src/i18n/fr/budget.ts +++ b/shared/src/i18n/fr/budget.ts @@ -175,6 +175,10 @@ const budget: TranslationStrings = { 'Donnez à chaque personne un nombre de parts, par exemple ses nuits sur place ; le total est réparti dans cette proportion.', 'costs.splitSharesOf': 'Parts de {name}', 'costs.splitSharesNone': 'Donnez une part à au moins une personne', + 'costs.mode.label': 'Partager les dépenses entre les voyageurs', + 'costs.mode.hintSplit': 'Qui a payé et qui partage chaque dépense, puis le règlement à la fin.', + 'costs.mode.hintTotals': + 'Totaux seulement : ni payeurs, ni parts, ni règlement. Ce qui était partagé reste enregistré.', 'costs.splitHint.ticket': 'Listez ce qui figurait sur le reçu et cochez qui a pris chaque article.', 'costs.filter.allCategories': 'Toutes les catégories', 'costs.filter.allDays': 'Tous les jours', diff --git a/shared/src/i18n/gr/budget.ts b/shared/src/i18n/gr/budget.ts index 14e4c6fa4..c078471f6 100644 --- a/shared/src/i18n/gr/budget.ts +++ b/shared/src/i18n/gr/budget.ts @@ -176,6 +176,9 @@ const budget: TranslationStrings = { 'Δώστε σε κάθε άτομο έναν αριθμό μεριδίων, όπως τις νύχτες που έμεινε· το σύνολο μοιράζεται με αυτή την αναλογία.', 'costs.splitSharesOf': 'Μερίδια για {name}', 'costs.splitSharesNone': 'Δώστε μερίδιο σε τουλάχιστον ένα άτομο', + 'costs.mode.label': 'Μοιρασιά των εξόδων μεταξύ των ταξιδιωτών', + 'costs.mode.hintSplit': 'Ποιος πλήρωσε και ποιοι μοιράζονται κάθε έξοδο, και η εκκαθάριση στο τέλος.', + 'costs.mode.hintTotals': 'Μόνο σύνολα: χωρίς πληρωτές, μερίδια ή εκκαθάριση. Ό,τι είχε μοιραστεί μένει αποθηκευμένο.', 'costs.splitHint.ticket': 'Καταγράψτε τι είχε η απόδειξη και σημειώστε ποιος πήρε κάθε είδος.', 'costs.filter.allCategories': 'Όλες οι κατηγορίες', 'costs.filter.allDays': 'Όλες οι ημέρες', diff --git a/shared/src/i18n/he/budget.ts b/shared/src/i18n/he/budget.ts index d66e2b1cf..384fb88a5 100644 --- a/shared/src/i18n/he/budget.ts +++ b/shared/src/i18n/he/budget.ts @@ -152,6 +152,9 @@ const budget: TranslationStrings = { 'costs.splitHint.shares': 'תנו לכל אדם מספר חלקים, למשל מספר הלילות שנשאר; הסכום הכולל מתחלק ביחס הזה.', 'costs.splitSharesOf': 'החלקים של {name}', 'costs.splitSharesNone': 'תנו חלק לאדם אחד לפחות', + 'costs.mode.label': 'חלוקת ההוצאות בין המטיילים', + 'costs.mode.hintSplit': 'מי שילם ומי מתחלק בכל הוצאה, והתחשבנות בסוף.', + 'costs.mode.hintTotals': 'סכומים בלבד: בלי משלמים, חלקים או התחשבנות. מה שחולק כבר נשאר שמור.', 'costs.splitHint.ticket': 'רשמו מה היה בקבלה וסמנו מי לקח כל פריט.', 'costs.pickSomeone': 'בחרו לפחות אדם אחד לחלוקה.', 'costs.splitSummary': 'חלוקה ל-{count} · {amount} לכל אחד', diff --git a/shared/src/i18n/hu/budget.ts b/shared/src/i18n/hu/budget.ts index b5ce34af3..095fee483 100644 --- a/shared/src/i18n/hu/budget.ts +++ b/shared/src/i18n/hu/budget.ts @@ -176,6 +176,10 @@ const budget: TranslationStrings = { 'Adj mindenkinek egy részszámot, például az eltöltött éjszakák számát; a végösszeg ebben az arányban oszlik meg.', 'costs.splitSharesOf': '{name} részei', 'costs.splitSharesNone': 'Adj részt legalább egy személynek', + 'costs.mode.label': 'Költségek elosztása az utazók között', + 'costs.mode.hintSplit': 'Ki fizetett és kik osztoznak az egyes kiadásokon, a végén pedig az elszámolás.', + 'costs.mode.hintTotals': + 'Csak összesítés: nincs fizető, részesedés vagy elszámolás. Ami már fel volt osztva, megmarad.', 'costs.splitHint.ticket': 'Sorold fel, mi szerepelt a nyugtán, és minden tételnél jelöld be, kié volt.', 'costs.filter.allCategories': 'Összes kategória', 'costs.filter.allDays': 'Összes nap', diff --git a/shared/src/i18n/id/budget.ts b/shared/src/i18n/id/budget.ts index f21c5744e..ba400f1f6 100644 --- a/shared/src/i18n/id/budget.ts +++ b/shared/src/i18n/id/budget.ts @@ -167,6 +167,10 @@ const budget: TranslationStrings = { 'Beri setiap orang sejumlah bagian, misalnya jumlah malam menginap; total dibagi menurut perbandingan itu.', 'costs.splitSharesOf': 'Bagian untuk {name}', 'costs.splitSharesNone': 'Beri bagian kepada setidaknya satu orang', + 'costs.mode.label': 'Bagi biaya di antara para pelancong', + 'costs.mode.hintSplit': + 'Siapa yang membayar dan siapa yang ikut menanggung tiap pengeluaran, lalu pelunasan di akhir.', + 'costs.mode.hintTotals': 'Hanya total: tanpa pembayar, bagian, atau pelunasan. Yang sudah dibagi tetap tersimpan.', 'costs.splitHint.ticket': 'Tuliskan isi kuitansi dan centang siapa yang mengambil tiap item.', 'costs.filter.allCategories': 'Semua kategori', 'costs.filter.allDays': 'Semua hari', diff --git a/shared/src/i18n/it/budget.ts b/shared/src/i18n/it/budget.ts index 601f8f3bc..19eb0ef08 100644 --- a/shared/src/i18n/it/budget.ts +++ b/shared/src/i18n/it/budget.ts @@ -174,6 +174,9 @@ const budget: TranslationStrings = { 'Assegna a ogni persona un numero di quote, ad esempio le notti in cui è rimasta; il totale viene diviso in questa proporzione.', 'costs.splitSharesOf': 'Quote di {name}', 'costs.splitSharesNone': 'Assegna una quota ad almeno una persona', + 'costs.mode.label': 'Dividere le spese tra i viaggiatori', + 'costs.mode.hintSplit': 'Chi ha pagato e chi condivide ogni spesa, e il saldo alla fine.', + 'costs.mode.hintTotals': 'Solo totali: niente pagatori, quote o saldi. Ciò che era già diviso resta salvato.', 'costs.splitHint.ticket': "Elenca cosa c'era sulla ricevuta e spunta chi ha preso ciascun articolo.", 'costs.filter.allCategories': 'Tutte le categorie', 'costs.filter.allDays': 'Tutti i giorni', diff --git a/shared/src/i18n/ja/budget.ts b/shared/src/i18n/ja/budget.ts index 2991492d6..12379b82b 100644 --- a/shared/src/i18n/ja/budget.ts +++ b/shared/src/i18n/ja/budget.ts @@ -166,6 +166,9 @@ const budget: TranslationStrings = { 'costs.splitHint.shares': '泊数など、各メンバーに口数を指定してください。合計金額はその比率で分けられます。', 'costs.splitSharesOf': '{name}の口数', 'costs.splitSharesNone': '少なくとも1人に口数を指定してください', + 'costs.mode.label': '旅行者の間で費用を分ける', + 'costs.mode.hintSplit': '誰が支払い、誰が各支出を分担するか、そして最後に精算します。', + 'costs.mode.hintTotals': '合計のみ:支払者・分担・精算はありません。分担済みの内容は保存されたままです。', 'costs.splitHint.ticket': 'レシートの品目を書き出し、それぞれ誰の分かにチェックを入れます。', 'costs.filter.allCategories': 'すべてのカテゴリー', 'costs.filter.allDays': 'すべての日', diff --git a/shared/src/i18n/ko/budget.ts b/shared/src/i18n/ko/budget.ts index 4df7929af..fb5afbf98 100644 --- a/shared/src/i18n/ko/budget.ts +++ b/shared/src/i18n/ko/budget.ts @@ -166,6 +166,9 @@ const budget: TranslationStrings = { 'costs.splitHint.shares': '머문 박 수처럼 각자에게 지분 수를 정하세요. 총액이 그 비율로 나뉩니다.', 'costs.splitSharesOf': '{name}의 지분', 'costs.splitSharesNone': '최소 한 명에게 지분을 정하세요', + 'costs.mode.label': '여행자끼리 비용 나누기', + 'costs.mode.hintSplit': '누가 냈고 누가 각 지출을 나누는지, 그리고 마지막 정산까지.', + 'costs.mode.hintTotals': '합계만: 결제자, 분담, 정산이 없습니다. 이미 나눈 내용은 그대로 저장됩니다.', 'costs.splitHint.ticket': '영수증에 있던 항목을 적고, 항목마다 누구 몫인지 체크하세요.', 'costs.filter.allCategories': '모든 카테고리', 'costs.filter.allDays': '모든 날짜', diff --git a/shared/src/i18n/nl/budget.ts b/shared/src/i18n/nl/budget.ts index 56e025c57..27fc481a5 100644 --- a/shared/src/i18n/nl/budget.ts +++ b/shared/src/i18n/nl/budget.ts @@ -175,6 +175,10 @@ const budget: TranslationStrings = { 'Geef iedereen een aantal delen, zoals het aantal overnachtingen; het totaal wordt in die verhouding verdeeld.', 'costs.splitSharesOf': 'Delen voor {name}', 'costs.splitSharesNone': 'Geef ten minste één persoon een deel', + 'costs.mode.label': 'Kosten verdelen tussen de reizigers', + 'costs.mode.hintSplit': 'Wie betaalde en wie elke uitgave deelt, en aan het eind de verrekening.', + 'costs.mode.hintTotals': + 'Alleen totalen: geen betalers, aandelen of verrekening. Wat al verdeeld was, blijft bewaard.', 'costs.splitHint.ticket': 'Zet op een rij wat er op de bon stond en vink aan wie welk item had.', 'costs.filter.allCategories': 'Alle categorieën', 'costs.filter.allDays': 'Alle dagen', diff --git a/shared/src/i18n/pl/budget.ts b/shared/src/i18n/pl/budget.ts index 2951fe741..fd4202293 100644 --- a/shared/src/i18n/pl/budget.ts +++ b/shared/src/i18n/pl/budget.ts @@ -188,6 +188,10 @@ const budget: TranslationStrings = { 'Przypisz każdej osobie liczbę udziałów, np. liczbę nocy; kwota zostanie podzielona w tej proporcji.', 'costs.splitSharesOf': 'Udziały: {name}', 'costs.splitSharesNone': 'Przypisz udział co najmniej jednej osobie', + 'costs.mode.label': 'Dziel koszty między podróżnych', + 'costs.mode.hintSplit': 'Kto zapłacił i kto dzieli każdy wydatek, a na koniec rozliczenie.', + 'costs.mode.hintTotals': + 'Tylko sumy: bez płacących, udziałów i rozliczeń. To, co już podzielono, pozostaje zapisane.', 'costs.splitHint.ticket': 'Wypisz, co było na paragonie, i zaznacz, kto miał którą pozycję.', 'costs.filter.allCategories': 'Wszystkie kategorie', 'costs.filter.allDays': 'Wszystkie dni', diff --git a/shared/src/i18n/ru/budget.ts b/shared/src/i18n/ru/budget.ts index eb741f011..b0e32cacd 100644 --- a/shared/src/i18n/ru/budget.ts +++ b/shared/src/i18n/ru/budget.ts @@ -187,6 +187,10 @@ const budget: TranslationStrings = { 'costs.splitHint.shares': 'Укажите каждому число долей, например число ночей; сумма делится в этой пропорции.', 'costs.splitSharesOf': 'Доли: {name}', 'costs.splitSharesNone': 'Укажите долю хотя бы одному человеку', + 'costs.mode.label': 'Делить расходы между путешественниками', + 'costs.mode.hintSplit': 'Кто заплатил и кто делит каждый расход, а в конце взаиморасчёт.', + 'costs.mode.hintTotals': + 'Только итоги: без плательщиков, долей и взаиморасчётов. Уже разделённое остаётся сохранённым.', 'costs.splitHint.ticket': 'Перечислите, что было в чеке, и отметьте, кто что брал.', 'costs.filter.allCategories': 'Все категории', 'costs.filter.allDays': 'Все дни', diff --git a/shared/src/i18n/sk/budget.ts b/shared/src/i18n/sk/budget.ts index 6c44099ec..db4e36ff7 100644 --- a/shared/src/i18n/sk/budget.ts +++ b/shared/src/i18n/sk/budget.ts @@ -153,6 +153,9 @@ const budget: TranslationStrings = { 'Dajte každej osobe počet podielov, napríklad podľa počtu nocí; celková suma sa rozdelí v tomto pomere.', 'costs.splitSharesOf': 'Podiely pre {name}', 'costs.splitSharesNone': 'Dajte podiel aspoň jednej osobe', + 'costs.mode.label': 'Deliť náklady medzi cestujúcich', + 'costs.mode.hintSplit': 'Kto platil a kto sa na každom výdavku podieľa, a na konci vyrovnanie.', + 'costs.mode.hintTotals': 'Len súčty: bez platiteľov, podielov a vyrovnania. Čo už bolo rozdelené, zostáva uložené.', 'costs.splitHint.ticket': 'Vypíšte, čo bolo na účtenke, a označte, kto mal ktorú položku.', 'costs.pickSomeone': 'Vyberte aspoň jednu osobu na rozdelenie.', 'costs.splitSummary': 'Rozdelené na {count} dielov · {amount} na osobu', diff --git a/shared/src/i18n/sv/budget.ts b/shared/src/i18n/sv/budget.ts index 59a8cf7c3..e6a7b6473 100644 --- a/shared/src/i18n/sv/budget.ts +++ b/shared/src/i18n/sv/budget.ts @@ -174,6 +174,9 @@ const budget: TranslationStrings = { 'Ge varje person ett antal andelar, till exempel antal nätter; summan delas i den proportionen.', 'costs.splitSharesOf': 'Andelar för {name}', 'costs.splitSharesNone': 'Ge minst en person en andel', + 'costs.mode.label': 'Dela kostnaderna mellan resenärerna', + 'costs.mode.hintSplit': 'Vem som betalade och vilka som delar varje utgift, och avräkning på slutet.', + 'costs.mode.hintTotals': 'Bara summor: inga betalare, andelar eller avräkning. Det som redan delats sparas.', 'costs.splitHint.ticket': 'Lista det som stod på kvittot och bocka för vem som hade varje artikel.', 'costs.filter.allCategories': 'Alla kategorier', 'costs.filter.allDays': 'Alla dagar', diff --git a/shared/src/i18n/th/budget.ts b/shared/src/i18n/th/budget.ts index 55cf08b57..c0c626502 100644 --- a/shared/src/i18n/th/budget.ts +++ b/shared/src/i18n/th/budget.ts @@ -138,6 +138,9 @@ const budget: TranslationStrings = { 'costs.splitHint.shares': 'กำหนดจำนวนส่วนแบ่งให้แต่ละคน เช่น จำนวนคืนที่พัก แล้วยอดรวมจะถูกแบ่งตามสัดส่วนนั้น', 'costs.splitSharesOf': 'ส่วนแบ่งของ {name}', 'costs.splitSharesNone': 'กำหนดส่วนแบ่งให้อย่างน้อยหนึ่งคน', + 'costs.mode.label': 'แบ่งค่าใช้จ่ายระหว่างผู้ร่วมเดินทาง', + 'costs.mode.hintSplit': 'ใครจ่ายและใครร่วมรับผิดชอบค่าใช้จ่ายแต่ละรายการ แล้วเคลียร์ยอดตอนท้าย', + 'costs.mode.hintTotals': 'เฉพาะยอดรวม: ไม่มีผู้จ่าย ส่วนแบ่ง หรือการเคลียร์ยอด สิ่งที่แบ่งไว้แล้วยังคงบันทึกอยู่', 'costs.splitHint.ticket': 'ใส่รายการที่อยู่ในใบเสร็จ แล้วติ๊กว่าใครสั่งรายการไหน', 'costs.pickSomeone': 'เลือกอย่างน้อยหนึ่งคนที่จะแยกด้วย', 'costs.splitSummary': '{count} คน · คนละ {amount}', diff --git a/shared/src/i18n/tr/budget.ts b/shared/src/i18n/tr/budget.ts index 8447b5fbc..3c28cf7e6 100644 --- a/shared/src/i18n/tr/budget.ts +++ b/shared/src/i18n/tr/budget.ts @@ -174,6 +174,9 @@ const budget: TranslationStrings = { 'costs.splitHint.shares': 'Herkese bir pay sayısı verin, örneğin kaldığı gece sayısı; toplam bu oranda bölünür.', 'costs.splitSharesOf': '{name} payları', 'costs.splitSharesNone': 'En az bir kişiye pay verin', + 'costs.mode.label': 'Masrafları yolcular arasında böl', + 'costs.mode.hintSplit': 'Her harcamayı kimin ödediği ve kimlerin paylaştığı, sonunda da hesaplaşma.', + 'costs.mode.hintTotals': 'Yalnızca toplamlar: ödeyen, pay veya hesaplaşma yok. Önceden bölünenler kayıtlı kalır.', 'costs.splitHint.ticket': 'Fişte neler olduğunu listeleyin ve her kalemi kimin aldığını işaretleyin.', 'costs.filter.allCategories': 'Tüm kategoriler', 'costs.filter.allDays': 'Tüm günler', diff --git a/shared/src/i18n/uk/budget.ts b/shared/src/i18n/uk/budget.ts index 62ade0c09..d3e6f6088 100644 --- a/shared/src/i18n/uk/budget.ts +++ b/shared/src/i18n/uk/budget.ts @@ -188,6 +188,9 @@ const budget: TranslationStrings = { 'Вкажіть кожному кількість часток, наприклад кількість ночей; сума ділиться в цій пропорції.', 'costs.splitSharesOf': 'Частки: {name}', 'costs.splitSharesNone': 'Вкажіть частку хоча б одній людині', + 'costs.mode.label': 'Ділити витрати між мандрівниками', + 'costs.mode.hintSplit': 'Хто заплатив і хто ділить кожну витрату, а наприкінці розрахунок.', + 'costs.mode.hintTotals': 'Лише підсумки: без платників, часток і розрахунків. Уже поділене залишається збереженим.', 'costs.splitHint.ticket': 'Перелічіть, що було в чеку, і позначте, хто брав кожну позицію.', 'costs.filter.allCategories': 'Усі категорії', 'costs.filter.allDays': 'Усі дні', diff --git a/shared/src/i18n/vi/budget.ts b/shared/src/i18n/vi/budget.ts index 83f470949..b479c9559 100644 --- a/shared/src/i18n/vi/budget.ts +++ b/shared/src/i18n/vi/budget.ts @@ -166,6 +166,10 @@ const budget: TranslationStrings = { 'costs.splitHint.shares': 'Đặt cho mỗi người một số phần, ví dụ số đêm đã ở; tổng tiền được chia theo tỷ lệ đó.', 'costs.splitSharesOf': 'Số phần của {name}', 'costs.splitSharesNone': 'Hãy đặt phần cho ít nhất một người', + 'costs.mode.label': 'Chia chi phí giữa các thành viên', + 'costs.mode.hintSplit': 'Ai đã trả và ai cùng chia từng khoản chi, rồi thanh toán bù trừ ở cuối.', + 'costs.mode.hintTotals': + 'Chỉ tổng cộng: không có người trả, phần chia hay thanh toán bù trừ. Những gì đã chia vẫn được lưu.', 'costs.splitHint.ticket': 'Liệt kê các món trên hóa đơn và đánh dấu ai đã dùng từng món.', 'costs.filter.allCategories': 'Tất cả danh mục', 'costs.filter.allDays': 'Tất cả các ngày', diff --git a/shared/src/i18n/zh-TW/budget.ts b/shared/src/i18n/zh-TW/budget.ts index 8eb587eb0..31c6f4782 100644 --- a/shared/src/i18n/zh-TW/budget.ts +++ b/shared/src/i18n/zh-TW/budget.ts @@ -165,6 +165,9 @@ const budget: TranslationStrings = { 'costs.splitHint.shares': '為每個人設定份數,例如住了幾晚;總額按此比例分攤。', 'costs.splitSharesOf': '{name} 的份數', 'costs.splitSharesNone': '請至少為一人設定份數', + 'costs.mode.label': '在旅伴之間分攤費用', + 'costs.mode.hintSplit': '記錄誰付款、誰分攤每筆支出,最後結算。', + 'costs.mode.hintTotals': '僅統計總額:沒有付款人、分攤或結算。已分攤的內容會保留。', 'costs.splitHint.ticket': '列出收據上的項目,並勾選每一項是誰的。', 'costs.filter.allCategories': '所有類別', 'costs.filter.allDays': '所有日期', diff --git a/shared/src/i18n/zh/budget.ts b/shared/src/i18n/zh/budget.ts index a8da96b9f..390038345 100644 --- a/shared/src/i18n/zh/budget.ts +++ b/shared/src/i18n/zh/budget.ts @@ -165,6 +165,9 @@ const budget: TranslationStrings = { 'costs.splitHint.shares': '为每个人设定份数,例如住了几晚;总额按此比例分摊。', 'costs.splitSharesOf': '{name} 的份数', 'costs.splitSharesNone': '请至少为一人设定份数', + 'costs.mode.label': '在旅伴之间分摊费用', + 'costs.mode.hintSplit': '记录谁付款、谁分摊每笔支出,最后结算。', + 'costs.mode.hintTotals': '仅统计总额:没有付款人、分摊或结算。已分摊的内容会保留。', 'costs.splitHint.ticket': '列出收据上的项目,并勾选每一项是谁的。', 'costs.filter.allCategories': '所有类别', 'costs.filter.allDays': '所有日期', diff --git a/shared/src/trip/trip.schema.spec.ts b/shared/src/trip/trip.schema.spec.ts index e95c9187e..5252de60e 100644 --- a/shared/src/trip/trip.schema.spec.ts +++ b/shared/src/trip/trip.schema.spec.ts @@ -5,6 +5,7 @@ import { tripUpdateRequestSchema, tripAddMemberRequestSchema, activeTripResponseSchema, + tripSchema, } from './trip.schema'; import { describe, it, expect } from 'vitest'; @@ -55,6 +56,23 @@ describe('tripUpdateRequestSchema', () => { ); expect(tripUpdateRequestSchema.safeParse({ cover_image: 'x'.repeat(2049) }).success).toBe(false); }); + + it('takes the costs mode, split or totals and nothing else (#1714)', () => { + expect(tripUpdateRequestSchema.safeParse({ costs_mode: 'totals' }).success).toBe(true); + expect(tripUpdateRequestSchema.safeParse({ costs_mode: 'split' }).success).toBe(true); + expect(tripUpdateRequestSchema.safeParse({ costs_mode: 'none' }).success).toBe(false); + expect(tripUpdateRequestSchema.safeParse({ costs_mode: null }).success).toBe(false); + }); +}); + +describe('tripSchema', () => { + const trip = { id: 1, user_id: 1, title: 'Japan', currency: 'EUR', is_archived: 0, reminder_days: 3 }; + + it('reads the costs mode, and a trip cached without one still parses (#1714)', () => { + expect(tripSchema.parse({ ...trip, costs_mode: 'totals' }).costs_mode).toBe('totals'); + expect(tripSchema.safeParse(trip).success).toBe(true); + expect(tripSchema.safeParse({ ...trip, costs_mode: 'shares' }).success).toBe(false); + }); }); describe('tripAddMemberRequestSchema', () => { diff --git a/shared/src/trip/trip.schema.ts b/shared/src/trip/trip.schema.ts index bf5bc5f22..3007fda3a 100644 --- a/shared/src/trip/trip.schema.ts +++ b/shared/src/trip/trip.schema.ts @@ -32,6 +32,16 @@ export function tripSpanDays(startDate: string, endDate: string): number { return Math.floor((utcDay(endDate) - utcDay(startDate)) / 86400000) + 1; } +/** + * How a trip keeps its costs (#1714): `split` tracks who paid and who shares + * each expense and settles up between the travellers, `totals` keeps only what + * the trip costs. Who paid and the shares stay stored either way; `totals` only + * leaves them out of Costs. Trips start out as `split`. + */ +export const TRIP_COSTS_MODES = ['split', 'totals'] as const; +export const tripCostsModeSchema = z.enum(TRIP_COSTS_MODES); +export type TripCostsMode = z.infer; + /** * Trip entity as returned by the trip list / get / create / update endpoints * (server/src/services/tripService.ts -> TRIP_SELECT). Columns of the `trips` @@ -49,6 +59,8 @@ export const tripSchema = z.object({ cover_image: z.string().nullable().optional(), is_archived: z.number(), reminder_days: z.number(), + // Optional: a trip cached before the column existed has none, and reads as `split`. + costs_mode: tripCostsModeSchema.optional(), created_at: z.string().optional(), updated_at: z.string().optional(), // computed in TRIP_SELECT (list/get) @@ -121,6 +133,8 @@ export const tripUpdateRequestSchema = z.object({ // Bounded like a place's image url: a replaced cover is queued for release, // and every upload path the old value spells out is one queued object. cover_image: z.string().max(2048).nullable().optional(), + /** Costs as totals only or split between the travellers; needs `budget_edit`, not `trip_edit`. */ + costs_mode: tripCostsModeSchema.optional(), }); export type TripUpdateRequest = z.infer; diff --git a/wiki/Budget-Tracking.md b/wiki/Budget-Tracking.md index 26858c901..200e79f15 100644 --- a/wiki/Budget-Tracking.md +++ b/wiki/Budget-Tracking.md @@ -161,6 +161,14 @@ The **Add payment** dialog has **From** and **To** (the head band reads *You → Balances are always netted in the **trip currency** and converted to your display currency once, at the end, so they stay stable even when the trip mixes currencies. An expense or payment in a foreign currency for which no exchange rate is known yet is left out of the balances and totals rather than counted 1:1; see [Currencies](Currencies#expense-currency). +## Totals only + +Not every trip splits its costs: a couple who share everything, or someone who only wants to know what the trip costs, has no use for who paid and who owes whom. **Split costs between travelers**, the switch at the foot of the right-hand column (on the phone, under the summary), turns that side off for the whole trip. It is shown to members who may edit costs (`budget_edit`) on a trip with more than one member. + +With the switch off, Costs keeps the totals: **Total trip spend**, the expenses with their categories, days and receipts, **By category**, the table and the CSV export. What is owed, the settle-up suggestions, the balances, the final budget, the payers, the split chips and the **Unfinished** chip are left out, as on a trip of one (see below). A new expense is saved without a payer and without a split, so it does not turn into a debt if the trip splits its costs again later. An expense that already has a payer or a split keeps them, and one whose split holds more than the obvious (several payers, an uneven split, a ticket) still shows them in its dialog. Turning the switch back on brings everything back, with the payments and splits that were recorded before. + +The setting belongs to the trip, so every member sees the same Costs. Through MCP it is `update_trip` with `costs_mode` (`split` or `totals`). + ## A trip of one With only you on the trip there is nothing to split: every share is the whole amount, and nobody can owe anybody. Costs then leaves that side out. The summary is the **Total trip spend** card on its own, with the number of expenses under it, and the right-hand column keeps **By category**. **You owe**, **You're owed**, **Outstanding amount**, **Settle up**, **Balances** and **Final budget** are gone, and so are the travelers in the bar, the **Settle up** button and the **All / Paid by me / I'm owed** switch. Rows show no payers, no split and no **Unfinished** chip, and the expense dialog asks for neither **Who paid?** nor **Split**; a new expense is saved as paid by you. The phone does the same. diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 398165e46..b3783d7bb 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -34,7 +34,7 @@ Requires `trips:read` or `trips:write` scope. |---|---| | `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. | | `create_trip` | Create a trip with title, dates, and currency. Days are auto-generated from the date range. Without a currency the trip takes your display currency (Settings → General, or the admin's User Defaults preset), and EUR when that is left on *Trip currency*; see [Currencies](Currencies). | -| `update_trip` | Update a trip's title, description, dates, or currency. Day plans follow their position, so dates that hold fewer days remove the last days with what is on them, also when only the start moved later. Every day a change removes is listed in `removed_days`, with its id, number and date as they stood before and a reason: `overflow` for a day past the new range, `spare` for an empty day without a date. | +| `update_trip` | Update a trip's title, description, dates, or currency, and with `costs_mode` whether Costs splits the expenses between the travelers (`split`) or keeps the totals only (`totals`); the costs mode needs `budget_edit` instead of `trip_edit`. Day plans follow their position, so dates that hold fewer days remove the last days with what is on them, also when only the start moved later. Every day a change removes is listed in `removed_days`, with its id, number and date as they stood before and a reason: `overflow` for a day past the new range, `spare` for an empty day without a date. | | `search_cover_images` | Search Unsplash for candidate cover photos and return their URLs, thumbnails and photographer credits. Nothing is saved; pass the chosen photo's URL to `update_trip` as `cover_image`. | | `delete_trip` | Delete a trip. Owner only. Requires `trips:delete`. | | `list_trip_members` | List the owner and all collaborators of a trip. |