From 51e5dbef033931529b1ef85b7f49a71af4d6f826 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 15:50:49 +0200 Subject: [PATCH] feat(assistant): expense, payment and settle-up cards, and expense links into Costs --- client/scripts/feature-pairs.json | 4 +- client/scripts/size-baseline.json | 4 +- .../cards/budget/CostPerPersonCard.tsx | 71 ++ .../Assistant/cards/budget/ExpenseChange.tsx | 120 ++++ .../cards/budget/ExpensesCard.test.tsx | 207 ++++++ .../Assistant/cards/budget/ExpensesCard.tsx | 134 ++++ .../cards/budget/PaymentsCard.test.tsx | 91 +++ .../Assistant/cards/budget/PaymentsCard.tsx | 134 ++++ .../cards/budget/SettlementCard.test.tsx | 131 ++++ .../Assistant/cards/budget/SettlementCard.tsx | 138 ++++ .../Assistant/cards/budget/budgetHooks.ts | 19 + .../Assistant/cards/budget/budgetLook.ts | 69 +- .../cards/budget/budgetModel.test.ts | 137 ++++ .../Assistant/cards/budget/budgetModel.ts | 136 ++++ .../Assistant/cards/budget/budgetParts.tsx | 224 ++++++ .../Assistant/cards/budget/budgetViews.tsx | 39 +- client/src/components/Budget/CostsPanel.tsx | 14 +- .../Budget/useExpenseLinks.test.tsx | 119 ++++ .../src/components/Budget/useExpenseLinks.ts | 62 ++ .../mobile/screens/trip/tabs/MCostsTab.tsx | 2 + client/tests/helpers/assistantBudget.ts | 70 ++ .../tests/unit/mobile/trip/MCostsTab.test.tsx | 18 +- .../assistant/cards/budget/budget-answers.ts | 158 +++++ .../assistant/cards/budget/budget-changes.ts | 279 ++++++++ .../assistant/cards/budget/budget-describe.ts | 120 ++++ .../assistant/cards/budget/budget-receipts.ts | 83 +++ .../assistant/cards/budget/budget-values.ts | 237 +++++++ .../src/nest/assistant/cards/budget/index.ts | 105 ++- server/src/nest/budget/budget-items.mcp.ts | 49 +- server/src/nest/budget/budget.mcp.ts | 6 +- .../cards/budget/budget-cards.test.ts | 658 ++++++++++++++++++ .../tests/unit/nest/budget-items.mcp.test.ts | 41 ++ .../src/assistant/cards/budget-cards.spec.ts | 154 ++++ shared/src/assistant/cards/budget.contract.ts | 6 +- .../assistant/cards/cost-per-person.card.ts | 29 +- shared/src/assistant/cards/expenses.card.ts | 100 ++- shared/src/assistant/cards/payments.card.ts | 50 +- shared/src/assistant/cards/settlement.card.ts | 35 +- shared/src/i18n/ar/assistantBudget.ts | 40 +- shared/src/i18n/az/assistantBudget.ts | 28 +- shared/src/i18n/br/assistantBudget.ts | 32 +- shared/src/i18n/ca/assistantBudget.ts | 32 +- shared/src/i18n/cs/assistantBudget.ts | 32 +- shared/src/i18n/de/assistantBudget.ts | 28 +- shared/src/i18n/en/assistantBudget.ts | 28 +- shared/src/i18n/es/assistantBudget.ts | 30 +- shared/src/i18n/et/assistantBudget.ts | 28 +- shared/src/i18n/fr/assistantBudget.ts | 32 +- shared/src/i18n/gr/assistantBudget.ts | 31 +- shared/src/i18n/hu/assistantBudget.ts | 28 +- shared/src/i18n/id/assistantBudget.ts | 25 +- shared/src/i18n/it/assistantBudget.ts | 31 +- shared/src/i18n/ja/assistantBudget.ts | 26 +- shared/src/i18n/ko/assistantBudget.ts | 25 +- shared/src/i18n/nl/assistantBudget.ts | 30 +- shared/src/i18n/pl/assistantBudget.ts | 39 +- shared/src/i18n/ru/assistantBudget.ts | 34 +- shared/src/i18n/sk/assistantBudget.ts | 35 +- shared/src/i18n/sv/assistantBudget.ts | 28 +- shared/src/i18n/th/assistantBudget.ts | 26 +- shared/src/i18n/tr/assistantBudget.ts | 28 +- shared/src/i18n/uk/assistantBudget.ts | 34 +- shared/src/i18n/vi/assistantBudget.ts | 25 +- shared/src/i18n/zh-TW/assistantBudget.ts | 25 +- shared/src/i18n/zh/assistantBudget.ts | 25 +- 65 files changed, 4763 insertions(+), 95 deletions(-) create mode 100644 client/src/components/Assistant/cards/budget/CostPerPersonCard.tsx create mode 100644 client/src/components/Assistant/cards/budget/ExpenseChange.tsx create mode 100644 client/src/components/Assistant/cards/budget/ExpensesCard.test.tsx create mode 100644 client/src/components/Assistant/cards/budget/ExpensesCard.tsx create mode 100644 client/src/components/Assistant/cards/budget/PaymentsCard.test.tsx create mode 100644 client/src/components/Assistant/cards/budget/PaymentsCard.tsx create mode 100644 client/src/components/Assistant/cards/budget/SettlementCard.test.tsx create mode 100644 client/src/components/Assistant/cards/budget/SettlementCard.tsx create mode 100644 client/src/components/Assistant/cards/budget/budgetHooks.ts create mode 100644 client/src/components/Assistant/cards/budget/budgetModel.test.ts create mode 100644 client/src/components/Assistant/cards/budget/budgetModel.ts create mode 100644 client/src/components/Assistant/cards/budget/budgetParts.tsx create mode 100644 client/src/components/Budget/useExpenseLinks.test.tsx create mode 100644 client/src/components/Budget/useExpenseLinks.ts create mode 100644 client/tests/helpers/assistantBudget.ts create mode 100644 server/src/nest/assistant/cards/budget/budget-answers.ts create mode 100644 server/src/nest/assistant/cards/budget/budget-changes.ts create mode 100644 server/src/nest/assistant/cards/budget/budget-describe.ts create mode 100644 server/src/nest/assistant/cards/budget/budget-receipts.ts create mode 100644 server/src/nest/assistant/cards/budget/budget-values.ts create mode 100644 server/tests/unit/nest/assistant/cards/budget/budget-cards.test.ts create mode 100644 shared/src/assistant/cards/budget-cards.spec.ts diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 49f099c96..f9b10f56b 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -265,8 +265,8 @@ "mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"] }, { - "feature": "Costs/budget ledger: useCostsLedger, useSettlementForm", - "hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts"], + "feature": "Costs/budget ledger: useCostsLedger, useSettlementForm, useExpenseLinks", + "hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Budget/useExpenseLinks.ts"], "desktop": ["src/components/Budget/CostsPanel.tsx"], "mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"] }, diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index cb9778aeb..bdfea4247 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -4,10 +4,10 @@ "src/components/Admin/AdminPluginsPanel.test.tsx": 2704, "src/components/Admin/AdminPluginsPanel.tsx": 1667, "src/components/Budget/CostsPanel.test.tsx": 2764, - "src/components/Budget/CostsPanel.tsx": 1816, + "src/components/Budget/CostsPanel.tsx": 1810, "src/components/Map/MapView.tsx": 1399, "src/components/Map/MapViewGL.test.tsx": 2920, - "src/components/Map/MapViewGL.tsx": 2291, + "src/components/Map/MapViewGL.tsx": 2290, "src/components/Map/RouteCalculator.ts": 1195, "src/components/PDF/TripPDF.tsx": 1082, "src/components/Planner/DayDetailPanel.test.tsx": 2333, diff --git a/client/src/components/Assistant/cards/budget/CostPerPersonCard.tsx b/client/src/components/Assistant/cards/budget/CostPerPersonCard.tsx new file mode 100644 index 000000000..f841a2a4f --- /dev/null +++ b/client/src/components/Assistant/cards/budget/CostPerPersonCard.tsx @@ -0,0 +1,71 @@ +import { Users } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { SoftPill } from '../../../Planner/planParts'; +import type { CardEntry } from '../cardTypes'; +import CardFrame from '../parts/CardFrame'; +import CardHeader from '../parts/CardHeader'; +import { CardOpenButton } from '../parts/CardOpen'; +import { PersonFace } from '../parts/CardPeople'; +import { CardMore, CardRow, CardRows } from '../parts/CardRows'; +import { useCardFormat } from '../parts/useCardFormat'; +import { useBudgetPeople } from './budgetHooks'; +import { costsTarget, latestOf } from './budgetModel'; + +/** + * What the trip's expenses come to for each traveller, largest share first: + * the share at the end, a bar against the largest one under the name, how + * many expenses it is made of and how much of it is marked paid. + */ +export default function CostPerPersonCard({ cards }: { cards: readonly CardEntry[] }) { + const { t } = useTranslation(); + const format = useCardFormat(); + const { nameOf } = useBudgetPeople(); + const card = latestOf(cards, 'costPerPerson'); + if (!card) return null; + const title = t('budget.perPerson'); + const largest = Math.max(...card.rows.map((row) => Math.abs(row.share))); + const money = (amount: number) => format.money({ amount, currency: card.currency }); + return ( + + } + /> + + {card.rows.map((row) => ( + } + title={{nameOf(row.person)}} + aside={{money(row.share)}} + facts={ + + + 0 ? (Math.abs(row.share) / largest) * 100 : 0}%` }} + /> + + + + {t('costs.expensesCount', { count: row.count })} + + {row.paid > 0 && ( + + {t('assistant.card.budget.markedPaid', { amount: money(row.paid) })} + + )} + + + } + /> + ))} + + + + ); +} diff --git a/client/src/components/Assistant/cards/budget/ExpenseChange.tsx b/client/src/components/Assistant/cards/budget/ExpenseChange.tsx new file mode 100644 index 000000000..4fbef1858 --- /dev/null +++ b/client/src/components/Assistant/cards/budget/ExpenseChange.tsx @@ -0,0 +1,120 @@ +import type { CardExpense, ExpensesCard } from '@trek/shared'; +import { CalendarDays, MapPin, Receipt, Tag } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { catMeta } from '../../../Budget/costsCategories'; +import type { ChangeItem } from '../../assistantParts'; +import { useAssistantView } from '../../assistantViewContext'; +import { Fact, FactChange } from '../parts/CardFacts'; +import { useCardFormat } from '../parts/useCardFormat'; +import { SPLIT_LABEL, togetherOf } from './budgetModel'; +import { Amount, NotePill, SplitLine } from './budgetParts'; + +/** + * An expense inside a change row, waiting or decided: its amount at the end + * of the subject line (the one an update replaces struck through before it), + * its category, day and place on the faint line with what an update changes, + * and under it the whole split, the part the user decides on. A run of new + * expenses in one currency says what they add up to. + */ + +interface SlotProps { + card: ExpensesCard; + item: ChangeItem; +} + +/** Whether the row is struck: an expense that goes, or a change that will not happen. */ +function struckOut(card: ExpensesCard, item: ChangeItem): boolean { + return card.rows[0]?.change === 'willDelete' || item.state === 'discarded' || item.state === 'expired'; +} + +export function ExpenseAside({ card, item }: SlotProps) { + const row = card.rows[0]; + if (!row) return null; + return ; +} + +/** The category, or the one it was struck through before the new one. */ +function CategoryFact({ row }: { row: CardExpense }) { + const { t } = useTranslation(); + const nameOf = (category: string | null) => (category === null ? null : t(catMeta(category).labelKey)); + const now = nameOf(row.category); + const was = row.before?.category ?? null; + if (was !== null) return ; + return now ? : null; +} + +/** The day, or the one it was struck through before the new one. */ +function DateFact({ row }: { row: CardExpense }) { + const format = useCardFormat(); + const was = row.before?.date ?? null; + const now = row.date === null ? '' : format.date(row.date); + if (was !== null) return ; + return now ? : null; +} + +/** The place the expense belongs to; shown on the map where the chat can show it. */ +function PlaceFact({ place }: { place: NonNullable }) { + const { onOpen } = useAssistantView(); + const fact = ; + if (!onOpen) return fact; + return ( + + ); +} + +export function ExpenseFacts({ card }: SlotProps) { + const { t } = useTranslation(); + const row = card.rows[0]; + if (!row) return null; + const split = row.before?.split ?? null; + return ( + <> + + + {row.place && } + {split && ( + + )} + {row.receipts > 0 && } + {row.note && } + + ); +} + +export function ExpenseBody({ card }: SlotProps) { + const row = card.rows[0]; + return row ? : null; +} + +/** Under a run of waiting expenses: what the included ones add up to, when they are two or more in one currency. */ +export function ExpensesTogether({ + items, + excluded, + place, +}: { + items: readonly ChangeItem[]; + excluded: ReadonlySet; + place: 'head' | 'foot'; +}) { + const { t } = useTranslation(); + const format = useCardFormat(); + const together = place === 'foot' ? togetherOf(items, excluded) : null; + if (!together) return null; + return ( +

+ {t('assistant.card.budget.together', { amount: format.money(together) })} +

+ ); +} diff --git a/client/src/components/Assistant/cards/budget/ExpensesCard.test.tsx b/client/src/components/Assistant/cards/budget/ExpensesCard.test.tsx new file mode 100644 index 000000000..ed59c79f0 --- /dev/null +++ b/client/src/components/Assistant/cards/budget/ExpensesCard.test.tsx @@ -0,0 +1,207 @@ +/** + * FE-AI-CARD-BUD-007 to FE-AI-CARD-BUD-014 + * + * An expense in the chat, as the Costs page draws it: the list in an answer + * (category tile, amount and what a foreign one comes to, day, payer and + * split, receipts, the foot with what is left out and the total, the way + * into the Costs tab), and an expense inside its change row (the amount and + * the one it replaces, the facts an update changes, the whole split with + * paid and highlighted parts, what a run of new expenses adds up to). + */ +import type { AssistantMessage, TrekCard } from '@trek/shared'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ANA, BEN, CLEO, changeItem, expense, expenses } from '../../../../../tests/helpers/assistantBudget'; +import { buildUser } from '../../../../../tests/helpers/factories'; +import { fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { useAuthStore } from '../../../../store/authStore'; +import { formatMoney } from '../../../../utils/formatters'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import { ExpenseAside, ExpenseBody, ExpenseFacts, ExpensesTogether } from './ExpenseChange'; +import ExpensesCard from './ExpensesCard'; + +const AUTH = useAuthStore.getState(); +const MESSAGE: AssistantMessage = { + id: 'm1', + role: 'assistant', + status: 'complete', + createdAt: '2026-10-22T08:00:00.000Z', + parts: [], +}; +const money = (amount: number, currency = 'EUR') => formatMoney(amount, currency, 'en'); + +function inView(ui: ReactNode, view: Partial = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', ...view }; + return render({ui}); +} + +const answer = (card: TrekCard) => ; + +beforeEach(() => { + useAuthStore.setState({ user: buildUser({ id: ANA.id }) }); +}); +afterEach(() => { + useAuthStore.setState(AUTH, true); + vi.restoreAllMocks(); +}); + +describe('the expense list', () => { + it('FE-AI-CARD-BUD-007: lists each expense with its category, amount, day, receipts and the trip total', () => { + const museum = expense({ + id: 32, + name: 'Museum', + category: 'activities', + amount: { amount: 50, currency: 'USD' }, + tripAmount: { amount: 40, currency: 'EUR' }, + receipts: 2, + }); + const card = expenses([expense(), museum], { more: 1, total: { amount: 1204.5, currency: 'EUR' }, unconverted: 1 }); + inView(answer(card), { onOpen: vi.fn() }); + expect(screen.getByRole('region', { name: 'Expenses' })).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Food & drink' })).toBeInTheDocument(); + expect(screen.getByText(money(84))).toBeInTheDocument(); + expect(screen.getByText(money(50, 'USD'))).toBeInTheDocument(); + expect(screen.getByText(`≈ ${money(40)}`)).toBeInTheDocument(); + expect(screen.getByText('2 receipts')).toBeInTheDocument(); + expect(screen.getByText(money(1204.5))).toBeInTheDocument(); + expect(screen.getByRole('img', { name: /1 foreign expense is left out/ })).toBeInTheDocument(); + }); + + it('FE-AI-CARD-BUD-008: an expense opens itself in the Costs tab, the head and the count the tab', () => { + const onOpen = vi.fn(); + inView(answer(expenses([expense()], { more: 4 })), { onOpen }); + fireEvent.click(screen.getByRole('button', { name: /Dinner at Lokanta/ })); + expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan', params: { expense: '31' } }); + fireEvent.click(screen.getByRole('button', { name: 'Open in Costs' })); + expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan' }); + fireEvent.click(screen.getByRole('button', { name: '4 more' })); + expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan' }); + }); + + it('FE-AI-CARD-BUD-009: the viewer reads as "You", a planned expense says so, and an unpaid one warns', () => { + const planned = expense({ id: 40, name: 'Hotel', split: 'none', shares: [], payers: [] }); + const unpaid = expense({ id: 41, name: 'Taxi', payers: [] }); + const shared = expense({ + id: 42, + name: 'Boat', + payers: [ + { person: BEN, amount: 40 }, + { person: CLEO, amount: 44 }, + ], + }); + inView(answer(expenses([expense(), planned, unpaid, shared]))); + expect(screen.getAllByRole('img', { name: 'You' }).length).toBeGreaterThan(0); + // Who paid is said in words, not only in a tooltip. + expect(screen.getByText('You paid')).toBeInTheDocument(); + expect(screen.getByText('Multiple people paid')).toBeInTheDocument(); + expect(screen.getByText('Planning only, not split')).toBeInTheDocument(); + expect(screen.getByText('No one paid yet')).toBeInTheDocument(); + // Nothing can open it here, so no row is a button. + expect(screen.queryByRole('button', { name: /Hotel/ })).toBeNull(); + }); + + it('FE-AI-CARD-BUD-010: a waiting new expense is no button, and an answer without expenses draws nothing', () => { + inView(answer(expenses([expense({ id: null, name: 'Gelato' })])), { onOpen: vi.fn() }); + expect(screen.getByText('Gelato')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Gelato/ })).toBeNull(); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('an expense in a change row', () => { + it('FE-AI-CARD-BUD-011: the amount it replaces is struck through, and an expense that goes is struck itself', () => { + const update = expense({ + amount: { amount: 96, currency: 'EUR' }, + before: { name: null, amount: { amount: 84, currency: 'EUR' }, category: null, date: null, split: null }, + }); + const first = inView(); + expect(screen.getByText(money(84))).toHaveClass('line-through'); + expect(screen.getByText(money(96))).not.toHaveClass('line-through'); + first.unmount(); + inView(); + expect(screen.getByText(money(84))).toHaveClass('line-through'); + }); + + it('FE-AI-CARD-BUD-012: the facts name the category, day and place, and what an update changes', () => { + const onOpen = vi.fn(); + const update = expense({ + category: 'transport', + date: '2026-06-05', + place: { id: 12, name: 'Lokanta' }, + receipts: 1, + note: 'Window table', + split: 'equal', + before: { name: null, amount: null, category: 'food', date: '2026-06-03', split: 'custom' }, + }); + inView(, { onOpen }); + expect(screen.getByText('Food & drink')).toHaveClass('line-through'); + expect(screen.getByText('Transport')).toBeInTheDocument(); + expect(screen.getByText('Split unevenly')).toHaveClass('line-through'); + expect(screen.getByText('Split evenly')).toBeInTheDocument(); + expect(screen.getByText('1 receipt')).toBeInTheDocument(); + expect(screen.getByText('Window table')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Lokanta/ })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null }); + }); + + it('FE-AI-CARD-BUD-013: the split shows who paid and each part, paid ones green, the ones it is about in the accent', () => { + const split = expense({ + payers: [{ person: BEN, amount: 84 }], + shares: [ + { person: ANA, amount: 28, paid: false }, + { person: BEN, amount: 28, paid: true }, + { person: CLEO, amount: 28, paid: false }, + ], + highlight: [CLEO.id], + }); + const first = inView(); + expect(screen.getByText('Ben paid')).toBeInTheDocument(); + expect(screen.getByLabelText(`You: ${money(28)}`)).toBeInTheDocument(); + expect(screen.getByLabelText(`Ben: ${money(28)}, Paid`).firstElementChild).toHaveClass('text-success'); + // The one the change is about stands out, and says whether the tick is set or taken off. + const cleo = screen.getByRole('img', { name: `Cleo: ${money(28)}, Not paid` }); + expect(cleo.firstElementChild).toHaveClass('bg-accent'); + expect(cleo.querySelector('.lucide-check')).toBeNull(); + first.unmount(); + + const ticked = expense({ shares: split.shares.map((share) => ({ ...share, paid: true })), highlight: [CLEO.id] }); + const tick = inView(); + const paid = screen.getByRole('img', { name: `Cleo: ${money(28)}, Paid` }); + expect(paid.firstElementChild).toHaveClass('bg-accent'); + expect(paid.querySelector('.lucide-check')).not.toBeNull(); + tick.unmount(); + + // An even split of many reads as a stack and the part per person. + const crowd = Array.from({ length: 6 }, (_, i) => ({ person: { ...CLEO, id: 100 + i }, amount: 14, paid: false })); + const second = inView(); + expect(screen.getByText('You paid')).toBeInTheDocument(); + expect(screen.getByText(`${money(14)} per person`)).toBeInTheDocument(); + second.unmount(); + + // Unless the change is about one of them: then each part is a badge, so that person shows. + const about = expense({ shares: crowd, highlight: [crowd[2]?.person.id ?? 0] }); + const third = inView(); + expect(screen.queryByText(`${money(14)} per person`)).toBeNull(); + const standsOut = screen.getByRole('img', { name: `Cleo: ${money(14)}, Not paid` }); + expect(standsOut.firstElementChild).toHaveClass('bg-accent'); + third.unmount(); + + inView(); + expect(screen.getByText('Planning only, not split')).toBeInTheDocument(); + }); + + it('FE-AI-CARD-BUD-014: under a run of new expenses, what the included ones add up to', () => { + const items = [ + changeItem('a', expenses([expense({ id: null, change: 'new' })])), + changeItem('b', expenses([expense({ id: null, change: 'new', amount: { amount: 12.1, currency: 'EUR' } })])), + ]; + const foot = inView(); + expect(screen.getByText(`Together ${money(96.1)}`)).toBeInTheDocument(); + foot.unmount(); + const { container } = inView(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/client/src/components/Assistant/cards/budget/ExpensesCard.tsx b/client/src/components/Assistant/cards/budget/ExpensesCard.tsx new file mode 100644 index 000000000..88e5299eb --- /dev/null +++ b/client/src/components/Assistant/cards/budget/ExpensesCard.tsx @@ -0,0 +1,134 @@ +import type { CardExpense, ExpensesCard as ExpensesCardData } from '@trek/shared'; +import { CalendarDays, Receipt, TriangleAlert, Wallet } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { SoftPill, WhiteBadge } from '../../../Planner/planParts'; +import { Tooltip } from '../../../shared/Tooltip'; +import { useAssistantView } from '../../assistantViewContext'; +import type { CardEntry } from '../cardTypes'; +import { Fact } from '../parts/CardFacts'; +import CardFrame from '../parts/CardFrame'; +import CardHeader from '../parts/CardHeader'; +import { CardOpenButton } from '../parts/CardOpen'; +import { PeopleStack, PersonFace } from '../parts/CardPeople'; +import { CardMore, CardRow, CardRows } from '../parts/CardRows'; +import { useCardFormat } from '../parts/useCardFormat'; +import { useBudgetPeople } from './budgetHooks'; +import { costsTarget, mergeExpenses, stackLimit } from './budgetModel'; +import { CategoryTile } from './budgetParts'; + +/** + * The expense list in an answer, as the Costs page lists it: per expense its + * category tile, the name, the amount (and what a foreign one comes to in the + * trip currency), the day, who paid and who shares it, its receipts. An + * expense opens itself in the Costs tab; the ones the card leaves out are + * counted there too, beside the trip's total. + */ + +/** Who paid, in words as the Costs page says it: "You paid", "Ana paid", or that several did. */ +function PayerBadge({ payers }: { payers: CardExpense['payers'] }) { + const { t } = useTranslation(); + const { paidBy } = useBudgetPeople(); + const [payer] = payers; + if (!payer) return null; + if (payers.length > 1) return {t('costs.multiplePayers')}; + return }>{paidBy(payer.person)}; +} + +/** The line under an expense's name: its day, who paid, who shares it, its receipts. */ +function ExpenseFacts({ row }: { row: CardExpense }) { + const { t } = useTranslation(); + const { density } = useAssistantView(); + const format = useCardFormat(); + const payer = row.payers[0]; + return ( + + {row.date && } + + {row.split === 'none' ? ( + {t('assistant.card.budget.split.none')} + ) : ( + <> + {!payer && {t('costs.noOnePaid')}} + share.person)} max={stackLimit(density)} /> + + )} + {row.receipts > 0 && } + + ); +} + +/** One expense of the list: a button into the Costs tab where something can open it. */ +function ExpenseRow({ row }: { row: CardExpense }) { + const { onOpen } = useAssistantView(); + const format = useCardFormat(); + const amount = format.money(row.amount); + const id = row.id; + return ( + } + title={ + + {row.name} + + } + aside={ + + {amount} + {row.tripAmount && ≈ {format.money(row.tripAmount)}} + + } + facts={} + onActivate={id !== null && onOpen ? () => onOpen(costsTarget(id)) : undefined} + /> + ); +} + +/** The foot of the list: the expenses left out, and the trip's total (marked when it leaves some out). */ +function ExpensesFoot({ card }: { card: ExpensesCardData }) { + const { t } = useTranslation(); + const format = useCardFormat(); + if (card.more === 0 && !card.total) return null; + const leftOut = t('assistant.card.budget.leftOut', { count: card.unconverted }); + return ( +
+ + {card.total && ( + + {card.unconverted > 0 && ( + + + + + )} + {t('budget.total')} + {format.money(card.total)} + + )} +
+ ); +} + +/** The expense cards of one answer, drawn as one list. */ +export default function ExpensesCard({ cards }: { cards: readonly CardEntry[] }) { + const { t } = useTranslation(); + const card = mergeExpenses(cards); + if (!card) return null; + const title = t('costs.expenses'); + return ( + + } + /> + + {card.rows.map((row, i) => )} + + + + ); +} diff --git a/client/src/components/Assistant/cards/budget/PaymentsCard.test.tsx b/client/src/components/Assistant/cards/budget/PaymentsCard.test.tsx new file mode 100644 index 000000000..46c7385a0 --- /dev/null +++ b/client/src/components/Assistant/cards/budget/PaymentsCard.test.tsx @@ -0,0 +1,91 @@ +/** + * FE-AI-CARD-BUD-015 to FE-AI-CARD-BUD-017 + * + * Settle-up payments in the chat: the history in an answer (who paid whom, + * face to face, how much, the day and the note), and a payment inside its + * change row (the amount it replaces struck through, the people it no longer + * moves money between, the two faces under the row). + */ +import type { AssistantMessage } from '@trek/shared'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ANA, BEN, CLEO, changeItem, payment, payments } from '../../../../../tests/helpers/assistantBudget'; +import { buildUser } from '../../../../../tests/helpers/factories'; +import { fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { useAuthStore } from '../../../../store/authStore'; +import { formatMoney } from '../../../../utils/formatters'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import PaymentsCard, { PaymentAside, PaymentBody, PaymentChangeFacts } from './PaymentsCard'; + +const AUTH = useAuthStore.getState(); +const MESSAGE: AssistantMessage = { + id: 'm1', + role: 'assistant', + status: 'complete', + createdAt: '2026-10-22T08:00:00.000Z', + parts: [], +}; +const money = (amount: number, currency = 'EUR') => formatMoney(amount, currency, 'en'); + +function inView(ui: ReactNode, view: Partial = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', ...view }; + return render({ui}); +} + +beforeEach(() => { + useAuthStore.setState({ user: buildUser({ id: ANA.id }) }); +}); +afterEach(() => { + useAuthStore.setState(AUTH, true); + vi.restoreAllMocks(); +}); + +describe('payments', () => { + it('FE-AI-CARD-BUD-015: the history names who paid whom, how much, when and why, and opens the Costs tab', () => { + const onOpen = vi.fn(); + const card = payments([payment({ note: 'Bank transfer' }), payment({ id: 6, from: CLEO, to: BEN, date: null })], 2); + inView(, { onOpen }); + expect(screen.getByRole('region', { name: 'Settle history' })).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); + expect(screen.getAllByText(money(42.5))).toHaveLength(2); + expect(screen.getByText('Bank transfer')).toBeInTheDocument(); + // The viewer reads as "You", in words and on the face. + expect(screen.getAllByText('You').length).toBeGreaterThan(0); + expect(screen.getByText('Cleo')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '2 more' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'finanzplan' }); + }); + + it('FE-AI-CARD-BUD-016: a changed payment strikes through the amount and the people it replaces', () => { + const changed = payment({ + from: CLEO, + amount: { amount: 50, currency: 'EUR' }, + before: { from: BEN, to: null, amount: { amount: 42.5, currency: 'EUR' } }, + change: 'changed', + }); + const card = payments([changed]); + const aside = inView(); + expect(screen.getByText(money(42.5))).toHaveClass('line-through'); + expect(screen.getByText(money(50))).not.toHaveClass('line-through'); + aside.unmount(); + const facts = inView(); + // The names stay apart, so the arrow between them turns with a right-to-left language. + expect(screen.getByText('Ben').closest('.line-through')).not.toBeNull(); + expect(screen.getByText('Cleo').closest('.line-through')).toBeNull(); + expect(screen.getAllByText('You')).toHaveLength(2); + facts.unmount(); + inView(); + expect(screen.getByRole('img', { name: 'Cleo' })).toBeInTheDocument(); + }); + + it('FE-AI-CARD-BUD-017: a payment that goes, or a change that will not happen, is struck through', () => { + const gone = payments([payment({ change: 'willDelete' })]); + const first = inView(); + expect(screen.getByText(money(42.5))).toHaveClass('line-through'); + first.unmount(); + const kept = payments([payment()]); + inView(); + expect(screen.getByText(money(42.5))).toHaveClass('line-through'); + }); +}); diff --git a/client/src/components/Assistant/cards/budget/PaymentsCard.tsx b/client/src/components/Assistant/cards/budget/PaymentsCard.tsx new file mode 100644 index 000000000..a448d0537 --- /dev/null +++ b/client/src/components/Assistant/cards/budget/PaymentsCard.tsx @@ -0,0 +1,134 @@ +import type { CardPayment, CardPerson, PaymentsCard as PaymentsCardData } from '@trek/shared'; +import { ArrowLeftRight, ArrowRight, CalendarDays } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { SoftPill } from '../../../Planner/planParts'; +import type { ChangeItem } from '../../assistantParts'; +import type { CardEntry } from '../cardTypes'; +import { Fact } from '../parts/CardFacts'; +import CardFrame from '../parts/CardFrame'; +import CardHeader from '../parts/CardHeader'; +import { CardOpenButton } from '../parts/CardOpen'; +import { CardMore, CardRow, CardRows } from '../parts/CardRows'; +import { useCardFormat } from '../parts/useCardFormat'; +import { costsTarget, mergePayments } from './budgetModel'; +import { Amount, NotePill, PaymentFaces, PaymentNames } from './budgetParts'; + +/** + * Settle-up payments as the Costs page's history lists them: who paid whom, + * face to face, how much, on which day and with what note. The same pieces + * fill a change row of a payment recorded, changed or removed, where a change + * strikes through the people and the amount it replaces. + */ + +/** The day of a payment and its note. */ +function PaymentFacts({ row }: { row: CardPayment }) { + const format = useCardFormat(); + return ( + <> + {row.date && } + {row.note && } + + ); +} + +/** One payment of the history. */ +function PaymentRow({ row }: { row: CardPayment }) { + const format = useCardFormat(); + return ( + } + title={} + aside={{format.money(row.amount)}} + facts={ + + + + } + /> + ); +} + +/** The payment cards of one answer, as one history. */ +export default function PaymentsCard({ cards }: { cards: readonly CardEntry[] }) { + const { t } = useTranslation(); + const card = mergePayments(cards); + if (!card) return null; + const title = t('costs.settleHistory'); + return ( + + } + /> + + {card.rows.map((row, i) => )} + + + + ); +} + +interface SlotProps { + card: PaymentsCardData; + item: ChangeItem; +} + +/** The amount at the end of a payment's change row, the one it replaces struck through before it. */ +export function PaymentAside({ card, item }: SlotProps) { + const row = card.rows[0]; + if (!row) return null; + const struck = row.change === 'willDelete' || item.state === 'discarded' || item.state === 'expired'; + return ; +} + +interface PaymentPeople { + from: CardPerson; + to: CardPerson; +} + +/** The people a change no longer moves money between, struck through, before the ones it does. */ +function PeopleChange({ was, now }: { was: PaymentPeople; now: PaymentPeople }) { + return ( + + ); +} + +/** The day and note of a payment's change, and the people it no longer moves money between. */ +export function PaymentChangeFacts({ card }: SlotProps) { + const row = card.rows[0]; + if (!row) return null; + const before = row.before; + const was = before && (before.from || before.to) ? { from: before.from ?? row.from, to: before.to ?? row.to } : null; + return ( + <> + {was && } + + + ); +} + +/** Under a payment's change row: the two people, face to face. */ +export function PaymentBody({ card }: SlotProps) { + const row = card.rows[0]; + if (!row) return null; + return ( + + + + + ); +} diff --git a/client/src/components/Assistant/cards/budget/SettlementCard.test.tsx b/client/src/components/Assistant/cards/budget/SettlementCard.test.tsx new file mode 100644 index 000000000..91aa7131f --- /dev/null +++ b/client/src/components/Assistant/cards/budget/SettlementCard.test.tsx @@ -0,0 +1,131 @@ +/** + * FE-AI-CARD-BUD-018 to FE-AI-CARD-BUD-021 + * + * Settle up and the share per traveller in the chat: who pays whom or the + * square trip, the balances on their bars (green owed, red owing, from the + * middle), the entries no rate converts, the payments already recorded, and + * each traveller's share with how much of it is marked paid. + */ +import type { + AssistantMessage, + CostPerPersonCard as CostPerPersonCardData, + SettlementCard as SettlementData, +} from '@trek/shared'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ANA, BEN, CLEO } from '../../../../../tests/helpers/assistantBudget'; +import { buildUser } from '../../../../../tests/helpers/factories'; +import { fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { useAuthStore } from '../../../../store/authStore'; +import { formatMoney } from '../../../../utils/formatters'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import CostPerPersonCard from './CostPerPersonCard'; +import SettlementCard from './SettlementCard'; + +const AUTH = useAuthStore.getState(); +const MESSAGE: AssistantMessage = { + id: 'm1', + role: 'assistant', + status: 'complete', + createdAt: '2026-10-22T08:00:00.000Z', + parts: [], +}; +const money = (amount: number) => formatMoney(amount, 'EUR', 'en'); + +function inView(ui: ReactNode, view: Partial = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', ...view }; + return render({ui}); +} + +function settle(overrides: Partial = {}): SettlementData { + return { + kind: 'settlement', + currency: 'EUR', + flows: [ + { from: BEN, to: ANA, amount: 42.5 }, + { from: CLEO, to: ANA, amount: 18 }, + ], + moreFlows: 0, + balances: [ + { person: ANA, balance: 60.5 }, + { person: CLEO, balance: -18 }, + { person: BEN, balance: -42.5 }, + ], + moreBalances: 0, + recorded: 3, + unconverted: { entries: 2, currencies: ['USD'] }, + ...overrides, + }; +} + +beforeEach(() => { + useAuthStore.setState({ user: buildUser({ id: ANA.id }) }); +}); +afterEach(() => { + useAuthStore.setState(AUTH, true); + vi.restoreAllMocks(); +}); + +describe('settle up', () => { + it('FE-AI-CARD-BUD-018: names who pays whom, the balances on their bars and the payments already recorded', () => { + const onOpen = vi.fn(); + inView(, { onOpen }); + expect(screen.getByRole('region', { name: 'Settle up' })).toBeInTheDocument(); + expect(screen.getByText(money(42.5))).toBeInTheDocument(); + expect(screen.getByText(money(18))).toBeInTheDocument(); + expect(screen.getByText('Balances')).toBeInTheDocument(); + expect(screen.getByText(`+${money(60.5)}`)).toHaveClass('text-success'); + expect(screen.getByText(money(-42.5))).toHaveClass('text-danger'); + expect(screen.getByText('3 payments settled')).toBeInTheDocument(); + expect(screen.getByText('2 entries in USD are not counted: no exchange rate yet.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open in Costs' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'finanzplan' }); + }); + + it('FE-AI-CARD-BUD-019: a square trip says so and counts nothing it does not have', () => { + const square = settle({ + flows: [], + balances: [{ person: ANA, balance: 0 }], + recorded: 0, + unconverted: { entries: 0, currencies: [] }, + }); + inView(); + expect(screen.getByText("Everyone's square")).toBeInTheDocument(); + expect(screen.getByText('No payments outstanding right now.')).toBeInTheDocument(); + expect(screen.getByText(money(0))).toHaveClass('text-content-faint'); + expect(screen.queryByText(/payments settled/)).toBeNull(); + expect(screen.queryByText(/not counted/)).toBeNull(); + }); + + it('FE-AI-CARD-BUD-020: a long settle up counts the payments and the balances it leaves out', () => { + const card = settle({ moreFlows: 3, moreBalances: 7 }); + inView(); + expect(screen.getByText('3 more')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('7 more')).toBeInTheDocument(); + }); +}); + +describe('the share per traveller', () => { + it('FE-AI-CARD-BUD-021: lists each share, largest first, with its expenses and what is marked paid', () => { + const card: CostPerPersonCardData = { + kind: 'costPerPerson', + currency: 'EUR', + rows: [ + { person: ANA, share: 69, paid: 45, count: 2 }, + { person: BEN, share: 61, paid: 0, count: 1 }, + ], + more: 1, + }; + inView(, { onOpen: vi.fn() }); + expect(screen.getByRole('region', { name: 'Per Person' })).toBeInTheDocument(); + expect(screen.getByText(money(69))).toBeInTheDocument(); + expect(screen.getByText('2 expenses')).toBeInTheDocument(); + expect(screen.getByText('1 expense')).toBeInTheDocument(); + expect(screen.getByText(`${money(45)} marked paid`)).toBeInTheDocument(); + expect(screen.queryByText(`${money(0)} marked paid`)).toBeNull(); + expect(screen.getByText('You')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '1 more' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Assistant/cards/budget/SettlementCard.tsx b/client/src/components/Assistant/cards/budget/SettlementCard.tsx new file mode 100644 index 000000000..4de88e05c --- /dev/null +++ b/client/src/components/Assistant/cards/budget/SettlementCard.tsx @@ -0,0 +1,138 @@ +import type { SettlementCard as SettlementCardData } from '@trek/shared'; +import { Check, Scale, TriangleAlert } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { Eyebrow } from '../../../Planner/bookings/bookingParts'; +import { TourNotice } from '../../../Tours/tourParts'; +import type { CardEntry } from '../cardTypes'; +import CardFrame from '../parts/CardFrame'; +import CardHeader from '../parts/CardHeader'; +import { CardOpenButton } from '../parts/CardOpen'; +import { PersonFace } from '../parts/CardPeople'; +import { useCardFormat } from '../parts/useCardFormat'; +import { useBudgetPeople } from './budgetHooks'; +import { balanceShare, costsTarget, latestOf } from './budgetModel'; +import { PaymentFaces, PaymentNames } from './budgetParts'; + +/** + * Settle up as the Costs page shows it: the payments that square the trip, + * face to face, or a green "everyone's square"; each traveller's balance on + * a bar from the middle (green to one side, red to the other, mirrored in a + * right-to-left language); the entries no rate converts yet, and how many + * payments were already recorded. + */ + +/** Who pays whom, or the square trip. */ +function Flows({ card }: { card: SettlementCardData }) { + const { t } = useTranslation(); + const format = useCardFormat(); + if (card.flows.length === 0) { + return ( +
+ + + + {t('costs.everyoneSquare')} + {t('costs.nothingOutstanding')} + +
+ ); + } + return ( +
    + {card.flows.map((flow) => ( +
  • + + + + + + {format.money({ amount: flow.amount, currency: card.currency })} + +
  • + ))} + {card.moreFlows > 0 && ( +
  • + {t('assistant.card.common.more', { count: card.moreFlows })} +
  • + )} +
+ ); +} + +/** Each traveller's balance: positive is owed money, negative owes it. */ +function Balances({ card }: { card: SettlementCardData }) { + const { t } = useTranslation(); + const format = useCardFormat(); + const { nameOf } = useBudgetPeople(); + if (card.balances.length === 0) return null; + const largest = Math.max(...card.balances.map((row) => Math.abs(row.balance))); + return ( +
+ {t('costs.balances')} +
    + {card.balances.map((row) => { + const owed = row.balance >= 0.005; + const owes = row.balance <= -0.005; + const reach = { width: `${balanceShare(row.balance, largest)}%` }; + const money = format.money({ amount: row.balance, currency: card.currency }); + const tone = owed ? 'text-success' : owes ? 'text-danger' : 'text-content-faint'; + return ( +
  • + + + {nameOf(row.person)} + + + {owed && } + {owes && } + + + {owed ? `+${money}` : money} +
  • + ); + })} + {card.moreBalances > 0 && ( +
  • + {t('assistant.card.common.more', { count: card.moreBalances })} +
  • + )} +
+
+ ); +} + +/** The latest settle up of an answer. */ +export default function SettlementCard({ cards }: { cards: readonly CardEntry[] }) { + const { t } = useTranslation(); + const card = latestOf(cards, 'settlement'); + if (!card) return null; + const title = t('costs.settleUp'); + const { entries, currencies } = card.unconverted; + return ( + + } + /> + + + {entries > 0 && ( +
+ +
+ )} + {card.recorded > 0 && ( +

+ {t('costs.paymentsSettled', { count: card.recorded })} +

+ )} +
+ ); +} diff --git a/client/src/components/Assistant/cards/budget/budgetHooks.ts b/client/src/components/Assistant/cards/budget/budgetHooks.ts new file mode 100644 index 000000000..b5d3906b7 --- /dev/null +++ b/client/src/components/Assistant/cards/budget/budgetHooks.ts @@ -0,0 +1,19 @@ +import type { CardPerson } from '@trek/shared'; + +import { useTranslation } from '../../../../i18n'; +import { useViewerId } from '../parts/useViewerId'; + +/** + * The words the costs' cards say about people, as the Costs page says them: + * the signed-in user is "You", and "You paid" rather than "Ana paid". + */ +export function useBudgetPeople() { + const { t } = useTranslation(); + const viewerId = useViewerId(); + const isViewer = (person: CardPerson) => person.id === viewerId; + return { + nameOf: (person: CardPerson) => (isViewer(person) ? t('costs.you') : person.name), + paidBy: (person: CardPerson) => + isViewer(person) ? t('costs.youPaid') : t('assistant.card.budget.paidBy', { name: person.name }), + }; +} diff --git a/client/src/components/Assistant/cards/budget/budgetLook.ts b/client/src/components/Assistant/cards/budget/budgetLook.ts index ad2670127..99355bc5f 100644 --- a/client/src/components/Assistant/cards/budget/budgetLook.ts +++ b/client/src/components/Assistant/cards/budget/budgetLook.ts @@ -1,25 +1,72 @@ -import { Wallet } from 'lucide-react'; +import type { AssistantApplied, TrekCard } from '@trek/shared'; +import { ArrowLeftRight, Wallet } from 'lucide-react'; import type { PackLook } from '../cardTypes'; +import { costsTarget } from './budgetModel'; /** * What the costs bring to the chat's pure look: the verbs, tool names and steps * they add, what an applied change of theirs opens, and the look of each of - * their kinds. An applied expense has no way to open yet, as before. A look - * stays pure, icons and literals only: the chat's main chunk reads every look, - * so whatever a look imports ships with it. + * their kinds. An applied expense opens itself in the Costs tab, a removed + * one and a payment the tab where they were listed. A look stays pure, icons + * and literals only: the chat's main chunk reads every look, so whatever a + * look imports ships with it. */ + +/** The expense an applied change leaves to open: none once it was removed. */ +function expenseLeft(applied: AssistantApplied, card: TrekCard | null): number | null { + const removed = card?.kind === 'expenses' && card.rows[0]?.change === 'willDelete'; + return removed ? null : (applied.ids[0] ?? null); +} + +/** Whether a card is one of the costs' change cards, which a lookup of the expenses only served. */ +function isBudgetChange(change: TrekCard): boolean { + return change.kind === 'expenses' || change.kind === 'payments'; +} + export const BUDGET_LOOK = { - verbs: {}, - tools: {}, - steps: {}, + verbs: { + addPayment: { Icon: ArrowLeftRight, tone: 'success', labelKey: 'assistant.change.verb.addPayment' }, + updatePayment: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.updatePayment' }, + }, + tools: { + create_budget_item_with_members: { labelKey: 'assistant.tool.create_budget_item_with_members' }, + set_budget_item_members: { labelKey: 'assistant.tool.set_budget_item_members' }, + toggle_budget_member_paid: { labelKey: 'assistant.tool.toggle_budget_member_paid' }, + create_settlement: { labelKey: 'assistant.tool.create_settlement' }, + update_settlement: { labelKey: 'assistant.tool.update_settlement' }, + delete_settlement: { labelKey: 'assistant.tool.delete_settlement' }, + freeze_budget_rates: { labelKey: 'assistant.tool.freeze_budget_rates' }, + }, + steps: { + list_settlements: { stepKey: 'assistant.step.costs' }, + }, entities: { - budget: { Icon: Wallet }, + budget: { + Icon: Wallet, + openLabelKey: 'assistant.card.budget.open', + open: (applied: AssistantApplied, card: TrekCard | null) => costsTarget(expenseLeft(applied, card)), + }, + settlement: { + Icon: ArrowLeftRight, + openLabelKey: 'assistant.card.budget.open', + open: () => costsTarget(), + }, }, cards: { - expenses: {}, - payments: {}, - settlement: {}, + expenses: { + entity: () => 'budget', + followUps: [{ id: 'whoOwes', labelKey: 'assistant.followUp.whoOwes' }], + // The list read to find an expense gives way to the change it was read for. + yieldsToChange: (_answer, change) => isBudgetChange(change), + }, + payments: { + entity: () => 'settlement', + yieldsToChange: (_answer, change) => change.kind === 'payments', + }, + settlement: { + followUps: [{ id: 'recordPayments', labelKey: 'assistant.followUp.recordPayments', edits: true }], + }, costPerPerson: {}, }, } satisfies PackLook<'expenses' | 'payments' | 'settlement' | 'costPerPerson'>; diff --git a/client/src/components/Assistant/cards/budget/budgetModel.test.ts b/client/src/components/Assistant/cards/budget/budgetModel.test.ts new file mode 100644 index 000000000..7ede82939 --- /dev/null +++ b/client/src/components/Assistant/cards/budget/budgetModel.test.ts @@ -0,0 +1,137 @@ +/** + * FE-AI-CARD-BUD-001 to FE-AI-CARD-BUD-006 + * + * The pure side of the costs' cards: pages of the expense list read as one + * list where they meet, the latest settle up wins, an even split of many reads as a + * stack, what a run of new expenses adds up to, how far a balance reaches, + * and what each card and receipt opens. + */ +import type { AssistantApplied, CostPerPersonCard, SettlementCard, TrekCard } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; + +import { ANA, changeItem, expense, expenses, payment, payments } from '../../../../../tests/helpers/assistantBudget'; +import { CARD_RULES, ENTITY_LOOK } from '../cardLooks'; +import { + balanceShare, + chipLimit, + costsTarget, + latestOf, + mergeExpenses, + mergePayments, + sharesAsChips, + stackLimit, + togetherOf, +} from './budgetModel'; + +const entry = (toolCallId: string, card: TrekCard) => ({ toolCallId, card }); + +const settlement = (recorded: number): SettlementCard => ({ + kind: 'settlement', + currency: 'EUR', + flows: [], + moreFlows: 0, + balances: [], + moreBalances: 0, + recorded, + unconverted: { entries: 0, currencies: [] }, +}); + +describe('budgetModel', () => { + it('FE-AI-CARD-BUD-001: pages of the expense list read as one list where they meet, each expense once', () => { + const total = { amount: 90, currency: 'EUR' }; + const first = expenses([expense({ id: 1 }), expense({ id: 2 })], { more: 2, total }); + const second = expenses([expense({ id: 2 }), expense({ id: 3 })], { more: 2 }); + const merged = mergeExpenses([entry('a', first), entry('b', second), entry('c', settlement(0))]); + expect(merged?.rows.map((row) => row.id)).toEqual([1, 2, 3]); + // Four in all: the pages show three of them. + expect(merged?.more).toBe(1); + expect(merged?.total).toEqual(total); + expect(mergeExpenses([entry('c', settlement(0))])).toBeNull(); + // A page read first that comes later in the list still follows the one before it. + const later = expenses([expense({ id: 3 })], { offset: 2, more: 3 }); + const start = expenses([expense({ id: 1 }), expense({ id: 2 })], { more: 2 }); + expect(mergeExpenses([entry('a', later), entry('b', start)])?.rows.map((row) => row.id)).toEqual([1, 2, 3]); + // A page past a gap is counted with the rest instead of read as if it came next. + const head = expenses([expense({ id: 1 }), expense({ id: 2 })], { more: 19 }); + const past = expenses([expense({ id: 21 })], { offset: 20, more: 20 }); + const gapped = mergeExpenses([entry('a', head), entry('b', past)]); + expect(gapped?.rows.map((row) => row.id)).toEqual([1, 2]); + expect(gapped?.more).toBe(19); + const twice = mergePayments([entry('a', payments([payment()], 3)), entry('b', payments([payment()], 3))]); + expect(twice?.rows).toHaveLength(1); + expect(twice?.more).toBe(3); + }); + + it('FE-AI-CARD-BUD-002: settle up and the shares answer once, with the latest card', () => { + const shares: CostPerPersonCard = { + kind: 'costPerPerson', + currency: 'EUR', + rows: [{ person: ANA, share: 1, paid: 0, count: 1 }], + more: 0, + }; + expect(latestOf([entry('a', settlement(1)), entry('b', settlement(4))], 'settlement')?.recorded).toBe(4); + expect(latestOf([entry('a', shares)], 'costPerPerson')).toBe(shares); + expect(latestOf([entry('a', shares)], 'settlement')).toBeNull(); + }); + + it('FE-AI-CARD-BUD-003: an uneven split is a badge per person, an even one of many a stack', () => { + const many = { split: 'equal' as const, shares: expense().shares, moreShares: 3, highlight: [] }; + expect(sharesAsChips(many, chipLimit('compact'))).toBe(false); + expect(sharesAsChips({ ...many, moreShares: 0 }, chipLimit('compact'))).toBe(true); + expect(sharesAsChips({ ...many, split: 'custom' }, chipLimit('compact'))).toBe(true); + expect(sharesAsChips(many, chipLimit('roomy'))).toBe(true); + expect([chipLimit(undefined), stackLimit(undefined), stackLimit('roomy')]).toEqual([4, 3, 5]); + // The person a change is about must stand out, so their split is never a stack. + expect(sharesAsChips({ ...many, highlight: [ANA.id] }, chipLimit('compact'))).toBe(true); + }); + + it('FE-AI-CARD-BUD-004: new expenses add up while two or more are included, in one currency', () => { + const fresh = (overrides: Parameters[0] = {}) => expense({ id: null, change: 'new', ...overrides }); + const dinner = changeItem('a', expenses([fresh()])); + const taxi = changeItem('b', expenses([fresh({ amount: { amount: 12.1, currency: 'EUR' } })])); + expect(togetherOf([dinner, taxi], new Set())).toEqual({ amount: 96.1, currency: 'EUR' }); + expect(togetherOf([dinner, taxi], new Set(['b']))).toBeNull(); + const dollars = changeItem('c', expenses([fresh({ amount: { amount: 5, currency: 'USD' } })])); + expect(togetherOf([dinner, dollars], new Set())).toBeNull(); + const gone = changeItem('d', expenses([expense({ change: 'willDelete' })])); + expect(togetherOf([dinner, gone], new Set())).toBeNull(); + const applied = changeItem('e', expenses([fresh()]), 'applied'); + expect(togetherOf([dinner, applied], new Set())).toBeNull(); + // A paid tick or an update to an expense already there adds no money. + const ticks = [ + changeItem('f', expenses([expense({ change: 'changed' })])), + changeItem('g', expenses([expense({ id: 32, change: 'changed' })])), + ]; + expect(togetherOf(ticks, new Set())).toBeNull(); + expect(togetherOf([dinner, taxi, ...ticks], new Set())).toEqual({ amount: 96.1, currency: 'EUR' }); + }); + + it('FE-AI-CARD-BUD-005: a balance reaches half the bar at most, against the largest one', () => { + expect(balanceShare(42.5, 42.5)).toBe(50); + expect(balanceShare(-21.25, 42.5)).toBe(25); + expect(balanceShare(0, 0)).toBe(0); + }); + + it('FE-AI-CARD-BUD-006: a receipt opens the Costs tab on its expense, a payment the tab, and a lookup gives way', () => { + const applied = (entity: AssistantApplied['entity'], ids: number[]): AssistantApplied => ({ + toolCallId: 't', + entity, + ids, + dayId: null, + undoUntil: null, + }); + expect(ENTITY_LOOK.budget.open?.(applied('budget', [31]), null)).toEqual(costsTarget(31)); + expect(costsTarget(31)).toEqual({ kind: 'tab', tab: 'finanzplan', params: { expense: '31' } }); + expect(ENTITY_LOOK.budget.open?.(applied('budget', []), null)).toEqual({ kind: 'tab', tab: 'finanzplan' }); + // A removed expense is no longer there to open: its receipt opens the tab. + const removed = expenses([expense({ change: 'willDelete' })]); + expect(ENTITY_LOOK.budget.open?.(applied('budget', [31]), removed)).toEqual(costsTarget()); + expect(ENTITY_LOOK.settlement.open?.(applied('settlement', [5]), null)).toEqual(costsTarget()); + expect(ENTITY_LOOK.budget.onMap).toBeUndefined(); + const list = expenses([expense()]); + expect(CARD_RULES.yieldsToChange(list, expenses([expense({ id: null })]))).toBe(true); + expect(CARD_RULES.yieldsToChange(list, payments([payment()]))).toBe(true); + expect(CARD_RULES.yieldsToChange(payments([payment()]), list)).toBe(false); + expect(CARD_RULES.yieldsToChange(list, settlement(0))).toBe(false); + }); +}); diff --git a/client/src/components/Assistant/cards/budget/budgetModel.ts b/client/src/components/Assistant/cards/budget/budgetModel.ts new file mode 100644 index 000000000..44bf3e430 --- /dev/null +++ b/client/src/components/Assistant/cards/budget/budgetModel.ts @@ -0,0 +1,136 @@ +import type { + CardExpense, + CardMoney, + CostPerPersonCard, + ExpensesCard, + ExpenseSplit, + PaymentsCard, + SettlementCard, + TrekCardKind, + TrekCardOf, +} from '@trek/shared'; + +import type { ChangeItem } from '../../assistantParts'; +import type { CardEntry, EntityTarget } from '../cardTypes'; + +/** + * The pure side of the costs' cards: how the cards of one answer come + * together (pages of the expense list read as one list where they meet, the + * last settle up wins), how a split is drawn (a badge per person, or a stack and the + * share per person for an even split of many), what a run of new expenses + * adds up to, and where each card opens. + */ + +/** The Costs tab, on one expense when there is one. */ +export function costsTarget(expenseId: number | null = null): EntityTarget { + return expenseId === null + ? { kind: 'tab', tab: 'finanzplan' } + : { kind: 'tab', tab: 'finanzplan', params: { expense: String(expenseId) } }; +} + +function cardsOf(entries: readonly CardEntry[], kind: K): TrekCardOf[] { + return entries.flatMap((entry) => (entry.card.kind === kind ? [entry.card as TrekCardOf] : [])); +} + +/** + * The expense cards of one answer as one list: the pages in the list's order, + * each expense once, and only as far as they follow on from each other. A + * page past a gap would read as if its expenses came next, so it and the + * ones after it are counted with the rest instead. + */ +export function mergeExpenses(entries: readonly CardEntry[]): ExpensesCard | null { + const cards = cardsOf(entries, 'expenses').sort((a, b) => a.offset - b.offset); + const first = cards[0]; + if (!first) return null; + const seen = new Set(); + const rows: CardExpense[] = []; + let reach = first.offset; + for (const card of cards) { + if (card.offset > reach) break; + for (const row of card.rows) { + if (row.id !== null && seen.has(row.id)) continue; + if (row.id !== null) seen.add(row.id); + rows.push(row); + } + reach = Math.max(reach, card.offset + card.rows.length); + } + // Each page counts the whole list: what is left out is that, less what the pages show. + const all = Math.max(...cards.map((card) => card.rows.length + card.more)); + return { ...first, rows, more: Math.max(0, all - rows.length) }; +} + +/** The payment cards of one answer as one list, each payment once. */ +export function mergePayments(entries: readonly CardEntry[]): PaymentsCard | null { + const cards = cardsOf(entries, 'payments'); + const first = cards[0]; + if (!first) return null; + const seen = new Set(); + const rows = cards + .flatMap((card) => card.rows) + .filter((row) => { + if (row.id === null) return true; + if (seen.has(row.id)) return false; + seen.add(row.id); + return true; + }); + return { ...first, rows, more: Math.max(...cards.map((card) => card.more)) }; +} + +/** Settle up and the share per traveller answer once: the latest card of the answer is the one shown. */ +export function latestOf(entries: readonly CardEntry[], kind: 'settlement'): SettlementCard | null; +export function latestOf(entries: readonly CardEntry[], kind: 'costPerPerson'): CostPerPersonCard | null; +export function latestOf(entries: readonly CardEntry[], kind: 'settlement' | 'costPerPerson') { + return cardsOf(entries, kind).at(-1) ?? null; +} + +/** How many people of a split get a badge of their own before an even one reads as a stack. */ +export function chipLimit(density: 'compact' | 'roomy' | undefined): number { + return density === 'roomy' ? 6 : 4; +} + +/** How many faces a stack shows. */ +export function stackLimit(density: 'compact' | 'roomy' | undefined): number { + return density === 'roomy' ? 5 : 3; +} + +/** + * Whether a split is drawn as a badge per person: an uneven one always, one + * a change is about someone in (a paid tick, someone joining) too, so that + * person stands out, and an even one while it is small. + */ +export function sharesAsChips( + row: Pick, + limit: number +): boolean { + return row.split === 'custom' || row.highlight.length > 0 || row.shares.length + row.moreShares <= limit; +} + +/** The words of each way a split can go. A table, so each key is named where it is read. */ +export const SPLIT_LABEL: Readonly> = { + equal: { labelKey: 'assistant.card.budget.split.equal' }, + custom: { labelKey: 'assistant.card.budget.split.custom' }, + none: { labelKey: 'assistant.card.budget.split.none' }, +}; + +/** + * What the new expenses of a run add up to, the ones still waiting that the + * user includes: two or more, all in one currency. A change to an expense + * already there (an update, a paid tick, who shares it) adds no money, nor + * does one going away, so neither counts. + */ +export function togetherOf(items: readonly ChangeItem[], excluded: ReadonlySet): CardMoney | null { + const amounts = items.flatMap((item) => { + const card = item.card; + if (item.state !== 'pending' || excluded.has(item.toolCallId) || card?.kind !== 'expenses') return []; + return card.rows.filter((row) => row.change === 'new').map((row) => row.amount); + }); + const currency = amounts[0]?.currency; + if (amounts.length < 2 || !currency || amounts.some((amount) => amount.currency !== currency)) return null; + const cents = amounts.reduce((sum, amount) => sum + Math.round(amount.amount * 100), 0); + return { amount: cents / 100, currency }; +} + +/** How far a balance reaches on its bar, against the largest one; half the bar is the most. */ +export function balanceShare(balance: number, largest: number): number { + return largest <= 0 ? 0 : Math.min(50, (Math.abs(balance) / largest) * 50); +} diff --git a/client/src/components/Assistant/cards/budget/budgetParts.tsx b/client/src/components/Assistant/cards/budget/budgetParts.tsx new file mode 100644 index 000000000..353948cbd --- /dev/null +++ b/client/src/components/Assistant/cards/budget/budgetParts.tsx @@ -0,0 +1,224 @@ +import type { CardExpense, CardMoney, CardPerson } from '@trek/shared'; +import { ArrowRight, Check, StickyNote } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { catMeta } from '../../../Budget/costsCategories'; +import { SoftPill, tintOf, WhiteBadge } from '../../../Planner/planParts'; +import { Tooltip } from '../../../shared/Tooltip'; +import { useAssistantView } from '../../assistantViewContext'; +import { PeopleStack, PersonFace } from '../parts/CardPeople'; +import { useCardFormat } from '../parts/useCardFormat'; +import { useBudgetPeople } from './budgetHooks'; +import { chipLimit, sharesAsChips, stackLimit } from './budgetModel'; + +/** + * The pieces the costs' cards share, drawn as the Costs page draws them: an + * expense's category tile, an amount (struck through where a change replaces + * it), the people of a split as small badges with their part, and the line + * that says who paid and who shares it. + */ + +/** The category's tile: its icon in its own colour on a tint of it, its name for a screen reader. */ +export function CategoryTile({ category }: { category: string | null }) { + const { t } = useTranslation(); + const { Icon, color, labelKey } = catMeta(category); + const label = t(labelKey); + return ( + + + ); +} + +/** An amount at the end of a row; the one a change replaces struck through before it. */ +export function Amount({ + value, + before = null, + struck = false, +}: { + value: CardMoney; + before?: CardMoney | null; + struck?: boolean; +}) { + const format = useCardFormat(); + return ( + + {before && ( + <> + {format.money(before)} + + ); +} + +/** + * One person's part of a split: a white badge, green with a tick once paid, + * in the accent when the change is about them, the tick there too when they + * have paid, so a paid tick set and one taken off read apart. Everything it + * shows is in its label, for a screen reader and a phone without a tooltip. + */ +function ShareChip({ + person, + amount, + paid = false, + highlighted = false, +}: { + person: CardPerson; + amount: CardMoney; + paid?: boolean; + highlighted?: boolean; +}) { + const { t } = useTranslation(); + const format = useCardFormat(); + const { nameOf } = useBudgetPeople(); + const money = format.money(amount); + const said = t('costs.splitChipLabel', { name: nameOf(person), amount: money }); + const state = paid ? t('costs.table.paid') : highlighted ? t('assistant.card.budget.notPaid') : null; + const label = state ? `${said}, ${state}` : said; + const face = ; + const pill = highlighted ? ( + + {money} + {paid && + ) : paid ? ( + + {money} + + ) : ( + {money} + ); + return ( + + + {pill} + + + ); +} + +/** Who paid: one payer by name ("You paid"), several each with what they paid; a warning when nobody did. */ +function Payers({ row }: { row: Pick }) { + const { t } = useTranslation(); + const { paidBy } = useBudgetPeople(); + const [only] = row.payers; + if (!only) return {t('costs.noOnePaid')}; + if (row.payers.length === 1) { + return }>{paidBy(only.person)}; + } + return ( + <> + {row.payers.map((payer) => ( + + ))} + + ); +} + +/** + * The split of an expense in a change row: who paid, an arrow, then each + * part as a badge, or a stack and the part per person for an even split of + * many. A planning-only expense says so instead. + */ +export function SplitLine({ row }: { row: CardExpense }) { + const { t } = useTranslation(); + const { density } = useAssistantView(); + const format = useCardFormat(); + if (row.split === 'none') { + return ( + + {t('assistant.card.budget.split.none')} + + ); + } + const currency = row.amount.currency; + const highlighted = new Set(row.highlight); + const firstShare = row.shares[0]; + return ( + + + + ); +} + +/** Two people of a payment, face to face: who paid, an arrow, who got it. */ +export function PaymentFaces({ from, to, size = 24 }: { from: CardPerson; to: CardPerson; size?: 20 | 24 }) { + return ( + + + + ); +} + +/** + * "Ben → Ana" in words, each name kept whole in either reading direction and + * the arrow turned for a right-to-left one. Inside a badge it stays on one line. + */ +export function PaymentNames({ from, to, wrap = true }: { from: CardPerson; to: CardPerson; wrap?: boolean }) { + const { nameOf } = useBudgetPeople(); + return ( + + {nameOf(from)} + + ); +} + +/** + * A note in the white of a badge, wrapping over up to three lines rather + * than cut on one, so a phone without a tooltip reads it too. The card + * carries a note already cut to a sentence or two; the tooltip still holds + * all of it where three lines are not enough. + */ +export function NotePill({ note }: { note: string }) { + return ( + + + ); +} diff --git a/client/src/components/Assistant/cards/budget/budgetViews.tsx b/client/src/components/Assistant/cards/budget/budgetViews.tsx index ac9d5c05f..58e98f4cd 100644 --- a/client/src/components/Assistant/cards/budget/budgetViews.tsx +++ b/client/src/components/Assistant/cards/budget/budgetViews.tsx @@ -1,15 +1,34 @@ -import { type CardView, PENDING_VIEW } from '../cardTypes'; +import type { CardView } from '../cardTypes'; +import CostPerPersonCard from './CostPerPersonCard'; +import { ExpenseAside, ExpenseBody, ExpenseFacts, ExpensesTogether } from './ExpenseChange'; +import ExpensesCard from './ExpensesCard'; +import PaymentsCard, { PaymentAside, PaymentBody, PaymentChangeFacts } from './PaymentsCard'; +import SettlementCard from './SettlementCard'; /** - * How the kinds of the costs draw in the chat; each one draws nothing until its - * cards are built. This file only maps each kind to its view: the components - * live in sibling files, since a file that exports anything besides components - * breaks fast refresh, and lint:warnings holds - * react-refresh/only-export-components at zero for a new file. + * How the kinds of the costs draw in the chat: the expense list, the payment + * history, settle up and the share per traveller as answer cards, and an + * expense or a payment inside its change row (the amount at the end, the + * facts on the faint line, the split or the two people under it). This file + * only maps each kind to its view: the components live in sibling files, + * since a file that exports anything besides components breaks fast refresh, + * and lint:warnings holds react-refresh/only-export-components at zero for a + * new file. */ export const BUDGET_VIEWS = { - expenses: PENDING_VIEW, - payments: PENDING_VIEW, - settlement: PENDING_VIEW, - costPerPerson: PENDING_VIEW, + expenses: { + Block: ExpensesCard, + Aside: ExpenseAside, + Facts: ExpenseFacts, + Body: ExpenseBody, + Group: ExpensesTogether, + }, + payments: { + Block: PaymentsCard, + Aside: PaymentAside, + Facts: PaymentChangeFacts, + Body: PaymentBody, + }, + settlement: { Block: SettlementCard }, + costPerPerson: { Block: CostPerPersonCard }, } satisfies { [K in 'expenses' | 'payments' | 'settlement' | 'costPerPerson']: CardView }; diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 857201a3f..ca0f50ea8 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1,5 +1,4 @@ -import { Fragment, useState, useEffect, useMemo, useCallback, useId, type ReactNode } from 'react' -import { useSearchParams } from 'react-router' +import { Fragment, useState, useMemo, useCallback, useId, type ReactNode } from 'react' import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip, ScanLine, Users } from 'lucide-react' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' @@ -22,6 +21,7 @@ import { type CostsSettlement as Settlement, type CostsSettlementResponse, } from './costsModel' import { useCostsLedger } from './useCostsLedger' +import { useExpenseLinks } from './useExpenseLinks' import { useSettlementForm } from './useSettlementForm' import { usePercentSplit, type CustomSplitUnit } from './usePercentSplit' import { useExpenseForm } from './useExpenseForm' @@ -116,14 +116,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale]) const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale]) - // The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense. - const [searchParams, setSearchParams] = useSearchParams() - useEffect(() => { - if (searchParams.get('create') === 'expense') { - setEditing(null); setModalOpen(true) - setSearchParams(p => { p.delete('create'); return p }, { replace: true }) - } - }, [searchParams]) + // The bottom-nav "+" (?create=expense) and a link to one expense (?expense=) open the expense modal. + useExpenseLinks(tripId, budgetItems, e => { setEditing(e); setModalOpen(true) }, { create: true }) // ── derived expense maths (everything converted to the base currency) ──── // Booked, not live: an expense entered in a foreign currency keeps the rate it was diff --git a/client/src/components/Budget/useExpenseLinks.test.tsx b/client/src/components/Budget/useExpenseLinks.test.tsx new file mode 100644 index 000000000..6908ec01e --- /dev/null +++ b/client/src/components/Budget/useExpenseLinks.test.tsx @@ -0,0 +1,119 @@ +/** + * FE-COSTS-LINK-001 to FE-COSTS-LINK-006 + * + * The links into the Costs tab that open the expense dialog: one expense + * (`?expense=`, the assistant's "Open in Costs"), reading the expenses + * once more when the store does not hold it yet, dropped once taken or when + * that read does not bring it either; and the bottom bar's "+" + * (`?create=expense`) where the view asks for it. + */ +import { useSearchParams } from 'react-router'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { buildBudgetItem } from '../../../tests/helpers/factories'; +import { render, screen, waitFor } from '../../../tests/helpers/render'; +import { resetAllStores } from '../../../tests/helpers/store'; +import { useTripStore } from '../../store/tripStore'; +import type { BudgetItem } from '../../types'; +import { useExpenseLinks } from './useExpenseLinks'; + +let reload: Mock<(tripId: number | string) => Promise>; + +function Probe({ + items, + open, + create = false, +}: { + items: BudgetItem[]; + open: (expense: BudgetItem | null) => void; + create?: boolean; +}) { + useExpenseLinks(4, items, open, { create }); + const [params] = useSearchParams(); + return {params.toString()}; +} + +const search = () => screen.getByRole('status', { name: 'search' }).textContent; + +describe('useExpenseLinks', () => { + beforeEach(() => { + resetAllStores(); + reload = vi.fn<(tripId: number | string) => Promise>(async () => undefined); + useTripStore.setState({ loadBudgetItems: reload }); + }); + + it('FE-COSTS-LINK-001: opens the expense a link names and drops only its own part of the address', () => { + const dinner = buildBudgetItem({ id: 31 }); + const open = vi.fn(); + render(, { + initialEntries: ['/trips/4?tab=finanzplan&expense=31'], + }); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith(dinner); + expect(search()).toBe('tab=finanzplan'); + expect(reload).not.toHaveBeenCalled(); + }); + + it('FE-COSTS-LINK-002: an expense not in the store yet reads the expenses again and opens once it arrives', async () => { + let arrive: () => void = () => undefined; + reload.mockImplementation( + () => + new Promise((resolve) => { + arrive = resolve; + }) + ); + const open = vi.fn(); + const first = render(, { + initialEntries: ['/trips/4?expense=31'], + }); + expect(reload).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledWith(4); + expect(search()).toBe('expense=31'); + const dinner = buildBudgetItem({ id: 31 }); + first.rerender(); + expect(open).toHaveBeenCalledWith(dinner); + expect(search()).toBe(''); + // The read that was still under way changes nothing once it lands. + arrive(); + await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + expect(search()).toBe(''); + }); + + it('FE-COSTS-LINK-003: a malformed id is dropped at once, expenses or not', () => { + const open = vi.fn(); + render(, { initialEntries: ['/trips/4?expense=dinner'] }); + expect(open).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + expect(search()).toBe(''); + }); + + it('FE-COSTS-LINK-004: the "+" opens an empty dialog where the view asks for it, and is left alone elsewhere', () => { + const open = vi.fn(); + const first = render(, { initialEntries: ['/trips/4?create=expense'] }); + expect(open).toHaveBeenCalledWith(null); + expect(search()).toBe(''); + first.unmount(); + + const phone = vi.fn(); + render(, { initialEntries: ['/trips/4?create=expense'] }); + expect(phone).not.toHaveBeenCalled(); + expect(search()).toBe('create=expense'); + }); + + it('FE-COSTS-LINK-005: an id the second read does not bring either is dropped without opening anything', async () => { + const open = vi.fn(); + render(, { initialEntries: ['/trips/4?expense=99'] }); + expect(search()).toBe('expense=99'); + await waitFor(() => expect(search()).toBe('')); + expect(reload).toHaveBeenCalledTimes(1); + expect(open).not.toHaveBeenCalled(); + }); + + it('FE-COSTS-LINK-006: on a trip without any expense the link is still dropped after the read', async () => { + const open = vi.fn(); + render(, { initialEntries: ['/trips/4?tab=finanzplan&expense=31'] }); + await waitFor(() => expect(search()).toBe('tab=finanzplan')); + expect(reload).toHaveBeenCalledTimes(1); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Budget/useExpenseLinks.ts b/client/src/components/Budget/useExpenseLinks.ts new file mode 100644 index 000000000..430dbb0ec --- /dev/null +++ b/client/src/components/Budget/useExpenseLinks.ts @@ -0,0 +1,62 @@ +import { useEffect, useEffectEvent, useState } from 'react'; +import { useSearchParams } from 'react-router'; + +import { useTripStore } from '../../store/tripStore'; +import type { BudgetItem } from '../../types'; + +/** + * The links into the Costs tab that open the expense dialog, taken once and + * then dropped from the URL so a reload or the way back does not repeat them. + * `?expense=` opens that expense the way a click on its row does: the + * assistant's "Open in Costs" on an expense lands here, on the desktop and on + * the phone alike. An id the store does not hold yet (an expense the + * assistant just created, before its broadcast arrived) reads the expenses + * once more; an id that read does not bring either is dropped without a word, + * on a trip without any expense too. What opening means is the view's: the + * phone opens its edit sheet only for an editor, as its rows do. With + * `create`, the bottom bar's "+" opens an empty dialog, as on the desktop. + */ +export function useExpenseLinks( + tripId: number | string, + budgetItems: readonly BudgetItem[], + open: (expense: BudgetItem | null) => void, + { create = false }: { create?: boolean } = {} +): void { + const [searchParams, setSearchParams] = useSearchParams(); + const loadBudgetItems = useTripStore((state) => state.loadBudgetItems); + // The link whose expenses were read again for it: after that read it is done, found or not. + const [checked, setChecked] = useState(null); + const creating = create && searchParams.get('create') === 'expense'; + const asked = searchParams.get('expense'); + const id = asked === null ? null : Number(asked); + const malformed = id !== null && (!Number.isInteger(id) || id <= 0); + const found = id === null || malformed ? undefined : budgetItems.find((item) => item.id === id); + const settled = asked !== null && (found !== undefined || malformed || checked === asked); + const openExpense = useEffectEvent(open); + const readAgain = useEffectEvent(() => Promise.resolve(loadBudgetItems(tripId))); + + useEffect(() => { + if (asked === null || settled) return; + let live = true; + void readAgain().finally(() => { + if (live) setChecked(asked); + }); + return () => { + live = false; + }; + }, [asked, settled]); + + useEffect(() => { + if (!creating && !settled) return; + if (creating) openExpense(null); + else if (found) openExpense(found); + setSearchParams( + (params) => { + if (creating) params.delete('create'); + if (settled) params.delete('expense'); + return params; + }, + { replace: true } + ); + }, [creating, settled, found, setSearchParams]); +} diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 753c2d6ed..0fcf739a7 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -10,6 +10,7 @@ import { openFile } from '../../../../utils/fileDownload' import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' import { useCostsLedger } from '../../../../components/Budget/useCostsLedger' +import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks' import { useSettlementForm } from '../../../../components/Budget/useSettlementForm' import { finalBudgetFor, finalBudgetSources, formerParticipantRows, NOTE_MAX, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' @@ -65,6 +66,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { const [editingExpense, setEditingExpense] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null) + useExpenseLinks(tripId, budgetItems, (e) => { if (canEdit) { setEditingExpense(e); setExpenseModalOpen(true) } }) const groups = useMemo(() => groupLedgerByDay(filtered, filteredSettlements), [filtered, filteredSettlements]) const catKeys = useMemo(() => categoryFilterKeys(budgetItems), [budgetItems]) diff --git a/client/tests/helpers/assistantBudget.ts b/client/tests/helpers/assistantBudget.ts new file mode 100644 index 000000000..7af36617c --- /dev/null +++ b/client/tests/helpers/assistantBudget.ts @@ -0,0 +1,70 @@ +import type { CardExpense, CardPayment, CardPerson, ExpensesCard, PaymentsCard } from '@trek/shared'; + +import type { ChangeItem } from '../../src/components/Assistant/assistantParts'; + +/** + * The costs' cards as the server builds them, for the tests of their views + * and model: Ana (the signed-in user in most tests), Ben and Cleo on a euro + * trip, a dinner Ana paid and the three share evenly. + */ + +export const ANA: CardPerson = { id: 7, name: 'Ana', avatarKey: 'ana.png' }; +export const BEN: CardPerson = { id: 8, name: 'Ben', avatarKey: null }; +export const CLEO: CardPerson = { id: 9, name: 'Cleo', avatarKey: null }; + +export function expense(overrides: Partial = {}): CardExpense { + return { + id: 31, + name: 'Dinner at Lokanta', + category: 'food', + amount: { amount: 84, currency: 'EUR' }, + tripAmount: null, + date: '2026-06-03', + place: null, + payers: [{ person: ANA, amount: 84 }], + split: 'equal', + shares: [ + { person: ANA, amount: 28, paid: true }, + { person: BEN, amount: 28, paid: false }, + { person: CLEO, amount: 28, paid: false }, + ], + moreShares: 0, + note: null, + receipts: 0, + before: null, + highlight: [], + change: 'none', + ...overrides, + }; +} + +export function expenses(rows: CardExpense[], overrides: Partial = {}): ExpensesCard { + return { kind: 'expenses', rows, offset: 0, more: 0, total: null, unconverted: 0, ...overrides }; +} + +export function payment(overrides: Partial = {}): CardPayment { + return { + id: 5, + from: BEN, + to: ANA, + amount: { amount: 42.5, currency: 'EUR' }, + date: '2026-06-07', + note: null, + before: null, + change: 'none', + ...overrides, + }; +} + +export function payments(rows: CardPayment[], more = 0): PaymentsCard { + return { kind: 'payments', rows, more }; +} + +/** A change row holding `card`, waiting unless said otherwise. */ +export function changeItem( + toolCallId: string, + card: ChangeItem['card'], + state: ChangeItem['state'] = 'pending' +): ChangeItem { + return { toolCallId, preview: null, applied: null, state, card }; +} diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index f4db8bf4c..74c17efa5 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -9,6 +9,7 @@ import { resetFreezeAttempts } from '../../../../src/components/Budget/useFreeze import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates' import { useAuthStore } from '../../../../src/store/authStore' import { useSettingsStore } from '../../../../src/store/settingsStore' +import { useTripStore } from '../../../../src/store/tripStore' import type { BudgetItem, Day, Trip } from '../../../../src/types' import { buildTrip, buildUser } from '../../../helpers/factories' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' @@ -16,7 +17,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store' import { server } from '../../../helpers/msw/server' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' -// FE-MOB-COSTT-001 to FE-MOB-COSTT-051 +// FE-MOB-COSTT-001 to FE-MOB-COSTT-053 // 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. @@ -916,4 +917,19 @@ describe('MCostsTab', () => { rerender() await waitFor(() => expect(settlementBases).toHaveLength(2)) }) + + it('FE-MOB-COSTT-052: a link to one expense opens its edit sheet for an editor', async () => { + render(, { initialEntries: ['/trips/7?expense=12'] }) + expect(await screen.findByText('sheet editing 12')).toBeInTheDocument() + }) + + it('FE-MOB-COSTT-053: a link to one expense opens no edit sheet for a member who may not edit costs', async () => { + const reload = vi.fn(async () => undefined) + useTripStore.setState({ loadBudgetItems: reload }) + const viewer = planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }) + render(, { initialEntries: ['/trips/7?expense=12'] }) + await screen.findByText('+$12.50') + expect(screen.queryByText('sheet editing 12')).not.toBeInTheDocument() + expect(reload).not.toHaveBeenCalled() + }) }) diff --git a/server/src/nest/assistant/cards/budget/budget-answers.ts b/server/src/nest/assistant/cards/budget/budget-answers.ts new file mode 100644 index 000000000..cb5b0e450 --- /dev/null +++ b/server/src/nest/assistant/cards/budget/budget-answers.ts @@ -0,0 +1,158 @@ +import type { TripCard } from '../../assistant-context'; +import type { AnswerInput } from '../card-types'; +import { finite, recordOf, type Row } from '../card-values'; +import { rowsOf } from '../undo-snapshot'; +import { + amountOf, + codeOf, + dateOf, + expenseFromRow, + fitted, + paymentFromRow, + personFrom, + positiveId, + type PeopleCaps, +} from './budget-values'; +import { + COST_PER_PERSON_ROWS_MAX, + EXPENSE_ROWS_MAX, + PAYMENT_ROWS_MAX, + SETTLEMENT_BALANCES_MAX, + SETTLEMENT_FLOWS_MAX, + type CardPerson, + type CostPerPersonCard, + type ExpensesCard, + type PaymentsCard, + type SettlementCard, +} from '@trek/shared'; + +/** + * The cards the budget's reads show beside the answer, built from each + * read's raw answer: the expense list, settle up, the payments recorded and + * what the trip costs each traveller. A read that found nothing shows no card. + */ + +/** People an expense row of the list names: the list stays short, a change row names everyone. */ +const LIST_PEOPLE: PeopleCaps = { shares: 6, payers: 3 }; + +/** Counts a card states, kept inside their bounds. */ +function count(value: number, max: number): number { + return Math.min(max, Math.max(0, Math.trunc(value))); +} + +/** A person an answer names as `{ user_id, username, avatar_url }`. */ +function personIn(card: TripCard, raw: unknown): CardPerson | null { + const row = recordOf(raw); + const id = positiveId(row.user_id); + return id === null ? null : personFrom(card, id, row.username, row.avatar_url); +} + +/** `list_budget_items`: the page's expenses in the list's order, where they start, the rest counted, the total. */ +export function expensesAnswer({ value, card }: AnswerInput): ExpensesCard | null { + const root = recordOf(value); + const tripCurrency = codeOf(root.currency, card.currency); + const names = new Map( + rowsOf(root.people).flatMap((person): Array<[number, string]> => { + const id = positiveId(person.user_id); + return id !== null && typeof person.username === 'string' ? [[id, person.username]] : []; + }), + ); + const items = rowsOf(root.items); + const rows = items.slice(0, EXPENSE_ROWS_MAX).flatMap((item) => { + const row = expenseFromRow(card, item, { tripCurrency, names, caps: LIST_PEOPLE, change: 'none' }); + return row ? [row] : []; + }); + if (rows.length === 0) return null; + const total = amountOf(root.total); + const all = finite(root.item_count) ?? items.length; + return fitted({ + kind: 'expenses', + rows, + offset: count(finite(root.offset) ?? 0, 100_000), + more: count(all - rows.length, 100_000), + total: total === null ? null : { amount: total, currency: tripCurrency }, + unconverted: count(Array.isArray(root.unconverted_item_ids) ? root.unconverted_item_ids.length : 0, 100_000), + }); +} + +/** The balances worth naming: everyone not square, or all of them when everyone is; largest credit first. */ +function balancesOf(card: TripCard, rows: Row[]) { + const all = rows.flatMap((row) => { + const person = personIn(card, row); + const balance = amountOf(row.balance); + return person && balance !== null ? [{ person, balance }] : []; + }); + const open = all.filter((row) => Math.abs(row.balance) >= 0.005); + return (open.length > 0 ? open : all).sort((a, b) => b.balance - a.balance); +} + +/** `get_settlement_summary`: who pays whom, the balances, the payments recorded, what no rate converts. */ +export function settlementAnswer({ value, card }: AnswerInput): SettlementCard | null { + const summary = recordOf(recordOf(value).summary); + if (!Array.isArray(summary.flows) && !Array.isArray(summary.balances)) return null; + const flows = rowsOf(summary.flows).flatMap((flow) => { + const from = personIn(card, flow.from); + const to = personIn(card, flow.to); + const amount = amountOf(flow.amount); + return from && to && amount !== null && amount > 0 ? [{ from, to, amount }] : []; + }); + const balances = balancesOf(card, rowsOf(summary.balances)); + const unconverted = recordOf(summary.unconverted); + const listed = (key: string) => (Array.isArray(unconverted[key]) ? (unconverted[key] as unknown[]).length : 0); + const currencies = Array.isArray(unconverted.currencies) + ? unconverted.currencies.filter((code): code is string => typeof code === 'string' && /^[A-Z]{3}$/.test(code)) + : []; + return { + kind: 'settlement', + currency: codeOf(summary.currency, card.currency), + flows: flows.slice(0, SETTLEMENT_FLOWS_MAX), + moreFlows: count(flows.length - SETTLEMENT_FLOWS_MAX, 1000), + balances: balances.slice(0, SETTLEMENT_BALANCES_MAX), + moreBalances: count(balances.length - SETTLEMENT_BALANCES_MAX, 1000), + recorded: count(Array.isArray(summary.settlements) ? summary.settlements.length : 0, 100_000), + unconverted: { + entries: count(listed('item_ids') + listed('settlement_ids'), 100_000), + currencies: currencies.slice(0, 6), + }, + }; +} + +/** `get_budget_per_person`: each traveller's share, largest first, with what of it is marked paid. */ +export function costPerPersonAnswer({ value, card }: AnswerInput): CostPerPersonCard | null { + const summary = rowsOf(recordOf(value).summary); + const rows = summary + .flatMap((row) => { + const person = personIn(card, row); + const share = amountOf(row.total_assigned); + if (!person || share === null) return []; + return [ + { person, share, paid: amountOf(row.total_paid) ?? 0, count: count(finite(row.items_count) ?? 0, 100_000) }, + ]; + }) + .sort((a, b) => b.share - a.share); + if (rows.length === 0) return null; + return { + kind: 'costPerPerson', + currency: codeOf(summary[0]?.currency, card.currency), + rows: rows.slice(0, COST_PER_PERSON_ROWS_MAX), + more: count(rows.length - COST_PER_PERSON_ROWS_MAX, 1000), + }; +} + +/** The day a payment is filed under, the newest first: the day it was paid, else the day it was recorded. */ +function newestFirst(a: Row, b: Row): number { + const day = (row: Row) => dateOf(row.settled_at) ?? dateOf(row.created_at) ?? ''; + const byDay = day(b).localeCompare(day(a)); + return byDay !== 0 ? byDay : (finite(b.id) ?? 0) - (finite(a.id) ?? 0); +} + +/** `list_settlements`: the payments recorded, newest first, the rest counted. */ +export function paymentsAnswer({ value, card }: AnswerInput): PaymentsCard | null { + const all = rowsOf(recordOf(value).settlements).sort(newestFirst); + const rows = all.slice(0, PAYMENT_ROWS_MAX).flatMap((row) => { + const payment = paymentFromRow(card, row, 'none'); + return payment ? [payment] : []; + }); + if (rows.length === 0) return null; + return fitted({ kind: 'payments', rows, more: count(all.length - rows.length, 100_000) }); +} diff --git a/server/src/nest/assistant/cards/budget/budget-changes.ts b/server/src/nest/assistant/cards/budget/budget-changes.ts new file mode 100644 index 000000000..2786863a7 --- /dev/null +++ b/server/src/nest/assistant/cards/budget/budget-changes.ts @@ -0,0 +1,279 @@ +import type { TripCard } from '../../assistant-context'; +import type { PreviewContext, ToolArgs } from '../card-types'; +import { argId, clipped, recordOf, type Row } from '../card-values'; +import { rowsOf } from '../undo-snapshot'; +import { + ALL_PEOPLE, + amountOf, + argLine, + budgetRows, + codeOf, + dateOf, + expenseFromRow, + payersOf, + paymentFromRow, + personFrom, + placeOf, + positiveId, + settlementRow, + splitOf, + type SplitMember, +} from './budget-values'; +import { + sumMinor, + type CardExpense, + type CardExpenseBefore, + type CardPayment, + type ExpensesCard, + type PaymentsCard, +} from '@trek/shared'; + +/** + * The cards a held budget write shows inside its change card: the expense + * as it will be (the full split, who paid, the amounts) or the payment, with + * what an update replaces and the people a change is about. A new expense is + * read from the call's arguments the way the write path reads them; a change + * to one starts from the expense in the turn's trip summary, a change to a + * payment from the payments its lookup read. Without the row a change is + * about there is no card, and the change card built from the words stands. + */ + +/** One expense as a change card holds it. */ +function expenseCard(row: CardExpense): ExpensesCard { + return { kind: 'expenses', rows: [row], offset: 0, more: 0, total: null, unconverted: 0 }; +} + +function paymentCard(row: CardPayment): PaymentsCard { + return { kind: 'payments', rows: [row], more: 0 }; +} + +function idsOf(value: unknown): number[] { + return Array.isArray(value) ? value.filter((id): id is number => positiveId(id) !== null) : []; +} + +/** Members splitting evenly, nobody marked paid yet. */ +function evenly(ids: readonly number[]): SplitMember[] { + return ids.map((id) => ({ id, amount: null, paid: false })); +} + +/** The uneven split of `members` (`[{ user_id, amount }]`). */ +function unevenly(value: unknown): SplitMember[] { + return rowsOf(value).flatMap((member) => { + const id = positiveId(member.user_id); + const amount = amountOf(member.amount); + return id === null || amount === null ? [] : [{ id, amount, paid: false }]; + }); +} + +/** Everyone on the trip, as an expense split across all members reads them. */ +function everyone(card: TripCard): number[] { + return [...card.members.keys()]; +} + +/** What a new expense comes to: the payers' sum when they are named, as the write path derives it. */ +function newTotal(card: TripCard, args: ToolArgs): number | null { + const payers = payersOf(card, args.payers, Number.MAX_SAFE_INTEGER); + return payers.length > 0 ? sumMinor(payers.map((payer) => payer.amount)) / 100 : amountOf(args.total_price); +} + +/** A new expense from its arguments: who shares it is `members`, else `split`, else everyone on the trip. */ +function newExpense(ctx: PreviewContext, args: ToolArgs, split: SplitMember[]): ExpensesCard | null { + const name = argLine(args.name, 120); + const total = newTotal(ctx.card, args); + if (name === null || total === null) return null; + return expenseCard({ + id: null, + name, + category: argLine(args.category, 60), + amount: { amount: total, currency: codeOf(args.currency, ctx.card.currency) }, + tripAmount: null, + date: dateOf(args.expense_date), + place: placeOf(ctx.card, args.place_id), + payers: payersOf(ctx.card, args.payers, ALL_PEOPLE.payers), + ...splitOf(ctx.card, split, total, 0, ALL_PEOPLE.shares), + note: argLine(args.note, 160), + receipts: 0, + before: null, + highlight: [], + change: 'new', + }); +} + +/** `create_budget_item`: an uneven split, an even one among `member_ids` (none for `[]`), else everyone. */ +export function createExpenseChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null { + const split = Array.isArray(args.members) + ? unevenly(args.members) + : evenly(Array.isArray(args.member_ids) ? idsOf(args.member_ids) : everyone(ctx.card)); + return newExpense(ctx, args, split); +} + +/** + * `create_budget_item_with_members`: shared evenly by `userIds`, or by + * everyone on the trip when it lists nobody, an empty list included, as the + * tool does and says. + */ +export function createWithMembersChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null { + const ids = idsOf(args.userIds); + // The tool takes no currency, date or payers: the expense is in the trip currency and nobody paid yet. + const own = { name: args.name, category: args.category, total_price: args.total_price, note: args.note }; + return newExpense(ctx, { ...own, place_id: args.place_id }, evenly(ids.length > 0 ? ids : everyone(ctx.card))); +} + +/** The stored expense a change is about, as a row and as the card reads it. */ +function storedExpense(ctx: PreviewContext, args: ToolArgs): { raw: Row; was: CardExpense } | null { + const itemId = argId(args, 'itemId'); + const raw = itemId === null ? undefined : budgetRows(ctx.card).get(itemId); + const was = raw ? expenseOf(ctx, raw) : null; + return raw && was ? { raw, was } : null; +} + +function expenseOf(ctx: PreviewContext, raw: unknown): CardExpense | null { + return expenseFromRow(ctx.card, raw, { + tripCurrency: codeOf(ctx.card.currency, null), + caps: ALL_PEOPLE, + change: 'changed', + }); +} + +/** Whether a member was marked paid on the stored expense. */ +function paidBefore(raw: Row, id: number): boolean { + return rowsOf(raw.members).some((member) => member.user_id === id && (member.paid === 1 || member.paid === true)); +} + +/** The people a change brings into the split. */ +function joined(was: CardExpense, now: CardExpense): number[] { + const before = new Set(was.shares.map((share) => share.person.id)); + return now.shares.map((share) => share.person.id).filter((id) => !before.has(id)); +} + +/** What a change replaces, each value only where it differs; null when nothing it shows changes. */ +function beforeOf(was: CardExpense, now: CardExpense): CardExpenseBefore | null { + const moved = was.amount.amount !== now.amount.amount || was.amount.currency !== now.amount.currency; + const before: CardExpenseBefore = { + name: was.name !== now.name ? was.name : null, + amount: moved ? was.amount : null, + category: was.category !== now.category ? was.category : null, + date: was.date !== now.date ? was.date : null, + split: was.split !== now.split ? was.split : null, + }; + return Object.values(before).some((value) => value !== null) ? before : null; +} + +/** The stored row with an update's arguments laid over it, the way the write path stores them. */ +function updatedRow(ctx: PreviewContext, raw: Row, args: ToolArgs): Row { + const next: Row = { ...raw }; + const payers = payersOf(ctx.card, args.payers, Number.MAX_SAFE_INTEGER); + const total = amountOf(args.total_price); + if (Array.isArray(args.payers)) { + next.payers = payers.map((payer) => ({ user_id: payer.person.id, amount: payer.amount })); + // The payers' sum is the total; clearing them keeps a total the call names, as the write path re-applies it. + next.total_price = + payers.length === 0 && total !== null ? total : sumMinor(payers.map((payer) => payer.amount)) / 100; + } else if (total !== null) next.total_price = total; + if (args.currency !== undefined) next.currency = args.currency; + if (args.expense_date !== undefined) next.expense_date = args.expense_date; + if (args.place_id !== undefined) next.place_id = args.place_id; + const keep = (id: number) => ({ user_id: id, paid: paidBefore(raw, id) }); + if (Array.isArray(args.members)) { + next.members = unevenly(args.members).map((member) => ({ ...keep(member.id), amount: member.amount })); + } else if (Array.isArray(args.member_ids)) { + next.members = idsOf(args.member_ids).map((id) => ({ ...keep(id), amount: null })); + } + return next; +} + +/** `update_budget_item`: the expense as it will be, with what it replaces and who joins its split. */ +export function updateExpenseChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null { + const stored = storedExpense(ctx, args); + const laid = stored ? expenseOf(ctx, updatedRow(ctx, stored.raw, args)) : null; + if (!stored || !laid) return null; + const now: CardExpense = { + ...laid, + name: argLine(args.name, 120) ?? laid.name, + category: typeof args.category === 'string' ? argLine(args.category, 60) : laid.category, + note: args.note === undefined ? null : argLine(args.note, 160), + }; + return expenseCard({ ...now, before: beforeOf(stored.was, now), highlight: joined(stored.was, now) }); +} + +/** `set_budget_item_members`: an even split among `userIds`, the paid ticks kept, own amounts dropped. */ +export function setMembersChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null { + const stored = storedExpense(ctx, args); + if (!stored || !Array.isArray(args.userIds)) return null; + const members = idsOf(args.userIds).map((id) => ({ user_id: id, paid: paidBefore(stored.raw, id), amount: null })); + const now = expenseOf(ctx, { ...stored.raw, members }); + if (!now) return null; + return expenseCard({ ...now, before: beforeOf(stored.was, now), highlight: joined(stored.was, now) }); +} + +/** `toggle_budget_member_paid`: one member's paid tick set or taken off. */ +export function togglePaidChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null { + const stored = storedExpense(ctx, args); + const memberId = argId(args, 'memberId'); + if (!stored || memberId === null || typeof args.paid !== 'boolean') return null; + const paid = args.paid; + const shares = stored.was.shares.map((share) => (share.person.id === memberId ? { ...share, paid } : share)); + return expenseCard({ ...stored.was, shares, highlight: [memberId] }); +} + +/** `delete_budget_item`: the expense that goes. */ +export function deleteExpenseChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null { + const stored = storedExpense(ctx, args); + return stored ? expenseCard({ ...stored.was, change: 'willDelete' }) : null; +} + +/** A payment from its arguments; the stored one's values stand in for those the call leaves out. */ +function paymentOf(card: TripCard, args: ToolArgs, base: Row | undefined): CardPayment | null { + const stored = recordOf(base); + const from = argId(args, 'from_user_id') ?? positiveId(stored.from_user_id); + const to = argId(args, 'to_user_id') ?? positiveId(stored.to_user_id); + const amount = amountOf(args.amount ?? stored.amount); + if (from === null || to === null || amount === null || amount <= 0) return null; + const currency = args.currency === undefined ? stored.currency : args.currency; + const settled = args.settled_at === undefined ? stored.settled_at : args.settled_at; + // Someone who left the trip keeps the name the stored payment gives them. + const side = (id: number, key: 'from' | 'to') => + stored[`${key}_user_id`] === id + ? personFrom(card, id, stored[`${key}_username`], stored[`${key}_avatar_url`]) + : personFrom(card, id); + return { + id: positiveId(stored.id), + from: side(from, 'from'), + to: side(to, 'to'), + amount: { amount, currency: codeOf(currency, card.currency) }, + date: dateOf(settled) ?? dateOf(stored.created_at), + note: args.note === undefined ? clipped(stored.note, 120) : argLine(args.note, 120), + before: null, + change: base ? 'changed' : 'new', + }; +} + +/** `create_settlement`: who pays whom how much, and on which day. */ +export function createPaymentChange(ctx: PreviewContext, args: ToolArgs): PaymentsCard | null { + const payment = paymentOf(ctx.card, args, undefined); + return payment ? paymentCard(payment) : null; +} + +/** `update_settlement`: the payment as it will be, with the people and the amount it replaces. */ +export function updatePaymentChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PaymentsCard | null { + const base = settlementRow(loaded, argId(args, 'settlementId')); + const was = base ? paymentFromRow(ctx.card, base, 'none') : null; + const now = paymentOf(ctx.card, args, base); + if (!now) return null; + if (!was) return paymentCard({ ...now, id: argId(args, 'settlementId'), change: 'changed' }); + const moved = was.amount.amount !== now.amount.amount || was.amount.currency !== now.amount.currency; + const before = { + from: was.from.id !== now.from.id ? was.from : null, + to: was.to.id !== now.to.id ? was.to : null, + amount: moved ? was.amount : null, + }; + const changed = before.from !== null || before.to !== null || before.amount !== null; + return paymentCard({ ...now, before: changed ? before : null }); +} + +/** `delete_settlement`: the payment that goes, when the lookup found it. */ +export function deletePaymentChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PaymentsCard | null { + const base = settlementRow(loaded, argId(args, 'settlementId')); + const payment = base ? paymentFromRow(ctx.card, base, 'willDelete') : null; + return payment ? paymentCard(payment) : null; +} diff --git a/server/src/nest/assistant/cards/budget/budget-describe.ts b/server/src/nest/assistant/cards/budget/budget-describe.ts new file mode 100644 index 000000000..3f07e9066 --- /dev/null +++ b/server/src/nest/assistant/cards/budget/budget-describe.ts @@ -0,0 +1,120 @@ +import type { Described, PreviewContext, ToolArgs } from '../card-types'; +import { argId, recordOf, type Row } from '../card-values'; +import { formatDate, id, memberNames, money, placeName, Rows, text } from '../preview-format'; +import { rowsOf } from '../undo-snapshot'; +import { budgetRows, codeOf, settlementRow } from './budget-values'; + +/** + * The change cards, in words, of the budget writes that had none of their + * own: an expense added with its split, who shares one, who has paid their + * part, an expense removed, and the settle-up payments recorded, changed and + * removed. Each names the tool it runs, so the card reads the tool's own + * action ("Record a payment") rather than a plain add or change. The + * expense or payment it is about is named from the turn's trip summary or the + * payments its lookup read; one the card cannot find reads as its id. + */ + +/** The stored expense a call names with `itemId`. */ +function expense(ctx: PreviewContext, args: ToolArgs): Row | undefined { + const itemId = argId(args, 'itemId'); + return itemId === null ? undefined : budgetRows(ctx.card).get(itemId); +} + +function expenseName(ctx: PreviewContext, args: ToolArgs): string { + const found = expense(ctx, args); + const itemId = argId(args, 'itemId'); + return text(found?.name) || (itemId === null ? '' : `#${itemId}`); +} + +/** Who shares a stored expense, by name. */ +function sharedBy(ctx: PreviewContext, row: Row | undefined): string { + return memberNames(ctx, rowsOf(row?.members).map((member) => member.user_id)); +} + +/** "Ana → Ben", from the two people of a payment. */ +function between(ctx: PreviewContext, from: unknown, to: unknown): string { + const named = [from, to].map((person) => (typeof person === 'number' ? memberNames(ctx, [person]) : '')); + return named.every((name) => name !== '') ? text(`${named[0]} → ${named[1]}`) : ''; +} + +/** An amount in its currency, empty for one that is no number. */ +function amount(ctx: PreviewContext, value: unknown, currency: unknown): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return ''; + return money(ctx, value, codeOf(currency, ctx.card.currency)); +} + +function day(ctx: PreviewContext, value: unknown): string { + return typeof value === 'string' && value !== '' ? formatDate(ctx, value.slice(0, 10)) : ''; +} + +/** The stored payment `settlementId` names, from the payments the lookup read. */ +function payment(args: ToolArgs, loaded: unknown): Row { + return recordOf(settlementRow(loaded, argId(args, 'settlementId'))); +} + +/** "Ana → Ben" for a payment, its id where the card cannot name both people. */ +function paymentSubject(ctx: PreviewContext, row: Row, args: ToolArgs): string { + const named = between(ctx, args.from_user_id ?? row.from_user_id, args.to_user_id ?? row.to_user_id); + const settlementId = argId(args, 'settlementId'); + return named || (settlementId === null ? '' : `#${settlementId}`); +} + +export const BUDGET_DESCRIBERS = { + create_budget_item_with_members: (ctx, args) => { + const placeId = id(args, 'place_id'); + const ids = Array.isArray(args.userIds) && args.userIds.length > 0 ? args.userIds : [...ctx.card.members.keys()]; + const rows = new Rows() + .add('price', amount(ctx, args.total_price, ctx.card.currency)) + .add('category', text(args.category)) + .add('members', memberNames(ctx, ids)) + .add('location', placeName(ctx, placeId)) + .add('notes', text(args.note)); + return { + subject: text(args.name), + rows, + dayId: null, + placeIds: placeId ? [placeId] : [], + tool: 'create_budget_item_with_members', + }; + }, + set_budget_item_members: (ctx, args) => { + const rows = new Rows(); + if (Array.isArray(args.userIds)) { + rows.change('members', sharedBy(ctx, expense(ctx, args)), memberNames(ctx, args.userIds)); + } + return { subject: expenseName(ctx, args), rows, dayId: null, placeIds: [], tool: 'set_budget_item_members' }; + }, + toggle_budget_member_paid: (ctx, args) => { + const memberId = argId(args, 'memberId'); + const who = memberId === null ? '' : memberNames(ctx, [memberId]); + const rows = new Rows().add('members', who && typeof args.paid === 'boolean' ? `${who} ${text(args.paid)}` : who); + return { subject: expenseName(ctx, args), rows, dayId: null, placeIds: [], tool: 'toggle_budget_member_paid' }; + }, + delete_budget_item: (ctx, args) => { + const row = expense(ctx, args); + const rows = new Rows().add('price', amount(ctx, row?.total_price, row?.currency)); + return { subject: expenseName(ctx, args), rows, dayId: null, placeIds: [], tool: 'delete_budget_item' }; + }, + create_settlement: (ctx, args) => { + const rows = new Rows() + .add('price', amount(ctx, args.amount, args.currency)) + .add('day', day(ctx, args.settled_at)) + .add('notes', text(args.note)); + return { subject: paymentSubject(ctx, {}, args), rows, dayId: null, placeIds: [], tool: 'create_settlement' }; + }, + update_settlement: (ctx, args, loaded) => { + const row = payment(args, loaded); + const rows = new Rows(); + const after = amount(ctx, args.amount, args.currency === undefined ? row.currency : args.currency); + const before = amount(ctx, row.amount, row.currency); + if (after !== '') rows.change('price', before === after ? null : before, after); + if (args.settled_at !== undefined) rows.change('day', day(ctx, row.settled_at) || null, day(ctx, args.settled_at)); + if (args.note !== undefined) rows.change('notes', text(row.note) || null, text(args.note)); + return { subject: paymentSubject(ctx, row, args), rows, dayId: null, placeIds: [], tool: 'update_settlement' }; + }, + delete_settlement: (ctx, args, loaded) => { + const row = payment(args, loaded); + const rows = new Rows().add('price', amount(ctx, row.amount, row.currency)); + return { subject: paymentSubject(ctx, row, args), rows, dayId: null, placeIds: [], tool: 'delete_settlement' }; + }, +} satisfies Readonly Described>>; diff --git a/server/src/nest/assistant/cards/budget/budget-receipts.ts b/server/src/nest/assistant/cards/budget/budget-receipts.ts new file mode 100644 index 000000000..0c3a5b8f1 --- /dev/null +++ b/server/src/nest/assistant/cards/budget/budget-receipts.ts @@ -0,0 +1,83 @@ +import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types'; +import { argId, idIn, readKey, recordOf } from '../card-values'; +import { byId, rowsOf, same, STABLE_FIELDS } from '../undo-snapshot'; +import type { AssistantChangePreview, TrekCard } from '@trek/shared'; + +/** + * What happens to a budget write once it is applied: the ids its receipt + * names, the card it leaves (the waiting one with the new row's id, so the + * receipt opens that very expense), and the way back from what it created, + * offered only while the created row is as the change left it. + */ + +type Undo = NonNullable; + +/** The fields that say a recorded payment is still the one that was created. */ +const PAYMENT_FIELDS = ['from_user_id', 'to_user_id', 'amount', 'currency', 'settled_at', 'note'] as const; + +/** The read the payment undo checks against. */ +const SETTLEMENTS_READ = { tool: 'list_settlements', args: {} } as const; + +/** The waiting card of a create, now with the id the answer names under `key`. */ +function withCreatedId(preview: AssistantChangePreview | null, result: unknown, key: 'item' | 'settlement') { + const card = preview?.card; + const createdId = idIn(result, key); + if (!card || createdId === null) return null; + if (key === 'item' && card.kind === 'expenses' && card.rows.length === 1) { + return { ...card, rows: card.rows.map((row) => ({ ...row, id: createdId })) }; + } + if (key === 'settlement' && card.kind === 'payments' && card.rows.length === 1) { + return { ...card, rows: card.rows.map((row) => ({ ...row, id: createdId })) }; + } + return null; +} + +/** The card of an applied expense create. */ +export function appliedExpense(input: { result: unknown; preview: AssistantChangePreview | null }): TrekCard | null { + return withCreatedId(input.preview, input.result, 'item'); +} + +/** The card of an applied payment. */ +export function appliedPayment(input: { result: unknown; preview: AssistantChangePreview | null }): TrekCard | null { + return withCreatedId(input.preview, input.result, 'settlement'); +} + +/** A payment's receipt names the payment: the one created, or the one the call changed or removed. */ +export function paymentIds(args: ToolArgs, result: unknown): number[] { + const paymentId = idIn(result, 'settlement') ?? argId(args, 'settlementId'); + return paymentId === null ? [] : [paymentId]; +} + +/** An expense created with its split, taken back while it is as it was created. */ +export const DELETE_CREATED_EXPENSE: Undo = { + tool: 'delete_budget_item', + args: (_args, result) => { + const itemId = idIn(result, 'item'); + return itemId ? { itemId } : null; + }, + unchanged: (result: unknown, snapshot: UndoSnapshot) => { + const created = recordOf(recordOf(result).item); + const items = rowsOf(recordOf(recordOf(snapshot.summary).budget).items); + return same(created, byId(items, created.id), STABLE_FIELDS.budget); + }, +}; + +/** + * A recorded payment, taken back while nobody changed it since: the tool + * calls itself the undo of create_settlement, and removing the payment puts + * the balances back as they were. + */ +export const DELETE_CREATED_PAYMENT: Undo = { + tool: 'delete_settlement', + args: (_args, result) => { + const settlementId = idIn(result, 'settlement'); + return settlementId ? { settlementId } : null; + }, + reads: () => [SETTLEMENTS_READ], + unchanged: (result: unknown, snapshot: UndoSnapshot) => { + const created = recordOf(recordOf(result).settlement); + const read = snapshot.reads.get(readKey(SETTLEMENTS_READ.tool, SETTLEMENTS_READ.args)); + const payments = rowsOf(recordOf(read).settlements); + return same(created, byId(payments, created.id), PAYMENT_FIELDS); + }, +}; diff --git a/server/src/nest/assistant/cards/budget/budget-values.ts b/server/src/nest/assistant/cards/budget/budget-values.ts new file mode 100644 index 000000000..f910d484c --- /dev/null +++ b/server/src/nest/assistant/cards/budget/budget-values.ts @@ -0,0 +1,237 @@ +import type { TripCard } from '../../assistant-context'; +import { clipped, fileKeyOf, finite, personOf, recordOf, type Row } from '../card-values'; +import { rowsOf } from '../undo-snapshot'; +import { + EXPENSE_PEOPLE_MAX, + MAX_CARD_CHARS, + splitEqualShares, + toMinor, + type CardExpense, + type CardPayment, + type CardPerson, + type ExpenseChange, +} from '@trek/shared'; + +/** + * The readers the budget's cards are built with. Expenses and payments come + * from three places: the expense list a read answered, the trip summary the + * turn holds (for the expense a change is about) and the payments + * `list_settlements` answered. Each reader takes the row as it comes, trusts + * no shape and leaves out what is not there. People are named from the trip + * card, and someone who left the trip from the name the row still carries; + * their picture is the stored file, never an address. + */ + +export function positiveId(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null; +} + +/** An amount a card may show, to the cent; null for anything else. */ +export function amountOf(value: unknown): number | null { + const number = finite(value); + return number !== null && Math.abs(number) <= 1e9 ? toMinor(number) / 100 : null; +} + +/** A currency code, upper case; the fallback's when the value is none, euros when neither is. */ +export function codeOf(value: unknown, fallback: string | null): string { + for (const candidate of [value, fallback]) { + const code = typeof candidate === 'string' ? candidate.trim().toUpperCase() : ''; + if (/^[A-Z]{3}$/.test(code)) return code; + } + return 'EUR'; +} + +/** The day of a stored date or date and time, `YYYY-MM-DD`. */ +export function dateOf(value: unknown): string | null { + if (typeof value !== 'string') return null; + const day = value.trim().slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null; +} + +/** A text from a call's arguments, on one line and cut to `max`: what will be stored, so it is not masked. */ +export function argLine(value: unknown, max: number): string | null { + if (typeof value !== 'string') return null; + const line = value.replace(/\s+/g, ' ').trim(); + if (line === '') return null; + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + +/** A traveller as a card shows them: from the trip card, else from what the row names. */ +export function personFrom(card: TripCard, id: number, name?: unknown, avatarUrl?: unknown): CardPerson { + return personOf(card, id) ?? { id, name: clipped(name, 80) ?? `#${id}`, avatarKey: fileKeyOf(avatarUrl) }; +} + +/** A trip place an expense belongs to, by its name on the trip card. */ +export function placeOf(card: TripCard, placeId: unknown): CardExpense['place'] { + const id = positiveId(placeId); + const name = id === null ? null : clipped(card.places.get(id)?.name, 120); + return id !== null && name !== null ? { id, name } : null; +} + +/** One member of a split as the rows store it: their own amount on an uneven split, null on an even one. */ +export interface SplitMember { + id: number; + amount: number | null; + paid: boolean; + name?: unknown; + avatarUrl?: unknown; +} + +/** How many people one row names. */ +export interface PeopleCaps { + shares: number; + payers: number; +} + +/** Every person a change row can name. */ +export const ALL_PEOPLE: PeopleCaps = { shares: EXPENSE_PEOPLE_MAX, payers: EXPENSE_PEOPLE_MAX }; + +/** The members of a split as a row lists them, either the expense list's or the trip summary's. */ +function membersOf(value: unknown, names?: ReadonlyMap): SplitMember[] { + return rowsOf(value).flatMap((member) => { + const id = positiveId(member.user_id); + if (id === null) return []; + return [ + { + id, + amount: member.amount === null || member.amount === undefined ? null : amountOf(member.amount), + paid: member.paid === true || member.paid === 1, + name: member.username ?? names?.get(id), + avatarUrl: member.avatar_url, + }, + ]; + }); +} + +/** + * Who shares an expense and what each part comes to. A member with an + * amount of their own owes exactly that and the split is uneven; the others + * share the total evenly, the left-over cents handed out from `itemId` on as + * the server does, so the parts add up to the total. + */ +export function splitOf( + card: TripCard, + members: readonly SplitMember[], + total: number, + itemId: number, + max: number, +): Pick { + if (members.length === 0) return { split: 'none', shares: [], moreShares: 0 }; + const equal = splitEqualShares(toMinor(total), members.map((member) => ({ user_id: member.id })), itemId); + const shares = members.map((member) => ({ + person: personFrom(card, member.id, member.name, member.avatarUrl), + amount: member.amount ?? (equal[member.id] ?? 0) / 100, + paid: member.paid, + })); + return { + split: members.some((member) => member.amount !== null) ? 'custom' : 'equal', + shares: shares.slice(0, max), + moreShares: Math.min(1000, Math.max(0, shares.length - max)), + }; +} + +/** Who paid how much, as the rows store it. */ +export function payersOf(card: TripCard, value: unknown, max: number, names?: ReadonlyMap) { + return rowsOf(value) + .flatMap((payer) => { + const id = positiveId(payer.user_id); + const amount = amountOf(payer.amount); + if (id === null || amount === null) return []; + return [{ person: personFrom(card, id, payer.username ?? names?.get(id), payer.avatar_url), amount }]; + }) + .slice(0, max); +} + +/** What an expense row needs beside the row: the trip currency, the names a list answered with and the caps. */ +interface ExpenseRowContext { + tripCurrency: string; + names?: ReadonlyMap; + caps: PeopleCaps; + change: ExpenseChange; +} + +/** + * One expense from a stored row: the expense list's slim one or the trip + * summary's whole one. Null for a row without an id, a name or an amount. + */ +export function expenseFromRow(card: TripCard, raw: unknown, at: ExpenseRowContext): CardExpense | null { + const row = recordOf(raw); + const id = positiveId(row.id); + const name = clipped(row.name, 120); + const total = amountOf(row.total_price); + if (id === null || name === null || total === null) return null; + const currency = codeOf(row.currency, at.tripCurrency); + const inTrip = amountOf(row.trip_amount); + const receipts = finite(row.receipt_count) ?? (Array.isArray(row.receipts) ? row.receipts.length : 0); + return { + id, + name, + category: clipped(row.category, 60), + amount: { amount: total, currency }, + tripAmount: inTrip !== null && currency !== at.tripCurrency ? { amount: inTrip, currency: at.tripCurrency } : null, + date: dateOf(row.expense_date), + place: placeOf(card, row.place_id), + payers: payersOf(card, row.payers, at.caps.payers, at.names), + ...splitOf(card, membersOf(row.members, at.names), total, id, at.caps.shares), + note: null, + receipts: Math.min(999, Math.max(0, Math.trunc(receipts))), + before: null, + highlight: [], + change: at.change, + }; +} + +/** One recorded payment from a row of `list_settlements`; null without an id, both people or an amount. */ +export function paymentFromRow(card: TripCard, raw: unknown, change: ExpenseChange): CardPayment | null { + const row = recordOf(raw); + const id = positiveId(row.id); + const from = positiveId(row.from_user_id); + const to = positiveId(row.to_user_id); + const amount = amountOf(row.amount); + if (id === null || from === null || to === null || amount === null || amount <= 0) return null; + return { + id, + from: personFrom(card, from, row.from_username, row.from_avatar_url), + to: personFrom(card, to, row.to_username, row.to_avatar_url), + amount: { amount, currency: codeOf(row.currency, card.currency) }, + date: dateOf(row.settled_at) ?? dateOf(row.created_at), + note: clipped(row.note, 120), + before: null, + change, + }; +} + +const EXPENSES = new WeakMap>(); + +/** The trip's expenses as the turn's trip summary holds them, by id; read once per trip card. */ +export function budgetRows(card: TripCard): ReadonlyMap { + let rows = EXPENSES.get(card); + if (!rows) { + const items = rowsOf(recordOf(recordOf(card.sources.summary).budget).items); + rows = new Map( + items.flatMap((item): Array<[number, Row]> => { + const id = positiveId(item.id); + return id === null ? [] : [[id, item]]; + }), + ); + EXPENSES.set(card, rows); + } + return rows; +} + +/** The recorded payment `list_settlements` answered under `id`. */ +export function settlementRow(loaded: unknown, id: number | null): Row | undefined { + return id === null ? undefined : rowsOf(recordOf(loaded).settlements).find((row) => row.id === id); +} + +/** + * A list card cut to what one card may weigh: rows come off its end, and + * into `more`, until its JSON fits, so a long list still shows its start. + */ +export function fitted(card: C): C { + let fit = card; + while (fit.rows.length > 1 && JSON.stringify(fit).length > MAX_CARD_CHARS) { + fit = { ...fit, rows: fit.rows.slice(0, -1), more: fit.more + 1 }; + } + return fit; +} diff --git a/server/src/nest/assistant/cards/budget/index.ts b/server/src/nest/assistant/cards/budget/index.ts index 8a45a950e..79af99348 100644 --- a/server/src/nest/assistant/cards/budget/index.ts +++ b/server/src/nest/assistant/cards/budget/index.ts @@ -1,4 +1,103 @@ -import type { DomainCards } from '../card-types'; +import type { TripCard } from '../../assistant-context'; +import type { CardReader, DomainCards, ToolArgs } from '../card-types'; +import { clipped } from '../card-values'; +import { costPerPersonAnswer, expensesAnswer, paymentsAnswer, settlementAnswer } from './budget-answers'; +import { + createExpenseChange, + createPaymentChange, + createWithMembersChange, + deleteExpenseChange, + deletePaymentChange, + setMembersChange, + togglePaidChange, + updateExpenseChange, + updatePaymentChange, +} from './budget-changes'; +import { BUDGET_DESCRIBERS } from './budget-describe'; +import { + appliedExpense, + appliedPayment, + DELETE_CREATED_EXPENSE, + DELETE_CREATED_PAYMENT, + paymentIds, +} from './budget-receipts'; +import { budgetRows } from './budget-values'; -/** The cards of the budget: expenses, payments and who settles with whom. None yet: this package fills them in. */ -export const BUDGET_CARDS: DomainCards = { id: 'budget', tools: {} }; +/** + * The costs in the assistant chat. The four reads (the expense list, settle + * up, the payments recorded, the share per traveller) show their answer as a + * card the way the Costs page draws it, and every write to an expense or a + * payment shows what it will be: the full split, who paid, the amounts, with + * what an update replaces. `create_budget_item` and `update_budget_item` keep + * the core's words, verb and undo and only add their card. + */ + +/** The payments a change to a recorded one starts from. */ +const readPayments = (_args: ToolArgs, read: CardReader) => read('list_settlements'); + +/** The name of the expense an `itemId` names, for the card built from the arguments. */ +function expenseName(key: string, value: number, card: TripCard): string | null { + return key === 'itemId' ? clipped(budgetRows(card).get(value)?.name, 160) : null; +} + +export const BUDGET_CARDS: DomainCards = { + id: 'budget', + prompt: [ + 'The chat draws the answers of list_budget_items, get_settlement_summary, list_settlements and get_budget_per_person as cards with every row; do not repeat those rows as text, sum up or answer the question instead.', + ], + tools: { + list_budget_items: { answer: expensesAnswer }, + get_settlement_summary: { answer: settlementAnswer }, + list_settlements: { answer: paymentsAnswer }, + get_budget_per_person: { answer: costPerPersonAnswer }, + create_budget_item: { change: createExpenseChange, applied: appliedExpense }, + update_budget_item: { change: updateExpenseChange }, + create_budget_item_with_members: { + write: { verbKey: 'addBudgetItem', entity: 'budget' }, + describe: BUDGET_DESCRIBERS.create_budget_item_with_members, + change: createWithMembersChange, + applied: appliedExpense, + undo: DELETE_CREATED_EXPENSE, + }, + set_budget_item_members: { + write: { verbKey: 'updateBudgetItem', entity: 'budget' }, + describe: BUDGET_DESCRIBERS.set_budget_item_members, + change: setMembersChange, + nameArg: expenseName, + }, + toggle_budget_member_paid: { + write: { verbKey: 'updateBudgetItem', entity: 'budget' }, + describe: BUDGET_DESCRIBERS.toggle_budget_member_paid, + change: togglePaidChange, + nameArg: expenseName, + }, + delete_budget_item: { + write: { verbKey: 'genericRemove', entity: 'budget' }, + describe: BUDGET_DESCRIBERS.delete_budget_item, + change: deleteExpenseChange, + nameArg: expenseName, + }, + create_settlement: { + write: { verbKey: 'addPayment', entity: 'settlement' }, + describe: BUDGET_DESCRIBERS.create_settlement, + change: createPaymentChange, + applied: appliedPayment, + appliedIds: paymentIds, + undo: DELETE_CREATED_PAYMENT, + }, + update_settlement: { + write: { verbKey: 'updatePayment', entity: 'settlement' }, + load: readPayments, + describe: BUDGET_DESCRIBERS.update_settlement, + change: updatePaymentChange, + appliedIds: paymentIds, + }, + delete_settlement: { + write: { verbKey: 'genericRemove', entity: 'settlement' }, + load: readPayments, + describe: BUDGET_DESCRIBERS.delete_settlement, + change: deletePaymentChange, + appliedIds: paymentIds, + }, + }, +}; diff --git a/server/src/nest/budget/budget-items.mcp.ts b/server/src/nest/budget/budget-items.mcp.ts index 9e7750871..d4dc69d53 100644 --- a/server/src/nest/budget/budget-items.mcp.ts +++ b/server/src/nest/budget/budget-items.mcp.ts @@ -4,8 +4,15 @@ import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } f import type { BudgetItem } from '../../types'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; +import { tripConverter } from './budget-money.helpers'; import { BudgetService } from './budget.service'; -import { BUDGET_ITEMS_PAGE_DEFAULT, budgetItemsPageSchema, idSchema, type BudgetItemsPage } from '@trek/shared'; +import { + BUDGET_ITEMS_PAGE_DEFAULT, + budgetItemsPageSchema, + idSchema, + toMinor, + type BudgetItemsPage, +} from '@trek/shared'; /** The whole budget surface rides the budget addon, as in BudgetMcp. */ const budgetAddonOn = addonGate(ADDON_IDS.BUDGET); @@ -33,25 +40,42 @@ function shortNote(note: string | null | undefined): string | null { return line.length > NOTE_CHARS ? `${line.slice(0, NOTE_CHARS - 1)}…` : line; } +/** Today's rates for the expenses that froze none, as the totals use them; null when none were needed. */ +type Rates = Record | null; + +/** + * A foreign expense in the trip currency, at the rate it was booked at (else + * today's), to the cent; null for one in the trip currency or one no rate + * converts. + */ +function tripAmount(item: BudgetItem, tripCurrency: string, rates: Rates): number | null { + if ((item.currency || tripCurrency).toUpperCase() === tripCurrency) return null; + const convert = tripConverter(item.currency, item.exchange_rate, tripCurrency, rates); + return convert ? toMinor(convert(item.total_price || 0)) / 100 : null; +} + /** * One expense as the tool answers it: the fields a model plans with and a - * card shows, without the avatars, receipts and itemised ticket the REST row - * carries, and with the start of its note. Amounts stay in the expense's own - * currency, which is the trip's when the row names none. + * card shows, without the avatars, receipt files and itemised ticket the REST + * row carries, and with the start of its note and the number of its receipts. + * Amounts stay in the expense's own currency, which is the trip's when the row + * names none; a foreign one also says what it comes to in the trip currency. */ -function slimItem(item: BudgetItem, tripCurrency: string) { +function slimItem(item: BudgetItem, tripCurrency: string, rates: Rates) { return { id: item.id, name: item.name, category: item.category, total_price: item.total_price, currency: item.currency || tripCurrency, + trip_amount: tripAmount(item, tripCurrency, rates), expense_date: item.expense_date ?? null, note: shortNote(item.note), place_id: item.place_id ?? null, reservation_id: item.reservation_id ?? null, payers: rows(item.payers).map((p) => ({ user_id: p.user_id, amount: p.amount })), members: rows(item.members).map((m) => ({ user_id: m.user_id, amount: m.amount ?? null, paid: m.paid === 1 })), + receipt_count: rows(item.receipts).length, }; } @@ -60,11 +84,11 @@ function slimItem(item: BudgetItem, tripCurrency: string) { * would pass PAGE_CHARS. Always at least one, so a page is never empty while * expenses are left. */ -function fillPage(items: BudgetItem[], tripCurrency: string) { +function fillPage(items: BudgetItem[], tripCurrency: string, rates: Rates) { const page: ReturnType[] = []; let chars = 0; for (const item of items) { - const slim = slimItem(item, tripCurrency); + const slim = slimItem(item, tripCurrency, rates); chars += JSON.stringify(slim).length + 1; if (page.length > 0 && chars > PAGE_CHARS) break; page.push(slim); @@ -100,7 +124,7 @@ export class BudgetItemsMcp { @Tool({ name: 'list_budget_items', - description: `List the trip's expenses with their ids, up to ${BUDGET_ITEMS_PAGE_DEFAULT} at a time from offset. A page of long expenses ends earlier, so always go on at next_offset (null on the last page). Each expense: name, category, total_price in its currency, expense_date, the start of its note, the place or booking it belongs to (place_id, reservation_id), who paid how much (payers) and who shares it (members: amount is the member's share on an uneven split, null on an equal one; paid says whether they paid their share). people names the user ids. Next to the page, over all expenses: the trip currency, the total and the totals per category in it, and unconverted_item_ids, foreign expenses no exchange rate converts, which count in no total. Use the ids with update_budget_item and delete_budget_item.`, + description: `List the trip's expenses with their ids, up to ${BUDGET_ITEMS_PAGE_DEFAULT} at a time from offset. A page of long expenses ends earlier, so always go on at next_offset (null on the last page). Each expense: name, category, total_price in its currency, expense_date, the start of its note, the place or booking it belongs to (place_id, reservation_id), who paid how much (payers), who shares it (members: amount is the member's share on an uneven split, null on an equal one; paid says whether they paid their share), trip_amount (a foreign expense in the trip currency, null otherwise or without a rate) and receipt_count. people names the user ids. Next to the page, over all expenses: the trip currency, the total and the totals per category in it, and unconverted_item_ids, foreign expenses no exchange rate converts, which count in no total. Use the ids with update_budget_item and delete_budget_item.`, inputSchema: { tripId: idSchema, ...budgetItemsPageSchema.shape }, annotations: TOOL_ANNOTATIONS_READONLY, when: budgetAddonOn, @@ -112,13 +136,10 @@ export class BudgetItemsMcp { const currency = (trip.currency || 'EUR').toUpperCase(); const items = await this.budget.listBudgetItems(tripId); // The figures get_trip_summary shows: each row at the rate it was booked at (#2525). - const totals = await this.budget.tripTotals( - tripId, - currency, - await this.budget.ratesForTripTotals(tripId, currency), - ); + const rates = await this.budget.ratesForTripTotals(tripId, currency); + const totals = await this.budget.tripTotals(tripId, currency, rates); const start = offset ?? 0; - const page = fillPage(items.slice(start, start + (limit ?? BUDGET_ITEMS_PAGE_DEFAULT)), currency); + const page = fillPage(items.slice(start, start + (limit ?? BUDGET_ITEMS_PAGE_DEFAULT)), currency, rates); const end = start + page.length; return ok({ currency, diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 229b732d4..64a72f340 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -470,7 +470,7 @@ export class BudgetMcp { @Tool({ name: 'create_budget_item_with_members', description: - 'Create a budget/expense item and set the trip members splitting it equally in one atomic operation. If userIds is omitted, the cost is split across all trip members; pass an explicit list to split among a subset, or an empty array for a planning-only entry with no split. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist; if it already exists, use set_budget_item_members directly. For an uneven split, a foreign currency or a date, use create_budget_item instead.', + 'Create a budget/expense item and set the trip members splitting it equally in one atomic operation. If userIds is omitted or empty, the cost is split across all trip members; pass an explicit list to split among a subset. For a planning-only entry with no split, use create_budget_item with member_ids []. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist; if it already exists, use set_budget_item_members directly. For an uneven split, a foreign currency or a date, use create_budget_item instead.', inputSchema: { tripId: idSchema, name: z.string().min(1).max(200), @@ -480,9 +480,7 @@ export class BudgetMcp { userIds: z .array(idSchema) .optional() - .describe( - 'User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split', - ), + .describe('User IDs splitting this item; omit or pass an empty array to split across all trip members'), place_id: idSchema .optional() .describe( diff --git a/server/tests/unit/nest/assistant/cards/budget/budget-cards.test.ts b/server/tests/unit/nest/assistant/cards/budget/budget-cards.test.ts new file mode 100644 index 000000000..c3ea82634 --- /dev/null +++ b/server/tests/unit/nest/assistant/cards/budget/budget-cards.test.ts @@ -0,0 +1,658 @@ +/** + * ASTCARD-BUD-001 to ASTCARD-BUD-027 + * + * The costs in the assistant chat: the cards the budget's reads show beside + * their answer (the expense list, settle up, the payments recorded, the share + * per traveller), the cards its held writes show inside the change card, the + * words of the writes that had none, the receipts and the way back. Every + * card goes through the registry's guarded calls, so each one asserted here + * also fits the shared card union and the size cap. + */ +import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context'; +import { unchangedSinceApplied, undoTargetsOf } from '../../../../../../src/nest/assistant/assistant-undo.helpers'; +import { loadFor } from '../../../../../../src/nest/assistant/cards/card-context'; +import type { AnswerInput, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types'; +import { readKey } from '../../../../../../src/nest/assistant/cards/card-values'; +import { + answerCardOf, + appliedCardOf, + ASSISTANT_UNDO_MAP, + ASSISTANT_WRITE_TOOLS, + CARD_PROMPT_LINES, + nameArgOf, +} from '../../../../../../src/nest/assistant/cards/registry'; +import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers'; +import { + assistantChangePreviewSchema, + MAX_CARD_CHARS, + type CostPerPersonCard, + type ExpensesCard, + type PaymentsCard, + type SettlementCard, + type TrekCard, +} from '@trek/shared'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() })); +vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => logMock); + +beforeEach(() => vi.clearAllMocks()); + +/** Mia (an uploaded picture), Jon (a picture from a sign-in provider) and Ada (none) on a euro trip in Lisbon. */ +const SUMMARY = { + trip: { title: 'Lisbon', currency: 'EUR' }, + members: { + owner: { id: 7, username: 'mia', avatar_url: '/uploads/avatars/mia.png' }, + collaborators: [ + { id: 8, username: 'jon', avatar_url: 'https://sso.example/jon.png' }, + { id: 9, username: 'ada', avatar_url: null }, + ], + }, + days: [], + budget: { + items: [ + { + id: 31, + name: 'Dinner', + category: 'food', + total_price: 90, + currency: null, + expense_date: '2026-06-03', + place_id: 12, + members: [ + { user_id: 7, paid: 1, username: 'mia', avatar_url: '/uploads/avatars/mia.png', amount: null }, + { user_id: 8, paid: 0, username: 'jon', avatar_url: null, amount: null }, + ], + payers: [{ user_id: 7, amount: 90, username: 'mia' }], + receipts: [{ id: 1 }], + }, + { + id: 32, + name: 'Museum', + category: 'activities', + total_price: 50, + currency: 'USD', + members: [ + { user_id: 7, paid: 0, username: 'mia', amount: 30 }, + { user_id: 8, paid: 1, username: 'jon', amount: 20 }, + ], + payers: [{ user_id: 8, amount: 50, username: 'jon' }], + receipts: [], + }, + ], + }, +}; +const card = buildTripCard(1, 7, SUMMARY, { places: [{ id: 12, name: 'Time Out Market' }] }, null); +const ctx: PreviewContext = { card, categories: new Map(), locale: 'en-GB', today: '2026-06-01', airport: () => null }; + +const MIA = { id: 7, name: 'mia', avatarKey: 'mia.png' }; +const JON = { id: 8, name: 'jon', avatarKey: null }; +const ADA = { id: 9, name: 'ada', avatarKey: null }; + +function answer(tool: string, value: unknown): C | null { + const input: AnswerInput = { toolCallId: 'r1', args: {}, value, card, today: '2026-06-01' }; + return answerCardOf(tool, input) as C | null; +} + +const describe_ = (tool: string, args: Record, loaded: unknown = null) => + describeToolCall(tool, 'tc-1', args, ctx, loaded); + +function expensesOf(tool: string, args: Record): ExpensesCard['rows'][number] | undefined { + const preview = describe_(tool, args); + return preview.card?.kind === 'expenses' ? preview.card.rows[0] : undefined; +} + +function paymentOf(tool: string, args: Record, loaded: unknown = null) { + const preview = describe_(tool, args, loaded); + return preview.card?.kind === 'payments' ? preview.card.rows[0] : undefined; +} + +/** A page of `list_budget_items` as the tool answers it. */ +const LIST = { + currency: 'EUR', + total: 140, + unconverted_item_ids: [34], + item_count: 3, + items: [ + { + id: 31, + name: 'Dinner', + category: 'food', + total_price: 100, + currency: 'EUR', + trip_amount: null, + expense_date: '2026-06-03', + note: 'tip', + place_id: 12, + reservation_id: null, + payers: [{ user_id: 7, amount: 100 }], + members: [ + { user_id: 7, amount: null, paid: true }, + { user_id: 8, amount: null, paid: false }, + { user_id: 9, amount: null, paid: false }, + ], + receipt_count: 2, + }, + { + id: 32, + name: 'Museum', + category: 'activities', + total_price: 50, + currency: 'USD', + trip_amount: 40, + expense_date: null, + place_id: null, + payers: [{ user_id: 44, amount: 50 }], + members: [ + { user_id: 7, amount: 30, paid: false }, + { user_id: 44, amount: 20, paid: true }, + ], + receipt_count: 0, + }, + ], + people: [ + { user_id: 7, username: 'mia' }, + { user_id: 44, username: 'gone@example.com' }, + ], +}; + +describe('answer cards', () => { + it('ASTCARD-BUD-001: the expense list shows each expense with its amounts, day, place, payers and split', () => { + const list = answer('list_budget_items', LIST); + expect(list).toMatchObject({ + kind: 'expenses', + offset: 0, + more: 1, + total: { amount: 140, currency: 'EUR' }, + unconverted: 1, + }); + const [dinner, museum] = list?.rows ?? []; + expect(dinner).toMatchObject({ + id: 31, + name: 'Dinner', + category: 'food', + amount: { amount: 100, currency: 'EUR' }, + tripAmount: null, + date: '2026-06-03', + place: { id: 12, name: 'Time Out Market' }, + payers: [{ person: MIA, amount: 100 }], + split: 'equal', + receipts: 2, + note: null, + before: null, + highlight: [], + change: 'none', + }); + // An even split hands the left-over cent out from the expense's id on, as the server does. + expect(dinner?.shares).toEqual([ + { person: MIA, amount: 33.33, paid: true }, + { person: JON, amount: 33.34, paid: false }, + { person: ADA, amount: 33.33, paid: false }, + ]); + // A foreign expense says what it comes to in the trip currency; an uneven split keeps its own amounts. + expect(museum).toMatchObject({ + amount: { amount: 50, currency: 'USD' }, + tripAmount: { amount: 40, currency: 'EUR' }, + }); + expect(museum?.split).toBe('custom'); + expect(museum?.shares.map((share) => share.amount)).toEqual([30, 20]); + }); + + it('ASTCARD-BUD-002: someone who left the trip keeps the name the list gives them, masked like any answer', () => { + const museum = answer('list_budget_items', LIST)?.rows[1]; + const gone = museum?.payers[0]?.person; + expect(gone).toMatchObject({ id: 44, avatarKey: null }); + expect(gone?.name).not.toContain('@'); + expect(museum?.shares[1]?.person.id).toBe(44); + }); + + it('ASTCARD-BUD-003: the list names twelve expenses and six people of a split, and counts the rest', () => { + const members = [7, 8, 9, 10, 11, 12, 13].map((id) => ({ user_id: id, amount: null, paid: false })); + const items = Array.from({ length: 14 }, (_, i) => ({ id: 100 + i, name: `Taxi ${i}`, total_price: 7, members })); + const list = answer('list_budget_items', { currency: 'EUR', total: 98, item_count: 14, items }); + expect(list?.rows).toHaveLength(12); + expect(list?.more).toBe(2); + expect(list?.rows[0]?.shares).toHaveLength(6); + expect(list?.rows[0]?.moreShares).toBe(1); + // Every share of the seven, named or not, adds up to the total. + expect(list?.total).toEqual({ amount: 98, currency: 'EUR' }); + }); + + it('ASTCARD-BUD-004: an empty list, a broken answer and rows without a name or an amount show no card', () => { + expect(answer('list_budget_items', { currency: 'EUR', total: 0, items: [] })).toBeNull(); + expect(answer('list_budget_items', 'not a list')).toBeNull(); + expect(answer('list_budget_items', { items: [{ id: 1, total_price: 5 }, { id: 2, name: 'Tip' }] })).toBeNull(); + expect(answer('get_settlement_summary', { error: 'nope' })).toBeNull(); + expect(answer('get_budget_per_person', { summary: [] })).toBeNull(); + expect(answer('list_settlements', { settlements: [] })).toBeNull(); + }); + + it('ASTCARD-BUD-005: settle up names who pays whom, the balances of everyone not square and what no rate converts', () => { + const settle = answer('get_settlement_summary', { + summary: { + currency: 'EUR', + flows: [{ from: { user_id: 8, username: 'jon' }, to: { user_id: 7, username: 'mia' }, amount: 42.5 }], + balances: [ + { user_id: 8, balance: -42.5 }, + { user_id: 9, balance: 0 }, + { user_id: 7, balance: 42.5 }, + ], + settlements: [{ id: 1 }, { id: 2 }], + unconverted: { item_ids: [3, 4], settlement_ids: [5], currencies: ['USD', 'bad', 'VND'] }, + }, + }); + expect(settle).toEqual({ + kind: 'settlement', + currency: 'EUR', + flows: [{ from: JON, to: MIA, amount: 42.5 }], + moreFlows: 0, + balances: [ + { person: MIA, balance: 42.5 }, + { person: JON, balance: -42.5 }, + ], + moreBalances: 0, + recorded: 2, + unconverted: { entries: 3, currencies: ['USD', 'VND'] }, + }); + }); + + it('ASTCARD-BUD-006: a trip everyone is square on keeps its balances and names no payment; long lists are counted', () => { + const square = answer('get_settlement_summary', { + summary: { currency: 'eur', flows: [], balances: [{ user_id: 7, balance: 0 }], settlements: [] }, + }); + expect(square).toMatchObject({ currency: 'EUR', flows: [], balances: [{ person: MIA, balance: 0 }], recorded: 0 }); + const flows = Array.from({ length: 13 }, () => ({ from: { user_id: 8 }, to: { user_id: 7 }, amount: 1 })); + const long = answer('get_settlement_summary', { summary: { currency: 'EUR', flows } }); + expect(long?.flows).toHaveLength(12); + expect(long?.moreFlows).toBe(1); + }); + + it('ASTCARD-BUD-007: the share per traveller lists the largest share first, with what of it is marked paid', () => { + const shares = answer('get_budget_per_person', { + summary: [ + { user_id: 8, username: 'jon', total_assigned: 61, total_paid: 61, items_count: 2, currency: 'EUR' }, + { user_id: 7, username: 'mia', total_assigned: 69, total_paid: 45, items_count: 2, currency: 'EUR' }, + ], + }); + expect(shares).toEqual({ + kind: 'costPerPerson', + currency: 'EUR', + rows: [ + { person: MIA, share: 69, paid: 45, count: 2 }, + { person: JON, share: 61, paid: 61, count: 2 }, + ], + more: 0, + }); + }); + + it('ASTCARD-BUD-008: the payments read newest first, each on the day it was paid or else recorded', () => { + const payments = answer('list_settlements', { + settlements: [ + { id: 1, from_user_id: 8, to_user_id: 7, amount: 20, currency: null, created_at: '2026-06-01 10:00:00' }, + { + id: 2, + from_user_id: 9, + to_user_id: 7, + amount: 5, + currency: 'usd', + created_at: '2026-06-02 10:00:00', + settled_at: '2026-06-05', + note: 'Cash to mia@example.com', + }, + { id: 3, from_user_id: 9, to_user_id: 8, amount: 7, created_at: '2026-06-05 08:00:00', settled_at: null }, + ], + }); + expect(payments?.rows.map((row) => row.id)).toEqual([3, 2, 1]); + expect(payments?.rows[1]).toMatchObject({ + from: ADA, + to: MIA, + amount: { amount: 5, currency: 'USD' }, + date: '2026-06-05', + change: 'none', + }); + expect(payments?.rows[1]?.note).toContain('Cash to'); + expect(payments?.rows[1]?.note).not.toContain('@'); + expect(payments?.rows[2]).toMatchObject({ amount: { amount: 20, currency: 'EUR' }, date: '2026-06-01' }); + }); + + it('ASTCARD-BUD-009: a list of long names and pictures stays under the size cap by counting the expenses it leaves out', () => { + const roster = Array.from({ length: 20 }, (_, i) => ({ + id: 200 + i, + username: `${'Traveller with a very long name '.repeat(3)}${i}`.slice(0, 80), + avatar_url: `/uploads/avatars/${'a'.repeat(180)}${i}.png`, + })); + const team = { owner: roster[0], collaborators: roster.slice(1) }; + const crowded = buildTripCard(2, 200, { members: team }, null, null); + const members = roster.map((person) => ({ user_id: person.id, amount: null, paid: false })); + const payers = roster.slice(0, 3).map((person) => ({ user_id: person.id, amount: 1 })); + const items = Array.from({ length: 12 }, (_, i) => ({ + id: 300 + i, + name: `${'Dinner at the harbour '.repeat(6)}${i}`, + total_price: 3, + members, + payers, + })); + const input: AnswerInput = { toolCallId: 'r1', args: {}, value: { items }, card: crowded, today: '2026-06-01' }; + const list = answerCardOf('list_budget_items', input) as ExpensesCard | null; + expect(list).not.toBeNull(); + expect(JSON.stringify(list).length).toBeLessThanOrEqual(MAX_CARD_CHARS); + expect(list?.rows.length).toBeLessThan(12); + expect((list?.rows.length ?? 0) + (list?.more ?? 0)).toBe(12); + }); +}); + +describe('change cards', () => { + it('ASTCARD-BUD-010: a new expense is split evenly across everyone when the call names nobody', () => { + const taxi = expensesOf('create_budget_item', { name: 'Taxi', total_price: 10, category: 'transport' }); + expect(taxi).toMatchObject({ id: null, name: 'Taxi', amount: { amount: 10, currency: 'EUR' }, change: 'new' }); + expect(taxi?.shares).toEqual([ + { person: MIA, amount: 3.34, paid: false }, + { person: JON, amount: 3.33, paid: false }, + { person: ADA, amount: 3.33, paid: false }, + ]); + expect(describe_('create_budget_item', { name: 'Taxi', total_price: 10 }).verbKey).toBe('addBudgetItem'); + }); + + it('ASTCARD-BUD-011: named payers set the amount, an empty member list plans only, members split unevenly', () => { + const paid = expensesOf('create_budget_item', { + name: 'Groceries', + total_price: 99, + currency: 'usd', + payers: [ + { user_id: 8, amount: 12.5 }, + { user_id: 9, amount: 7.5 }, + ], + member_ids: [8, 9], + expense_date: '2026-06-04', + place_id: 12, + note: 'Market', + }); + expect(paid).toMatchObject({ + amount: { amount: 20, currency: 'USD' }, + payers: [ + { person: JON, amount: 12.5 }, + { person: ADA, amount: 7.5 }, + ], + date: '2026-06-04', + place: { id: 12, name: 'Time Out Market' }, + note: 'Market', + split: 'equal', + }); + const planned = expensesOf('create_budget_item', { name: 'Hotel', total_price: 300, member_ids: [] }); + expect(planned).toMatchObject({ split: 'none', shares: [], moreShares: 0 }); + const uneven = expensesOf('create_budget_item', { + name: 'Boat', + total_price: 10, + members: [ + { user_id: 7, amount: 6 }, + { user_id: 8, amount: 4 }, + ], + }); + expect(uneven).toMatchObject({ split: 'custom' }); + expect(uneven?.shares.map((share) => [share.person.id, share.amount])).toEqual([ + [7, 6], + [8, 4], + ]); + expect(describe_('create_budget_item', {}).card).toBeUndefined(); + }); + + it('ASTCARD-BUD-012: an expense with members is split by everyone when the list is empty, as the tool does', () => { + const preview = describe_('create_budget_item_with_members', { name: 'Fuel', total_price: 30, userIds: [] }); + expect(preview).toMatchObject({ + verbKey: 'addBudgetItem', + tool: 'create_budget_item_with_members', + undoable: true, + }); + expect(preview.rows).toContainEqual({ op: 'add', labelKey: 'members', before: null, after: 'mia, jon, ada' }); + const row = preview.card?.kind === 'expenses' ? preview.card.rows[0] : undefined; + expect(row?.shares.map((share) => share.person.id)).toEqual([7, 8, 9]); + expect(row?.amount).toEqual({ amount: 30, currency: 'EUR' }); + const two = expensesOf('create_budget_item_with_members', { name: 'Fuel', total_price: 30, userIds: [8, 9] }); + expect(two?.shares.map((share) => share.amount)).toEqual([15, 15]); + }); + + it('ASTCARD-BUD-013: a changed expense shows what it replaces, its new split and who joins it', () => { + const preview = describe_('update_budget_item', { + itemId: 31, + name: 'Dinner for three', + total_price: 120, + member_ids: [7, 8, 9], + }); + // The core's words stay; the card comes on top. + expect(preview).toMatchObject({ verbKey: 'updateBudgetItem', subject: 'Dinner for three' }); + const row = preview.card?.kind === 'expenses' ? preview.card.rows[0] : undefined; + expect(row).toMatchObject({ + id: 31, + name: 'Dinner for three', + amount: { amount: 120, currency: 'EUR' }, + date: '2026-06-03', + receipts: 1, + change: 'changed', + highlight: [9], + before: { name: 'Dinner', amount: { amount: 90, currency: 'EUR' }, category: null, date: null, split: null }, + }); + // Mia's paid tick stays; the even split is worked out again for the new total. + expect(row?.shares).toEqual([ + { person: MIA, amount: 40, paid: true }, + { person: JON, amount: 40, paid: false }, + { person: ADA, amount: 40, paid: false }, + ]); + const cleared = expensesOf('update_budget_item', { itemId: 31, expense_date: null, currency: 'USD' }); + expect(cleared?.before).toMatchObject({ amount: { amount: 90, currency: 'EUR' }, date: '2026-06-03' }); + expect(cleared).toMatchObject({ date: null, amount: { amount: 90, currency: 'USD' } }); + expect(describe_('update_budget_item', { itemId: 99, name: 'Ghost' }).card).toBeUndefined(); + }); + + it('ASTCARD-BUD-014: new members of an expense share it evenly, the paid ticks kept and own amounts dropped', () => { + const preview = describe_('set_budget_item_members', { itemId: 32, userIds: [7, 8, 9] }); + expect(preview).toMatchObject({ verbKey: 'updateBudgetItem', subject: 'Museum', tool: 'set_budget_item_members' }); + expect(preview.rows).toEqual([{ op: 'change', labelKey: 'members', before: 'mia, jon', after: 'mia, jon, ada' }]); + const row = preview.card?.kind === 'expenses' ? preview.card.rows[0] : undefined; + expect(row).toMatchObject({ split: 'equal', highlight: [9], before: { split: 'custom' } }); + expect(row?.shares).toEqual([ + { person: MIA, amount: 16.67, paid: false }, + { person: JON, amount: 16.66, paid: true }, + { person: ADA, amount: 16.67, paid: false }, + ]); + }); + + it('ASTCARD-BUD-015: a paid tick changes one share and names the person it is about', () => { + const preview = describe_('toggle_budget_member_paid', { itemId: 31, memberId: 8, paid: true }); + expect(preview).toMatchObject({ subject: 'Dinner', tool: 'toggle_budget_member_paid' }); + expect(preview.rows).toEqual([{ op: 'add', labelKey: 'members', before: null, after: 'jon ✓' }]); + const row = preview.card?.kind === 'expenses' ? preview.card.rows[0] : undefined; + expect(row?.shares.map((share) => share.paid)).toEqual([true, true]); + expect(row?.highlight).toEqual([8]); + expect(row?.before).toBeNull(); + }); + + it('ASTCARD-BUD-016: a removed expense shows as it is, marked to go; one the trip does not hold reads as its id', () => { + const preview = describe_('delete_budget_item', { itemId: 32 }); + expect(preview).toMatchObject({ verbKey: 'genericRemove', subject: 'Museum', tool: 'delete_budget_item' }); + expect(preview.rows).toEqual([{ op: 'add', labelKey: 'price', before: null, after: 'US$50.00' }]); + const row = preview.card?.kind === 'expenses' ? preview.card.rows[0] : undefined; + expect(row).toMatchObject({ id: 32, change: 'willDelete', amount: { amount: 50, currency: 'USD' } }); + const ghost = describe_('delete_budget_item', { itemId: 99 }); + expect(ghost.subject).toBe('#99'); + expect(ghost.card).toBeUndefined(); + expect(nameArgOf('delete_budget_item', 'itemId', 31, card)).toBe('Dinner'); + expect(nameArgOf('delete_budget_item', 'itemId', 99, card)).toBeNull(); + }); + + it('ASTCARD-BUD-017: a recorded payment names who pays whom, how much and on which day', () => { + const args = { from_user_id: 8, to_user_id: 7, amount: 42.5, settled_at: '2026-06-07', note: 'Bank' }; + const preview = describe_('create_settlement', args); + expect(preview).toMatchObject({ + verbKey: 'addPayment', + subject: 'jon → mia', + tool: 'create_settlement', + undoable: true, + }); + expect(preview.rows.map((row) => row.labelKey)).toEqual(['price', 'day', 'notes']); + expect(paymentOf('create_settlement', args)).toEqual({ + id: null, + from: JON, + to: MIA, + amount: { amount: 42.5, currency: 'EUR' }, + date: '2026-06-07', + note: 'Bank', + before: null, + change: 'new', + }); + expect(describe_('create_settlement', { from_user_id: 8, to_user_id: 7, amount: 0 }).card).toBeUndefined(); + }); + + it('ASTCARD-BUD-018: a changed payment shows the people and amount it replaces, a removed one as it is', () => { + const stored = { + id: 5, + from_user_id: 8, + to_user_id: 7, + amount: 20, + currency: 'EUR', + created_at: '2026-06-02 09:00:00', + settled_at: null, + note: 'cash', + }; + const loaded = { settlements: [stored] }; + const args = { settlementId: 5, from_user_id: 9, to_user_id: 7, amount: 25 }; + const changed = describe_('update_settlement', args, loaded); + expect(changed).toMatchObject({ verbKey: 'updatePayment', subject: 'ada → mia', tool: 'update_settlement' }); + expect(changed.rows).toEqual([{ op: 'change', labelKey: 'price', before: '€20.00', after: '€25.00' }]); + const row = changed.card?.kind === 'payments' ? changed.card.rows[0] : undefined; + expect(row).toMatchObject({ + id: 5, + from: ADA, + amount: { amount: 25, currency: 'EUR' }, + date: '2026-06-02', + note: 'cash', + change: 'changed', + before: { from: JON, to: null, amount: { amount: 20, currency: 'EUR' } }, + }); + expect(paymentOf('delete_settlement', { settlementId: 5 }, loaded)).toMatchObject({ id: 5, change: 'willDelete' }); + const unknown = describe_('delete_settlement', { settlementId: 6 }, loaded); + expect(unknown).toMatchObject({ verbKey: 'genericRemove', subject: '#6' }); + expect(unknown.card).toBeUndefined(); + }); + + it('ASTCARD-BUD-019: a change to a payment looks the payments up once, through the turn reader', async () => { + const read = vi.fn(async (_tool: string, _args?: Record) => ({ settlements: [] })); + expect(await loadFor('update_settlement', { settlementId: 5 }, read)).toEqual({ settlements: [] }); + expect(await loadFor('delete_settlement', { settlementId: 5 }, read)).toEqual({ settlements: [] }); + expect(read.mock.calls.map((call) => call[0])).toEqual(['list_settlements', 'list_settlements']); + expect(await loadFor('create_settlement', {}, read)).toBeNull(); + expect(read).toHaveBeenCalledTimes(2); + }); + + it('ASTCARD-BUD-020: every budget write reads without arguments, and its card names the verb it declares', () => { + const tools = [ + 'create_budget_item_with_members', + 'set_budget_item_members', + 'toggle_budget_member_paid', + 'delete_budget_item', + 'create_settlement', + 'update_settlement', + 'delete_settlement', + ]; + for (const tool of tools) { + const preview = describe_(tool, {}); + expect(assistantChangePreviewSchema.safeParse(preview).success, tool).toBe(true); + expect(preview.verbKey, tool).toBe(ASSISTANT_WRITE_TOOLS[tool]?.verbKey); + expect(preview.card, tool).toBeUndefined(); + } + }); +}); + +describe('receipts and the way back', () => { + it('ASTCARD-BUD-021: a payment receipt names the payment, an expense receipt the expense', () => { + const receipt = (tool: string, args: Record, result: unknown) => + appliedChange(tool, 'tc', args, result, null, null); + expect(receipt('create_settlement', {}, { settlement: { id: 77 } })).toMatchObject({ + entity: 'settlement', + ids: [77], + }); + expect(receipt('update_settlement', { settlementId: 5 }, { settlement: {} })).toMatchObject({ ids: [5] }); + expect(receipt('delete_settlement', { settlementId: 5 }, { success: true })).toMatchObject({ ids: [5] }); + expect(receipt('toggle_budget_member_paid', { itemId: 31 }, { member: {} })).toMatchObject({ + entity: 'budget', + ids: [31], + }); + expect(receipt('set_budget_item_members', { itemId: 32 }, { item: { id: 32 } })?.ids).toEqual([32]); + }); + + it('ASTCARD-BUD-022: an applied create keeps its card with the id the answer names', () => { + const preview = describe_('create_budget_item', { name: 'Taxi', total_price: 10 }); + const expense = appliedCardOf('create_budget_item', { args: {}, result: { item: { id: 40 } }, preview }); + expect(expense?.kind === 'expenses' && expense.rows[0]?.id).toBe(40); + const payment = describe_('create_settlement', { from_user_id: 8, to_user_id: 7, amount: 5 }); + const recorded = { settlement: { id: 9 } }; + const applied = appliedCardOf('create_settlement', { args: {}, result: recorded, preview: payment }); + expect(applied?.kind === 'payments' && applied.rows[0]?.id).toBe(9); + // A card of another kind, or an answer that names no id, leaves the waiting card as it was. + expect(appliedCardOf('create_settlement', { args: {}, result: { settlement: { id: 9 } }, preview })).toBeNull(); + expect(appliedCardOf('create_budget_item', { args: {}, result: { success: true }, preview })).toBeNull(); + }); + + it('ASTCARD-BUD-023: a recorded payment is taken back while nobody changed it since', () => { + const created = { + id: 77, + from_user_id: 8, + to_user_id: 7, + amount: 5, + currency: 'EUR', + settled_at: null, + note: null, + }; + const result = { settlement: created }; + expect(ASSISTANT_UNDO_MAP.create_settlement?.tool).toBe('delete_settlement'); + expect(ASSISTANT_UNDO_MAP.create_settlement?.args({}, result, null)).toEqual({ settlementId: 77 }); + expect(ASSISTANT_UNDO_MAP.create_settlement?.args({}, { success: true }, null)).toBeNull(); + expect([...undoTargetsOf('delete_settlement', 'create_settlement', {}, result)]).toContain('list_settlements'); + const snapshot = (settlements: unknown[]) => ({ + summary: {}, + places: {}, + reads: new Map([[readKey('list_settlements', {}), { settlements }]]), + }); + expect(unchangedSinceApplied('create_settlement', result, snapshot([created]))).toBe(true); + const edited = { ...created, note: 'paid back' }; + expect(unchangedSinceApplied('create_settlement', result, snapshot([edited]))).toBe(false); + expect(unchangedSinceApplied('create_settlement', result, snapshot([]))).toBe(false); + }); + + it('ASTCARD-BUD-024: an expense created with its split is taken back while it is as it was created', () => { + const created = { id: 31, name: 'Dinner', total_price: 90, category: 'food' }; + const undo = ASSISTANT_UNDO_MAP.create_budget_item_with_members; + expect(undo?.tool).toBe('delete_budget_item'); + expect(undo?.args({}, { item: created }, null)).toEqual({ itemId: 31 }); + const snapshot = { summary: SUMMARY, places: {} }; + expect(unchangedSinceApplied('create_budget_item_with_members', { item: created }, snapshot)).toBe(true); + const renamed = { ...created, name: 'Lunch' }; + expect(unchangedSinceApplied('create_budget_item_with_members', { item: renamed }, snapshot)).toBe(false); + expect(CARD_PROMPT_LINES.some((line) => line.includes('list_budget_items'))).toBe(true); + }); +}); + +describe('edges', () => { + it('ASTCARD-BUD-025: a later page of the list says where its expenses start, so pages join only where they meet', () => { + const items = [{ id: 120, name: 'Taxi', total_price: 7 }]; + const page = answer('list_budget_items', { currency: 'EUR', item_count: 21, offset: 20, items }); + expect(page).toMatchObject({ offset: 20, more: 20 }); + expect(answer('list_budget_items', { items, offset: 'twenty' })?.offset).toBe(0); + }); + + it('ASTCARD-BUD-026: an update that clears the payers but names a total keeps that total, as the write path does', () => { + const row = expensesOf('update_budget_item', { itemId: 31, payers: [], total_price: 75 }); + expect(row).toMatchObject({ amount: { amount: 75, currency: 'EUR' }, payers: [] }); + expect(row?.before?.amount).toEqual({ amount: 90, currency: 'EUR' }); + const unpaid = expensesOf('update_budget_item', { itemId: 31, payers: [] }); + expect(unpaid?.amount).toEqual({ amount: 0, currency: 'EUR' }); + }); + + it('ASTCARD-BUD-027: a split of more people than a card may count says the most it may', () => { + const members = Array.from({ length: 1100 }, (_, i) => ({ user_id: 1000 + i, amount: null, paid: false })); + const items = [{ id: 5, name: 'Festival', total_price: 1100, members }]; + const list = answer('list_budget_items', { items }); + expect(list?.rows[0]?.shares).toHaveLength(6); + expect(list?.rows[0]?.moreShares).toBe(1000); + }); +}); diff --git a/server/tests/unit/nest/budget-items.mcp.test.ts b/server/tests/unit/nest/budget-items.mcp.test.ts index ef803e9b6..91658c00d 100644 --- a/server/tests/unit/nest/budget-items.mcp.test.ts +++ b/server/tests/unit/nest/budget-items.mcp.test.ts @@ -155,6 +155,7 @@ describe('list_budget_items', () => { category: 'food', total_price: 90, currency: 'EUR', + trip_amount: null, expense_date: '2026-05-02', note: 'Window table, tip included', place_id: f.place.id, @@ -164,6 +165,7 @@ describe('list_budget_items', () => { { user_id: f.owner.id, amount: null, paid: true }, { user_id: f.guest.id, amount: null, paid: true }, ], + receipt_count: 0, }, { id: f.museum.id, @@ -171,6 +173,8 @@ describe('list_budget_items', () => { category: 'activities', total_price: 50, currency: 'USD', + // 50 USD at the 1.25 frozen when it was booked. + trip_amount: 40, expense_date: null, note: null, place_id: null, @@ -180,6 +184,7 @@ describe('list_budget_items', () => { { user_id: f.owner.id, amount: 30, paid: false }, { user_id: f.guest.id, amount: 20, paid: true }, ], + receipt_count: 0, }, ], people: [ @@ -248,6 +253,8 @@ describe('list_budget_items', () => { const answer = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id))); expect(answer).toMatchObject({ total: 200, by_category: { accommodation: 200 }, unconverted_item_ids: [pho.id] }); expect(getRates).toHaveBeenCalledWith('EUR'); + // Nothing converts it, so it names no amount in the trip currency either. + expect(answer.items.find((item: { id: number }) => item.id === pho.id)).toMatchObject({ trip_amount: null }); }); it('BGI-MCP-014: someone who cannot open the trip gets the trip refusal and no row', async () => { @@ -274,6 +281,40 @@ describe('list_budget_items', () => { expect(answer.people).toEqual([]); }); + it('BGI-MCP-019: a foreign expense says what it comes to in the trip currency, and every expense how many receipts it has', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { currency: 'EUR' }); + const receipt = { filename: 'r.pdf', original_name: 'r.pdf', file_size: 1, mime_type: 'application/pdf' }; + const items: BudgetItem[] = [ + { + id: 8, + trip_id: trip.id, + category: 'food', + name: 'Diner', + total_price: 30, + currency: 'USD', + exchange_rate: 1.2, + sort_order: 0, + receipts: [ + { id: 1, url: '/a', ...receipt }, + { id: 2, url: '/b', ...receipt }, + ], + }, + { id: 9, trip_id: trip.id, category: 'food', name: 'Bakery', total_price: 4, currency: 'eur', sort_order: 1 }, + ]; + vi.spyOn(t.get(BudgetService), 'listBudgetItems').mockResolvedValue(items); + + const answer = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id))); + + // 30 USD at the 1.2 frozen when it was booked; a lower-case trip currency is the trip's own. + expect(answer.items).toEqual([ + expect.objectContaining({ id: 8, currency: 'USD', trip_amount: 25, receipt_count: 2 }), + expect.objectContaining({ id: 9, trip_amount: null, receipt_count: 0 }), + ]); + // A count, never the files: no receipt name or link reaches the model. + expect(JSON.stringify(answer)).not.toMatch(/r\.pdf|receipts/); + }); + it('BGI-MCP-016: a page of long expenses ends before the assistant cuts the answer, and next_offset starts at the first one it left out', async () => { const { user } = await makeUser(orm, { username: 'organiser' }); const trip = await makeTrip(orm, user.id, { currency: 'EUR' }); diff --git a/shared/src/assistant/cards/budget-cards.spec.ts b/shared/src/assistant/cards/budget-cards.spec.ts new file mode 100644 index 000000000..63197b94e --- /dev/null +++ b/shared/src/assistant/cards/budget-cards.spec.ts @@ -0,0 +1,154 @@ +import { ASSISTANT_CHANGE_VERB_KEYS, assistantAppliedSchema, assistantChangePreviewSchema } from '../assistant.schema'; +import { BUDGET_APPLIED_ENTITIES, BUDGET_CARD_SCHEMAS, BUDGET_VERB_KEYS } from './budget.contract'; +import { costPerPersonCardSchema, type CostPerPersonCard } from './cost-per-person.card'; +import { EXPENSE_PEOPLE_MAX, expensesCardSchema, type CardExpense, type ExpensesCard } from './expenses.card'; +import { storedTrekCardSchema, trekCardSchema } from './index'; +import { paymentsCardSchema, type PaymentsCard } from './payments.card'; +import { settlementCardSchema, type SettlementCard } from './settlement.card'; + +import { describe, expect, it } from 'vitest'; + +const ana = { id: 7, name: 'Ana', avatarKey: 'ana.png' }; +const ben = { id: 8, name: 'Ben', avatarKey: null }; + +const dinner: CardExpense = { + id: 31, + name: 'Dinner at Lokanta', + category: 'food', + amount: { amount: 84, currency: 'EUR' }, + tripAmount: null, + date: '2026-06-03', + place: { id: 12, name: 'Lokanta' }, + payers: [{ person: ana, amount: 84 }], + split: 'equal', + shares: [ + { person: ana, amount: 42, paid: true }, + { person: ben, amount: 42, paid: false }, + ], + moreShares: 0, + note: null, + receipts: 2, + before: null, + highlight: [], + change: 'none', +}; + +const expenses: ExpensesCard = { + kind: 'expenses', + rows: [dinner], + offset: 0, + more: 13, + total: { amount: 1204.5, currency: 'EUR' }, + unconverted: 1, +}; + +const payments: PaymentsCard = { + kind: 'payments', + rows: [ + { + id: null, + from: ben, + to: ana, + amount: { amount: 42.5, currency: 'EUR' }, + date: '2026-06-07', + note: 'Bank transfer', + before: { from: ana, to: null, amount: { amount: 40, currency: 'EUR' } }, + change: 'changed', + }, + ], + more: 0, +}; + +const settlement: SettlementCard = { + kind: 'settlement', + currency: 'EUR', + flows: [{ from: ben, to: ana, amount: 42.5 }], + moreFlows: 0, + balances: [ + { person: ana, balance: 42.5 }, + { person: ben, balance: -42.5 }, + ], + moreBalances: 0, + recorded: 3, + unconverted: { entries: 2, currencies: ['USD'] }, +}; + +const perPerson: CostPerPersonCard = { + kind: 'costPerPerson', + currency: 'EUR', + rows: [{ person: ana, share: 69, paid: 45, count: 2 }], + more: 0, +}; + +describe('budget cards', () => { + it('CARDS-BUD-001: the four kinds join the card union and read back as built', () => { + expect(BUDGET_CARD_SCHEMAS).toEqual([ + expensesCardSchema, + paymentsCardSchema, + settlementCardSchema, + costPerPersonCardSchema, + ]); + for (const card of [expenses, payments, settlement, perPerson]) { + expect(trekCardSchema.parse(card)).toEqual(card); + expect(storedTrekCardSchema.parse(card)).toEqual(card); + } + }); + + it('CARDS-BUD-002: a waiting change states what it replaces and whom it is about', () => { + const update: CardExpense = { + ...dinner, + amount: { amount: 96, currency: 'EUR' }, + before: { name: null, amount: { amount: 84, currency: 'EUR' }, category: null, date: null, split: 'custom' }, + highlight: [8], + change: 'changed', + }; + expect(expensesCardSchema.parse({ ...expenses, rows: [update], total: null }).rows[0]).toEqual(update); + expect(expensesCardSchema.safeParse({ ...expenses, rows: [{ ...update, change: 'moved' }] }).success).toBe(false); + // A refund is a negative amount; nothing past a billion is. + const refund = { ...dinner, amount: { amount: -20, currency: 'EUR' } }; + expect(expensesCardSchema.safeParse({ ...expenses, rows: [refund] }).success).toBe(true); + expect(expensesCardSchema.safeParse({ ...expenses, total: { amount: 2e9, currency: 'EUR' } }).success).toBe(false); + }); + + it('CARDS-BUD-003: lists and splits are bounded, and a card past a bound reads as no card', () => { + const many = Array.from({ length: EXPENSE_PEOPLE_MAX + 1 }, (_, i) => ({ + person: { ...ben, id: 100 + i }, + amount: 1, + paid: false, + })); + expect(storedTrekCardSchema.parse({ ...expenses, rows: [{ ...dinner, shares: many }] })).toBeNull(); + expect(storedTrekCardSchema.parse({ ...expenses, rows: Array.from({ length: 13 }, () => dinner) })).toBeNull(); + expect(storedTrekCardSchema.parse({ ...expenses, rows: [] })).toBeNull(); + expect(storedTrekCardSchema.parse({ ...expenses, offset: -1 })).toBeNull(); + const free = { ...payments.rows[0], amount: { amount: 0, currency: 'EUR' } }; + expect(storedTrekCardSchema.parse({ ...payments, rows: [free] })).toBeNull(); + expect(storedTrekCardSchema.parse({ ...settlement, currency: 'euro' })).toBeNull(); + expect(storedTrekCardSchema.parse({ ...perPerson, rows: [] })).toBeNull(); + }); + + it('CARDS-BUD-004: people carry a stored file name only, never an address', () => { + const linked = { ...ana, avatarKey: 'https://sso.example/ana.png' }; + expect(storedTrekCardSchema.parse({ ...perPerson, rows: [{ ...perPerson.rows[0], person: linked }] })).toBeNull(); + const upward = { from: ana, to: { ...ben, avatarKey: '../x' }, amount: 1 }; + expect(storedTrekCardSchema.parse({ ...settlement, flows: [upward] })).toBeNull(); + }); + + it('CARDS-BUD-005: a payment change reads its verb, and a payment receipt its own entity', () => { + expect(BUDGET_VERB_KEYS).toEqual(['addPayment', 'updatePayment']); + for (const verb of BUDGET_VERB_KEYS) expect(ASSISTANT_CHANGE_VERB_KEYS).toContain(verb); + const preview = { + toolCallId: 'c1', + verbKey: 'addPayment', + subject: 'Ben → Ana', + rows: [], + dayId: null, + placeIds: [], + undoable: true, + card: payments, + }; + expect(assistantChangePreviewSchema.parse(preview)).toMatchObject({ verbKey: 'addPayment', card: payments }); + expect(BUDGET_APPLIED_ENTITIES).toEqual(['settlement']); + const receipt = { toolCallId: 'c1', entity: 'settlement', ids: [5], dayId: null, undoUntil: null }; + expect(assistantAppliedSchema.parse(receipt).entity).toBe('settlement'); + }); +}); diff --git a/shared/src/assistant/cards/budget.contract.ts b/shared/src/assistant/cards/budget.contract.ts index 0b80379e5..7a3a7bcb8 100644 --- a/shared/src/assistant/cards/budget.contract.ts +++ b/shared/src/assistant/cards/budget.contract.ts @@ -21,5 +21,7 @@ export const BUDGET_CARD_SCHEMAS = [ settlementCardSchema, costPerPersonCardSchema, ] as const; -export const BUDGET_VERB_KEYS = [] as const; -export const BUDGET_APPLIED_ENTITIES = [] as const; +/** A settle-up payment recorded or changed; removing one is a plain removal. */ +export const BUDGET_VERB_KEYS = ['addPayment', 'updatePayment'] as const; +/** A settle-up payment: the receipt opens the Costs tab, where the payments are listed. */ +export const BUDGET_APPLIED_ENTITIES = ['settlement'] as const; diff --git a/shared/src/assistant/cards/cost-per-person.card.ts b/shared/src/assistant/cards/cost-per-person.card.ts index 19385bbdb..d91cce7c0 100644 --- a/shared/src/assistant/cards/cost-per-person.card.ts +++ b/shared/src/assistant/cards/cost-per-person.card.ts @@ -1,5 +1,30 @@ +import { cardCurrencySchema, cardPersonSchema } from './card-parts'; + import { z } from 'zod'; -/** What the trip costs each traveller. Only its kind so far; the budget cards give it its fields. */ -export const costPerPersonCardSchema = z.object({ kind: z.literal('costPerPerson') }); +/** + * What the trip's expenses come to for each traveller, in the trip + * currency: the sum of their shares, how much of that is marked paid, and in + * how many expenses they take part. Largest share first. + */ + +/** Travellers one card names; the rest are counted in `more`. */ +export const COST_PER_PERSON_ROWS_MAX = 20; + +export const costPerPersonCardSchema = z.object({ + kind: z.literal('costPerPerson'), + currency: cardCurrencySchema, + rows: z + .array( + z.object({ + person: cardPersonSchema, + share: z.number().min(-1e9).max(1e9), + paid: z.number().min(-1e9).max(1e9), + count: z.number().int().min(0).max(100_000), + }), + ) + .min(1) + .max(COST_PER_PERSON_ROWS_MAX), + more: z.number().int().min(0).max(1000), +}); export type CostPerPersonCard = z.infer; diff --git a/shared/src/assistant/cards/expenses.card.ts b/shared/src/assistant/cards/expenses.card.ts index 1ad3820a1..46b20ccaa 100644 --- a/shared/src/assistant/cards/expenses.card.ts +++ b/shared/src/assistant/cards/expenses.card.ts @@ -1,8 +1,102 @@ +import { idSchema } from '../../common/primitives.schema'; +import { cardDateSchema, cardMoneySchema, cardPersonSchema, cardText } from './card-parts'; + import { z } from 'zod'; /** - * Expenses of the trip, with who paid and who shares them. Only its kind so far; the budget cards give - * it its fields. + * Expenses of the trip as the Costs page shows them: the name, the amount in + * the currency it was paid in (and in the trip currency when that differs and + * a rate is known), the day, the place it belongs to, who paid how much and + * who shares it. One card serves the expense list (`list_budget_items`) and + * every change to a single expense: the row of a change carries what it + * replaces (`before`), the people it is about (`highlight`) and what happens + * to it (`change`). Amounts are numbers with a currency code; the client + * writes them the way the Costs page does. */ -export const expensesCardSchema = z.object({ kind: z.literal('expenses') }); + +/** Rows one expenses card lists; the rest are counted in `more`. */ +export const EXPENSE_ROWS_MAX = 12; +/** Payers or shares one row names; the rest of a split are counted in `moreShares`. */ +export const EXPENSE_PEOPLE_MAX = 20; + +/** A signed amount: a refund is negative (#2176). */ +const cardAmountSchema = z.number().min(-1e9).max(1e9); + +/** How an expense is shared: evenly, in amounts of its own per person, or by nobody (planning only). */ +export const expenseSplitSchema = z.enum(['equal', 'custom', 'none']); + +/** What a change does to a row; an answer's rows are `none`. */ +export const expenseChangeSchema = z.enum(['none', 'new', 'changed', 'willDelete']); + +/** One person's part of an expense, and whether they have paid it. */ +export const cardExpenseShareSchema = z.object({ + person: cardPersonSchema, + amount: cardAmountSchema, + paid: z.boolean(), +}); + +/** Someone who paid for the expense, and how much. */ +export const cardExpensePayerSchema = z.object({ person: cardPersonSchema, amount: cardAmountSchema }); + +/** + * The values a waiting update replaces, each null where the update leaves it + * alone: a category or a date that was empty before reads as unchanged. + */ +export const cardExpenseBeforeSchema = z.object({ + name: cardText(120).nullable(), + amount: cardMoneySchema.nullable(), + category: z.string().max(60).nullable(), + date: cardDateSchema.nullable(), + split: expenseSplitSchema.nullable(), +}); + +export const cardExpenseSchema = z.object({ + /** Null while a new expense waits for its decision. */ + id: idSchema.nullable(), + name: cardText(120), + /** As stored; the client maps it to a Costs category, legacy free text included. */ + category: z.string().max(60).nullable(), + /** The total in the currency it was entered in (the trip's when it names none). */ + amount: cardMoneySchema, + /** The total in the trip currency, only when that differs and a rate converts it. */ + tripAmount: cardMoneySchema.nullable(), + date: cardDateSchema.nullable(), + place: z.object({ id: idSchema, name: cardText(120) }).nullable(), + payers: z.array(cardExpensePayerSchema).max(EXPENSE_PEOPLE_MAX), + split: expenseSplitSchema, + shares: z.array(cardExpenseShareSchema).max(EXPENSE_PEOPLE_MAX), + /** Shares of the split past the ones named. */ + moreShares: z.number().int().min(0).max(1000), + /** A change row's note; list rows leave it out. */ + note: cardText(160).nullable(), + /** How many receipts are attached; never the files themselves. */ + receipts: z.number().int().min(0).max(999), + before: cardExpenseBeforeSchema.nullable(), + /** The people the change is about: a paid tick set, someone joining the split. */ + highlight: z.array(idSchema).max(EXPENSE_PEOPLE_MAX), + change: expenseChangeSchema, +}); + +export const expensesCardSchema = z.object({ + kind: z.literal('expenses'), + rows: z.array(cardExpenseSchema).min(1).max(EXPENSE_ROWS_MAX), + /** + * Where in the trip's list the rows start: a later page of the list starts + * past 0, so two pages read as one list only where they meet. 0 on a change. + */ + offset: z.number().int().min(0).max(100_000), + /** Expenses of the trip the card leaves out. */ + more: z.number().int().min(0).max(100_000), + /** The list's total over every expense, in the trip currency; null on a change. */ + total: cardMoneySchema.nullable(), + /** Expenses the total leaves out: foreign ones no rate converts yet. */ + unconverted: z.number().int().min(0).max(100_000), +}); + export type ExpensesCard = z.infer; +export type CardExpense = z.infer; +export type CardExpenseShare = z.infer; +export type CardExpensePayer = z.infer; +export type CardExpenseBefore = z.infer; +export type ExpenseSplit = z.infer; +export type ExpenseChange = z.infer; diff --git a/shared/src/assistant/cards/payments.card.ts b/shared/src/assistant/cards/payments.card.ts index d69936197..34679a7ec 100644 --- a/shared/src/assistant/cards/payments.card.ts +++ b/shared/src/assistant/cards/payments.card.ts @@ -1,5 +1,51 @@ +import { idSchema } from '../../common/primitives.schema'; +import { cardCurrencySchema, cardDateSchema, cardPersonSchema, cardText } from './card-parts'; +import { expenseChangeSchema } from './expenses.card'; + import { z } from 'zod'; -/** Payments between the travellers. Only its kind so far; the budget cards give it its fields. */ -export const paymentsCardSchema = z.object({ kind: z.literal('payments') }); +/** + * Settle-up payments between the travellers, as the Costs page lists them + * under its payment history: who paid whom, how much, on which day, with the + * note it carries. One card serves the list (`list_settlements`) and every + * change to one payment; a waiting change names what it replaces in `before`. + */ + +/** Payments one card lists; the rest are counted in `more`. */ +export const PAYMENT_ROWS_MAX = 12; + +/** What one payment moved: always more than nothing. */ +export const cardPaymentMoneySchema = z.object({ + amount: z.number().positive().max(1e9), + currency: cardCurrencySchema, +}); + +/** The values a waiting update of a payment replaces, each null where it stays. */ +export const cardPaymentBeforeSchema = z.object({ + from: cardPersonSchema.nullable(), + to: cardPersonSchema.nullable(), + amount: cardPaymentMoneySchema.nullable(), +}); + +export const cardPaymentSchema = z.object({ + /** Null while a new payment waits for its decision. */ + id: idSchema.nullable(), + from: cardPersonSchema, + to: cardPersonSchema, + amount: cardPaymentMoneySchema, + /** The day it was paid, else the day it was recorded. */ + date: cardDateSchema.nullable(), + note: cardText(120).nullable(), + before: cardPaymentBeforeSchema.nullable(), + change: expenseChangeSchema, +}); + +export const paymentsCardSchema = z.object({ + kind: z.literal('payments'), + rows: z.array(cardPaymentSchema).min(1).max(PAYMENT_ROWS_MAX), + more: z.number().int().min(0).max(100_000), +}); + export type PaymentsCard = z.infer; +export type CardPayment = z.infer; +export type CardPaymentMoney = z.infer; diff --git a/shared/src/assistant/cards/settlement.card.ts b/shared/src/assistant/cards/settlement.card.ts index f7c0d6097..23bee2747 100644 --- a/shared/src/assistant/cards/settlement.card.ts +++ b/shared/src/assistant/cards/settlement.card.ts @@ -1,5 +1,36 @@ +import { cardCurrencySchema, cardPersonSchema } from './card-parts'; + import { z } from 'zod'; -/** Who pays whom to settle up. Only its kind so far; the budget cards give it its fields. */ -export const settlementCardSchema = z.object({ kind: z.literal('settlement') }); +/** + * Settle up as the Costs page shows it: the payments that would square the + * trip (who pays whom), each traveller's balance (positive is owed money, + * negative owes it), how many payments were already recorded, and the + * entries no exchange rate converts yet, which count in no figure. Every + * amount is in `currency`. + */ + +/** Suggested payments one card names; the rest are counted in `moreFlows`. */ +export const SETTLEMENT_FLOWS_MAX = 12; +/** Balances one card names; the rest are counted in `moreBalances`. */ +export const SETTLEMENT_BALANCES_MAX = 20; + +export const settlementCardSchema = z.object({ + kind: z.literal('settlement'), + currency: cardCurrencySchema, + flows: z + .array(z.object({ from: cardPersonSchema, to: cardPersonSchema, amount: z.number().positive().max(1e9) })) + .max(SETTLEMENT_FLOWS_MAX), + moreFlows: z.number().int().min(0).max(1000), + balances: z + .array(z.object({ person: cardPersonSchema, balance: z.number().min(-1e9).max(1e9) })) + .max(SETTLEMENT_BALANCES_MAX), + moreBalances: z.number().int().min(0).max(1000), + /** Payments already recorded. */ + recorded: z.number().int().min(0).max(100_000), + unconverted: z.object({ + entries: z.number().int().min(0).max(100_000), + currencies: z.array(cardCurrencySchema).max(6), + }), +}); export type SettlementCard = z.infer; diff --git a/shared/src/i18n/ar/assistantBudget.ts b/shared/src/i18n/ar/assistantBudget.ts index 838ca0be1..d38753a8a 100644 --- a/shared/src/i18n/ar/assistantBudget.ts +++ b/shared/src/i18n/ar/assistantBudget.ts @@ -1,4 +1,42 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'تسجيل دفعة', + 'assistant.change.verb.updatePayment': 'تغيير دفعة', + 'assistant.tool.create_budget_item_with_members': 'إضافة مصروف', + 'assistant.tool.set_budget_item_members': 'تغيير من يتقاسم التكلفة', + 'assistant.tool.toggle_budget_member_paid': 'تحديث من دفع', + 'assistant.tool.create_settlement': 'تسجيل دفعة', + 'assistant.tool.update_settlement': 'تغيير هذه الدفعة', + 'assistant.tool.delete_settlement': 'حذف هذه الدفعة', + 'assistant.tool.freeze_budget_rates': 'تثبيت أسعار الصرف لليوم', + 'assistant.followUp.whoOwes': 'من يدين لمن؟', + 'assistant.followUp.recordPayments': 'تسجيل هذه الدفعات', + 'assistant.card.budget.open': 'فتح في التكاليف', + 'assistant.card.budget.paidBy': 'دفع {name}', + 'assistant.card.budget.notPaid': 'غير مدفوع', + 'assistant.card.budget.split.equal': 'مقسوم بالتساوي', + 'assistant.card.budget.split.custom': 'مقسوم بمبالغ مختلفة', + 'assistant.card.budget.split.none': 'للتخطيط فقط، دون تقسيم', + 'assistant.card.budget.together': 'المجموع {amount}', + 'assistant.card.budget.markedPaid': '{amount} معلَّم كمدفوع', + 'assistant.card.budget.receipts': '{count} إيصال', + 'assistant.card.budget.receipts.zero': '{count} إيصال', + 'assistant.card.budget.receipts.one': '{count} إيصال', + 'assistant.card.budget.receipts.two': '{count} إيصالان', + 'assistant.card.budget.receipts.few': '{count} إيصالات', + 'assistant.card.budget.receipts.many': '{count} إيصالًا', + 'assistant.card.budget.leftOut': '{count} مصروف بعملة أجنبية خارج المجموع: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.leftOut.zero': '{count} مصروف بعملة أجنبية خارج المجموع: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.leftOut.one': '{count} مصروف بعملة أجنبية خارج المجموع: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.leftOut.two': '{count} مصروفان بعملة أجنبية خارج المجموع: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.leftOut.few': '{count} مصاريف بعملة أجنبية خارج المجموع: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.leftOut.many': '{count} مصروفًا بعملة أجنبية خارج المجموع: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.unconverted': '{count} قيد بعملة {currencies} لا يُحتسب: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.unconverted.zero': '{count} قيد بعملة {currencies} لا يُحتسب: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.unconverted.one': '{count} قيد بعملة {currencies} لا يُحتسب: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.unconverted.two': '{count} قيدان بعملة {currencies} لا يُحتسبان: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.unconverted.few': '{count} قيود بعملة {currencies} لا تُحتسب: لا يوجد سعر صرف بعد.', + 'assistant.card.budget.unconverted.many': '{count} قيدًا بعملة {currencies} لا يُحتسب: لا يوجد سعر صرف بعد.', +}; export default assistantBudget; diff --git a/shared/src/i18n/az/assistantBudget.ts b/shared/src/i18n/az/assistantBudget.ts index 838ca0be1..8668a98ca 100644 --- a/shared/src/i18n/az/assistantBudget.ts +++ b/shared/src/i18n/az/assistantBudget.ts @@ -1,4 +1,30 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Ödəniş qeyd et', + 'assistant.change.verb.updatePayment': 'Ödənişi dəyiş', + 'assistant.tool.create_budget_item_with_members': 'Xərc əlavə et', + 'assistant.tool.set_budget_item_members': 'Xərci kimlərin bölüşdüyünü dəyiş', + 'assistant.tool.toggle_budget_member_paid': 'Kimin ödədiyini yenilə', + 'assistant.tool.create_settlement': 'Ödəniş qeyd et', + 'assistant.tool.update_settlement': 'Bu ödənişi dəyiş', + 'assistant.tool.delete_settlement': 'Bu ödənişi sil', + 'assistant.tool.freeze_budget_rates': 'Bugünkü valyuta məzənnələrini sabitlə', + 'assistant.followUp.whoOwes': 'Kim kimə borcludur?', + 'assistant.followUp.recordPayments': 'Bu ödənişləri qeyd et', + 'assistant.card.budget.open': 'Xərclər bölməsində aç', + 'assistant.card.budget.paidBy': '{name} ödədi', + 'assistant.card.budget.notPaid': 'Ödənilməyib', + 'assistant.card.budget.split.equal': 'Bərabər bölünüb', + 'assistant.card.budget.split.custom': 'Fərqli məbləğlərlə bölünüb', + 'assistant.card.budget.split.none': 'Yalnız planlaşdırma, bölünməyib', + 'assistant.card.budget.together': 'Cəmi {amount}', + 'assistant.card.budget.markedPaid': '{amount} ödənilmiş kimi qeyd olunub', + 'assistant.card.budget.receipts': '{count} qəbz', + 'assistant.card.budget.receipts.one': '{count} qəbz', + 'assistant.card.budget.leftOut': 'Xarici valyutada {count} xərc cəmə daxil edilmir: hələ məzənnə yoxdur.', + 'assistant.card.budget.leftOut.one': 'Xarici valyutada {count} xərc cəmə daxil edilmir: hələ məzənnə yoxdur.', + 'assistant.card.budget.unconverted': '{currencies} ilə {count} qeyd hesablanmır: hələ məzənnə yoxdur.', + 'assistant.card.budget.unconverted.one': '{currencies} ilə {count} qeyd hesablanmır: hələ məzənnə yoxdur.', +}; export default assistantBudget; diff --git a/shared/src/i18n/br/assistantBudget.ts b/shared/src/i18n/br/assistantBudget.ts index 838ca0be1..a725afbc8 100644 --- a/shared/src/i18n/br/assistantBudget.ts +++ b/shared/src/i18n/br/assistantBudget.ts @@ -1,4 +1,34 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Registrar um pagamento', + 'assistant.change.verb.updatePayment': 'Alterar um pagamento', + 'assistant.tool.create_budget_item_with_members': 'Adicionar despesa', + 'assistant.tool.set_budget_item_members': 'Alterar quem divide o custo', + 'assistant.tool.toggle_budget_member_paid': 'Atualizar quem já pagou', + 'assistant.tool.create_settlement': 'Registrar um pagamento', + 'assistant.tool.update_settlement': 'Alterar o pagamento', + 'assistant.tool.delete_settlement': 'Excluir o pagamento', + 'assistant.tool.freeze_budget_rates': 'Fixar as taxas de câmbio de hoje', + 'assistant.followUp.whoOwes': 'Quem deve a quem?', + 'assistant.followUp.recordPayments': 'Registrar estes pagamentos', + 'assistant.card.budget.open': 'Abrir em Custos', + 'assistant.card.budget.paidBy': '{name} pagou', + 'assistant.card.budget.notPaid': 'Não pago', + 'assistant.card.budget.split.equal': 'Dividido igualmente', + 'assistant.card.budget.split.custom': 'Dividido de forma desigual', + 'assistant.card.budget.split.none': 'Só planejamento, sem divisão', + 'assistant.card.budget.together': 'No total {amount}', + 'assistant.card.budget.markedPaid': '{amount} marcado como pago', + 'assistant.card.budget.receipts': '{count} recibos', + 'assistant.card.budget.receipts.one': '{count} recibo', + 'assistant.card.budget.leftOut': + '{count} despesas em moeda estrangeira ficam fora do total: ainda sem taxa de câmbio.', + 'assistant.card.budget.leftOut.one': + '{count} despesa em moeda estrangeira fica fora do total: ainda sem taxa de câmbio.', + 'assistant.card.budget.unconverted': + '{count} lançamentos em {currencies} não são contados: ainda sem taxa de câmbio.', + 'assistant.card.budget.unconverted.one': + '{count} lançamento em {currencies} não é contado: ainda sem taxa de câmbio.', +}; export default assistantBudget; diff --git a/shared/src/i18n/ca/assistantBudget.ts b/shared/src/i18n/ca/assistantBudget.ts index 838ca0be1..da2c24b2e 100644 --- a/shared/src/i18n/ca/assistantBudget.ts +++ b/shared/src/i18n/ca/assistantBudget.ts @@ -1,4 +1,34 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Registra un pagament', + 'assistant.change.verb.updatePayment': 'Canvia un pagament', + 'assistant.tool.create_budget_item_with_members': 'Afegeix una despesa', + 'assistant.tool.set_budget_item_members': 'Canvia qui comparteix la despesa', + 'assistant.tool.toggle_budget_member_paid': 'Actualitza qui ha pagat', + 'assistant.tool.create_settlement': 'Registra un pagament', + 'assistant.tool.update_settlement': 'Canvia el pagament', + 'assistant.tool.delete_settlement': 'Elimina el pagament', + 'assistant.tool.freeze_budget_rates': "Fixa els tipus de canvi d'avui", + 'assistant.followUp.whoOwes': 'Qui deu a qui?', + 'assistant.followUp.recordPayments': 'Registra aquests pagaments', + 'assistant.card.budget.open': 'Obre a Despeses', + 'assistant.card.budget.paidBy': '{name} ha pagat', + 'assistant.card.budget.notPaid': 'No pagat', + 'assistant.card.budget.split.equal': 'Repartit a parts iguals', + 'assistant.card.budget.split.custom': 'Repartit de manera desigual', + 'assistant.card.budget.split.none': 'Només planificació, sense repartir', + 'assistant.card.budget.together': 'En total {amount}', + 'assistant.card.budget.markedPaid': '{amount} marcat com a pagat', + 'assistant.card.budget.receipts': '{count} rebuts', + 'assistant.card.budget.receipts.one': '{count} rebut', + 'assistant.card.budget.leftOut': + '{count} despeses en moneda estrangera no es compten al total: encara no hi ha tipus de canvi.', + 'assistant.card.budget.leftOut.one': + '{count} despesa en moneda estrangera no es compta al total: encara no hi ha tipus de canvi.', + 'assistant.card.budget.unconverted': + '{count} entrades en {currencies} no es compten: encara no hi ha tipus de canvi.', + 'assistant.card.budget.unconverted.one': + '{count} entrada en {currencies} no es compta: encara no hi ha tipus de canvi.', +}; export default assistantBudget; diff --git a/shared/src/i18n/cs/assistantBudget.ts b/shared/src/i18n/cs/assistantBudget.ts index 838ca0be1..e99cc351b 100644 --- a/shared/src/i18n/cs/assistantBudget.ts +++ b/shared/src/i18n/cs/assistantBudget.ts @@ -1,4 +1,34 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Zaznamenat platbu', + 'assistant.change.verb.updatePayment': 'Změnit platbu', + 'assistant.tool.create_budget_item_with_members': 'Přidat výdaj', + 'assistant.tool.set_budget_item_members': 'Změnit, kdo se dělí o náklad', + 'assistant.tool.toggle_budget_member_paid': 'Aktualizovat, kdo zaplatil', + 'assistant.tool.create_settlement': 'Zaznamenat platbu', + 'assistant.tool.update_settlement': 'Změnit tuto platbu', + 'assistant.tool.delete_settlement': 'Smazat tuto platbu', + 'assistant.tool.freeze_budget_rates': 'Zafixovat dnešní směnné kurzy', + 'assistant.followUp.whoOwes': 'Kdo dluží komu?', + 'assistant.followUp.recordPayments': 'Zaznamenat tyto platby', + 'assistant.card.budget.open': 'Otevřít v Nákladech', + 'assistant.card.budget.paidBy': '{name} zaplatil(a)', + 'assistant.card.budget.notPaid': 'Nezaplaceno', + 'assistant.card.budget.split.equal': 'Rozděleno rovným dílem', + 'assistant.card.budget.split.custom': 'Rozděleno nerovným dílem', + 'assistant.card.budget.split.none': 'Jen plánování, nerozděleno', + 'assistant.card.budget.together': 'Celkem {amount}', + 'assistant.card.budget.markedPaid': '{amount} označeno jako zaplaceno', + 'assistant.card.budget.receipts': '{count} účtenek', + 'assistant.card.budget.receipts.one': '{count} účtenka', + 'assistant.card.budget.receipts.few': '{count} účtenky', + 'assistant.card.budget.leftOut': '{count} výdajů v cizí měně se nezapočítává do součtu: zatím chybí směnný kurz.', + 'assistant.card.budget.leftOut.one': '{count} výdaj v cizí měně se nezapočítává do součtu: zatím chybí směnný kurz.', + 'assistant.card.budget.leftOut.few': + '{count} výdaje v cizí měně se nezapočítávají do součtu: zatím chybí směnný kurz.', + 'assistant.card.budget.unconverted': '{count} položek v {currencies} se nezapočítává: zatím chybí směnný kurz.', + 'assistant.card.budget.unconverted.one': '{count} položka v {currencies} se nezapočítává: zatím chybí směnný kurz.', + 'assistant.card.budget.unconverted.few': '{count} položky v {currencies} se nezapočítávají: zatím chybí směnný kurz.', +}; export default assistantBudget; diff --git a/shared/src/i18n/de/assistantBudget.ts b/shared/src/i18n/de/assistantBudget.ts index 838ca0be1..72d048cea 100644 --- a/shared/src/i18n/de/assistantBudget.ts +++ b/shared/src/i18n/de/assistantBudget.ts @@ -1,4 +1,30 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Zahlung erfassen', + 'assistant.change.verb.updatePayment': 'Zahlung ändern', + 'assistant.tool.create_budget_item_with_members': 'Ausgabe hinzufügen', + 'assistant.tool.set_budget_item_members': 'Ändern, wer die Kosten teilt', + 'assistant.tool.toggle_budget_member_paid': 'Aktualisieren, wer bezahlt hat', + 'assistant.tool.create_settlement': 'Zahlung erfassen', + 'assistant.tool.update_settlement': 'Die Zahlung ändern', + 'assistant.tool.delete_settlement': 'Die Zahlung löschen', + 'assistant.tool.freeze_budget_rates': 'Heutige Wechselkurse festschreiben', + 'assistant.followUp.whoOwes': 'Wer schuldet wem was?', + 'assistant.followUp.recordPayments': 'Diese Zahlungen erfassen', + 'assistant.card.budget.open': 'In Kosten öffnen', + 'assistant.card.budget.paidBy': '{name} hat bezahlt', + 'assistant.card.budget.notPaid': 'Nicht bezahlt', + 'assistant.card.budget.split.equal': 'Gleich aufgeteilt', + 'assistant.card.budget.split.custom': 'Ungleich aufgeteilt', + 'assistant.card.budget.split.none': 'Nur Planung, nicht aufgeteilt', + 'assistant.card.budget.together': 'Zusammen {amount}', + 'assistant.card.budget.markedPaid': '{amount} als bezahlt markiert', + 'assistant.card.budget.receipts': '{count} Belege', + 'assistant.card.budget.receipts.one': '{count} Beleg', + 'assistant.card.budget.leftOut': '{count} Ausgaben in Fremdwährung fehlen in der Summe: noch kein Wechselkurs.', + 'assistant.card.budget.leftOut.one': '{count} Ausgabe in Fremdwährung fehlt in der Summe: noch kein Wechselkurs.', + 'assistant.card.budget.unconverted': '{count} Einträge in {currencies} werden nicht gezählt: noch kein Wechselkurs.', + 'assistant.card.budget.unconverted.one': '{count} Eintrag in {currencies} wird nicht gezählt: noch kein Wechselkurs.', +}; export default assistantBudget; diff --git a/shared/src/i18n/en/assistantBudget.ts b/shared/src/i18n/en/assistantBudget.ts index 838ca0be1..4158df944 100644 --- a/shared/src/i18n/en/assistantBudget.ts +++ b/shared/src/i18n/en/assistantBudget.ts @@ -1,4 +1,30 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Record a payment', + 'assistant.change.verb.updatePayment': 'Change a payment', + 'assistant.tool.create_budget_item_with_members': 'Add expense', + 'assistant.tool.set_budget_item_members': 'Change who shares the cost', + 'assistant.tool.toggle_budget_member_paid': 'Update who has paid', + 'assistant.tool.create_settlement': 'Record a payment', + 'assistant.tool.update_settlement': 'Change the payment', + 'assistant.tool.delete_settlement': 'Delete the payment', + 'assistant.tool.freeze_budget_rates': "Pin today's exchange rates", + 'assistant.followUp.whoOwes': 'Who owes whom?', + 'assistant.followUp.recordPayments': 'Record these payments', + 'assistant.card.budget.open': 'Open in Costs', + 'assistant.card.budget.paidBy': '{name} paid', + 'assistant.card.budget.notPaid': 'Not paid', + 'assistant.card.budget.split.equal': 'Split evenly', + 'assistant.card.budget.split.custom': 'Split unevenly', + 'assistant.card.budget.split.none': 'Planning only, not split', + 'assistant.card.budget.together': 'Together {amount}', + 'assistant.card.budget.markedPaid': '{amount} marked paid', + 'assistant.card.budget.receipts': '{count} receipts', + 'assistant.card.budget.receipts.one': '{count} receipt', + 'assistant.card.budget.leftOut': '{count} foreign expenses are left out of the total: no exchange rate yet.', + 'assistant.card.budget.leftOut.one': '{count} foreign expense is left out of the total: no exchange rate yet.', + 'assistant.card.budget.unconverted': '{count} entries in {currencies} are not counted: no exchange rate yet.', + 'assistant.card.budget.unconverted.one': '{count} entry in {currencies} is not counted: no exchange rate yet.', +}; export default assistantBudget; diff --git a/shared/src/i18n/es/assistantBudget.ts b/shared/src/i18n/es/assistantBudget.ts index 838ca0be1..066d59e38 100644 --- a/shared/src/i18n/es/assistantBudget.ts +++ b/shared/src/i18n/es/assistantBudget.ts @@ -1,4 +1,32 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Registrar un pago', + 'assistant.change.verb.updatePayment': 'Cambiar un pago', + 'assistant.tool.create_budget_item_with_members': 'Añadir gasto', + 'assistant.tool.set_budget_item_members': 'Cambiar quién comparte el gasto', + 'assistant.tool.toggle_budget_member_paid': 'Actualizar quién ha pagado', + 'assistant.tool.create_settlement': 'Registrar un pago', + 'assistant.tool.update_settlement': 'Cambiar el pago', + 'assistant.tool.delete_settlement': 'Eliminar el pago', + 'assistant.tool.freeze_budget_rates': 'Fijar los tipos de cambio de hoy', + 'assistant.followUp.whoOwes': '¿Quién debe a quién?', + 'assistant.followUp.recordPayments': 'Registrar estos pagos', + 'assistant.card.budget.open': 'Abrir en Costes', + 'assistant.card.budget.paidBy': '{name} pagó', + 'assistant.card.budget.notPaid': 'No pagado', + 'assistant.card.budget.split.equal': 'Repartido a partes iguales', + 'assistant.card.budget.split.custom': 'Repartido de forma desigual', + 'assistant.card.budget.split.none': 'Solo planificación, sin repartir', + 'assistant.card.budget.together': 'En total {amount}', + 'assistant.card.budget.markedPaid': '{amount} marcado como pagado', + 'assistant.card.budget.receipts': '{count} recibos', + 'assistant.card.budget.receipts.one': '{count} recibo', + 'assistant.card.budget.leftOut': + '{count} gastos en moneda extranjera no se incluyen en el total: aún no hay tipo de cambio.', + 'assistant.card.budget.leftOut.one': + '{count} gasto en moneda extranjera no se incluye en el total: aún no hay tipo de cambio.', + 'assistant.card.budget.unconverted': '{count} entradas en {currencies} no se cuentan: aún no hay tipo de cambio.', + 'assistant.card.budget.unconverted.one': '{count} entrada en {currencies} no se cuenta: aún no hay tipo de cambio.', +}; export default assistantBudget; diff --git a/shared/src/i18n/et/assistantBudget.ts b/shared/src/i18n/et/assistantBudget.ts index 838ca0be1..fa0beda58 100644 --- a/shared/src/i18n/et/assistantBudget.ts +++ b/shared/src/i18n/et/assistantBudget.ts @@ -1,4 +1,30 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Registreeri makse', + 'assistant.change.verb.updatePayment': 'Muuda makset', + 'assistant.tool.create_budget_item_with_members': 'Lisa kulu', + 'assistant.tool.set_budget_item_members': 'Muuda, kes kulu jagab', + 'assistant.tool.toggle_budget_member_paid': 'Uuenda, kes on maksnud', + 'assistant.tool.create_settlement': 'Registreeri makse', + 'assistant.tool.update_settlement': 'Muuda seda makset', + 'assistant.tool.delete_settlement': 'Kustuta see makse', + 'assistant.tool.freeze_budget_rates': 'Kinnita tänased vahetuskursid', + 'assistant.followUp.whoOwes': 'Kes võlgneb kellele?', + 'assistant.followUp.recordPayments': 'Registreeri need maksed', + 'assistant.card.budget.open': 'Ava kuludes', + 'assistant.card.budget.paidBy': '{name} maksis', + 'assistant.card.budget.notPaid': 'Maksmata', + 'assistant.card.budget.split.equal': 'Jagatud võrdselt', + 'assistant.card.budget.split.custom': 'Jagatud ebavõrdselt', + 'assistant.card.budget.split.none': 'Ainult planeerimine, jagamata', + 'assistant.card.budget.together': 'Kokku {amount}', + 'assistant.card.budget.markedPaid': '{amount} märgitud makstuks', + 'assistant.card.budget.receipts': '{count} kviitungit', + 'assistant.card.budget.receipts.one': '{count} kviitung', + 'assistant.card.budget.leftOut': '{count} välisvaluutas kulu ei ole summas: vahetuskurssi veel pole.', + 'assistant.card.budget.leftOut.one': '{count} välisvaluutas kulu ei ole summas: vahetuskurssi veel pole.', + 'assistant.card.budget.unconverted': '{count} kirjet valuutas {currencies} ei arvestata: vahetuskurssi veel pole.', + 'assistant.card.budget.unconverted.one': '{count} kirje valuutas {currencies} ei arvestata: vahetuskurssi veel pole.', +}; export default assistantBudget; diff --git a/shared/src/i18n/fr/assistantBudget.ts b/shared/src/i18n/fr/assistantBudget.ts index 838ca0be1..038463038 100644 --- a/shared/src/i18n/fr/assistantBudget.ts +++ b/shared/src/i18n/fr/assistantBudget.ts @@ -1,4 +1,34 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Enregistrer un paiement', + 'assistant.change.verb.updatePayment': 'Modifier un paiement', + 'assistant.tool.create_budget_item_with_members': 'Ajouter une dépense', + 'assistant.tool.set_budget_item_members': 'Modifier qui partage la dépense', + 'assistant.tool.toggle_budget_member_paid': 'Mettre à jour qui a payé', + 'assistant.tool.create_settlement': 'Enregistrer un paiement', + 'assistant.tool.update_settlement': 'Modifier le paiement', + 'assistant.tool.delete_settlement': 'Supprimer le paiement', + 'assistant.tool.freeze_budget_rates': 'Figer les taux de change du jour', + 'assistant.followUp.whoOwes': 'Qui doit combien à qui ?', + 'assistant.followUp.recordPayments': 'Enregistrer ces paiements', + 'assistant.card.budget.open': 'Ouvrir dans Coûts', + 'assistant.card.budget.paidBy': '{name} a payé', + 'assistant.card.budget.notPaid': 'Non payé', + 'assistant.card.budget.split.equal': 'Partagé à parts égales', + 'assistant.card.budget.split.custom': 'Partagé à parts inégales', + 'assistant.card.budget.split.none': 'Prévision seulement, non partagé', + 'assistant.card.budget.together': 'Au total {amount}', + 'assistant.card.budget.markedPaid': '{amount} marqué comme payé', + 'assistant.card.budget.receipts': '{count} justificatifs', + 'assistant.card.budget.receipts.one': '{count} justificatif', + 'assistant.card.budget.leftOut': + '{count} dépenses en devise étrangère ne sont pas comptées dans le total : pas encore de taux de change.', + 'assistant.card.budget.leftOut.one': + "{count} dépense en devise étrangère n'est pas comptée dans le total : pas encore de taux de change.", + 'assistant.card.budget.unconverted': + '{count} entrées en {currencies} ne sont pas comptées : pas encore de taux de change.', + 'assistant.card.budget.unconverted.one': + "{count} entrée en {currencies} n'est pas comptée : pas encore de taux de change.", +}; export default assistantBudget; diff --git a/shared/src/i18n/gr/assistantBudget.ts b/shared/src/i18n/gr/assistantBudget.ts index 838ca0be1..27a56514f 100644 --- a/shared/src/i18n/gr/assistantBudget.ts +++ b/shared/src/i18n/gr/assistantBudget.ts @@ -1,4 +1,33 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Καταγραφή πληρωμής', + 'assistant.change.verb.updatePayment': 'Αλλαγή πληρωμής', + 'assistant.tool.create_budget_item_with_members': 'Προσθήκη εξόδου', + 'assistant.tool.set_budget_item_members': 'Αλλαγή του ποιοι μοιράζονται το κόστος', + 'assistant.tool.toggle_budget_member_paid': 'Ενημέρωση του ποιος έχει πληρώσει', + 'assistant.tool.create_settlement': 'Καταγραφή πληρωμής', + 'assistant.tool.update_settlement': 'Αλλαγή της πληρωμής', + 'assistant.tool.delete_settlement': 'Διαγραφή της πληρωμής', + 'assistant.tool.freeze_budget_rates': 'Κλείδωμα των σημερινών ισοτιμιών', + 'assistant.followUp.whoOwes': 'Ποιος χρωστάει σε ποιον;', + 'assistant.followUp.recordPayments': 'Καταγραφή αυτών των πληρωμών', + 'assistant.card.budget.open': 'Άνοιγμα στα Κόστη', + 'assistant.card.budget.paidBy': '{name} πλήρωσε', + 'assistant.card.budget.notPaid': 'Απλήρωτο', + 'assistant.card.budget.split.equal': 'Μοιρασμένο ισόποσα', + 'assistant.card.budget.split.custom': 'Μοιρασμένο άνισα', + 'assistant.card.budget.split.none': 'Μόνο σχεδιασμός, χωρίς μοιρασιά', + 'assistant.card.budget.together': 'Συνολικά {amount}', + 'assistant.card.budget.markedPaid': '{amount} σημειωμένα ως πληρωμένα', + 'assistant.card.budget.receipts': '{count} αποδείξεις', + 'assistant.card.budget.receipts.one': '{count} απόδειξη', + 'assistant.card.budget.leftOut': '{count} έξοδα σε ξένο νόμισμα δεν μετρούν στο σύνολο: δεν υπάρχει ακόμη ισοτιμία.', + 'assistant.card.budget.leftOut.one': + '{count} έξοδο σε ξένο νόμισμα δεν μετράει στο σύνολο: δεν υπάρχει ακόμη ισοτιμία.', + 'assistant.card.budget.unconverted': + '{count} καταχωρίσεις σε {currencies} δεν υπολογίζονται: δεν υπάρχει ακόμη ισοτιμία.', + 'assistant.card.budget.unconverted.one': + '{count} καταχώριση σε {currencies} δεν υπολογίζεται: δεν υπάρχει ακόμη ισοτιμία.', +}; export default assistantBudget; diff --git a/shared/src/i18n/hu/assistantBudget.ts b/shared/src/i18n/hu/assistantBudget.ts index 838ca0be1..328cbcc11 100644 --- a/shared/src/i18n/hu/assistantBudget.ts +++ b/shared/src/i18n/hu/assistantBudget.ts @@ -1,4 +1,30 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Fizetés rögzítése', + 'assistant.change.verb.updatePayment': 'Fizetés módosítása', + 'assistant.tool.create_budget_item_with_members': 'Kiadás hozzáadása', + 'assistant.tool.set_budget_item_members': 'Annak módosítása, kik osztoznak a költségen', + 'assistant.tool.toggle_budget_member_paid': 'Annak frissítése, ki fizetett', + 'assistant.tool.create_settlement': 'Fizetés rögzítése', + 'assistant.tool.update_settlement': 'A fizetés módosítása', + 'assistant.tool.delete_settlement': 'A fizetés törlése', + 'assistant.tool.freeze_budget_rates': 'A mai árfolyamok rögzítése', + 'assistant.followUp.whoOwes': 'Ki kinek tartozik?', + 'assistant.followUp.recordPayments': 'Ezeknek a fizetéseknek a rögzítése', + 'assistant.card.budget.open': 'Megnyitás a Költségekben', + 'assistant.card.budget.paidBy': '{name} fizetett', + 'assistant.card.budget.notPaid': 'Nincs fizetve', + 'assistant.card.budget.split.equal': 'Egyenlően elosztva', + 'assistant.card.budget.split.custom': 'Nem egyenlően elosztva', + 'assistant.card.budget.split.none': 'Csak tervezés, nincs elosztva', + 'assistant.card.budget.together': 'Összesen {amount}', + 'assistant.card.budget.markedPaid': '{amount} fizetettként jelölve', + 'assistant.card.budget.receipts': '{count} bizonylat', + 'assistant.card.budget.receipts.one': '{count} bizonylat', + 'assistant.card.budget.leftOut': '{count} devizás kiadás nem számít bele az összegbe: még nincs árfolyam.', + 'assistant.card.budget.leftOut.one': '{count} devizás kiadás nem számít bele az összegbe: még nincs árfolyam.', + 'assistant.card.budget.unconverted': '{count} tétel {currencies} pénznemben nem számít bele: még nincs árfolyam.', + 'assistant.card.budget.unconverted.one': '{count} tétel {currencies} pénznemben nem számít bele: még nincs árfolyam.', +}; export default assistantBudget; diff --git a/shared/src/i18n/id/assistantBudget.ts b/shared/src/i18n/id/assistantBudget.ts index 838ca0be1..2ed0a70be 100644 --- a/shared/src/i18n/id/assistantBudget.ts +++ b/shared/src/i18n/id/assistantBudget.ts @@ -1,4 +1,27 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Catat pembayaran', + 'assistant.change.verb.updatePayment': 'Ubah pembayaran', + 'assistant.tool.create_budget_item_with_members': 'Tambah pengeluaran', + 'assistant.tool.set_budget_item_members': 'Ubah siapa yang berbagi biaya', + 'assistant.tool.toggle_budget_member_paid': 'Perbarui siapa yang sudah membayar', + 'assistant.tool.create_settlement': 'Catat pembayaran', + 'assistant.tool.update_settlement': 'Ubah pembayaran ini', + 'assistant.tool.delete_settlement': 'Hapus pembayaran ini', + 'assistant.tool.freeze_budget_rates': 'Kunci kurs hari ini', + 'assistant.followUp.whoOwes': 'Siapa berutang kepada siapa?', + 'assistant.followUp.recordPayments': 'Catat semua pembayaran ini', + 'assistant.card.budget.open': 'Buka di Biaya', + 'assistant.card.budget.paidBy': '{name} membayar', + 'assistant.card.budget.notPaid': 'Belum dibayar', + 'assistant.card.budget.split.equal': 'Dibagi rata', + 'assistant.card.budget.split.custom': 'Dibagi dengan jumlah berbeda', + 'assistant.card.budget.split.none': 'Hanya perencanaan, tidak dibagi', + 'assistant.card.budget.together': 'Semuanya {amount}', + 'assistant.card.budget.markedPaid': '{amount} ditandai sudah dibayar', + 'assistant.card.budget.receipts': '{count} struk', + 'assistant.card.budget.leftOut': '{count} pengeluaran dalam mata uang asing tidak masuk total: belum ada kurs.', + 'assistant.card.budget.unconverted': '{count} entri dalam {currencies} tidak dihitung: belum ada kurs.', +}; export default assistantBudget; diff --git a/shared/src/i18n/it/assistantBudget.ts b/shared/src/i18n/it/assistantBudget.ts index 838ca0be1..db475e78d 100644 --- a/shared/src/i18n/it/assistantBudget.ts +++ b/shared/src/i18n/it/assistantBudget.ts @@ -1,4 +1,33 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Registra un pagamento', + 'assistant.change.verb.updatePayment': 'Modifica un pagamento', + 'assistant.tool.create_budget_item_with_members': 'Aggiungi spesa', + 'assistant.tool.set_budget_item_members': 'Cambia chi divide la spesa', + 'assistant.tool.toggle_budget_member_paid': 'Aggiorna chi ha pagato', + 'assistant.tool.create_settlement': 'Registra un pagamento', + 'assistant.tool.update_settlement': 'Modifica il pagamento', + 'assistant.tool.delete_settlement': 'Elimina il pagamento', + 'assistant.tool.freeze_budget_rates': 'Blocca i tassi di cambio di oggi', + 'assistant.followUp.whoOwes': 'Chi deve quanto a chi?', + 'assistant.followUp.recordPayments': 'Registra questi pagamenti', + 'assistant.card.budget.open': 'Apri in Costi', + 'assistant.card.budget.paidBy': '{name} ha pagato', + 'assistant.card.budget.notPaid': 'Non pagato', + 'assistant.card.budget.split.equal': 'Diviso in parti uguali', + 'assistant.card.budget.split.custom': 'Diviso in parti diverse', + 'assistant.card.budget.split.none': 'Solo pianificazione, non divisa', + 'assistant.card.budget.together': 'In tutto {amount}', + 'assistant.card.budget.markedPaid': '{amount} segnato come pagato', + 'assistant.card.budget.receipts': '{count} ricevute', + 'assistant.card.budget.receipts.one': '{count} ricevuta', + 'assistant.card.budget.leftOut': '{count} spese in valuta estera non sono nel totale: ancora nessun tasso di cambio.', + 'assistant.card.budget.leftOut.one': + '{count} spesa in valuta estera non è nel totale: ancora nessun tasso di cambio.', + 'assistant.card.budget.unconverted': + '{count} voci in {currencies} non sono conteggiate: ancora nessun tasso di cambio.', + 'assistant.card.budget.unconverted.one': + '{count} voce in {currencies} non è conteggiata: ancora nessun tasso di cambio.', +}; export default assistantBudget; diff --git a/shared/src/i18n/ja/assistantBudget.ts b/shared/src/i18n/ja/assistantBudget.ts index 838ca0be1..e7de2d374 100644 --- a/shared/src/i18n/ja/assistantBudget.ts +++ b/shared/src/i18n/ja/assistantBudget.ts @@ -1,4 +1,28 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': '支払いを記録', + 'assistant.change.verb.updatePayment': '支払いを変更', + 'assistant.tool.create_budget_item_with_members': '支出を追加', + 'assistant.tool.set_budget_item_members': '費用を分け合う人を変更', + 'assistant.tool.toggle_budget_member_paid': '支払い済みの人を更新', + 'assistant.tool.create_settlement': '支払いを記録', + 'assistant.tool.update_settlement': 'この支払いを変更', + 'assistant.tool.delete_settlement': 'この支払いを削除', + 'assistant.tool.freeze_budget_rates': '今日の為替レートで固定', + 'assistant.followUp.whoOwes': '誰が誰にいくら借りている?', + 'assistant.followUp.recordPayments': 'これらの支払いを記録', + 'assistant.card.budget.open': '費用で開く', + 'assistant.card.budget.paidBy': '{name}が支払い', + 'assistant.card.budget.notPaid': '未払い', + 'assistant.card.budget.split.equal': '均等に分割', + 'assistant.card.budget.split.custom': '金額を指定して分割', + 'assistant.card.budget.split.none': '計画のみ、分割なし', + 'assistant.card.budget.together': '合計 {amount}', + 'assistant.card.budget.markedPaid': '{amount} 支払い済み', + 'assistant.card.budget.receipts': '領収書 {count} 件', + 'assistant.card.budget.leftOut': '外貨の支出 {count} 件は合計に含まれていません。為替レートがまだありません。', + 'assistant.card.budget.unconverted': + '{currencies} の {count} 件は計算に含まれていません。為替レートがまだありません。', +}; export default assistantBudget; diff --git a/shared/src/i18n/ko/assistantBudget.ts b/shared/src/i18n/ko/assistantBudget.ts index 838ca0be1..dab48b27a 100644 --- a/shared/src/i18n/ko/assistantBudget.ts +++ b/shared/src/i18n/ko/assistantBudget.ts @@ -1,4 +1,27 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': '결제 기록', + 'assistant.change.verb.updatePayment': '결제 변경', + 'assistant.tool.create_budget_item_with_members': '지출 추가', + 'assistant.tool.set_budget_item_members': '비용을 나누는 사람 변경', + 'assistant.tool.toggle_budget_member_paid': '지불한 사람 업데이트', + 'assistant.tool.create_settlement': '결제 기록', + 'assistant.tool.update_settlement': '이 결제 변경', + 'assistant.tool.delete_settlement': '이 결제 삭제', + 'assistant.tool.freeze_budget_rates': '오늘 환율로 고정', + 'assistant.followUp.whoOwes': '누가 누구에게 얼마를 빚졌나요?', + 'assistant.followUp.recordPayments': '이 결제들 기록', + 'assistant.card.budget.open': '비용에서 열기', + 'assistant.card.budget.paidBy': '{name} 님이 지불', + 'assistant.card.budget.notPaid': '미결제', + 'assistant.card.budget.split.equal': '균등 분할', + 'assistant.card.budget.split.custom': '금액별 분할', + 'assistant.card.budget.split.none': '계획만, 분할 안 함', + 'assistant.card.budget.together': '합계 {amount}', + 'assistant.card.budget.markedPaid': '{amount} 지불 완료로 표시됨', + 'assistant.card.budget.receipts': '영수증 {count}개', + 'assistant.card.budget.leftOut': '외화 지출 {count}건이 합계에서 빠졌습니다: 아직 환율이 없습니다.', + 'assistant.card.budget.unconverted': '{currencies} 항목 {count}건이 계산되지 않습니다: 아직 환율이 없습니다.', +}; export default assistantBudget; diff --git a/shared/src/i18n/nl/assistantBudget.ts b/shared/src/i18n/nl/assistantBudget.ts index 838ca0be1..7f9e84842 100644 --- a/shared/src/i18n/nl/assistantBudget.ts +++ b/shared/src/i18n/nl/assistantBudget.ts @@ -1,4 +1,32 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Betaling vastleggen', + 'assistant.change.verb.updatePayment': 'Betaling wijzigen', + 'assistant.tool.create_budget_item_with_members': 'Uitgave toevoegen', + 'assistant.tool.set_budget_item_members': 'Wijzigen wie de kosten deelt', + 'assistant.tool.toggle_budget_member_paid': 'Bijwerken wie heeft betaald', + 'assistant.tool.create_settlement': 'Betaling vastleggen', + 'assistant.tool.update_settlement': 'De betaling wijzigen', + 'assistant.tool.delete_settlement': 'De betaling verwijderen', + 'assistant.tool.freeze_budget_rates': 'Wisselkoersen van vandaag vastzetten', + 'assistant.followUp.whoOwes': 'Wie is wie wat schuldig?', + 'assistant.followUp.recordPayments': 'Deze betalingen vastleggen', + 'assistant.card.budget.open': 'Openen in Onkosten', + 'assistant.card.budget.paidBy': '{name} heeft betaald', + 'assistant.card.budget.notPaid': 'Niet betaald', + 'assistant.card.budget.split.equal': 'Gelijk verdeeld', + 'assistant.card.budget.split.custom': 'Ongelijk verdeeld', + 'assistant.card.budget.split.none': 'Alleen planning, niet verdeeld', + 'assistant.card.budget.together': 'Samen {amount}', + 'assistant.card.budget.markedPaid': '{amount} als betaald gemarkeerd', + 'assistant.card.budget.receipts': '{count} bonnen', + 'assistant.card.budget.receipts.one': '{count} bon', + 'assistant.card.budget.leftOut': + '{count} uitgaven in vreemde valuta tellen niet mee in het totaal: nog geen wisselkoers.', + 'assistant.card.budget.leftOut.one': + '{count} uitgave in vreemde valuta telt niet mee in het totaal: nog geen wisselkoers.', + 'assistant.card.budget.unconverted': '{count} posten in {currencies} tellen niet mee: nog geen wisselkoers.', + 'assistant.card.budget.unconverted.one': '{count} post in {currencies} telt niet mee: nog geen wisselkoers.', +}; export default assistantBudget; diff --git a/shared/src/i18n/pl/assistantBudget.ts b/shared/src/i18n/pl/assistantBudget.ts index 838ca0be1..5daa3129a 100644 --- a/shared/src/i18n/pl/assistantBudget.ts +++ b/shared/src/i18n/pl/assistantBudget.ts @@ -1,4 +1,41 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Zapisz płatność', + 'assistant.change.verb.updatePayment': 'Zmień płatność', + 'assistant.tool.create_budget_item_with_members': 'Dodaj wydatek', + 'assistant.tool.set_budget_item_members': 'Zmień, kto dzieli koszt', + 'assistant.tool.toggle_budget_member_paid': 'Zaktualizuj, kto zapłacił', + 'assistant.tool.create_settlement': 'Zapisz płatność', + 'assistant.tool.update_settlement': 'Zmień tę płatność', + 'assistant.tool.delete_settlement': 'Usuń tę płatność', + 'assistant.tool.freeze_budget_rates': 'Zablokuj dzisiejsze kursy walut', + 'assistant.followUp.whoOwes': 'Kto komu jest winien?', + 'assistant.followUp.recordPayments': 'Zapisz te płatności', + 'assistant.card.budget.open': 'Otwórz w Kosztach', + 'assistant.card.budget.paidBy': '{name} zapłacił(a)', + 'assistant.card.budget.notPaid': 'Niezapłacone', + 'assistant.card.budget.split.equal': 'Podzielone po równo', + 'assistant.card.budget.split.custom': 'Podzielone nierówno', + 'assistant.card.budget.split.none': 'Tylko planowanie, bez podziału', + 'assistant.card.budget.together': 'Razem {amount}', + 'assistant.card.budget.markedPaid': '{amount} oznaczono jako zapłacone', + 'assistant.card.budget.receipts': '{count} paragonu', + 'assistant.card.budget.receipts.one': '{count} paragon', + 'assistant.card.budget.receipts.few': '{count} paragony', + 'assistant.card.budget.receipts.many': '{count} paragonów', + 'assistant.card.budget.leftOut': '{count} wydatku w obcej walucie nie liczy się do sumy: brak jeszcze kursu wymiany.', + 'assistant.card.budget.leftOut.one': + '{count} wydatek w obcej walucie nie liczy się do sumy: brak jeszcze kursu wymiany.', + 'assistant.card.budget.leftOut.few': + '{count} wydatki w obcej walucie nie liczą się do sumy: brak jeszcze kursu wymiany.', + 'assistant.card.budget.leftOut.many': + '{count} wydatków w obcej walucie nie liczy się do sumy: brak jeszcze kursu wymiany.', + 'assistant.card.budget.unconverted': '{count} pozycji w {currencies} nie jest liczone: brak jeszcze kursu wymiany.', + 'assistant.card.budget.unconverted.one': + '{count} pozycja w {currencies} nie jest liczona: brak jeszcze kursu wymiany.', + 'assistant.card.budget.unconverted.few': '{count} pozycje w {currencies} nie są liczone: brak jeszcze kursu wymiany.', + 'assistant.card.budget.unconverted.many': + '{count} pozycji w {currencies} nie jest liczonych: brak jeszcze kursu wymiany.', +}; export default assistantBudget; diff --git a/shared/src/i18n/ru/assistantBudget.ts b/shared/src/i18n/ru/assistantBudget.ts index 838ca0be1..cfb6cc0bc 100644 --- a/shared/src/i18n/ru/assistantBudget.ts +++ b/shared/src/i18n/ru/assistantBudget.ts @@ -1,4 +1,36 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Записать платёж', + 'assistant.change.verb.updatePayment': 'Изменить платёж', + 'assistant.tool.create_budget_item_with_members': 'Добавить расход', + 'assistant.tool.set_budget_item_members': 'Изменить, кто делит расход', + 'assistant.tool.toggle_budget_member_paid': 'Обновить, кто заплатил', + 'assistant.tool.create_settlement': 'Записать платёж', + 'assistant.tool.update_settlement': 'Изменить этот платёж', + 'assistant.tool.delete_settlement': 'Удалить этот платёж', + 'assistant.tool.freeze_budget_rates': 'Зафиксировать сегодняшние курсы валют', + 'assistant.followUp.whoOwes': 'Кто кому должен?', + 'assistant.followUp.recordPayments': 'Записать эти платежи', + 'assistant.card.budget.open': 'Открыть в разделе «Расходы»', + 'assistant.card.budget.paidBy': '{name} заплатил(а)', + 'assistant.card.budget.notPaid': 'Не оплачено', + 'assistant.card.budget.split.equal': 'Поровну', + 'assistant.card.budget.split.custom': 'Не поровну', + 'assistant.card.budget.split.none': 'Только план, без разделения', + 'assistant.card.budget.together': 'Вместе {amount}', + 'assistant.card.budget.markedPaid': '{amount} отмечено как оплаченное', + 'assistant.card.budget.receipts': '{count} чека', + 'assistant.card.budget.receipts.one': '{count} чек', + 'assistant.card.budget.receipts.few': '{count} чека', + 'assistant.card.budget.receipts.many': '{count} чеков', + 'assistant.card.budget.leftOut': '{count} расхода в иностранной валюте не входят в итог: курса пока нет.', + 'assistant.card.budget.leftOut.one': '{count} расход в иностранной валюте не входит в итог: курса пока нет.', + 'assistant.card.budget.leftOut.few': '{count} расхода в иностранной валюте не входят в итог: курса пока нет.', + 'assistant.card.budget.leftOut.many': '{count} расходов в иностранной валюте не входят в итог: курса пока нет.', + 'assistant.card.budget.unconverted': '{count} записи в {currencies} не учитываются: курса пока нет.', + 'assistant.card.budget.unconverted.one': '{count} запись в {currencies} не учитывается: курса пока нет.', + 'assistant.card.budget.unconverted.few': '{count} записи в {currencies} не учитываются: курса пока нет.', + 'assistant.card.budget.unconverted.many': '{count} записей в {currencies} не учитываются: курса пока нет.', +}; export default assistantBudget; diff --git a/shared/src/i18n/sk/assistantBudget.ts b/shared/src/i18n/sk/assistantBudget.ts index 838ca0be1..ca2b4a016 100644 --- a/shared/src/i18n/sk/assistantBudget.ts +++ b/shared/src/i18n/sk/assistantBudget.ts @@ -1,4 +1,37 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Zaznamenať platbu', + 'assistant.change.verb.updatePayment': 'Zmeniť platbu', + 'assistant.tool.create_budget_item_with_members': 'Pridať výdavok', + 'assistant.tool.set_budget_item_members': 'Zmeniť, kto sa delí o náklad', + 'assistant.tool.toggle_budget_member_paid': 'Aktualizovať, kto zaplatil', + 'assistant.tool.create_settlement': 'Zaznamenať platbu', + 'assistant.tool.update_settlement': 'Zmeniť túto platbu', + 'assistant.tool.delete_settlement': 'Odstrániť túto platbu', + 'assistant.tool.freeze_budget_rates': 'Zafixovať dnešné výmenné kurzy', + 'assistant.followUp.whoOwes': 'Kto komu dlhuje?', + 'assistant.followUp.recordPayments': 'Zaznamenať tieto platby', + 'assistant.card.budget.open': 'Otvoriť v Nákladoch', + 'assistant.card.budget.paidBy': '{name} zaplatil(a)', + 'assistant.card.budget.notPaid': 'Nezaplatené', + 'assistant.card.budget.split.equal': 'Rozdelené rovnakým dielom', + 'assistant.card.budget.split.custom': 'Rozdelené nerovnakým dielom', + 'assistant.card.budget.split.none': 'Len plánovanie, nerozdelené', + 'assistant.card.budget.together': 'Spolu {amount}', + 'assistant.card.budget.markedPaid': '{amount} označené ako zaplatené', + 'assistant.card.budget.receipts': '{count} účteniek', + 'assistant.card.budget.receipts.one': '{count} účtenka', + 'assistant.card.budget.receipts.few': '{count} účtenky', + 'assistant.card.budget.leftOut': + '{count} výdavkov v cudzej mene sa nezapočítava do súčtu: zatiaľ chýba výmenný kurz.', + 'assistant.card.budget.leftOut.one': + '{count} výdavok v cudzej mene sa nezapočítava do súčtu: zatiaľ chýba výmenný kurz.', + 'assistant.card.budget.leftOut.few': + '{count} výdavky v cudzej mene sa nezapočítavajú do súčtu: zatiaľ chýba výmenný kurz.', + 'assistant.card.budget.unconverted': '{count} položiek v {currencies} sa nezapočítava: zatiaľ chýba výmenný kurz.', + 'assistant.card.budget.unconverted.one': '{count} položka v {currencies} sa nezapočítava: zatiaľ chýba výmenný kurz.', + 'assistant.card.budget.unconverted.few': + '{count} položky v {currencies} sa nezapočítavajú: zatiaľ chýba výmenný kurz.', +}; export default assistantBudget; diff --git a/shared/src/i18n/sv/assistantBudget.ts b/shared/src/i18n/sv/assistantBudget.ts index 838ca0be1..20701f681 100644 --- a/shared/src/i18n/sv/assistantBudget.ts +++ b/shared/src/i18n/sv/assistantBudget.ts @@ -1,4 +1,30 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Registrera en betalning', + 'assistant.change.verb.updatePayment': 'Ändra en betalning', + 'assistant.tool.create_budget_item_with_members': 'Lägg till utgift', + 'assistant.tool.set_budget_item_members': 'Ändra vem som delar kostnaden', + 'assistant.tool.toggle_budget_member_paid': 'Uppdatera vem som har betalat', + 'assistant.tool.create_settlement': 'Registrera en betalning', + 'assistant.tool.update_settlement': 'Ändra betalningen', + 'assistant.tool.delete_settlement': 'Ta bort betalningen', + 'assistant.tool.freeze_budget_rates': 'Lås dagens växelkurser', + 'assistant.followUp.whoOwes': 'Vem är skyldig vem?', + 'assistant.followUp.recordPayments': 'Registrera de här betalningarna', + 'assistant.card.budget.open': 'Öppna i Kostnader', + 'assistant.card.budget.paidBy': '{name} betalade', + 'assistant.card.budget.notPaid': 'Inte betald', + 'assistant.card.budget.split.equal': 'Delad lika', + 'assistant.card.budget.split.custom': 'Delad ojämnt', + 'assistant.card.budget.split.none': 'Bara planering, inte delad', + 'assistant.card.budget.together': 'Totalt {amount}', + 'assistant.card.budget.markedPaid': '{amount} markerat som betalt', + 'assistant.card.budget.receipts': '{count} kvitton', + 'assistant.card.budget.receipts.one': '{count} kvitto', + 'assistant.card.budget.leftOut': '{count} utgifter i utländsk valuta räknas inte i summan: ingen växelkurs än.', + 'assistant.card.budget.leftOut.one': '{count} utgift i utländsk valuta räknas inte i summan: ingen växelkurs än.', + 'assistant.card.budget.unconverted': '{count} poster i {currencies} räknas inte: ingen växelkurs än.', + 'assistant.card.budget.unconverted.one': '{count} post i {currencies} räknas inte: ingen växelkurs än.', +}; export default assistantBudget; diff --git a/shared/src/i18n/th/assistantBudget.ts b/shared/src/i18n/th/assistantBudget.ts index 838ca0be1..ba243bcdd 100644 --- a/shared/src/i18n/th/assistantBudget.ts +++ b/shared/src/i18n/th/assistantBudget.ts @@ -1,4 +1,28 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'บันทึกการชำระเงิน', + 'assistant.change.verb.updatePayment': 'เปลี่ยนการชำระเงิน', + 'assistant.tool.create_budget_item_with_members': 'เพิ่มค่าใช้จ่าย', + 'assistant.tool.set_budget_item_members': 'เปลี่ยนผู้ที่หารค่าใช้จ่าย', + 'assistant.tool.toggle_budget_member_paid': 'อัปเดตผู้ที่จ่ายแล้ว', + 'assistant.tool.create_settlement': 'บันทึกการชำระเงิน', + 'assistant.tool.update_settlement': 'เปลี่ยนการชำระเงินนี้', + 'assistant.tool.delete_settlement': 'ลบการชำระเงินนี้', + 'assistant.tool.freeze_budget_rates': 'ล็อกอัตราแลกเปลี่ยนของวันนี้', + 'assistant.followUp.whoOwes': 'ใครติดเงินใคร?', + 'assistant.followUp.recordPayments': 'บันทึกการชำระเงินเหล่านี้', + 'assistant.card.budget.open': 'เปิดในค่าใช้จ่าย', + 'assistant.card.budget.paidBy': '{name} จ่ายแล้ว', + 'assistant.card.budget.notPaid': 'ยังไม่จ่าย', + 'assistant.card.budget.split.equal': 'หารเท่ากัน', + 'assistant.card.budget.split.custom': 'หารตามจำนวนที่กำหนด', + 'assistant.card.budget.split.none': 'วางแผนเท่านั้น ไม่หาร', + 'assistant.card.budget.together': 'รวม {amount}', + 'assistant.card.budget.markedPaid': '{amount} ทำเครื่องหมายว่าจ่ายแล้ว', + 'assistant.card.budget.receipts': 'ใบเสร็จ {count} ใบ', + 'assistant.card.budget.leftOut': + 'ค่าใช้จ่ายสกุลเงินต่างประเทศ {count} รายการไม่ได้รวมในยอดรวม: ยังไม่มีอัตราแลกเปลี่ยน', + 'assistant.card.budget.unconverted': 'รายการใน {currencies} จำนวน {count} รายการไม่ถูกนับ: ยังไม่มีอัตราแลกเปลี่ยน', +}; export default assistantBudget; diff --git a/shared/src/i18n/tr/assistantBudget.ts b/shared/src/i18n/tr/assistantBudget.ts index 838ca0be1..6f4feffcb 100644 --- a/shared/src/i18n/tr/assistantBudget.ts +++ b/shared/src/i18n/tr/assistantBudget.ts @@ -1,4 +1,30 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Ödeme kaydet', + 'assistant.change.verb.updatePayment': 'Ödemeyi değiştir', + 'assistant.tool.create_budget_item_with_members': 'Harcama ekle', + 'assistant.tool.set_budget_item_members': 'Masrafı kimlerin paylaştığını değiştir', + 'assistant.tool.toggle_budget_member_paid': 'Kimin ödediğini güncelle', + 'assistant.tool.create_settlement': 'Ödeme kaydet', + 'assistant.tool.update_settlement': 'Bu ödemeyi değiştir', + 'assistant.tool.delete_settlement': 'Bu ödemeyi sil', + 'assistant.tool.freeze_budget_rates': 'Bugünkü döviz kurlarını sabitle', + 'assistant.followUp.whoOwes': 'Kim kime borçlu?', + 'assistant.followUp.recordPayments': 'Bu ödemeleri kaydet', + 'assistant.card.budget.open': 'Maliyetler içinde aç', + 'assistant.card.budget.paidBy': '{name} ödedi', + 'assistant.card.budget.notPaid': 'Ödenmedi', + 'assistant.card.budget.split.equal': 'Eşit bölündü', + 'assistant.card.budget.split.custom': 'Farklı tutarlarla bölündü', + 'assistant.card.budget.split.none': 'Yalnızca planlama, bölünmedi', + 'assistant.card.budget.together': 'Toplam {amount}', + 'assistant.card.budget.markedPaid': '{amount} ödendi olarak işaretlendi', + 'assistant.card.budget.receipts': '{count} fiş', + 'assistant.card.budget.receipts.one': '{count} fiş', + 'assistant.card.budget.leftOut': '{count} yabancı para harcaması toplama dahil değil: henüz döviz kuru yok.', + 'assistant.card.budget.leftOut.one': '{count} yabancı para harcaması toplama dahil değil: henüz döviz kuru yok.', + 'assistant.card.budget.unconverted': '{currencies} cinsinden {count} kayıt sayılmıyor: henüz döviz kuru yok.', + 'assistant.card.budget.unconverted.one': '{currencies} cinsinden {count} kayıt sayılmıyor: henüz döviz kuru yok.', +}; export default assistantBudget; diff --git a/shared/src/i18n/uk/assistantBudget.ts b/shared/src/i18n/uk/assistantBudget.ts index 838ca0be1..4148214af 100644 --- a/shared/src/i18n/uk/assistantBudget.ts +++ b/shared/src/i18n/uk/assistantBudget.ts @@ -1,4 +1,36 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Записати платіж', + 'assistant.change.verb.updatePayment': 'Змінити платіж', + 'assistant.tool.create_budget_item_with_members': 'Додати витрату', + 'assistant.tool.set_budget_item_members': 'Змінити, хто ділить витрату', + 'assistant.tool.toggle_budget_member_paid': 'Оновити, хто заплатив', + 'assistant.tool.create_settlement': 'Записати платіж', + 'assistant.tool.update_settlement': 'Змінити цей платіж', + 'assistant.tool.delete_settlement': 'Видалити цей платіж', + 'assistant.tool.freeze_budget_rates': 'Зафіксувати сьогоднішні курси валют', + 'assistant.followUp.whoOwes': 'Хто кому винен?', + 'assistant.followUp.recordPayments': 'Записати ці платежі', + 'assistant.card.budget.open': 'Відкрити в розділі «Витрати»', + 'assistant.card.budget.paidBy': '{name} заплатив(ла)', + 'assistant.card.budget.notPaid': 'Не сплачено', + 'assistant.card.budget.split.equal': 'Порівну', + 'assistant.card.budget.split.custom': 'Не порівну', + 'assistant.card.budget.split.none': 'Лише план, без поділу', + 'assistant.card.budget.together': 'Разом {amount}', + 'assistant.card.budget.markedPaid': '{amount} позначено як оплачене', + 'assistant.card.budget.receipts': '{count} чека', + 'assistant.card.budget.receipts.one': '{count} чек', + 'assistant.card.budget.receipts.few': '{count} чеки', + 'assistant.card.budget.receipts.many': '{count} чеків', + 'assistant.card.budget.leftOut': '{count} витрати в іноземній валюті не входять у підсумок: курсу ще немає.', + 'assistant.card.budget.leftOut.one': '{count} витрата в іноземній валюті не входить у підсумок: курсу ще немає.', + 'assistant.card.budget.leftOut.few': '{count} витрати в іноземній валюті не входять у підсумок: курсу ще немає.', + 'assistant.card.budget.leftOut.many': '{count} витрат в іноземній валюті не входять у підсумок: курсу ще немає.', + 'assistant.card.budget.unconverted': '{count} записи в {currencies} не враховуються: курсу ще немає.', + 'assistant.card.budget.unconverted.one': '{count} запис в {currencies} не враховується: курсу ще немає.', + 'assistant.card.budget.unconverted.few': '{count} записи в {currencies} не враховуються: курсу ще немає.', + 'assistant.card.budget.unconverted.many': '{count} записів в {currencies} не враховуються: курсу ще немає.', +}; export default assistantBudget; diff --git a/shared/src/i18n/vi/assistantBudget.ts b/shared/src/i18n/vi/assistantBudget.ts index 838ca0be1..dcb24facd 100644 --- a/shared/src/i18n/vi/assistantBudget.ts +++ b/shared/src/i18n/vi/assistantBudget.ts @@ -1,4 +1,27 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': 'Ghi nhận một khoản thanh toán', + 'assistant.change.verb.updatePayment': 'Sửa một khoản thanh toán', + 'assistant.tool.create_budget_item_with_members': 'Thêm khoản chi', + 'assistant.tool.set_budget_item_members': 'Đổi người cùng chia chi phí', + 'assistant.tool.toggle_budget_member_paid': 'Cập nhật ai đã trả', + 'assistant.tool.create_settlement': 'Ghi nhận một khoản thanh toán', + 'assistant.tool.update_settlement': 'Sửa khoản thanh toán này', + 'assistant.tool.delete_settlement': 'Xóa khoản thanh toán này', + 'assistant.tool.freeze_budget_rates': 'Chốt tỷ giá hôm nay', + 'assistant.followUp.whoOwes': 'Ai nợ ai?', + 'assistant.followUp.recordPayments': 'Ghi nhận các khoản thanh toán này', + 'assistant.card.budget.open': 'Mở trong Chi phí', + 'assistant.card.budget.paidBy': '{name} đã trả', + 'assistant.card.budget.notPaid': 'Chưa trả', + 'assistant.card.budget.split.equal': 'Chia đều', + 'assistant.card.budget.split.custom': 'Chia theo số tiền riêng', + 'assistant.card.budget.split.none': 'Chỉ để lên kế hoạch, không chia', + 'assistant.card.budget.together': 'Tổng cộng {amount}', + 'assistant.card.budget.markedPaid': '{amount} đã đánh dấu là đã trả', + 'assistant.card.budget.receipts': '{count} hóa đơn', + 'assistant.card.budget.leftOut': '{count} khoản chi ngoại tệ không được tính vào tổng: chưa có tỷ giá.', + 'assistant.card.budget.unconverted': '{count} mục bằng {currencies} không được tính: chưa có tỷ giá.', +}; export default assistantBudget; diff --git a/shared/src/i18n/zh-TW/assistantBudget.ts b/shared/src/i18n/zh-TW/assistantBudget.ts index 838ca0be1..00b33cba4 100644 --- a/shared/src/i18n/zh-TW/assistantBudget.ts +++ b/shared/src/i18n/zh-TW/assistantBudget.ts @@ -1,4 +1,27 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': '記錄一筆付款', + 'assistant.change.verb.updatePayment': '修改付款', + 'assistant.tool.create_budget_item_with_members': '新增支出', + 'assistant.tool.set_budget_item_members': '變更分攤費用的人', + 'assistant.tool.toggle_budget_member_paid': '更新已付款的人', + 'assistant.tool.create_settlement': '記錄一筆付款', + 'assistant.tool.update_settlement': '修改這筆付款', + 'assistant.tool.delete_settlement': '刪除這筆付款', + 'assistant.tool.freeze_budget_rates': '鎖定今天的匯率', + 'assistant.followUp.whoOwes': '誰欠誰多少?', + 'assistant.followUp.recordPayments': '記錄這些付款', + 'assistant.card.budget.open': '在費用中開啟', + 'assistant.card.budget.paidBy': '{name} 已付款', + 'assistant.card.budget.notPaid': '未付', + 'assistant.card.budget.split.equal': '平均分攤', + 'assistant.card.budget.split.custom': '按金額分攤', + 'assistant.card.budget.split.none': '僅供規劃,不分攤', + 'assistant.card.budget.together': '合計 {amount}', + 'assistant.card.budget.markedPaid': '{amount} 已標記為已付', + 'assistant.card.budget.receipts': '{count} 張收據', + 'assistant.card.budget.leftOut': '{count} 筆外幣支出未計入總額:尚無匯率。', + 'assistant.card.budget.unconverted': '{currencies} 的 {count} 筆紀錄未計入:尚無匯率。', +}; export default assistantBudget; diff --git a/shared/src/i18n/zh/assistantBudget.ts b/shared/src/i18n/zh/assistantBudget.ts index 838ca0be1..35b5d914c 100644 --- a/shared/src/i18n/zh/assistantBudget.ts +++ b/shared/src/i18n/zh/assistantBudget.ts @@ -1,4 +1,27 @@ import type { TranslationStrings } from '../types'; -const assistantBudget: TranslationStrings = {}; +const assistantBudget: TranslationStrings = { + 'assistant.change.verb.addPayment': '记录一笔付款', + 'assistant.change.verb.updatePayment': '修改付款', + 'assistant.tool.create_budget_item_with_members': '添加支出', + 'assistant.tool.set_budget_item_members': '更改分摊费用的人', + 'assistant.tool.toggle_budget_member_paid': '更新已付款的人', + 'assistant.tool.create_settlement': '记录一笔付款', + 'assistant.tool.update_settlement': '修改这笔付款', + 'assistant.tool.delete_settlement': '删除这笔付款', + 'assistant.tool.freeze_budget_rates': '锁定今天的汇率', + 'assistant.followUp.whoOwes': '谁欠谁多少?', + 'assistant.followUp.recordPayments': '记录这些付款', + 'assistant.card.budget.open': '在费用中打开', + 'assistant.card.budget.paidBy': '{name} 已付款', + 'assistant.card.budget.notPaid': '未付', + 'assistant.card.budget.split.equal': '平均分摊', + 'assistant.card.budget.split.custom': '按金额分摊', + 'assistant.card.budget.split.none': '仅用于计划,不分摊', + 'assistant.card.budget.together': '合计 {amount}', + 'assistant.card.budget.markedPaid': '{amount} 已标记为已付', + 'assistant.card.budget.receipts': '{count} 张收据', + 'assistant.card.budget.leftOut': '{count} 笔外币支出未计入总额:暂无汇率。', + 'assistant.card.budget.unconverted': '{currencies} 的 {count} 条记录未计入:暂无汇率。', +}; export default assistantBudget;