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