mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): expense, payment and settle-up cards, and expense links into Costs
This commit is contained in:
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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> };
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user