feat(assistant): expense, payment and settle-up cards, and expense links into Costs

This commit is contained in:
Maurice
2026-10-22 15:50:49 +02:00
parent c94545419b
commit b52990bf27
65 changed files with 4763 additions and 95 deletions
+2 -2
View File
@@ -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"]
},
+2 -2
View File
@@ -4,10 +4,10 @@
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Budget/CostsPanel.tsx": 1804,
"src/components/Budget/CostsPanel.tsx": 1798,
"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,
@@ -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 (
<CardFrame label={title}>
<CardHeader
icon={Users}
tone="info"
title={title}
count={card.rows.length + card.more}
actions={<CardOpenButton target={costsTarget()} labelKey="assistant.card.budget.open" />}
/>
<CardRows>
{card.rows.map((row) => (
<CardRow
key={row.person.id}
lead={<PersonFace person={row.person} size={24} />}
title={<bdi>{nameOf(row.person)}</bdi>}
aside={<span className="text-body font-semibold tabular-nums text-content">{money(row.share)}</span>}
facts={
<span className="mt-1 block">
<span className="block h-[5px] overflow-hidden rounded-full bg-surface-secondary">
<span
className="block h-full rounded-full bg-accent"
style={{ width: `${largest > 0 ? (Math.abs(row.share) / largest) * 100 : 0}%` }}
/>
</span>
<span className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-caption font-normal text-content-faint">
{t('costs.expensesCount', { count: row.count })}
</span>
{row.paid > 0 && (
<SoftPill tone="success">
{t('assistant.card.budget.markedPaid', { amount: money(row.paid) })}
</SoftPill>
)}
</span>
</span>
}
/>
))}
</CardRows>
<CardMore count={card.more} target={costsTarget()} />
</CardFrame>
);
}
@@ -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 <Amount value={row.amount} before={row.before?.amount ?? null} struck={struckOut(card, item)} />;
}
/** 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 <FactChange icon={Tag} before={nameOf(was)} after={now ?? ''} tone="info" />;
return now ? <Fact icon={Tag} label={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 <FactChange icon={CalendarDays} before={format.date(was)} after={now} tone="info" />;
return now ? <Fact icon={CalendarDays} label={now} /> : null;
}
/** The place the expense belongs to; shown on the map where the chat can show it. */
function PlaceFact({ place }: { place: NonNullable<CardExpense['place']> }) {
const { onOpen } = useAssistantView();
const fact = <Fact icon={MapPin} label={place.name} />;
if (!onOpen) return fact;
return (
<button
type="button"
onClick={() => onOpen({ kind: 'map', placeId: place.id, dayId: null })}
className="inline-flex max-w-full rounded-full"
>
{fact}
</button>
);
}
export function ExpenseFacts({ card }: SlotProps) {
const { t } = useTranslation();
const row = card.rows[0];
if (!row) return null;
const split = row.before?.split ?? null;
return (
<>
<CategoryFact row={row} />
<DateFact row={row} />
{row.place && <PlaceFact place={row.place} />}
{split && (
<FactChange
icon={Tag}
before={t(SPLIT_LABEL[split].labelKey)}
after={t(SPLIT_LABEL[row.split].labelKey)}
tone="info"
/>
)}
{row.receipts > 0 && <Fact icon={Receipt} label={t('assistant.card.budget.receipts', { count: row.receipts })} />}
{row.note && <NotePill note={row.note} />}
</>
);
}
export function ExpenseBody({ card }: SlotProps) {
const row = card.rows[0];
return row ? <SplitLine row={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<string>;
place: 'head' | 'foot';
}) {
const { t } = useTranslation();
const format = useCardFormat();
const together = place === 'foot' ? togetherOf(items, excluded) : null;
if (!together) return null;
return (
<p className="m-0 pt-1 text-end text-caption tabular-nums text-content-secondary">
{t('assistant.card.budget.together', { amount: format.money(together) })}
</p>
);
}
@@ -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<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
}
const answer = (card: TrekCard) => <ExpensesCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />;
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(<ExpensesCard cards={[]} message={MESSAGE} />);
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(<ExpenseAside card={expenses([update])} item={changeItem('a', null)} />);
expect(screen.getByText(money(84))).toHaveClass('line-through');
expect(screen.getByText(money(96))).not.toHaveClass('line-through');
first.unmount();
inView(<ExpenseAside card={expenses([expense({ change: 'willDelete' })])} item={changeItem('b', null)} />);
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(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />, { 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(<ExpenseBody card={expenses([split])} item={changeItem('a', null)} />);
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(<ExpenseBody card={expenses([ticked])} item={changeItem('t', null)} />);
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(<ExpenseBody card={expenses([expense({ shares: crowd })])} item={changeItem('b', null)} />);
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(<ExpenseBody card={expenses([about])} item={changeItem('d', null)} />);
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(<ExpenseBody card={expenses([expense({ split: 'none', shares: [] })])} item={changeItem('c', null)} />);
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(<ExpensesTogether items={items} excluded={new Set()} place="foot" />);
expect(screen.getByText(`Together ${money(96.1)}`)).toBeInTheDocument();
foot.unmount();
const { container } = inView(<ExpensesTogether items={items} excluded={new Set()} place="head" />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -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 <WhiteBadge>{t('costs.multiplePayers')}</WhiteBadge>;
return <WhiteBadge icon={<PersonFace person={payer.person} size={14} />}>{paidBy(payer.person)}</WhiteBadge>;
}
/** 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 (
<span className="mt-1 flex flex-wrap items-center gap-1">
{row.date && <Fact icon={CalendarDays} label={format.date(row.date)} />}
<PayerBadge payers={row.payers} />
{row.split === 'none' ? (
<SoftPill>{t('assistant.card.budget.split.none')}</SoftPill>
) : (
<>
{!payer && <SoftPill tone="warning">{t('costs.noOnePaid')}</SoftPill>}
<PeopleStack people={row.shares.map((share) => share.person)} max={stackLimit(density)} />
</>
)}
{row.receipts > 0 && <Fact icon={Receipt} label={t('assistant.card.budget.receipts', { count: row.receipts })} />}
</span>
);
}
/** 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 (
<CardRow
lead={<CategoryTile category={row.category} />}
title={
<bdi title={row.name} className="line-clamp-2">
{row.name}
</bdi>
}
aside={
<span className="flex flex-col items-end">
<span className="whitespace-nowrap text-body font-semibold tabular-nums text-content">{amount}</span>
{row.tripAmount && <span className="whitespace-nowrap tabular-nums">≈ {format.money(row.tripAmount)}</span>}
</span>
}
facts={<ExpenseFacts row={row} />}
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 (
<div className="mt-1 flex items-center gap-2 border-t border-edge-faint pt-2 text-caption">
<CardMore count={card.more} target={costsTarget()} />
{card.total && (
<span className="ms-auto flex items-center gap-1 text-content-secondary">
{card.unconverted > 0 && (
<Tooltip label={leftOut}>
<span role="img" aria-label={leftOut} className="inline-flex text-warning">
<TriangleAlert size={12} aria-hidden="true" />
</span>
</Tooltip>
)}
{t('budget.total')}
<span className="font-semibold tabular-nums text-content">{format.money(card.total)}</span>
</span>
)}
</div>
);
}
/** 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 (
<CardFrame label={title}>
<CardHeader
icon={Wallet}
tone="info"
title={title}
count={card.rows.length + card.more}
actions={<CardOpenButton target={costsTarget()} labelKey="assistant.card.budget.open" />}
/>
<CardRows>
{card.rows.map((row, i) => <ExpenseRow key={row.id ?? `new-${i}`} row={row} />)}
</CardRows>
<ExpensesFoot card={card} />
</CardFrame>
);
}
@@ -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<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
}
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(<PaymentsCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />, { 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(<PaymentAside card={card} item={changeItem('a', card)} />);
expect(screen.getByText(money(42.5))).toHaveClass('line-through');
expect(screen.getByText(money(50))).not.toHaveClass('line-through');
aside.unmount();
const facts = inView(<PaymentChangeFacts card={card} item={changeItem('a', card)} />);
// 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(<PaymentBody card={card} item={changeItem('a', card)} />);
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(<PaymentAside card={gone} item={changeItem('a', gone)} />);
expect(screen.getByText(money(42.5))).toHaveClass('line-through');
first.unmount();
const kept = payments([payment()]);
inView(<PaymentAside card={kept} item={changeItem('b', kept, 'discarded')} />);
expect(screen.getByText(money(42.5))).toHaveClass('line-through');
});
});
@@ -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 && <Fact icon={CalendarDays} label={format.date(row.date)} />}
{row.note && <NotePill note={row.note} />}
</>
);
}
/** One payment of the history. */
function PaymentRow({ row }: { row: CardPayment }) {
const format = useCardFormat();
return (
<CardRow
lead={<PaymentFaces from={row.from} to={row.to} />}
title={<PaymentNames from={row.from} to={row.to} />}
aside={<span className="text-body font-semibold tabular-nums text-content">{format.money(row.amount)}</span>}
facts={
<span className="mt-1 flex flex-wrap items-center gap-1">
<PaymentFacts row={row} />
</span>
}
/>
);
}
/** 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 (
<CardFrame label={title}>
<CardHeader
icon={ArrowLeftRight}
tone="info"
title={title}
count={card.rows.length + card.more}
actions={<CardOpenButton target={costsTarget()} labelKey="assistant.card.budget.open" />}
/>
<CardRows>
{card.rows.map((row, i) => <PaymentRow key={row.id ?? `new-${i}`} row={row} />)}
</CardRows>
<CardMore count={card.more} target={costsTarget()} />
</CardFrame>
);
}
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 <Amount value={row.amount} before={row.before?.amount ?? null} struck={struck} />;
}
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 (
<SoftPill
tone="info"
className="max-w-full"
icon={<ArrowLeftRight size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none" />}
>
<span className="inline-flex min-w-0 text-content-faint line-through">
<PaymentNames from={was.from} to={was.to} wrap={false} />
</span>
<ArrowRight size={9} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
<PaymentNames from={now.from} to={now.to} wrap={false} />
</SoftPill>
);
}
/** 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 && <PeopleChange was={was} now={row} />}
<PaymentFacts row={row} />
</>
);
}
/** 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 (
<span className="flex items-center gap-2 text-caption text-content-secondary">
<PaymentFaces from={row.from} to={row.to} size={20} />
<PaymentNames from={row.from} to={row.to} />
</span>
);
}
@@ -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<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
}
function settle(overrides: Partial<SettlementData> = {}): 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(<SettlementCard cards={[{ toolCallId: 'r1', card: settle() }]} message={MESSAGE} />, { 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(<SettlementCard cards={[{ toolCallId: 'r1', card: square }]} message={MESSAGE} />);
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(<SettlementCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />);
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(<CostPerPersonCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />, { 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();
});
});
@@ -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 (
<div className="flex items-center gap-2.5 py-2">
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-success-soft text-success">
<Check size={14} strokeWidth={2.4} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block text-body font-semibold text-content">{t('costs.everyoneSquare')}</span>
<span className="block text-caption text-content-faint">{t('costs.nothingOutstanding')}</span>
</span>
</div>
);
}
return (
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
{card.flows.map((flow) => (
<li key={`${flow.from.id}-${flow.to.id}`} className="flex items-center gap-2 py-2">
<PaymentFaces from={flow.from} to={flow.to} />
<span className="min-w-0 flex-1 text-caption text-content-secondary">
<PaymentNames from={flow.from} to={flow.to} />
</span>
<span className="flex-none text-body font-semibold tabular-nums text-content">
{format.money({ amount: flow.amount, currency: card.currency })}
</span>
</li>
))}
{card.moreFlows > 0 && (
<li className="py-1 text-caption text-content-faint">
{t('assistant.card.common.more', { count: card.moreFlows })}
</li>
)}
</ul>
);
}
/** 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 (
<section className="mt-2">
<Eyebrow className="mb-1.5">{t('costs.balances')}</Eyebrow>
<ul className="m-0 flex list-none flex-col gap-2 p-0">
{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 (
<li key={row.person.id} className="grid grid-cols-[20px_minmax(0,1fr)_auto] items-center gap-2.5">
<PersonFace person={row.person} size={20} />
<span className="min-w-0">
<bdi className="block truncate text-caption font-semibold text-content">{nameOf(row.person)}</bdi>
<span className="relative mt-1 block h-[5px] overflow-hidden rounded-full bg-surface-secondary">
<span className="absolute inset-y-0 start-1/2 w-px bg-edge" />
{owed && <span className="absolute inset-y-0 start-1/2 rounded-full bg-success" style={reach} />}
{owes && <span className="absolute inset-y-0 end-1/2 rounded-full bg-danger" style={reach} />}
</span>
</span>
<span className={`text-caption font-semibold tabular-nums ${tone}`}>{owed ? `+${money}` : money}</span>
</li>
);
})}
{card.moreBalances > 0 && (
<li className="text-caption text-content-faint">
{t('assistant.card.common.more', { count: card.moreBalances })}
</li>
)}
</ul>
</section>
);
}
/** 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 (
<CardFrame label={title}>
<CardHeader
icon={Scale}
tone="info"
title={title}
count={card.flows.length + card.moreFlows}
actions={<CardOpenButton target={costsTarget()} labelKey="assistant.card.budget.open" />}
/>
<Flows card={card} />
<Balances card={card} />
{entries > 0 && (
<div className="mt-2">
<TourNotice tone="warning" icon={<TriangleAlert size={12} aria-hidden="true" />}>
{t('assistant.card.budget.unconverted', { count: entries, currencies: currencies.join(', ') })}
</TourNotice>
</div>
)}
{card.recorded > 0 && (
<p className="m-0 pt-2 text-caption text-content-faint">
{t('costs.paymentsSettled', { count: card.recorded })}
</p>
)}
</CardFrame>
);
}
@@ -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 }),
};
}
@@ -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'>;
@@ -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<typeof expense>[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);
});
});
@@ -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<K extends TrekCardKind>(entries: readonly CardEntry[], kind: K): TrekCardOf<K>[] {
return entries.flatMap((entry) => (entry.card.kind === kind ? [entry.card as TrekCardOf<K>] : []));
}
/**
* 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<number>();
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<number>();
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<CardExpense, 'split' | 'shares' | 'moreShares' | 'highlight'>,
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<Record<ExpenseSplit, { labelKey: string }>> = {
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<string>): 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);
}
@@ -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 (
<span
role="img"
aria-label={label}
title={label}
className="flex h-7 w-7 flex-none items-center justify-center rounded-[9px]"
style={{ background: tintOf(color, 14), color }}
>
<Icon size={14} strokeWidth={2.1} aria-hidden="true" />
</span>
);
}
/** 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 (
<span className="flex flex-none flex-wrap items-center justify-end gap-1 whitespace-nowrap text-body font-semibold tabular-nums text-content">
{before && (
<>
<bdi className="font-normal text-content-faint line-through">{format.money(before)}</bdi>
<ArrowRight size={9} aria-hidden="true" className="text-content-faint rtl:-scale-x-100" />
</>
)}
<bdi className={struck ? 'text-content-faint line-through' : ''}>{format.money(value)}</bdi>
</span>
);
}
/**
* 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 = <PersonFace person={person} size={14} />;
const pill = highlighted ? (
<SoftPill tone="accent" icon={face} className="tabular-nums">
{money}
{paid && <Check size={9} strokeWidth={3} aria-hidden="true" />}
</SoftPill>
) : paid ? (
<SoftPill tone="success" icon={face} className="tabular-nums">
{money}
<Check size={9} strokeWidth={3} aria-hidden="true" />
</SoftPill>
) : (
<WhiteBadge icon={face}>{money}</WhiteBadge>
);
return (
<Tooltip label={label}>
<span role="img" className="inline-flex" aria-label={label}>
{pill}
</span>
</Tooltip>
);
}
/** Who paid: one payer by name ("You paid"), several each with what they paid; a warning when nobody did. */
function Payers({ row }: { row: Pick<CardExpense, 'payers' | 'amount'> }) {
const { t } = useTranslation();
const { paidBy } = useBudgetPeople();
const [only] = row.payers;
if (!only) return <SoftPill tone="warning">{t('costs.noOnePaid')}</SoftPill>;
if (row.payers.length === 1) {
return <WhiteBadge icon={<PersonFace person={only.person} size={14} />}>{paidBy(only.person)}</WhiteBadge>;
}
return (
<>
{row.payers.map((payer) => (
<ShareChip
key={payer.person.id}
person={payer.person}
amount={{ amount: payer.amount, currency: row.amount.currency }}
/>
))}
</>
);
}
/**
* 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 (
<span className="flex flex-wrap items-center gap-1">
<SoftPill>{t('assistant.card.budget.split.none')}</SoftPill>
</span>
);
}
const currency = row.amount.currency;
const highlighted = new Set(row.highlight);
const firstShare = row.shares[0];
return (
<span className="flex flex-wrap items-center gap-1">
<Payers row={row} />
<ArrowRight size={10} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
{sharesAsChips(row, chipLimit(density)) || !firstShare ? (
row.shares.map((share) => (
<ShareChip
key={share.person.id}
person={share.person}
amount={{ amount: share.amount, currency }}
paid={share.paid}
highlighted={highlighted.has(share.person.id)}
/>
))
) : (
<>
<PeopleStack people={row.shares.map((share) => share.person)} max={stackLimit(density)} />
<span className="text-caption tabular-nums text-content-secondary">
{t('costs.perPerson', { amount: format.money({ amount: firstShare.amount, currency }) })}
</span>
</>
)}
{row.moreShares > 0 && (
<span className="text-caption text-content-faint">
{t('assistant.card.common.more', { count: row.moreShares })}
</span>
)}
</span>
);
}
/** 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 (
<span className="flex flex-none items-center gap-1">
<PersonFace person={from} size={size} />
<ArrowRight size={13} aria-hidden="true" className="text-content-faint rtl:-scale-x-100" />
<PersonFace person={to} size={size} />
</span>
);
}
/**
* "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 (
<span className={`inline-flex min-w-0 items-center gap-1 ${wrap ? 'flex-wrap' : 'flex-nowrap'}`}>
<bdi>{nameOf(from)}</bdi>
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
<bdi>{nameOf(to)}</bdi>
</span>
);
}
/**
* 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 (
<span
title={note}
className="inline-flex min-w-0 max-w-full items-start gap-1 rounded-[10px] bg-surface-card px-2 py-[3px] text-caption leading-snug text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint"
>
<StickyNote size={10} aria-hidden="true" className="mt-[3px] flex-none text-content-faint" />
<bdi className="line-clamp-3 min-w-0 break-words">{note}</bdi>
</span>
);
}
@@ -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<K> };
+4 -10
View File
@@ -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'
@@ -119,14 +119,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=<id>) 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
@@ -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=<id>`, 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<void>>;
function Probe({
items,
open,
create = false,
}: {
items: BudgetItem[];
open: (expense: BudgetItem | null) => void;
create?: boolean;
}) {
useExpenseLinks(4, items, open, { create });
const [params] = useSearchParams();
return <output aria-label="search">{params.toString()}</output>;
}
const search = () => screen.getByRole('status', { name: 'search' }).textContent;
describe('useExpenseLinks', () => {
beforeEach(() => {
resetAllStores();
reload = vi.fn<(tripId: number | string) => Promise<void>>(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(<Probe items={[buildBudgetItem({ id: 30 }), dinner]} open={open} />, {
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<void>((resolve) => {
arrive = resolve;
})
);
const open = vi.fn();
const first = render(<Probe items={[buildBudgetItem({ id: 30 })]} open={open} />, {
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(<Probe items={[buildBudgetItem({ id: 30 }), dinner]} open={open} />);
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(<Probe items={[]} open={open} />, { 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(<Probe items={[]} open={open} create />, { initialEntries: ['/trips/4?create=expense'] });
expect(open).toHaveBeenCalledWith(null);
expect(search()).toBe('');
first.unmount();
const phone = vi.fn();
render(<Probe items={[]} open={phone} />, { 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(<Probe items={[buildBudgetItem({ id: 5 })]} open={open} />, { 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(<Probe items={[]} open={open} />, { initialEntries: ['/trips/4?tab=finanzplan&expense=31'] });
await waitFor(() => expect(search()).toBe('tab=finanzplan'));
expect(reload).toHaveBeenCalledTimes(1);
expect(open).not.toHaveBeenCalled();
});
});
@@ -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=<id>` 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<string | null>(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]);
}
@@ -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<BudgetItem | null>(null)
const [confirmDelete, setConfirmDelete] = useState<BudgetItem | null>(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])
+70
View File
@@ -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> = {}): 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> = {}): ExpensesCard {
return { kind: 'expenses', rows, offset: 0, more: 0, total: null, unconverted: 0, ...overrides };
}
export function payment(overrides: Partial<CardPayment> = {}): 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 };
}
@@ -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(<MCostsTab planner={{ ...p, budgetItems: [...ITEMS, bread] }} shell={shell} />)
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(<MCostsTab planner={planner()} shell={buildShell()} />, { 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(<MCostsTab planner={viewer} shell={buildShell()} />, { initialEntries: ['/trips/7?expense=12'] })
await screen.findByText('+$12.50')
expect(screen.queryByText('sheet editing 12')).not.toBeInTheDocument()
expect(reload).not.toHaveBeenCalled()
})
})
@@ -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<ExpensesCard>({
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<PaymentsCard>({ kind: 'payments', rows, more: count(all.length - rows.length, 100_000) });
}
@@ -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;
}
@@ -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<Record<string, (ctx: PreviewContext, args: ToolArgs, loaded: unknown) => Described>>;
@@ -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<ToolCards['undo']>;
/** 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);
},
};
@@ -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<number, string>): 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<CardExpense, 'split' | 'shares' | 'moreShares'> {
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<number, string>) {
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<number, string>;
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<TripCard, ReadonlyMap<number, Row>>();
/** 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<number, Row> {
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<C extends { rows: readonly unknown[]; more: number }>(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;
}
+102 -3
View File
@@ -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,
},
},
};
+35 -14
View File
@@ -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<string, number> | 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<typeof slimItem>[] = [];
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,
+2 -4
View File
@@ -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(
@@ -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<C extends TrekCard>(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<string, unknown>, loaded: unknown = null) =>
describeToolCall(tool, 'tc-1', args, ctx, loaded);
function expensesOf(tool: string, args: Record<string, unknown>): 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<string, unknown>, 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<ExpensesCard>('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<ExpensesCard>('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<ExpensesCard>('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<SettlementCard>('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<SettlementCard>('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<SettlementCard>('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<CostPerPersonCard>('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<PaymentsCard>('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<string, unknown>) => ({ 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<string, unknown>, 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<ExpensesCard>('list_budget_items', { currency: 'EUR', item_count: 21, offset: 20, items });
expect(page).toMatchObject({ offset: 20, more: 20 });
expect(answer<ExpensesCard>('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<ExpensesCard>('list_budget_items', { items });
expect(list?.rows[0]?.shares).toHaveLength(6);
expect(list?.rows[0]?.moreShares).toBe(1000);
});
});
@@ -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' });
@@ -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');
});
});
@@ -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;
@@ -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<typeof costPerPersonCardSchema>;
+97 -3
View File
@@ -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<typeof expensesCardSchema>;
export type CardExpense = z.infer<typeof cardExpenseSchema>;
export type CardExpenseShare = z.infer<typeof cardExpenseShareSchema>;
export type CardExpensePayer = z.infer<typeof cardExpensePayerSchema>;
export type CardExpenseBefore = z.infer<typeof cardExpenseBeforeSchema>;
export type ExpenseSplit = z.infer<typeof expenseSplitSchema>;
export type ExpenseChange = z.infer<typeof expenseChangeSchema>;
+48 -2
View File
@@ -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<typeof paymentsCardSchema>;
export type CardPayment = z.infer<typeof cardPaymentSchema>;
export type CardPaymentMoney = z.infer<typeof cardPaymentMoneySchema>;
+33 -2
View File
@@ -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<typeof settlementCardSchema>;
+39 -1
View File
@@ -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;
+27 -1
View File
@@ -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;
+31 -1
View File
@@ -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;
+31 -1
View File
@@ -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;
+31 -1
View File
@@ -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;
+27 -1
View File
@@ -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;
+27 -1
View File
@@ -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;
+29 -1
View File
@@ -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;
+27 -1
View File
@@ -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;
+31 -1
View File
@@ -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;
+30 -1
View File
@@ -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;
+27 -1
View File
@@ -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;
+24 -1
View File
@@ -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;
+30 -1
View File
@@ -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;
+25 -1
View File
@@ -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;
+24 -1
View File
@@ -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;
+29 -1
View File
@@ -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;
+38 -1
View File
@@ -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;
+33 -1
View File
@@ -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;
+34 -1
View File
@@ -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;
+27 -1
View File
@@ -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;
+25 -1
View File
@@ -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;
+27 -1
View File
@@ -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;
+33 -1
View File
@@ -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;
+24 -1
View File
@@ -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;
+24 -1
View File
@@ -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;
+24 -1
View File
@@ -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;