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

This commit is contained in:
Maurice
2026-10-22 15:50:49 +02:00
parent c94545419b
commit b52990bf27
65 changed files with 4763 additions and 95 deletions
@@ -0,0 +1,158 @@
import type { TripCard } from '../../assistant-context';
import type { AnswerInput } from '../card-types';
import { finite, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import {
amountOf,
codeOf,
dateOf,
expenseFromRow,
fitted,
paymentFromRow,
personFrom,
positiveId,
type PeopleCaps,
} from './budget-values';
import {
COST_PER_PERSON_ROWS_MAX,
EXPENSE_ROWS_MAX,
PAYMENT_ROWS_MAX,
SETTLEMENT_BALANCES_MAX,
SETTLEMENT_FLOWS_MAX,
type CardPerson,
type CostPerPersonCard,
type ExpensesCard,
type PaymentsCard,
type SettlementCard,
} from '@trek/shared';
/**
* The cards the budget's reads show beside the answer, built from each
* read's raw answer: the expense list, settle up, the payments recorded and
* what the trip costs each traveller. A read that found nothing shows no card.
*/
/** People an expense row of the list names: the list stays short, a change row names everyone. */
const LIST_PEOPLE: PeopleCaps = { shares: 6, payers: 3 };
/** Counts a card states, kept inside their bounds. */
function count(value: number, max: number): number {
return Math.min(max, Math.max(0, Math.trunc(value)));
}
/** A person an answer names as `{ user_id, username, avatar_url }`. */
function personIn(card: TripCard, raw: unknown): CardPerson | null {
const row = recordOf(raw);
const id = positiveId(row.user_id);
return id === null ? null : personFrom(card, id, row.username, row.avatar_url);
}
/** `list_budget_items`: the page's expenses in the list's order, where they start, the rest counted, the total. */
export function expensesAnswer({ value, card }: AnswerInput): ExpensesCard | null {
const root = recordOf(value);
const tripCurrency = codeOf(root.currency, card.currency);
const names = new Map(
rowsOf(root.people).flatMap((person): Array<[number, string]> => {
const id = positiveId(person.user_id);
return id !== null && typeof person.username === 'string' ? [[id, person.username]] : [];
}),
);
const items = rowsOf(root.items);
const rows = items.slice(0, EXPENSE_ROWS_MAX).flatMap((item) => {
const row = expenseFromRow(card, item, { tripCurrency, names, caps: LIST_PEOPLE, change: 'none' });
return row ? [row] : [];
});
if (rows.length === 0) return null;
const total = amountOf(root.total);
const all = finite(root.item_count) ?? items.length;
return fitted<ExpensesCard>({
kind: 'expenses',
rows,
offset: count(finite(root.offset) ?? 0, 100_000),
more: count(all - rows.length, 100_000),
total: total === null ? null : { amount: total, currency: tripCurrency },
unconverted: count(Array.isArray(root.unconverted_item_ids) ? root.unconverted_item_ids.length : 0, 100_000),
});
}
/** The balances worth naming: everyone not square, or all of them when everyone is; largest credit first. */
function balancesOf(card: TripCard, rows: Row[]) {
const all = rows.flatMap((row) => {
const person = personIn(card, row);
const balance = amountOf(row.balance);
return person && balance !== null ? [{ person, balance }] : [];
});
const open = all.filter((row) => Math.abs(row.balance) >= 0.005);
return (open.length > 0 ? open : all).sort((a, b) => b.balance - a.balance);
}
/** `get_settlement_summary`: who pays whom, the balances, the payments recorded, what no rate converts. */
export function settlementAnswer({ value, card }: AnswerInput): SettlementCard | null {
const summary = recordOf(recordOf(value).summary);
if (!Array.isArray(summary.flows) && !Array.isArray(summary.balances)) return null;
const flows = rowsOf(summary.flows).flatMap((flow) => {
const from = personIn(card, flow.from);
const to = personIn(card, flow.to);
const amount = amountOf(flow.amount);
return from && to && amount !== null && amount > 0 ? [{ from, to, amount }] : [];
});
const balances = balancesOf(card, rowsOf(summary.balances));
const unconverted = recordOf(summary.unconverted);
const listed = (key: string) => (Array.isArray(unconverted[key]) ? (unconverted[key] as unknown[]).length : 0);
const currencies = Array.isArray(unconverted.currencies)
? unconverted.currencies.filter((code): code is string => typeof code === 'string' && /^[A-Z]{3}$/.test(code))
: [];
return {
kind: 'settlement',
currency: codeOf(summary.currency, card.currency),
flows: flows.slice(0, SETTLEMENT_FLOWS_MAX),
moreFlows: count(flows.length - SETTLEMENT_FLOWS_MAX, 1000),
balances: balances.slice(0, SETTLEMENT_BALANCES_MAX),
moreBalances: count(balances.length - SETTLEMENT_BALANCES_MAX, 1000),
recorded: count(Array.isArray(summary.settlements) ? summary.settlements.length : 0, 100_000),
unconverted: {
entries: count(listed('item_ids') + listed('settlement_ids'), 100_000),
currencies: currencies.slice(0, 6),
},
};
}
/** `get_budget_per_person`: each traveller's share, largest first, with what of it is marked paid. */
export function costPerPersonAnswer({ value, card }: AnswerInput): CostPerPersonCard | null {
const summary = rowsOf(recordOf(value).summary);
const rows = summary
.flatMap((row) => {
const person = personIn(card, row);
const share = amountOf(row.total_assigned);
if (!person || share === null) return [];
return [
{ person, share, paid: amountOf(row.total_paid) ?? 0, count: count(finite(row.items_count) ?? 0, 100_000) },
];
})
.sort((a, b) => b.share - a.share);
if (rows.length === 0) return null;
return {
kind: 'costPerPerson',
currency: codeOf(summary[0]?.currency, card.currency),
rows: rows.slice(0, COST_PER_PERSON_ROWS_MAX),
more: count(rows.length - COST_PER_PERSON_ROWS_MAX, 1000),
};
}
/** The day a payment is filed under, the newest first: the day it was paid, else the day it was recorded. */
function newestFirst(a: Row, b: Row): number {
const day = (row: Row) => dateOf(row.settled_at) ?? dateOf(row.created_at) ?? '';
const byDay = day(b).localeCompare(day(a));
return byDay !== 0 ? byDay : (finite(b.id) ?? 0) - (finite(a.id) ?? 0);
}
/** `list_settlements`: the payments recorded, newest first, the rest counted. */
export function paymentsAnswer({ value, card }: AnswerInput): PaymentsCard | null {
const all = rowsOf(recordOf(value).settlements).sort(newestFirst);
const rows = all.slice(0, PAYMENT_ROWS_MAX).flatMap((row) => {
const payment = paymentFromRow(card, row, 'none');
return payment ? [payment] : [];
});
if (rows.length === 0) return null;
return fitted<PaymentsCard>({ kind: 'payments', rows, more: count(all.length - rows.length, 100_000) });
}
@@ -0,0 +1,279 @@
import type { TripCard } from '../../assistant-context';
import type { PreviewContext, ToolArgs } from '../card-types';
import { argId, clipped, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import {
ALL_PEOPLE,
amountOf,
argLine,
budgetRows,
codeOf,
dateOf,
expenseFromRow,
payersOf,
paymentFromRow,
personFrom,
placeOf,
positiveId,
settlementRow,
splitOf,
type SplitMember,
} from './budget-values';
import {
sumMinor,
type CardExpense,
type CardExpenseBefore,
type CardPayment,
type ExpensesCard,
type PaymentsCard,
} from '@trek/shared';
/**
* The cards a held budget write shows inside its change card: the expense
* as it will be (the full split, who paid, the amounts) or the payment, with
* what an update replaces and the people a change is about. A new expense is
* read from the call's arguments the way the write path reads them; a change
* to one starts from the expense in the turn's trip summary, a change to a
* payment from the payments its lookup read. Without the row a change is
* about there is no card, and the change card built from the words stands.
*/
/** One expense as a change card holds it. */
function expenseCard(row: CardExpense): ExpensesCard {
return { kind: 'expenses', rows: [row], offset: 0, more: 0, total: null, unconverted: 0 };
}
function paymentCard(row: CardPayment): PaymentsCard {
return { kind: 'payments', rows: [row], more: 0 };
}
function idsOf(value: unknown): number[] {
return Array.isArray(value) ? value.filter((id): id is number => positiveId(id) !== null) : [];
}
/** Members splitting evenly, nobody marked paid yet. */
function evenly(ids: readonly number[]): SplitMember[] {
return ids.map((id) => ({ id, amount: null, paid: false }));
}
/** The uneven split of `members` (`[{ user_id, amount }]`). */
function unevenly(value: unknown): SplitMember[] {
return rowsOf(value).flatMap((member) => {
const id = positiveId(member.user_id);
const amount = amountOf(member.amount);
return id === null || amount === null ? [] : [{ id, amount, paid: false }];
});
}
/** Everyone on the trip, as an expense split across all members reads them. */
function everyone(card: TripCard): number[] {
return [...card.members.keys()];
}
/** What a new expense comes to: the payers' sum when they are named, as the write path derives it. */
function newTotal(card: TripCard, args: ToolArgs): number | null {
const payers = payersOf(card, args.payers, Number.MAX_SAFE_INTEGER);
return payers.length > 0 ? sumMinor(payers.map((payer) => payer.amount)) / 100 : amountOf(args.total_price);
}
/** A new expense from its arguments: who shares it is `members`, else `split`, else everyone on the trip. */
function newExpense(ctx: PreviewContext, args: ToolArgs, split: SplitMember[]): ExpensesCard | null {
const name = argLine(args.name, 120);
const total = newTotal(ctx.card, args);
if (name === null || total === null) return null;
return expenseCard({
id: null,
name,
category: argLine(args.category, 60),
amount: { amount: total, currency: codeOf(args.currency, ctx.card.currency) },
tripAmount: null,
date: dateOf(args.expense_date),
place: placeOf(ctx.card, args.place_id),
payers: payersOf(ctx.card, args.payers, ALL_PEOPLE.payers),
...splitOf(ctx.card, split, total, 0, ALL_PEOPLE.shares),
note: argLine(args.note, 160),
receipts: 0,
before: null,
highlight: [],
change: 'new',
});
}
/** `create_budget_item`: an uneven split, an even one among `member_ids` (none for `[]`), else everyone. */
export function createExpenseChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null {
const split = Array.isArray(args.members)
? unevenly(args.members)
: evenly(Array.isArray(args.member_ids) ? idsOf(args.member_ids) : everyone(ctx.card));
return newExpense(ctx, args, split);
}
/**
* `create_budget_item_with_members`: shared evenly by `userIds`, or by
* everyone on the trip when it lists nobody, an empty list included, as the
* tool does and says.
*/
export function createWithMembersChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null {
const ids = idsOf(args.userIds);
// The tool takes no currency, date or payers: the expense is in the trip currency and nobody paid yet.
const own = { name: args.name, category: args.category, total_price: args.total_price, note: args.note };
return newExpense(ctx, { ...own, place_id: args.place_id }, evenly(ids.length > 0 ? ids : everyone(ctx.card)));
}
/** The stored expense a change is about, as a row and as the card reads it. */
function storedExpense(ctx: PreviewContext, args: ToolArgs): { raw: Row; was: CardExpense } | null {
const itemId = argId(args, 'itemId');
const raw = itemId === null ? undefined : budgetRows(ctx.card).get(itemId);
const was = raw ? expenseOf(ctx, raw) : null;
return raw && was ? { raw, was } : null;
}
function expenseOf(ctx: PreviewContext, raw: unknown): CardExpense | null {
return expenseFromRow(ctx.card, raw, {
tripCurrency: codeOf(ctx.card.currency, null),
caps: ALL_PEOPLE,
change: 'changed',
});
}
/** Whether a member was marked paid on the stored expense. */
function paidBefore(raw: Row, id: number): boolean {
return rowsOf(raw.members).some((member) => member.user_id === id && (member.paid === 1 || member.paid === true));
}
/** The people a change brings into the split. */
function joined(was: CardExpense, now: CardExpense): number[] {
const before = new Set(was.shares.map((share) => share.person.id));
return now.shares.map((share) => share.person.id).filter((id) => !before.has(id));
}
/** What a change replaces, each value only where it differs; null when nothing it shows changes. */
function beforeOf(was: CardExpense, now: CardExpense): CardExpenseBefore | null {
const moved = was.amount.amount !== now.amount.amount || was.amount.currency !== now.amount.currency;
const before: CardExpenseBefore = {
name: was.name !== now.name ? was.name : null,
amount: moved ? was.amount : null,
category: was.category !== now.category ? was.category : null,
date: was.date !== now.date ? was.date : null,
split: was.split !== now.split ? was.split : null,
};
return Object.values(before).some((value) => value !== null) ? before : null;
}
/** The stored row with an update's arguments laid over it, the way the write path stores them. */
function updatedRow(ctx: PreviewContext, raw: Row, args: ToolArgs): Row {
const next: Row = { ...raw };
const payers = payersOf(ctx.card, args.payers, Number.MAX_SAFE_INTEGER);
const total = amountOf(args.total_price);
if (Array.isArray(args.payers)) {
next.payers = payers.map((payer) => ({ user_id: payer.person.id, amount: payer.amount }));
// The payers' sum is the total; clearing them keeps a total the call names, as the write path re-applies it.
next.total_price =
payers.length === 0 && total !== null ? total : sumMinor(payers.map((payer) => payer.amount)) / 100;
} else if (total !== null) next.total_price = total;
if (args.currency !== undefined) next.currency = args.currency;
if (args.expense_date !== undefined) next.expense_date = args.expense_date;
if (args.place_id !== undefined) next.place_id = args.place_id;
const keep = (id: number) => ({ user_id: id, paid: paidBefore(raw, id) });
if (Array.isArray(args.members)) {
next.members = unevenly(args.members).map((member) => ({ ...keep(member.id), amount: member.amount }));
} else if (Array.isArray(args.member_ids)) {
next.members = idsOf(args.member_ids).map((id) => ({ ...keep(id), amount: null }));
}
return next;
}
/** `update_budget_item`: the expense as it will be, with what it replaces and who joins its split. */
export function updateExpenseChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null {
const stored = storedExpense(ctx, args);
const laid = stored ? expenseOf(ctx, updatedRow(ctx, stored.raw, args)) : null;
if (!stored || !laid) return null;
const now: CardExpense = {
...laid,
name: argLine(args.name, 120) ?? laid.name,
category: typeof args.category === 'string' ? argLine(args.category, 60) : laid.category,
note: args.note === undefined ? null : argLine(args.note, 160),
};
return expenseCard({ ...now, before: beforeOf(stored.was, now), highlight: joined(stored.was, now) });
}
/** `set_budget_item_members`: an even split among `userIds`, the paid ticks kept, own amounts dropped. */
export function setMembersChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null {
const stored = storedExpense(ctx, args);
if (!stored || !Array.isArray(args.userIds)) return null;
const members = idsOf(args.userIds).map((id) => ({ user_id: id, paid: paidBefore(stored.raw, id), amount: null }));
const now = expenseOf(ctx, { ...stored.raw, members });
if (!now) return null;
return expenseCard({ ...now, before: beforeOf(stored.was, now), highlight: joined(stored.was, now) });
}
/** `toggle_budget_member_paid`: one member's paid tick set or taken off. */
export function togglePaidChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null {
const stored = storedExpense(ctx, args);
const memberId = argId(args, 'memberId');
if (!stored || memberId === null || typeof args.paid !== 'boolean') return null;
const paid = args.paid;
const shares = stored.was.shares.map((share) => (share.person.id === memberId ? { ...share, paid } : share));
return expenseCard({ ...stored.was, shares, highlight: [memberId] });
}
/** `delete_budget_item`: the expense that goes. */
export function deleteExpenseChange(ctx: PreviewContext, args: ToolArgs): ExpensesCard | null {
const stored = storedExpense(ctx, args);
return stored ? expenseCard({ ...stored.was, change: 'willDelete' }) : null;
}
/** A payment from its arguments; the stored one's values stand in for those the call leaves out. */
function paymentOf(card: TripCard, args: ToolArgs, base: Row | undefined): CardPayment | null {
const stored = recordOf(base);
const from = argId(args, 'from_user_id') ?? positiveId(stored.from_user_id);
const to = argId(args, 'to_user_id') ?? positiveId(stored.to_user_id);
const amount = amountOf(args.amount ?? stored.amount);
if (from === null || to === null || amount === null || amount <= 0) return null;
const currency = args.currency === undefined ? stored.currency : args.currency;
const settled = args.settled_at === undefined ? stored.settled_at : args.settled_at;
// Someone who left the trip keeps the name the stored payment gives them.
const side = (id: number, key: 'from' | 'to') =>
stored[`${key}_user_id`] === id
? personFrom(card, id, stored[`${key}_username`], stored[`${key}_avatar_url`])
: personFrom(card, id);
return {
id: positiveId(stored.id),
from: side(from, 'from'),
to: side(to, 'to'),
amount: { amount, currency: codeOf(currency, card.currency) },
date: dateOf(settled) ?? dateOf(stored.created_at),
note: args.note === undefined ? clipped(stored.note, 120) : argLine(args.note, 120),
before: null,
change: base ? 'changed' : 'new',
};
}
/** `create_settlement`: who pays whom how much, and on which day. */
export function createPaymentChange(ctx: PreviewContext, args: ToolArgs): PaymentsCard | null {
const payment = paymentOf(ctx.card, args, undefined);
return payment ? paymentCard(payment) : null;
}
/** `update_settlement`: the payment as it will be, with the people and the amount it replaces. */
export function updatePaymentChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PaymentsCard | null {
const base = settlementRow(loaded, argId(args, 'settlementId'));
const was = base ? paymentFromRow(ctx.card, base, 'none') : null;
const now = paymentOf(ctx.card, args, base);
if (!now) return null;
if (!was) return paymentCard({ ...now, id: argId(args, 'settlementId'), change: 'changed' });
const moved = was.amount.amount !== now.amount.amount || was.amount.currency !== now.amount.currency;
const before = {
from: was.from.id !== now.from.id ? was.from : null,
to: was.to.id !== now.to.id ? was.to : null,
amount: moved ? was.amount : null,
};
const changed = before.from !== null || before.to !== null || before.amount !== null;
return paymentCard({ ...now, before: changed ? before : null });
}
/** `delete_settlement`: the payment that goes, when the lookup found it. */
export function deletePaymentChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): PaymentsCard | null {
const base = settlementRow(loaded, argId(args, 'settlementId'));
const payment = base ? paymentFromRow(ctx.card, base, 'willDelete') : null;
return payment ? paymentCard(payment) : null;
}
@@ -0,0 +1,120 @@
import type { Described, PreviewContext, ToolArgs } from '../card-types';
import { argId, recordOf, type Row } from '../card-values';
import { formatDate, id, memberNames, money, placeName, Rows, text } from '../preview-format';
import { rowsOf } from '../undo-snapshot';
import { budgetRows, codeOf, settlementRow } from './budget-values';
/**
* The change cards, in words, of the budget writes that had none of their
* own: an expense added with its split, who shares one, who has paid their
* part, an expense removed, and the settle-up payments recorded, changed and
* removed. Each names the tool it runs, so the card reads the tool's own
* action ("Record a payment") rather than a plain add or change. The
* expense or payment it is about is named from the turn's trip summary or the
* payments its lookup read; one the card cannot find reads as its id.
*/
/** The stored expense a call names with `itemId`. */
function expense(ctx: PreviewContext, args: ToolArgs): Row | undefined {
const itemId = argId(args, 'itemId');
return itemId === null ? undefined : budgetRows(ctx.card).get(itemId);
}
function expenseName(ctx: PreviewContext, args: ToolArgs): string {
const found = expense(ctx, args);
const itemId = argId(args, 'itemId');
return text(found?.name) || (itemId === null ? '' : `#${itemId}`);
}
/** Who shares a stored expense, by name. */
function sharedBy(ctx: PreviewContext, row: Row | undefined): string {
return memberNames(ctx, rowsOf(row?.members).map((member) => member.user_id));
}
/** "Ana → Ben", from the two people of a payment. */
function between(ctx: PreviewContext, from: unknown, to: unknown): string {
const named = [from, to].map((person) => (typeof person === 'number' ? memberNames(ctx, [person]) : ''));
return named.every((name) => name !== '') ? text(`${named[0]} → ${named[1]}`) : '';
}
/** An amount in its currency, empty for one that is no number. */
function amount(ctx: PreviewContext, value: unknown, currency: unknown): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
return money(ctx, value, codeOf(currency, ctx.card.currency));
}
function day(ctx: PreviewContext, value: unknown): string {
return typeof value === 'string' && value !== '' ? formatDate(ctx, value.slice(0, 10)) : '';
}
/** The stored payment `settlementId` names, from the payments the lookup read. */
function payment(args: ToolArgs, loaded: unknown): Row {
return recordOf(settlementRow(loaded, argId(args, 'settlementId')));
}
/** "Ana → Ben" for a payment, its id where the card cannot name both people. */
function paymentSubject(ctx: PreviewContext, row: Row, args: ToolArgs): string {
const named = between(ctx, args.from_user_id ?? row.from_user_id, args.to_user_id ?? row.to_user_id);
const settlementId = argId(args, 'settlementId');
return named || (settlementId === null ? '' : `#${settlementId}`);
}
export const BUDGET_DESCRIBERS = {
create_budget_item_with_members: (ctx, args) => {
const placeId = id(args, 'place_id');
const ids = Array.isArray(args.userIds) && args.userIds.length > 0 ? args.userIds : [...ctx.card.members.keys()];
const rows = new Rows()
.add('price', amount(ctx, args.total_price, ctx.card.currency))
.add('category', text(args.category))
.add('members', memberNames(ctx, ids))
.add('location', placeName(ctx, placeId))
.add('notes', text(args.note));
return {
subject: text(args.name),
rows,
dayId: null,
placeIds: placeId ? [placeId] : [],
tool: 'create_budget_item_with_members',
};
},
set_budget_item_members: (ctx, args) => {
const rows = new Rows();
if (Array.isArray(args.userIds)) {
rows.change('members', sharedBy(ctx, expense(ctx, args)), memberNames(ctx, args.userIds));
}
return { subject: expenseName(ctx, args), rows, dayId: null, placeIds: [], tool: 'set_budget_item_members' };
},
toggle_budget_member_paid: (ctx, args) => {
const memberId = argId(args, 'memberId');
const who = memberId === null ? '' : memberNames(ctx, [memberId]);
const rows = new Rows().add('members', who && typeof args.paid === 'boolean' ? `${who} ${text(args.paid)}` : who);
return { subject: expenseName(ctx, args), rows, dayId: null, placeIds: [], tool: 'toggle_budget_member_paid' };
},
delete_budget_item: (ctx, args) => {
const row = expense(ctx, args);
const rows = new Rows().add('price', amount(ctx, row?.total_price, row?.currency));
return { subject: expenseName(ctx, args), rows, dayId: null, placeIds: [], tool: 'delete_budget_item' };
},
create_settlement: (ctx, args) => {
const rows = new Rows()
.add('price', amount(ctx, args.amount, args.currency))
.add('day', day(ctx, args.settled_at))
.add('notes', text(args.note));
return { subject: paymentSubject(ctx, {}, args), rows, dayId: null, placeIds: [], tool: 'create_settlement' };
},
update_settlement: (ctx, args, loaded) => {
const row = payment(args, loaded);
const rows = new Rows();
const after = amount(ctx, args.amount, args.currency === undefined ? row.currency : args.currency);
const before = amount(ctx, row.amount, row.currency);
if (after !== '') rows.change('price', before === after ? null : before, after);
if (args.settled_at !== undefined) rows.change('day', day(ctx, row.settled_at) || null, day(ctx, args.settled_at));
if (args.note !== undefined) rows.change('notes', text(row.note) || null, text(args.note));
return { subject: paymentSubject(ctx, row, args), rows, dayId: null, placeIds: [], tool: 'update_settlement' };
},
delete_settlement: (ctx, args, loaded) => {
const row = payment(args, loaded);
const rows = new Rows().add('price', amount(ctx, row.amount, row.currency));
return { subject: paymentSubject(ctx, row, args), rows, dayId: null, placeIds: [], tool: 'delete_settlement' };
},
} satisfies Readonly<Record<string, (ctx: PreviewContext, args: ToolArgs, loaded: unknown) => Described>>;
@@ -0,0 +1,83 @@
import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
import { argId, idIn, readKey, recordOf } from '../card-values';
import { byId, rowsOf, same, STABLE_FIELDS } from '../undo-snapshot';
import type { AssistantChangePreview, TrekCard } from '@trek/shared';
/**
* What happens to a budget write once it is applied: the ids its receipt
* names, the card it leaves (the waiting one with the new row's id, so the
* receipt opens that very expense), and the way back from what it created,
* offered only while the created row is as the change left it.
*/
type Undo = NonNullable<ToolCards['undo']>;
/** The fields that say a recorded payment is still the one that was created. */
const PAYMENT_FIELDS = ['from_user_id', 'to_user_id', 'amount', 'currency', 'settled_at', 'note'] as const;
/** The read the payment undo checks against. */
const SETTLEMENTS_READ = { tool: 'list_settlements', args: {} } as const;
/** The waiting card of a create, now with the id the answer names under `key`. */
function withCreatedId(preview: AssistantChangePreview | null, result: unknown, key: 'item' | 'settlement') {
const card = preview?.card;
const createdId = idIn(result, key);
if (!card || createdId === null) return null;
if (key === 'item' && card.kind === 'expenses' && card.rows.length === 1) {
return { ...card, rows: card.rows.map((row) => ({ ...row, id: createdId })) };
}
if (key === 'settlement' && card.kind === 'payments' && card.rows.length === 1) {
return { ...card, rows: card.rows.map((row) => ({ ...row, id: createdId })) };
}
return null;
}
/** The card of an applied expense create. */
export function appliedExpense(input: { result: unknown; preview: AssistantChangePreview | null }): TrekCard | null {
return withCreatedId(input.preview, input.result, 'item');
}
/** The card of an applied payment. */
export function appliedPayment(input: { result: unknown; preview: AssistantChangePreview | null }): TrekCard | null {
return withCreatedId(input.preview, input.result, 'settlement');
}
/** A payment's receipt names the payment: the one created, or the one the call changed or removed. */
export function paymentIds(args: ToolArgs, result: unknown): number[] {
const paymentId = idIn(result, 'settlement') ?? argId(args, 'settlementId');
return paymentId === null ? [] : [paymentId];
}
/** An expense created with its split, taken back while it is as it was created. */
export const DELETE_CREATED_EXPENSE: Undo = {
tool: 'delete_budget_item',
args: (_args, result) => {
const itemId = idIn(result, 'item');
return itemId ? { itemId } : null;
},
unchanged: (result: unknown, snapshot: UndoSnapshot) => {
const created = recordOf(recordOf(result).item);
const items = rowsOf(recordOf(recordOf(snapshot.summary).budget).items);
return same(created, byId(items, created.id), STABLE_FIELDS.budget);
},
};
/**
* A recorded payment, taken back while nobody changed it since: the tool
* calls itself the undo of create_settlement, and removing the payment puts
* the balances back as they were.
*/
export const DELETE_CREATED_PAYMENT: Undo = {
tool: 'delete_settlement',
args: (_args, result) => {
const settlementId = idIn(result, 'settlement');
return settlementId ? { settlementId } : null;
},
reads: () => [SETTLEMENTS_READ],
unchanged: (result: unknown, snapshot: UndoSnapshot) => {
const created = recordOf(recordOf(result).settlement);
const read = snapshot.reads.get(readKey(SETTLEMENTS_READ.tool, SETTLEMENTS_READ.args));
const payments = rowsOf(recordOf(read).settlements);
return same(created, byId(payments, created.id), PAYMENT_FIELDS);
},
};
@@ -0,0 +1,237 @@
import type { TripCard } from '../../assistant-context';
import { clipped, fileKeyOf, finite, personOf, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import {
EXPENSE_PEOPLE_MAX,
MAX_CARD_CHARS,
splitEqualShares,
toMinor,
type CardExpense,
type CardPayment,
type CardPerson,
type ExpenseChange,
} from '@trek/shared';
/**
* The readers the budget's cards are built with. Expenses and payments come
* from three places: the expense list a read answered, the trip summary the
* turn holds (for the expense a change is about) and the payments
* `list_settlements` answered. Each reader takes the row as it comes, trusts
* no shape and leaves out what is not there. People are named from the trip
* card, and someone who left the trip from the name the row still carries;
* their picture is the stored file, never an address.
*/
export function positiveId(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
/** An amount a card may show, to the cent; null for anything else. */
export function amountOf(value: unknown): number | null {
const number = finite(value);
return number !== null && Math.abs(number) <= 1e9 ? toMinor(number) / 100 : null;
}
/** A currency code, upper case; the fallback's when the value is none, euros when neither is. */
export function codeOf(value: unknown, fallback: string | null): string {
for (const candidate of [value, fallback]) {
const code = typeof candidate === 'string' ? candidate.trim().toUpperCase() : '';
if (/^[A-Z]{3}$/.test(code)) return code;
}
return 'EUR';
}
/** The day of a stored date or date and time, `YYYY-MM-DD`. */
export function dateOf(value: unknown): string | null {
if (typeof value !== 'string') return null;
const day = value.trim().slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
}
/** A text from a call's arguments, on one line and cut to `max`: what will be stored, so it is not masked. */
export function argLine(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
const line = value.replace(/\s+/g, ' ').trim();
if (line === '') return null;
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
/** A traveller as a card shows them: from the trip card, else from what the row names. */
export function personFrom(card: TripCard, id: number, name?: unknown, avatarUrl?: unknown): CardPerson {
return personOf(card, id) ?? { id, name: clipped(name, 80) ?? `#${id}`, avatarKey: fileKeyOf(avatarUrl) };
}
/** A trip place an expense belongs to, by its name on the trip card. */
export function placeOf(card: TripCard, placeId: unknown): CardExpense['place'] {
const id = positiveId(placeId);
const name = id === null ? null : clipped(card.places.get(id)?.name, 120);
return id !== null && name !== null ? { id, name } : null;
}
/** One member of a split as the rows store it: their own amount on an uneven split, null on an even one. */
export interface SplitMember {
id: number;
amount: number | null;
paid: boolean;
name?: unknown;
avatarUrl?: unknown;
}
/** How many people one row names. */
export interface PeopleCaps {
shares: number;
payers: number;
}
/** Every person a change row can name. */
export const ALL_PEOPLE: PeopleCaps = { shares: EXPENSE_PEOPLE_MAX, payers: EXPENSE_PEOPLE_MAX };
/** The members of a split as a row lists them, either the expense list's or the trip summary's. */
function membersOf(value: unknown, names?: ReadonlyMap<number, string>): SplitMember[] {
return rowsOf(value).flatMap((member) => {
const id = positiveId(member.user_id);
if (id === null) return [];
return [
{
id,
amount: member.amount === null || member.amount === undefined ? null : amountOf(member.amount),
paid: member.paid === true || member.paid === 1,
name: member.username ?? names?.get(id),
avatarUrl: member.avatar_url,
},
];
});
}
/**
* Who shares an expense and what each part comes to. A member with an
* amount of their own owes exactly that and the split is uneven; the others
* share the total evenly, the left-over cents handed out from `itemId` on as
* the server does, so the parts add up to the total.
*/
export function splitOf(
card: TripCard,
members: readonly SplitMember[],
total: number,
itemId: number,
max: number,
): Pick<CardExpense, 'split' | 'shares' | 'moreShares'> {
if (members.length === 0) return { split: 'none', shares: [], moreShares: 0 };
const equal = splitEqualShares(toMinor(total), members.map((member) => ({ user_id: member.id })), itemId);
const shares = members.map((member) => ({
person: personFrom(card, member.id, member.name, member.avatarUrl),
amount: member.amount ?? (equal[member.id] ?? 0) / 100,
paid: member.paid,
}));
return {
split: members.some((member) => member.amount !== null) ? 'custom' : 'equal',
shares: shares.slice(0, max),
moreShares: Math.min(1000, Math.max(0, shares.length - max)),
};
}
/** Who paid how much, as the rows store it. */
export function payersOf(card: TripCard, value: unknown, max: number, names?: ReadonlyMap<number, string>) {
return rowsOf(value)
.flatMap((payer) => {
const id = positiveId(payer.user_id);
const amount = amountOf(payer.amount);
if (id === null || amount === null) return [];
return [{ person: personFrom(card, id, payer.username ?? names?.get(id), payer.avatar_url), amount }];
})
.slice(0, max);
}
/** What an expense row needs beside the row: the trip currency, the names a list answered with and the caps. */
interface ExpenseRowContext {
tripCurrency: string;
names?: ReadonlyMap<number, string>;
caps: PeopleCaps;
change: ExpenseChange;
}
/**
* One expense from a stored row: the expense list's slim one or the trip
* summary's whole one. Null for a row without an id, a name or an amount.
*/
export function expenseFromRow(card: TripCard, raw: unknown, at: ExpenseRowContext): CardExpense | null {
const row = recordOf(raw);
const id = positiveId(row.id);
const name = clipped(row.name, 120);
const total = amountOf(row.total_price);
if (id === null || name === null || total === null) return null;
const currency = codeOf(row.currency, at.tripCurrency);
const inTrip = amountOf(row.trip_amount);
const receipts = finite(row.receipt_count) ?? (Array.isArray(row.receipts) ? row.receipts.length : 0);
return {
id,
name,
category: clipped(row.category, 60),
amount: { amount: total, currency },
tripAmount: inTrip !== null && currency !== at.tripCurrency ? { amount: inTrip, currency: at.tripCurrency } : null,
date: dateOf(row.expense_date),
place: placeOf(card, row.place_id),
payers: payersOf(card, row.payers, at.caps.payers, at.names),
...splitOf(card, membersOf(row.members, at.names), total, id, at.caps.shares),
note: null,
receipts: Math.min(999, Math.max(0, Math.trunc(receipts))),
before: null,
highlight: [],
change: at.change,
};
}
/** One recorded payment from a row of `list_settlements`; null without an id, both people or an amount. */
export function paymentFromRow(card: TripCard, raw: unknown, change: ExpenseChange): CardPayment | null {
const row = recordOf(raw);
const id = positiveId(row.id);
const from = positiveId(row.from_user_id);
const to = positiveId(row.to_user_id);
const amount = amountOf(row.amount);
if (id === null || from === null || to === null || amount === null || amount <= 0) return null;
return {
id,
from: personFrom(card, from, row.from_username, row.from_avatar_url),
to: personFrom(card, to, row.to_username, row.to_avatar_url),
amount: { amount, currency: codeOf(row.currency, card.currency) },
date: dateOf(row.settled_at) ?? dateOf(row.created_at),
note: clipped(row.note, 120),
before: null,
change,
};
}
const EXPENSES = new WeakMap<TripCard, ReadonlyMap<number, Row>>();
/** The trip's expenses as the turn's trip summary holds them, by id; read once per trip card. */
export function budgetRows(card: TripCard): ReadonlyMap<number, Row> {
let rows = EXPENSES.get(card);
if (!rows) {
const items = rowsOf(recordOf(recordOf(card.sources.summary).budget).items);
rows = new Map(
items.flatMap((item): Array<[number, Row]> => {
const id = positiveId(item.id);
return id === null ? [] : [[id, item]];
}),
);
EXPENSES.set(card, rows);
}
return rows;
}
/** The recorded payment `list_settlements` answered under `id`. */
export function settlementRow(loaded: unknown, id: number | null): Row | undefined {
return id === null ? undefined : rowsOf(recordOf(loaded).settlements).find((row) => row.id === id);
}
/**
* A list card cut to what one card may weigh: rows come off its end, and
* into `more`, until its JSON fits, so a long list still shows its start.
*/
export function fitted<C extends { rows: readonly unknown[]; more: number }>(card: C): C {
let fit = card;
while (fit.rows.length > 1 && JSON.stringify(fit).length > MAX_CARD_CHARS) {
fit = { ...fit, rows: fit.rows.slice(0, -1), more: fit.more + 1 };
}
return fit;
}
+102 -3
View File
@@ -1,4 +1,103 @@
import type { DomainCards } from '../card-types';
import type { TripCard } from '../../assistant-context';
import type { CardReader, DomainCards, ToolArgs } from '../card-types';
import { clipped } from '../card-values';
import { costPerPersonAnswer, expensesAnswer, paymentsAnswer, settlementAnswer } from './budget-answers';
import {
createExpenseChange,
createPaymentChange,
createWithMembersChange,
deleteExpenseChange,
deletePaymentChange,
setMembersChange,
togglePaidChange,
updateExpenseChange,
updatePaymentChange,
} from './budget-changes';
import { BUDGET_DESCRIBERS } from './budget-describe';
import {
appliedExpense,
appliedPayment,
DELETE_CREATED_EXPENSE,
DELETE_CREATED_PAYMENT,
paymentIds,
} from './budget-receipts';
import { budgetRows } from './budget-values';
/** The cards of the budget: expenses, payments and who settles with whom. None yet: this package fills them in. */
export const BUDGET_CARDS: DomainCards = { id: 'budget', tools: {} };
/**
* The costs in the assistant chat. The four reads (the expense list, settle
* up, the payments recorded, the share per traveller) show their answer as a
* card the way the Costs page draws it, and every write to an expense or a
* payment shows what it will be: the full split, who paid, the amounts, with
* what an update replaces. `create_budget_item` and `update_budget_item` keep
* the core's words, verb and undo and only add their card.
*/
/** The payments a change to a recorded one starts from. */
const readPayments = (_args: ToolArgs, read: CardReader) => read('list_settlements');
/** The name of the expense an `itemId` names, for the card built from the arguments. */
function expenseName(key: string, value: number, card: TripCard): string | null {
return key === 'itemId' ? clipped(budgetRows(card).get(value)?.name, 160) : null;
}
export const BUDGET_CARDS: DomainCards = {
id: 'budget',
prompt: [
'The chat draws the answers of list_budget_items, get_settlement_summary, list_settlements and get_budget_per_person as cards with every row; do not repeat those rows as text, sum up or answer the question instead.',
],
tools: {
list_budget_items: { answer: expensesAnswer },
get_settlement_summary: { answer: settlementAnswer },
list_settlements: { answer: paymentsAnswer },
get_budget_per_person: { answer: costPerPersonAnswer },
create_budget_item: { change: createExpenseChange, applied: appliedExpense },
update_budget_item: { change: updateExpenseChange },
create_budget_item_with_members: {
write: { verbKey: 'addBudgetItem', entity: 'budget' },
describe: BUDGET_DESCRIBERS.create_budget_item_with_members,
change: createWithMembersChange,
applied: appliedExpense,
undo: DELETE_CREATED_EXPENSE,
},
set_budget_item_members: {
write: { verbKey: 'updateBudgetItem', entity: 'budget' },
describe: BUDGET_DESCRIBERS.set_budget_item_members,
change: setMembersChange,
nameArg: expenseName,
},
toggle_budget_member_paid: {
write: { verbKey: 'updateBudgetItem', entity: 'budget' },
describe: BUDGET_DESCRIBERS.toggle_budget_member_paid,
change: togglePaidChange,
nameArg: expenseName,
},
delete_budget_item: {
write: { verbKey: 'genericRemove', entity: 'budget' },
describe: BUDGET_DESCRIBERS.delete_budget_item,
change: deleteExpenseChange,
nameArg: expenseName,
},
create_settlement: {
write: { verbKey: 'addPayment', entity: 'settlement' },
describe: BUDGET_DESCRIBERS.create_settlement,
change: createPaymentChange,
applied: appliedPayment,
appliedIds: paymentIds,
undo: DELETE_CREATED_PAYMENT,
},
update_settlement: {
write: { verbKey: 'updatePayment', entity: 'settlement' },
load: readPayments,
describe: BUDGET_DESCRIBERS.update_settlement,
change: updatePaymentChange,
appliedIds: paymentIds,
},
delete_settlement: {
write: { verbKey: 'genericRemove', entity: 'settlement' },
load: readPayments,
describe: BUDGET_DESCRIBERS.delete_settlement,
change: deletePaymentChange,
appliedIds: paymentIds,
},
},
};
+35 -14
View File
@@ -4,8 +4,15 @@ import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } f
import type { BudgetItem } from '../../types';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { tripConverter } from './budget-money.helpers';
import { BudgetService } from './budget.service';
import { BUDGET_ITEMS_PAGE_DEFAULT, budgetItemsPageSchema, idSchema, type BudgetItemsPage } from '@trek/shared';
import {
BUDGET_ITEMS_PAGE_DEFAULT,
budgetItemsPageSchema,
idSchema,
toMinor,
type BudgetItemsPage,
} from '@trek/shared';
/** The whole budget surface rides the budget addon, as in BudgetMcp. */
const budgetAddonOn = addonGate(ADDON_IDS.BUDGET);
@@ -33,25 +40,42 @@ function shortNote(note: string | null | undefined): string | null {
return line.length > NOTE_CHARS ? `${line.slice(0, NOTE_CHARS - 1)}…` : line;
}
/** Today's rates for the expenses that froze none, as the totals use them; null when none were needed. */
type Rates = Record<string, number> | null;
/**
* A foreign expense in the trip currency, at the rate it was booked at (else
* today's), to the cent; null for one in the trip currency or one no rate
* converts.
*/
function tripAmount(item: BudgetItem, tripCurrency: string, rates: Rates): number | null {
if ((item.currency || tripCurrency).toUpperCase() === tripCurrency) return null;
const convert = tripConverter(item.currency, item.exchange_rate, tripCurrency, rates);
return convert ? toMinor(convert(item.total_price || 0)) / 100 : null;
}
/**
* One expense as the tool answers it: the fields a model plans with and a
* card shows, without the avatars, receipts and itemised ticket the REST row
* carries, and with the start of its note. Amounts stay in the expense's own
* currency, which is the trip's when the row names none.
* card shows, without the avatars, receipt files and itemised ticket the REST
* row carries, and with the start of its note and the number of its receipts.
* Amounts stay in the expense's own currency, which is the trip's when the row
* names none; a foreign one also says what it comes to in the trip currency.
*/
function slimItem(item: BudgetItem, tripCurrency: string) {
function slimItem(item: BudgetItem, tripCurrency: string, rates: Rates) {
return {
id: item.id,
name: item.name,
category: item.category,
total_price: item.total_price,
currency: item.currency || tripCurrency,
trip_amount: tripAmount(item, tripCurrency, rates),
expense_date: item.expense_date ?? null,
note: shortNote(item.note),
place_id: item.place_id ?? null,
reservation_id: item.reservation_id ?? null,
payers: rows(item.payers).map((p) => ({ user_id: p.user_id, amount: p.amount })),
members: rows(item.members).map((m) => ({ user_id: m.user_id, amount: m.amount ?? null, paid: m.paid === 1 })),
receipt_count: rows(item.receipts).length,
};
}
@@ -60,11 +84,11 @@ function slimItem(item: BudgetItem, tripCurrency: string) {
* would pass PAGE_CHARS. Always at least one, so a page is never empty while
* expenses are left.
*/
function fillPage(items: BudgetItem[], tripCurrency: string) {
function fillPage(items: BudgetItem[], tripCurrency: string, rates: Rates) {
const page: ReturnType<typeof slimItem>[] = [];
let chars = 0;
for (const item of items) {
const slim = slimItem(item, tripCurrency);
const slim = slimItem(item, tripCurrency, rates);
chars += JSON.stringify(slim).length + 1;
if (page.length > 0 && chars > PAGE_CHARS) break;
page.push(slim);
@@ -100,7 +124,7 @@ export class BudgetItemsMcp {
@Tool({
name: 'list_budget_items',
description: `List the trip's expenses with their ids, up to ${BUDGET_ITEMS_PAGE_DEFAULT} at a time from offset. A page of long expenses ends earlier, so always go on at next_offset (null on the last page). Each expense: name, category, total_price in its currency, expense_date, the start of its note, the place or booking it belongs to (place_id, reservation_id), who paid how much (payers) and who shares it (members: amount is the member's share on an uneven split, null on an equal one; paid says whether they paid their share). people names the user ids. Next to the page, over all expenses: the trip currency, the total and the totals per category in it, and unconverted_item_ids, foreign expenses no exchange rate converts, which count in no total. Use the ids with update_budget_item and delete_budget_item.`,
description: `List the trip's expenses with their ids, up to ${BUDGET_ITEMS_PAGE_DEFAULT} at a time from offset. A page of long expenses ends earlier, so always go on at next_offset (null on the last page). Each expense: name, category, total_price in its currency, expense_date, the start of its note, the place or booking it belongs to (place_id, reservation_id), who paid how much (payers), who shares it (members: amount is the member's share on an uneven split, null on an equal one; paid says whether they paid their share), trip_amount (a foreign expense in the trip currency, null otherwise or without a rate) and receipt_count. people names the user ids. Next to the page, over all expenses: the trip currency, the total and the totals per category in it, and unconverted_item_ids, foreign expenses no exchange rate converts, which count in no total. Use the ids with update_budget_item and delete_budget_item.`,
inputSchema: { tripId: idSchema, ...budgetItemsPageSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY,
when: budgetAddonOn,
@@ -112,13 +136,10 @@ export class BudgetItemsMcp {
const currency = (trip.currency || 'EUR').toUpperCase();
const items = await this.budget.listBudgetItems(tripId);
// The figures get_trip_summary shows: each row at the rate it was booked at (#2525).
const totals = await this.budget.tripTotals(
tripId,
currency,
await this.budget.ratesForTripTotals(tripId, currency),
);
const rates = await this.budget.ratesForTripTotals(tripId, currency);
const totals = await this.budget.tripTotals(tripId, currency, rates);
const start = offset ?? 0;
const page = fillPage(items.slice(start, start + (limit ?? BUDGET_ITEMS_PAGE_DEFAULT)), currency);
const page = fillPage(items.slice(start, start + (limit ?? BUDGET_ITEMS_PAGE_DEFAULT)), currency, rates);
const end = start + page.length;
return ok({
currency,
+2 -4
View File
@@ -470,7 +470,7 @@ export class BudgetMcp {
@Tool({
name: 'create_budget_item_with_members',
description:
'Create a budget/expense item and set the trip members splitting it equally in one atomic operation. If userIds is omitted, the cost is split across all trip members; pass an explicit list to split among a subset, or an empty array for a planning-only entry with no split. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist; if it already exists, use set_budget_item_members directly. For an uneven split, a foreign currency or a date, use create_budget_item instead.',
'Create a budget/expense item and set the trip members splitting it equally in one atomic operation. If userIds is omitted or empty, the cost is split across all trip members; pass an explicit list to split among a subset. For a planning-only entry with no split, use create_budget_item with member_ids []. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist; if it already exists, use set_budget_item_members directly. For an uneven split, a foreign currency or a date, use create_budget_item instead.',
inputSchema: {
tripId: idSchema,
name: z.string().min(1).max(200),
@@ -480,9 +480,7 @@ export class BudgetMcp {
userIds: z
.array(idSchema)
.optional()
.describe(
'User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split',
),
.describe('User IDs splitting this item; omit or pass an empty array to split across all trip members'),
place_id: idSchema
.optional()
.describe(