mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked cost is labelled in the trip currency, a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch.
This commit is contained in:
@@ -62,8 +62,8 @@ export class BudgetController {
|
||||
}
|
||||
|
||||
@Get('summary/per-person')
|
||||
perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { summary: this.budget.perPersonSummary(tripId) };
|
||||
async perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { summary: await this.budget.perPersonSummary(tripId) };
|
||||
}
|
||||
|
||||
@Get('settlement')
|
||||
|
||||
@@ -402,10 +402,9 @@ export class BudgetMcp {
|
||||
async getSettlementSummary({ tripId, base }: { tripId: number; base?: string }, ctx: McpContext) {
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
const tripCurrency = trip?.currency || 'EUR';
|
||||
const effectiveBase = (base || tripCurrency).toUpperCase();
|
||||
const rates = await this.exchangeRates.getRates(effectiveBase);
|
||||
const summary = this.budget.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
// The same call the REST route makes, so both convert with the trip currency's own
|
||||
// quote and an amount entered in `base` reads back to the cent (#2525).
|
||||
const summary = await this.budget.settlement(tripId, base, trip?.currency || 'EUR');
|
||||
return ok({ summary });
|
||||
}
|
||||
|
||||
@@ -556,7 +555,8 @@ export class BudgetMcp {
|
||||
}],
|
||||
};
|
||||
}
|
||||
const summary = this.budget.getPerPersonSummary(id);
|
||||
// In the trip currency, each share at the rate its expense was booked at (#2525).
|
||||
const summary = await this.budget.perPersonSummary(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
|
||||
@@ -88,6 +88,44 @@ function allocateDisplayCents(cents: number[], factor: number, total = Math.roun
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* An amount of a row, in the row's own currency, in the trip currency. The rate frozen
|
||||
* when the row was entered wins (#1335), so a booked expense keeps the value it was
|
||||
* booked at. A row written before the freeze existed (rate 1 or none) converts through
|
||||
* today's `rates`, units of each currency per 1 of their base, and without them stays
|
||||
* as it is. Pre-rework rows store currency = NULL, which means the trip's own.
|
||||
*
|
||||
* The settlement nets with it and every trip total adds up with it, so the two can never
|
||||
* read the same rows differently.
|
||||
*/
|
||||
function toTripAmount(
|
||||
amount: number,
|
||||
itemCurrency: string | null | undefined,
|
||||
itemRate: number | null | undefined,
|
||||
tripCurrency: string,
|
||||
rates: Record<string, number> | null,
|
||||
): number {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate;
|
||||
if (!rates) return amount;
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
return amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Units of `to` per 1 `from`, from a set of rates quoted against any base (each rate is
|
||||
* units of that currency per 1 base). 1 when either quote is missing, which leaves an
|
||||
* amount as it is, the way every conversion here degrades without rates.
|
||||
*/
|
||||
function quoteRatio(rates: Record<string, number> | null, to: string, from: string): number {
|
||||
const rTo = rates?.[to];
|
||||
const rFrom = rates?.[from];
|
||||
return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget domain service — owns the budget SQL (moved from the legacy
|
||||
* services/budgetService.ts: identical statements, the `||` falsy-coercion
|
||||
@@ -541,7 +579,7 @@ export class BudgetService {
|
||||
linkBudgetItemToReservation(
|
||||
tripId: string | number,
|
||||
reservationId: number,
|
||||
data: { name: string; category?: string; total_price: number },
|
||||
data: { name: string; category?: string; total_price: number; currency?: string | null; exchange_rate?: number },
|
||||
) {
|
||||
// createBudgetItem accepts reservation_id directly — the legacy separate
|
||||
// UPDATE after the insert was redundant (and non-atomic).
|
||||
@@ -814,20 +852,101 @@ export class BudgetService {
|
||||
// Per-person summary
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getPerPersonSummary(tripId: string | number) {
|
||||
const summary = this.db.all<{ user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }>(`
|
||||
SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar,
|
||||
SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned,
|
||||
SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid,
|
||||
COUNT(bi.id) as items_count
|
||||
/**
|
||||
* What each member is down for across the trip (`total_assigned`), and how much of
|
||||
* that is marked paid (`total_paid`), in the trip currency.
|
||||
*
|
||||
* Each share is converted to trip cents the way the settlement converts it, at the
|
||||
* rate frozen when the expense was entered, and an equal split is a largest-remainder
|
||||
* split of those cents (#2525). The SQL this replaces summed raw amounts across
|
||||
* currencies and divided them in floats, so a bill of 801.76 USD counted as 801.76 of
|
||||
* the trip's euros and a third of 100 came out as 33.333…
|
||||
*/
|
||||
getPerPersonSummary(tripId: string | number, rates: Record<string, number> | null = null) {
|
||||
const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
const tripCurrency = (trip?.currency || 'EUR').toUpperCase();
|
||||
const items = this.db.all<{ id: number; total_price: number | null; currency: string | null; exchange_rate: number | null }>(
|
||||
'SELECT id, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ?', tripId,
|
||||
);
|
||||
const members = this.db.all<{ budget_item_id: number; user_id: number; amount: number | null; paid: number | null; username: string; avatar: string | null }>(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.amount, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN budget_items bi ON bm.budget_item_id = bi.id
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bi.trip_id = ?
|
||||
GROUP BY bm.user_id
|
||||
`, tripId);
|
||||
const toTripCents = (amount: number, item: { currency: string | null; exchange_rate: number | null }) =>
|
||||
Math.round(toTripAmount(amount, item.currency, item.exchange_rate, tripCurrency, rates) * 100);
|
||||
|
||||
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
|
||||
const people = new Map<number, { user_id: number; username: string; avatar: string | null; assigned: number; paid: number; items_count: number }>();
|
||||
for (const item of items) {
|
||||
const own = members.filter(m => m.budget_item_id === item.id);
|
||||
if (own.length === 0) continue;
|
||||
// A member with an amount of their own owes exactly that; the rest split the
|
||||
// total evenly, as the query this replaces did.
|
||||
const equal = this.splitEqualShares(toTripCents(item.total_price || 0, item), own, item.id);
|
||||
for (const m of own) {
|
||||
const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount, item) : (equal[m.user_id] || 0);
|
||||
let p = people.get(m.user_id);
|
||||
if (!p) {
|
||||
p = { user_id: m.user_id, username: m.username, avatar: m.avatar, assigned: 0, paid: 0, items_count: 0 };
|
||||
people.set(m.user_id, p);
|
||||
}
|
||||
p.assigned += share;
|
||||
if (m.paid === 1) p.paid += share;
|
||||
p.items_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return [...people.values()]
|
||||
.sort((a, b) => a.user_id - b.user_id)
|
||||
.map(p => ({
|
||||
user_id: p.user_id, username: p.username, avatar: p.avatar,
|
||||
total_assigned: p.assigned / 100, total_paid: p.paid / 100, items_count: p.items_count,
|
||||
currency: tripCurrency,
|
||||
avatar_url: avatarUrl(p),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the trip's expenses add up to in the trip currency, overall and per category
|
||||
* (#2525). Every row is converted once, the way the settlement converts it, and
|
||||
* rounded to a whole cent before anything is added. Summing total_price as stored adds
|
||||
* dollars to euros and labels the result with the trip currency.
|
||||
*/
|
||||
tripTotals(tripId: string | number, tripCurrency: string, rates: Record<string, number> | null = null): { total: number; byCategory: Record<string, number> } {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase();
|
||||
const rows = this.db.all<{ category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>(
|
||||
'SELECT category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId,
|
||||
);
|
||||
let total = 0;
|
||||
const byCategory: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
const cents = Math.round(toTripAmount(r.total_price || 0, r.currency, r.exchange_rate, trip, rates) * 100);
|
||||
total += cents;
|
||||
const cat = r.category || '';
|
||||
byCategory[cat] = (byCategory[cat] || 0) + cents;
|
||||
}
|
||||
return {
|
||||
total: total / 100,
|
||||
byCategory: Object.fromEntries(Object.entries(byCategory).map(([cat, cents]) => [cat, cents / 100])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's rates against the trip currency, for the totals above. Fetched only when a
|
||||
* row in another currency never froze a rate of its own: every other row converts
|
||||
* without them, so a trip whose rows were all booked never waits on the network.
|
||||
*/
|
||||
async ratesForTripTotals(tripId: string | number, tripCurrency: string): Promise<Record<string, number> | null> {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase();
|
||||
const unbooked = this.db.get(`
|
||||
SELECT 1 FROM budget_items
|
||||
WHERE trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ?
|
||||
AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)
|
||||
LIMIT 1
|
||||
`, tripId, trip);
|
||||
return unbooked ? this.exchangeRates.getRates(trip) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,26 +1014,23 @@ export class BudgetService {
|
||||
// is the identity, so behaviour is unchanged.
|
||||
// rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency
|
||||
// per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency".
|
||||
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
|
||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
|
||||
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
|
||||
if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate;
|
||||
// Legacy rows without a frozen rate: convert via base with live rates.
|
||||
if (!rates) return amount;
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
return amount;
|
||||
};
|
||||
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate
|
||||
// it was booked at, so a later live-rate drift doesn't re-open it with a residual.
|
||||
// Legacy rows without a frozen rate convert via base with live rates.
|
||||
const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number =>
|
||||
toTripAmount(amount, itemCurrency, itemRate, tripCurrency, rates);
|
||||
// trip-currency → display currency, applied once to the final netted totals.
|
||||
// Held as a plain factor so it is exactly linear: the balances are converted as
|
||||
// one set (allocateDisplayCents) rather than one at a time, which is what keeps
|
||||
// them adding up to zero in whatever currency the viewer picked (#1382).
|
||||
const displayFactor = base === tripCurrency
|
||||
? 1
|
||||
: (rates && rates[tripCurrency] > 0 ? 1 / rates[tripCurrency] : 1);
|
||||
//
|
||||
// `rates` may be quoted against any base; the ratio of the two quotes is the
|
||||
// factor either way. Callers pass the trip currency's own quote (see settlement()),
|
||||
// the one freezeForeignRate froze every entry rate from, so a bill entered today in
|
||||
// the display currency converts back to exactly what was typed (#2525). Taken the
|
||||
// other way round, from the display currency's quote, the two quotes are not exact
|
||||
// inverses and 12,345.67 USD came back as 12,346.06.
|
||||
const displayFactor = base === tripCurrency ? 1 : quoteRatio(rates, base, tripCurrency);
|
||||
// A recorded settle-up amount is entered in whatever display currency the payer
|
||||
// was viewing. New rows capture that currency and the rate frozen at settle time
|
||||
// (#1445), so a settled position stays balanced when live rates drift — mirroring
|
||||
@@ -934,7 +1050,8 @@ export class BudgetService {
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
return base === tripCurrency ? amount : (rates && rates[tripCurrency] > 0 ? amount * rates[tripCurrency] : amount);
|
||||
// The inverse of the display factor, so such a transfer reads back as entered.
|
||||
return amount / displayFactor;
|
||||
};
|
||||
|
||||
const items = this.db.all<BudgetItem>('SELECT * FROM budget_items WHERE trip_id = ?', tripId);
|
||||
@@ -974,6 +1091,12 @@ export class BudgetService {
|
||||
// cents rather than converting the expense list a second time on the client.
|
||||
const frontedRows: Record<number, { item_id: number; cents: number }[]> = {};
|
||||
const movedRows: Record<number, { settlement_id: number; from_user_id: number; to_user_id: number; cents: number }[]> = {};
|
||||
// Read in the trip currency, those figures are the ledger's own trip cents. Read in
|
||||
// another one, they are built from each row's unrounded trip amount instead: a trip
|
||||
// cent is worth more than a dollar cent on a euro trip, so a bill of 12,345.67 USD
|
||||
// rounded to 10,831.44 EUR first came back as 12,345.68 (#2525). The balances, and
|
||||
// with them settle-up, still net whole trip cents.
|
||||
const finalUnit = (exactTripCents: number) => (displayFactor === 1 ? Math.round(exactTripCents) : exactTripCents);
|
||||
|
||||
for (const item of items) {
|
||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||
@@ -1003,10 +1126,11 @@ export class BudgetService {
|
||||
// refund (#2176) — is debited by the same arithmetic: their credit is negative.
|
||||
let creditCents = 0;
|
||||
for (const p of payers) {
|
||||
const paid = toTripCents(p.amount, item.currency, item.exchange_rate);
|
||||
const exact = toTrip(p.amount, item.currency, item.exchange_rate) * 100;
|
||||
const paid = Math.round(exact);
|
||||
ensure(p.user_id, p).cents += paid;
|
||||
frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + paid;
|
||||
if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: paid });
|
||||
frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact);
|
||||
if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: finalUnit(exact) });
|
||||
creditCents += paid;
|
||||
}
|
||||
// …and each split participant owes their share — a custom per-member amount
|
||||
@@ -1043,17 +1167,19 @@ export class BudgetService {
|
||||
for (const s of settlements) {
|
||||
// Rounded to a trip cent per transfer, so recording one in a display currency
|
||||
// can't leave a sliver of a cent behind to accumulate over a trip's lifetime.
|
||||
const inTrip = Math.round(settleToTrip(s.amount, s.currency, s.exchange_rate) * 100);
|
||||
const exact = settleToTrip(s.amount, s.currency, s.exchange_rate) * 100;
|
||||
const inTrip = Math.round(exact);
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip;
|
||||
// Net of the transfers in both directions: sending one back is a reimbursement
|
||||
// received in reverse, and netting them is what keeps the final budget's
|
||||
// subtraction equal to the balance it is taken from.
|
||||
reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + inTrip;
|
||||
reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - inTrip;
|
||||
const shown = finalUnit(exact);
|
||||
reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + shown;
|
||||
reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - shown;
|
||||
const moved = { settlement_id: s.id, from_user_id: s.from_user_id, to_user_id: s.to_user_id };
|
||||
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: inTrip });
|
||||
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -inTrip });
|
||||
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown });
|
||||
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown });
|
||||
}
|
||||
|
||||
// Into the display currency as one set, then simplify — balances and flows are
|
||||
@@ -1237,14 +1363,24 @@ export class BudgetService {
|
||||
return this.listBudgetItems(tripId);
|
||||
}
|
||||
|
||||
perPersonSummary(tripId: string) {
|
||||
return this.getPerPersonSummary(tripId);
|
||||
async perPersonSummary(tripId: string | number) {
|
||||
const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId);
|
||||
return this.getPerPersonSummary(tripId, await this.ratesForTripTotals(tripId, trip?.currency || 'EUR'));
|
||||
}
|
||||
|
||||
async settlement(tripId: string, base: string | undefined, tripCurrency: string) {
|
||||
const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase();
|
||||
const rates = await this.exchangeRates.getRates(effectiveBase);
|
||||
return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
/**
|
||||
* The settlement in the viewer's currency, for REST and MCP alike. Converted with the
|
||||
* trip currency's own quote, the one every entry rate was frozen from, so a same-day
|
||||
* amount in the display currency round-trips to the cent and paying what settle-up
|
||||
* offers closes the balance (#2525). The display currency's quote only stands in when
|
||||
* the trip's cannot be fetched.
|
||||
*/
|
||||
async settlement(tripId: string | number, base: string | undefined, tripCurrency: string) {
|
||||
const trip = (tripCurrency || 'EUR').toUpperCase();
|
||||
const effectiveBase = (base || trip).toUpperCase();
|
||||
const rates = (await this.exchangeRates.getRates(trip))
|
||||
?? (effectiveBase === trip ? null : await this.exchangeRates.getRates(effectiveBase));
|
||||
return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency: trip });
|
||||
}
|
||||
|
||||
async create(tripId: string, data: Parameters<BudgetService['createBudgetItem']>[1]) {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type ReservationBody = Record<string, unknown> & {
|
||||
title?: string;
|
||||
type?: string;
|
||||
create_budget_entry?: { total_price?: number; category?: string };
|
||||
create_budget_entry?: { total_price?: number; category?: string; currency?: string | null };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,7 +61,7 @@ export class ReservationsController {
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Post()
|
||||
create(
|
||||
async create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() rawBody: ReservationCreateDto,
|
||||
@@ -69,11 +69,14 @@ export class ReservationsController {
|
||||
) {
|
||||
const body = rawBody as ReservationBody & { title: string };
|
||||
this.rejectForeignReferences(tripId, body);
|
||||
// Before the synchronous writes: the price keeps the currency it was quoted in,
|
||||
// at a rate frozen now (#2525).
|
||||
const budgetEntry = await this.reservations.withFrozenRate(tripId, body.create_budget_entry);
|
||||
const { reservation, accommodationCreated } = this.reservations.create(tripId, body as never);
|
||||
if (accommodationCreated) {
|
||||
this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId);
|
||||
}
|
||||
this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId);
|
||||
this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, budgetEntry, socketId);
|
||||
this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? '');
|
||||
return { reservation };
|
||||
|
||||
@@ -30,7 +30,7 @@ export class ReservationsRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('reservations.create', { permission: 'db:write:reservations' })
|
||||
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'reservation');
|
||||
const parsed = reservationCreateRequestSchema.safeParse(params.input);
|
||||
@@ -39,10 +39,12 @@ export class ReservationsRpc {
|
||||
this.requireValidEndpoints(input.endpoints);
|
||||
this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
|
||||
this.requireOwnReferences(tripId, input);
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
// Same as the REST route: the price keeps its currency, at a rate frozen now (#2525).
|
||||
const budgetEntry = await this.reservations.withFrozenRate(tripId, i.create_budget_entry as never);
|
||||
const { reservation, accommodationCreated } = this.reservations.create(String(tripId), input as never);
|
||||
if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined);
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined);
|
||||
this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, budgetEntry, undefined);
|
||||
this.realtime.broadcast(tripId, 'reservation:created', { reservation }, undefined);
|
||||
this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? '');
|
||||
return reservation;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AccommodationsService, noStayMirror, type AccommodationMirror } from '../accommodations/accommodations.service';
|
||||
|
||||
type Trip = TripAccess;
|
||||
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||
type BudgetEntry = { total_price?: number; category?: string; currency?: string | null; exchange_rate?: number } | undefined;
|
||||
|
||||
export interface ReservationEndpoint {
|
||||
id?: number;
|
||||
@@ -1028,6 +1028,25 @@ export class ReservationsService {
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked cost a new booking's price becomes, in the currency the price was quoted
|
||||
* in and at the rate frozen for it now (#2525). An imported booking previewed its
|
||||
* $801.76 in dollars, then stored 801.76 in the trip's own currency, because only the
|
||||
* amount travelled. The rate is resolved here, before the synchronous writes, the way
|
||||
* the direct booking import and the Costs routes resolve it. A currency that is not a
|
||||
* three-letter code is dropped, which leaves the price in the trip currency as before,
|
||||
* and a rate is never taken from the caller.
|
||||
*/
|
||||
async withFrozenRate(tripId: string | number, entry: BudgetEntry): Promise<BudgetEntry> {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
const { currency: rawCurrency, exchange_rate: _callerRate, ...rest } = entry;
|
||||
const currency = typeof rawCurrency === 'string' ? rawCurrency.trim().toUpperCase() : '';
|
||||
if (!/^[A-Z]{3}$/.test(currency)) return rest;
|
||||
const priced: { currency?: string | null; exchange_rate?: number } = { currency };
|
||||
await this.budget.freezeForeignRate(tripId, priced);
|
||||
return { ...rest, currency, ...(priced.exchange_rate != null ? { exchange_rate: priced.exchange_rate } : {}) };
|
||||
}
|
||||
|
||||
/** POST side effect: auto-create a linked budget item when a price is provided. */
|
||||
syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
||||
if (!entry || !(Number(entry.total_price) > 0)) return;
|
||||
@@ -1036,6 +1055,8 @@ export class ReservationsService {
|
||||
name: title,
|
||||
category: entry.category || type || 'Other',
|
||||
total_price: entry.total_price!,
|
||||
...(entry.currency ? { currency: entry.currency } : {}),
|
||||
...(entry.exchange_rate != null ? { exchange_rate: entry.exchange_rate } : {}),
|
||||
});
|
||||
this.realtime.broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export class TripReadModelService {
|
||||
|
||||
// ── Trip summary (used by MCP get_trip_summary tool) ──────────────────────
|
||||
|
||||
getTripSummary(tripId: number, viewerUserId?: number) {
|
||||
async getTripSummary(tripId: number, viewerUserId?: number) {
|
||||
const trip = withoutFeedToken(
|
||||
this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> | undefined,
|
||||
);
|
||||
@@ -63,10 +63,15 @@ export class TripReadModelService {
|
||||
const accommodations = this.accommodations.list(tripId);
|
||||
|
||||
const budgetItems = this.budget.listBudgetItems(tripId);
|
||||
// In the trip currency, each row at the rate it was booked at (#2525). A raw sum of
|
||||
// total_price added a dollar bill to the euros and called the result euros.
|
||||
const tripCurrency = String(trip.currency || 'EUR');
|
||||
const totals = this.budget.tripTotals(tripId, tripCurrency, await this.budget.ratesForTripTotals(tripId, tripCurrency));
|
||||
const budget = {
|
||||
items: budgetItems,
|
||||
item_count: budgetItems.length,
|
||||
total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0),
|
||||
total: totals.total,
|
||||
by_category: totals.byCategory,
|
||||
currency: trip.currency,
|
||||
};
|
||||
|
||||
|
||||
@@ -43,18 +43,20 @@ export class TripPromptsMcp {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
const summary = await this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
|
||||
}
|
||||
const { trip, budget } = summary;
|
||||
const currency = trip?.currency || 'EUR';
|
||||
const byCategory = (budget?.items || []).reduce((acc: Record<string, number>, item: { category?: string; total_price?: number }) => {
|
||||
const cat = item.category || 'Uncategorized';
|
||||
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0);
|
||||
// The summary's own totals, each row in the trip currency at the rate it was booked
|
||||
// at (#2525). Adding total_price up here printed a dollar bill as that many euros.
|
||||
const byCategory: Record<string, number> = {};
|
||||
for (const [cat, amount] of Object.entries(budget?.by_category || {})) {
|
||||
const key = cat || 'Uncategorized';
|
||||
byCategory[key] = Math.round(((byCategory[key] || 0) + amount) * 100) / 100;
|
||||
}
|
||||
const total = budget?.total || 0;
|
||||
const lines = Object.entries(byCategory)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
|
||||
@@ -94,7 +96,9 @@ export class TripPromptsMcp {
|
||||
const lines = Object.entries(grouped).map(([cat, catItems]) =>
|
||||
`## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
|
||||
).join('\n\n');
|
||||
const { trip } = this.readModel.getTripSummary(tripId, ctx.userId) || {};
|
||||
// Only the title is needed. The whole summary also adds up the budget, which can
|
||||
// wait on a rates fetch for a row that never froze its rate.
|
||||
const trip = this.trips.getRaw(tripId);
|
||||
return {
|
||||
description: `Packing list for "${trip?.title || tripId}"`,
|
||||
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
|
||||
|
||||
@@ -265,7 +265,7 @@ export class TripsMcp {
|
||||
})
|
||||
async getTripSummary({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
const summary = await this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
if (!summary) return noAccess();
|
||||
const R = canReadTrips(ctx.scopes);
|
||||
// Addon availability gates
|
||||
@@ -599,7 +599,7 @@ export class TripsMcp {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
const summary = await this.readModel.getTripSummary(tripId, ctx.userId);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
|
||||
}
|
||||
|
||||
@@ -228,6 +228,27 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('GET /summary/per-person reads a dollar bill on a euro trip at its booked rate (#2525)', async () => {
|
||||
const hotel = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ name: 'Aparthotel Silver', currency: 'USD', exchange_rate: 1.17, payers: [{ user_id: 1, amount: 801.76 }], member_ids: [1, 2] });
|
||||
expect(hotel.status).toBe(201);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/api/trips/${tripId}/budget/summary/per-person`)
|
||||
.set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
// 801.76 USD at 1.17 is 685.26 EUR, half of it each. The summary used to put
|
||||
// 400.88 on both and leave the reader to guess the currency.
|
||||
const row = (uid: number) => res.body.summary.find((r: { user_id: number }) => r.user_id === uid);
|
||||
expect(row(1)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' });
|
||||
expect(row(2)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' });
|
||||
|
||||
const del = await request(server).delete(`/api/trips/${tripId}/budget/${hotel.body.item.id}`).set('Cookie', sessionCookie(1));
|
||||
expect(del.status).toBe(200);
|
||||
});
|
||||
|
||||
it('200 on settlement update with permission, persisting the new amount and day', async () => {
|
||||
const created = await request(server)
|
||||
.post(`/api/trips/${tripId}/budget/settlements`)
|
||||
|
||||
@@ -57,6 +57,7 @@ let checkPermission: MockInstance;
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
|
||||
|
||||
describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => {
|
||||
let server: Server;
|
||||
@@ -67,6 +68,10 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] })
|
||||
.overrideProvider(NotificationsService)
|
||||
.useValue({ send: notificationSend })
|
||||
// A price quoted in a foreign currency freezes the rate of the day; this is that
|
||||
// day's table, so no case ever reaches the network.
|
||||
.overrideProvider(ExchangeRatesService)
|
||||
.useValue({ getRates: async (base: string) => (base.toUpperCase() === 'EUR' ? { EUR: 1, USD: 1.17 } : null) })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
@@ -133,6 +138,31 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 create keeps an imported price in the currency it was quoted in, at a frozen rate (#2525)', async () => {
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${tripId}/reservations`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({
|
||||
title: 'Aparthotel Silver', type: 'hotel',
|
||||
metadata: { price: '801.76', priceCurrency: 'USD' },
|
||||
create_budget_entry: { total_price: 801.76, category: 'accommodation', currency: 'USD' },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
// It used to land as 801.76 with no currency, which is 801.76 of the trip's euros.
|
||||
const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id);
|
||||
expect(item).toEqual({ total_price: 801.76, currency: 'USD', exchange_rate: 1.17 });
|
||||
});
|
||||
|
||||
it('201 create leaves a price without a currency in the trip currency, as before', async () => {
|
||||
const res = await request(server)
|
||||
.post(`/api/trips/${tripId}/reservations`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ title: 'Museum', type: 'event', create_budget_entry: { total_price: 20, currency: 'not a code' } });
|
||||
expect(res.status).toBe(201);
|
||||
const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id);
|
||||
expect(item).toEqual({ total_price: 20, currency: null, exchange_rate: 1 });
|
||||
});
|
||||
|
||||
it('201 create reservation (real insert + booking notification), 400 without title', async () => {
|
||||
const ok = await request(server)
|
||||
.post(`/api/trips/${tripId}/reservations`)
|
||||
|
||||
@@ -374,6 +374,46 @@ describe('Settlement tools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('get_settlement_summary reads a same-day bill in the viewer\'s currency to the cent, like REST (#2525)', async () => {
|
||||
// Frankfurter's two quotes for the pair, each rounded, so not exact inverses. The entry
|
||||
// rate is frozen from the euro's, and converting back with the dollar's read the
|
||||
// 12,345.67 dollars as 12,346.06.
|
||||
const quotes: Record<string, Record<string, number>> = { EUR: { EUR: 1, USD: 1.1398 }, USD: { USD: 1, EUR: 0.87732 } };
|
||||
const spy = vi.spyOn(ExchangeRatesService.prototype, 'getRates').mockImplementation(async (b: string) => quotes[b] ?? null);
|
||||
try {
|
||||
const { user, other, trip } = tripWithTwo();
|
||||
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { total_price: 12345.67 });
|
||||
testDb.prepare("UPDATE budget_items SET currency = 'USD', exchange_rate = 1.1398 WHERE id = ?").run(item.id);
|
||||
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)')
|
||||
.run(item.id, user.id, item.id, other.id);
|
||||
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)')
|
||||
.run(item.id, user.id, 12345.67);
|
||||
|
||||
const dbService = new DatabaseService(testDb);
|
||||
const controller = new BudgetController(
|
||||
new BudgetService(dbService, new PermissionsService(dbService), new ExchangeRatesService(), new RealtimeService()),
|
||||
);
|
||||
const rest = await controller.settlement(
|
||||
{ id: user.id } as User,
|
||||
{ id: trip.id, user_id: user.id, currency: 'EUR' } as TripAccess,
|
||||
String(trip.id),
|
||||
'USD',
|
||||
);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id, base: 'USD' } });
|
||||
const data = parseToolResult(result) as { summary: typeof rest };
|
||||
expect(data.summary).toEqual(rest);
|
||||
const mine = data.summary.finalBudgets.find(f => f.user_id === user.id)!;
|
||||
expect(mine.expenses).toBe(12345.67);
|
||||
expect(mine.sources.fronted).toEqual([{ item_id: item.id, cents: 1234567 }]);
|
||||
expect(data.summary.flows.map(f => f.amount)).toEqual([6172.84]);
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('get_settlement_summary returns balances and flows', async () => {
|
||||
try {
|
||||
const { user, other, trip } = tripWithTwo();
|
||||
|
||||
@@ -93,6 +93,7 @@ import { notificationsStub } from '../../helpers/notifications';
|
||||
// getTripSummary is the same controllable mock the legacy path used.
|
||||
const tripsStub = {
|
||||
canAccessTrip: (tripId: number, userId: number) => dbMock.canAccessTrip(tripId, userId),
|
||||
getRaw: (tripId: number) => testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId),
|
||||
} as unknown as TripsService;
|
||||
// getTripSummary moved to TripReadModelService with the trip split; the mock is
|
||||
// the same controllable one, one constructor slot further along.
|
||||
@@ -119,8 +120,9 @@ const promptDbs = () => new DatabaseService(testDb);
|
||||
const authStub = { isDemoUser: () => false } as unknown as AuthService;
|
||||
const promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub());
|
||||
const packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards);
|
||||
const promptBudget = new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService());
|
||||
const budgetMcp = new BudgetMcp(
|
||||
new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()),
|
||||
promptBudget,
|
||||
new ExchangeRatesService(),
|
||||
promptDbs(),
|
||||
new RuntimeEnvService(),
|
||||
@@ -158,6 +160,8 @@ beforeEach(() => {
|
||||
`).all(tripId) as any[];
|
||||
const budgetRows = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as any[];
|
||||
const packingRows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(tripId) as any[];
|
||||
// The totals come from the same BudgetService.tripTotals the real summary uses.
|
||||
const totals = promptBudget.tripTotals(tripId, trip.currency || 'EUR');
|
||||
return {
|
||||
trip,
|
||||
days: [],
|
||||
@@ -165,7 +169,8 @@ beforeEach(() => {
|
||||
budget: {
|
||||
items: budgetRows,
|
||||
item_count: budgetRows.length,
|
||||
total: budgetRows.reduce((sum, i) => sum + (i.total_price || 0), 0),
|
||||
total: totals.total,
|
||||
by_category: totals.byCategory,
|
||||
currency: trip.currency,
|
||||
},
|
||||
packing: packingRows, // array shape; packing prompt tolerates it
|
||||
@@ -418,19 +423,18 @@ describe('Prompt: packing-list', () => {
|
||||
expect(text).toMatch(/\[[ x]\]/);
|
||||
});
|
||||
|
||||
it('uses tripId as title fallback when getTripSummary returns null (covers || {} branch)', async () => {
|
||||
it('reads the trip title without building the whole trip summary (#2525)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Null Trip' });
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Hygiene' });
|
||||
|
||||
// Null out the getTripSummary call inside packing-list (line 94: || {})
|
||||
mockGetTripSummary.mockReturnValueOnce(null);
|
||||
mockGetTripSummary.mockClear();
|
||||
|
||||
const client = await buildServer(user.id);
|
||||
const text = await invokePromptText(client, 'packing-list', { tripId: trip.id });
|
||||
expect(text).toContain('Toothbrush');
|
||||
// Falls back to 'Trip' literal since trip?.title is undefined (getTripSummary null → || {})
|
||||
expect(text).toContain('Packing List: Trip');
|
||||
expect(text).toContain('Packing List: Null Trip');
|
||||
// The summary adds the budget up, and that can wait on a rates fetch.
|
||||
expect(mockGetTripSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,6 +517,24 @@ describe('Prompt: budget-overview', () => {
|
||||
expect(text).toContain('300'); // Accommodation total
|
||||
});
|
||||
|
||||
it('prints a foreign-currency bill at the rate it was booked at, not as that many euros (#2525)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Dollar Trip' });
|
||||
promptBudget.createBudgetItem(trip.id, {
|
||||
name: 'Aparthotel Silver', category: 'Accommodation', currency: 'USD', exchange_rate: 1.17,
|
||||
payers: [{ user_id: user.id, amount: 801.76 }], members: [{ user_id: user.id }],
|
||||
});
|
||||
createBudgetItem(testDb, trip.id, { name: 'Dinner', category: 'Food', total_price: 100 });
|
||||
|
||||
const client = await buildServer(user.id);
|
||||
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
|
||||
// 801.76 USD at 1.17 is 685.26 EUR. Summed as stored it read "Total: 901.76 EUR".
|
||||
expect(text).toContain('Total: 785.26 EUR');
|
||||
expect(text).toContain('- Accommodation: 685.26 EUR');
|
||||
expect(text).toContain('- Food: 100 EUR');
|
||||
expect(text).not.toContain('801.76');
|
||||
});
|
||||
|
||||
it('renders "No expenses recorded." when budget array is empty', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Empty Budget' });
|
||||
|
||||
@@ -49,13 +49,13 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
expect(new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('GET /summary/per-person + /settlement delegate', () => {
|
||||
it('GET /summary/per-person + /settlement delegate', async () => {
|
||||
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
||||
const svc = makeService({
|
||||
perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]),
|
||||
settlement,
|
||||
} as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
||||
expect(await new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
||||
// A trip with no currency set falls back to EUR rather than passing undefined on.
|
||||
expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] });
|
||||
expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR');
|
||||
|
||||
@@ -39,6 +39,9 @@ const { RATES } = vi.hoisted(() => ({
|
||||
RATES: {
|
||||
RUB: { RUB: 1, USD: 0.013042, EUR: 0.011412 },
|
||||
EUR: { EUR: 1, USD: 1.1429, RUB: 87.63 },
|
||||
// The dollar's own quote, as Frankfurter prints it: rounded, so not the exact
|
||||
// inverse of the euro's 1.1429 (1 / 0.87497 = 1.142896...).
|
||||
USD: { USD: 1, EUR: 0.87497 },
|
||||
} as Record<string, Record<string, number>>,
|
||||
}));
|
||||
// Constructor-injected since the fold; the class is mocked at the module path
|
||||
@@ -1156,3 +1159,157 @@ describe('an expense whose split leaves a remainder', () => {
|
||||
expect(after.id).toBe(before.id);
|
||||
});
|
||||
});
|
||||
|
||||
// #2525: a bill entered in dollars on a euro trip was booked at the rate of the day. The
|
||||
// totals the MCP summary, the budget prompt and the per-person resource hand out added its
|
||||
// dollars to the euros as they stood and called the sum euros.
|
||||
describe('trip totals read every row in the trip currency (#2525)', () => {
|
||||
function seedDollarBill() {
|
||||
const { user: me } = createUser(testDb);
|
||||
const { user: bob } = createUser(testDb);
|
||||
const trip = createTrip(testDb, me.id);
|
||||
addTripMember(testDb, trip.id, bob.id);
|
||||
const members = [{ user_id: me.id }, { user_id: bob.id }];
|
||||
const hotel = budget.createBudgetItem(trip.id, {
|
||||
name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17,
|
||||
payers: [{ user_id: me.id, amount: 801.76 }], members,
|
||||
});
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'Dinner', category: 'food', currency: 'EUR',
|
||||
payers: [{ user_id: bob.id, amount: 100 }], members,
|
||||
});
|
||||
return { trip, me, bob, hotel };
|
||||
}
|
||||
|
||||
it('BUDGET-SVC-DB-044: adds each row up at the rate it was booked at, in whole cents', async () => {
|
||||
const { trip } = seedDollarBill();
|
||||
// 801.76 USD at 1.17 is 685.26 EUR, the figure the settlement nets as well.
|
||||
expect(budget.tripTotals(trip.id, 'EUR')).toEqual({
|
||||
total: 785.26,
|
||||
byCategory: { accommodation: 685.26, food: 100 },
|
||||
});
|
||||
// Both rows carry what they need, so nothing waits on today's rates.
|
||||
expect(await budget.ratesForTripTotals(trip.id, 'EUR')).toBeNull();
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-045: fetches today\'s rate only for a row that never froze one', async () => {
|
||||
const { user: me } = createUser(testDb);
|
||||
const trip = createTrip(testDb, me.id);
|
||||
// Written before the freeze existed: the column default of 1 is not a booked rate.
|
||||
budget.createBudgetItem(trip.id, { name: 'Old taxi', category: 'transport', total_price: 114.29, currency: 'USD' });
|
||||
const rates = await budget.ratesForTripTotals(trip.id, 'EUR');
|
||||
expect(rates).toEqual(RATES.EUR);
|
||||
// 114.29 USD at 1.1429 per euro, the same way the settlement reads such a row.
|
||||
expect(budget.tripTotals(trip.id, 'EUR', rates).total).toBe(100);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-046: the per-person summary splits trip cents, not raw amounts', async () => {
|
||||
const { trip, me, bob, hotel } = seedDollarBill();
|
||||
budget.toggleMemberPaid(hotel.id, trip.id, bob.id, true);
|
||||
|
||||
const summary = await budget.perPersonSummary(trip.id);
|
||||
const of = (id: number) => summary.find(s => s.user_id === id)!;
|
||||
// Half of 685.26 EUR plus half of 100 EUR each. The query this replaced put
|
||||
// 450.88 on both, half the dollars counted as euros, and never said which.
|
||||
expect(of(me.id)).toMatchObject({ total_assigned: 392.63, total_paid: 0, items_count: 2, currency: 'EUR' });
|
||||
expect(of(bob.id)).toMatchObject({ total_assigned: 392.63, total_paid: 342.63, items_count: 2, currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-047: an equal split hands out the odd cent instead of a float tail', async () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
const { user: c } = createUser(testDb);
|
||||
const trip = createTrip(testDb, a.id);
|
||||
addTripMember(testDb, trip.id, b.id);
|
||||
addTripMember(testDb, trip.id, c.id);
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'Boat', currency: 'EUR', payers: [{ user_id: a.id, amount: 100 }],
|
||||
members: [{ user_id: a.id }, { user_id: b.id }, { user_id: c.id }],
|
||||
});
|
||||
|
||||
const shares = (await budget.perPersonSummary(trip.id)).map(s => Math.round(s.total_assigned * 100));
|
||||
expect(shares.slice().sort((x, y) => x - y)).toEqual([3333, 3333, 3334]);
|
||||
expect(shares.reduce((x, y) => x + y, 0)).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
// #2525: a euro trip read in dollars froze a dollar bill's rate from the euro's quote and
|
||||
// converted the ledger back with the dollar's. The two quotes are not exact inverses, so a
|
||||
// bill entered today read a few cents off and paying what settle-up offered left a balance.
|
||||
describe('the settlement converts with the quote the entry rate was frozen from (#2525)', () => {
|
||||
function seedSameDayBill() {
|
||||
const { user: me } = createUser(testDb);
|
||||
const { user: bob } = createUser(testDb);
|
||||
const trip = createTrip(testDb, me.id);
|
||||
addTripMember(testDb, trip.id, bob.id);
|
||||
return { trip, me, bob };
|
||||
}
|
||||
|
||||
it('BUDGET-SVC-DB-048: a same-day bill in the display currency reads as typed and settles to zero', async () => {
|
||||
const { trip, me, bob } = seedSameDayBill();
|
||||
// Frozen now, from the euro's quote: 1.1429.
|
||||
const bill = await budget.create(String(trip.id), {
|
||||
name: 'Villa', category: 'accommodation', currency: 'USD', total_price: 12345.67,
|
||||
payers: [{ user_id: me.id, amount: 12345.67 }], members: [{ user_id: me.id }, { user_id: bob.id }],
|
||||
}) as { id: number; exchange_rate: number };
|
||||
expect(bill.exchange_rate).toBe(1.1429);
|
||||
|
||||
const before = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
const mine = before.finalBudgets.find(f => f.user_id === me.id)!;
|
||||
expect(mine.expenses).toBe(12345.67);
|
||||
expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 1234567 }]);
|
||||
expect(before.flows.map(f => f.amount)).toEqual([6172.84]);
|
||||
|
||||
// Bob pays exactly what settle-up offers, in dollars, as the Costs screen records it.
|
||||
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 6172.84, currency: 'USD' }, me.id);
|
||||
const after = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
expect(after.balances.map(b => b.balance)).toEqual([0, 0]);
|
||||
expect(after.flows).toEqual([]);
|
||||
expect((await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => b.balance)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-051: the final budget lists a bill at what was typed even where its trip cent rounds away', async () => {
|
||||
const { trip, me, bob } = seedSameDayBill();
|
||||
// 123.45 USD is 108.0147 EUR. As a whole trip cent, 108.01 EUR, it came back as 123.44.
|
||||
const bill = await budget.create(String(trip.id), {
|
||||
name: 'Taxi', currency: 'USD', total_price: 123.45,
|
||||
payers: [{ user_id: me.id, amount: 123.45 }], members: [{ user_id: me.id }, { user_id: bob.id }],
|
||||
}) as { id: number };
|
||||
const s = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
const mine = s.finalBudgets.find(f => f.user_id === me.id)!;
|
||||
const bobs = s.finalBudgets.find(f => f.user_id === bob.id)!;
|
||||
expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 12345 }]);
|
||||
expect(mine.expenses).toBe(123.45);
|
||||
// The two shares still add up to the bill, and the ledger in euros is untouched.
|
||||
expect(Math.round((mine.final + bobs.final) * 100)).toBe(12345);
|
||||
const inEur = await budget.settlement(trip.id, 'EUR', 'EUR');
|
||||
expect(inEur.finalBudgets.find(f => f.user_id === me.id)!.sources.fronted).toEqual([{ item_id: bill.id, cents: 10801 }]);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-049: a transfer saved without a currency reads back in the display currency as entered', async () => {
|
||||
const { trip, me, bob } = seedSameDayBill();
|
||||
await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 30 }, me.id);
|
||||
const s = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
const mine = s.finalBudgets.find(f => f.user_id === me.id)!;
|
||||
expect(mine.reimbursed).toBe(30);
|
||||
expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(-30);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-050: falls back to the display currency\'s quote when the trip\'s is unavailable', async () => {
|
||||
const { trip, me, bob } = seedSameDayBill();
|
||||
budget.createBudgetItem(trip.id, {
|
||||
name: 'Dinner', currency: 'EUR', payers: [{ user_id: me.id, amount: 100 }],
|
||||
members: [{ user_id: me.id }, { user_id: bob.id }],
|
||||
});
|
||||
const rates = (budget as unknown as { exchangeRates: ExchangeRatesService }).exchangeRates;
|
||||
const spy = vi.spyOn(rates, 'getRates').mockImplementation(async (base: string) => (base === 'EUR' ? null : RATES[base] ?? null));
|
||||
try {
|
||||
const s = await budget.settlement(trip.id, 'USD', 'EUR');
|
||||
expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']);
|
||||
// 50 EUR at 1 / 0.87497 USD per euro.
|
||||
expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(57.14);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,24 +73,36 @@ describe('BudgetService', () => {
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
||||
});
|
||||
|
||||
it('list / perPersonSummary resolve through the folded SQL methods', () => {
|
||||
it('list / perPersonSummary resolve through the folded SQL methods', async () => {
|
||||
const s = svc();
|
||||
const listSpy = vi.spyOn(s, 'listBudgetItems').mockReturnValue([{ id: 1 }] as never);
|
||||
expect(s.list('5')).toEqual([{ id: 1 }]);
|
||||
expect(listSpy).toHaveBeenCalledWith('5');
|
||||
const summarySpy = vi.spyOn(s, 'getPerPersonSummary').mockReturnValue([{ userId: 1 }] as never);
|
||||
expect(s.perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
expect(summarySpy).toHaveBeenCalledWith('5');
|
||||
expect(await s.perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
// No row is waiting on today's rates, so none are fetched for it.
|
||||
expect(summarySpy).toHaveBeenCalledWith('5', null);
|
||||
expect(getRates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('settlement', () => {
|
||||
it('upper-cases the explicit base and forwards the rates', async () => {
|
||||
it('upper-cases the explicit base and converts with the trip currency\'s own quote (#2525)', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue({ USD: 1.1 });
|
||||
await s.settlement('5', 'usd', 'EUR');
|
||||
expect(getRates).toHaveBeenCalledWith('USD');
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
getRates.mockResolvedValue({ EUR: 1, USD: 1.1 });
|
||||
await s.settlement('5', 'usd', 'eur');
|
||||
// The quote every entry rate was frozen from; the dollar's is not its exact inverse.
|
||||
expect(getRates.mock.calls).toEqual([['EUR']]);
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { EUR: 1, USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
});
|
||||
|
||||
it('stands in the display currency\'s quote when the trip\'s cannot be fetched', async () => {
|
||||
const s = svc();
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockImplementation(async (b: string) => (b === 'USD' ? { USD: 1, EUR: 0.9 } : null));
|
||||
await s.settlement('5', 'USD', 'EUR');
|
||||
expect(getRates.mock.calls).toEqual([['EUR'], ['USD']]);
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1, EUR: 0.9 }, tripCurrency: 'EUR' });
|
||||
});
|
||||
|
||||
it('falls back to the trip currency when no base is given', async () => {
|
||||
@@ -98,8 +110,8 @@ describe('BudgetService', () => {
|
||||
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
|
||||
getRates.mockResolvedValue(null);
|
||||
await s.settlement('5', undefined, 'gbp');
|
||||
expect(getRates).toHaveBeenCalledWith('GBP');
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
||||
expect(getRates.mock.calls).toEqual([['GBP']]);
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'GBP' });
|
||||
});
|
||||
|
||||
it('falls back to EUR when neither base nor trip currency is present', async () => {
|
||||
@@ -108,7 +120,7 @@ describe('BudgetService', () => {
|
||||
getRates.mockResolvedValue(null);
|
||||
await s.settlement('5', undefined, '');
|
||||
expect(getRates).toHaveBeenCalledWith('EUR');
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
||||
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: 'EUR' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,10 +21,22 @@ function makeService(overrides: Partial<ReservationsService> = {}): Reservations
|
||||
syncBudgetOnCreate: vi.fn(),
|
||||
syncBudgetOnUpdate: vi.fn(),
|
||||
notifyBookingChange: vi.fn(),
|
||||
// Hands the entry back as it came: what freezing does to it is the service's test.
|
||||
withFrozenRate: vi.fn(async (_tripId: string, entry: unknown) => entry),
|
||||
...overrides,
|
||||
} as unknown as ReservationsService;
|
||||
}
|
||||
|
||||
/** The create route awaits the price's rate, so its 400s arrive as a rejection. */
|
||||
async function rejected(promise: Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try { await promise; } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
@@ -46,31 +58,31 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
|
||||
// The bespoke 'Title is required' 400 moved to the global ZodValidationPipe
|
||||
// (ReservationCreateDto) — covered by the e2e suite.
|
||||
|
||||
it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', () => {
|
||||
it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', async () => {
|
||||
const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: true });
|
||||
const broadcast = vi.fn(); const syncBudgetOnCreate = vi.fn(); const notifyBookingChange = vi.fn();
|
||||
const svc = makeService({ create, broadcast, syncBudgetOnCreate, notifyBookingChange } as Partial<ReservationsService>);
|
||||
const body = { title: 'Hotel', type: 'lodging', create_budget_entry: { total_price: 200 } };
|
||||
expect(new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } });
|
||||
expect(await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock');
|
||||
expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Hotel', 'lodging', { total_price: 200 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock');
|
||||
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging');
|
||||
});
|
||||
|
||||
it('400s on a body id belonging to another trip, without writing', () => {
|
||||
it('400s on a body id belonging to another trip, without writing', async () => {
|
||||
const create = vi.fn();
|
||||
const svc = makeService({
|
||||
create,
|
||||
referencesOutsideTrip: vi.fn().mockReturnValue(['accommodation_id']),
|
||||
} as Partial<ReservationsService>);
|
||||
const body = { title: 'Hotel', accommodation_id: 4711 };
|
||||
expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body)))
|
||||
expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', body)))
|
||||
.toEqual({ status: 400, body: { error: 'Not part of this trip: accommodation_id' } });
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400s on a body id that exists nowhere, in its own words, without writing', () => {
|
||||
it('400s on a body id that exists nowhere, in its own words, without writing', async () => {
|
||||
const create = vi.fn();
|
||||
const svc = makeService({
|
||||
create,
|
||||
@@ -79,20 +91,36 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
|
||||
const body = { title: 'Hotel', place_id: 4711 };
|
||||
// Not 'Not part of this trip': an id that is part of nothing would send
|
||||
// the caller looking for it on another trip.
|
||||
expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body)))
|
||||
expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', body)))
|
||||
.toEqual({ status: 400, body: { error: 'Unknown reference: place_id' } });
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers a foreign id with the older message when it is both', () => {
|
||||
it('answers a foreign id with the older message when it is both', async () => {
|
||||
const svc = makeService({
|
||||
create: vi.fn(),
|
||||
referencesOutsideTrip: vi.fn().mockReturnValue(['place_id']),
|
||||
unresolvedReferences: vi.fn().mockReturnValue(['place_id']),
|
||||
} as Partial<ReservationsService>);
|
||||
expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 })))
|
||||
expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 })))
|
||||
.toEqual({ status: 400, body: { error: 'Not part of this trip: place_id' } });
|
||||
});
|
||||
|
||||
// #2525: an imported booking quoted in dollars. The price has to reach the linked
|
||||
// cost in dollars, at a rate frozen before anything is written.
|
||||
it('hands the budget sync the entry with its currency and frozen rate', async () => {
|
||||
const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: false });
|
||||
const syncBudgetOnCreate = vi.fn();
|
||||
const withFrozenRate = vi.fn(async () => {
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
return { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 };
|
||||
});
|
||||
const svc = makeService({ create, syncBudgetOnCreate, withFrozenRate } as Partial<ReservationsService>);
|
||||
const body = { title: 'Aparthotel Silver', type: 'hotel', create_budget_entry: { total_price: 801.76, currency: 'usd' } };
|
||||
await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock');
|
||||
expect(withFrozenRate).toHaveBeenCalledWith('5', { total_price: 801.76, currency: 'usd' });
|
||||
expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Aparthotel Silver', 'hotel', { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /positions', () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
||||
|
||||
// Constructor-injected since the budget fold (was a path mock of the deleted
|
||||
// services/budgetService).
|
||||
const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() };
|
||||
const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn(), freezeForeignRate: vi.fn() };
|
||||
|
||||
const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } }));
|
||||
|
||||
@@ -532,6 +532,37 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
budget.linkBudgetItemToReservation.mockImplementation(() => { throw new Error('boom'); });
|
||||
expect(() => svc.syncBudgetOnCreate('5', 9, 'Hotel', undefined, { total_price: 50 }, 'sock')).not.toThrow();
|
||||
});
|
||||
|
||||
// #2525: an imported booking quoted in dollars became a cost of that many euros.
|
||||
it('links the cost in the currency and at the rate it arrives with', () => {
|
||||
budget.linkBudgetItemToReservation.mockReturnValue({ id: 7 });
|
||||
svc.syncBudgetOnCreate('5', 9, 'Hotel', 'hotel', { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }, 'sock');
|
||||
expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith('5', 9, {
|
||||
name: 'Hotel', category: 'hotel', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withFrozenRate (#2525)', () => {
|
||||
it('keeps a quoted currency and freezes a rate for it', async () => {
|
||||
budget.freezeForeignRate.mockImplementation(async (_tripId: unknown, data: { exchange_rate?: number }) => { data.exchange_rate = 1.17; });
|
||||
expect(await svc.withFrozenRate('5', { total_price: 801.76, category: 'hotel', currency: ' usd ' })).toEqual({
|
||||
total_price: 801.76, category: 'hotel', currency: 'USD', exchange_rate: 1.17,
|
||||
});
|
||||
expect(budget.freezeForeignRate).toHaveBeenCalledWith('5', { currency: 'USD', exchange_rate: 1.17 });
|
||||
});
|
||||
|
||||
it('leaves the rate off when none could be frozen, so the cost converts live', async () => {
|
||||
budget.freezeForeignRate.mockResolvedValue(undefined);
|
||||
expect(await svc.withFrozenRate('5', { total_price: 10, currency: 'EUR' })).toEqual({ total_price: 10, currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('drops a currency that is not a code, and never takes a rate from the caller', async () => {
|
||||
expect(await svc.withFrozenRate('5', { total_price: 10, currency: 'dollars', exchange_rate: 99 })).toEqual({ total_price: 10 });
|
||||
expect(await svc.withFrozenRate('5', { total_price: 10, exchange_rate: 99 })).toEqual({ total_price: 10 });
|
||||
expect(budget.freezeForeignRate).not.toHaveBeenCalled();
|
||||
expect(await svc.withFrozenRate('5', undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncBudgetOnUpdate', () => {
|
||||
|
||||
@@ -158,31 +158,31 @@ function ownerlessDbs(): DatabaseService {
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getTripSummary guards', () => {
|
||||
it('TRIP-READ-001: returns null for a missing trip instead of throwing', () => {
|
||||
it('TRIP-READ-001: returns null for a missing trip instead of throwing', async () => {
|
||||
// The MCP get_trip_summary tool hands over whatever id the model produced, so
|
||||
// an unknown id has to come back as an empty answer; a throw there surfaces as
|
||||
// a tool error rather than "no such trip".
|
||||
expect(svc.getTripSummary(99999)).toBeNull();
|
||||
expect(svc.getTripSummary(99999, 1)).toBeNull();
|
||||
expect(await svc.getTripSummary(99999)).toBeNull();
|
||||
expect(await svc.getTripSummary(99999, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-READ-002: returns null when the owner row cannot be read', () => {
|
||||
it('TRIP-READ-002: returns null when the owner row cannot be read', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
// Trip row and owner id are two separate SELECTs; if the second one comes back
|
||||
// empty (trip deleted in between) the guard has to stop. Without it listMembers
|
||||
// runs with an undefined owner id and the summary reports an ownerless trip.
|
||||
expect(buildReadModel(ownerlessDbs()).getTripSummary(trip.id, owner.id)).toBeNull();
|
||||
expect(await buildReadModel(ownerlessDbs()).getTripSummary(trip.id, owner.id)).toBeNull();
|
||||
|
||||
// Same trip through the real connection still aggregates — the null above is
|
||||
// the missing owner row, not a broken fixture.
|
||||
expect(svc.getTripSummary(trip.id, owner.id)).not.toBeNull();
|
||||
expect(await svc.getTripSummary(trip.id, owner.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTripSummary shaping', () => {
|
||||
it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', () => {
|
||||
it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addBudgetItem(trip.id, 'Dinner', 40);
|
||||
@@ -193,13 +193,43 @@ describe('getTripSummary shaping', () => {
|
||||
// the offline clients both render as an empty budget.
|
||||
addBudgetItem(trip.id, 'Free walking tour', 0);
|
||||
|
||||
const summary = svc.getTripSummary(trip.id, owner.id)!;
|
||||
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary.budget.item_count).toBe(2);
|
||||
expect(summary.budget.total).toBe(40);
|
||||
expect(summary.budget.currency).toBe('EUR');
|
||||
});
|
||||
|
||||
it('TRIP-READ-004: counts only checked packing items, not the whole list', () => {
|
||||
it('TRIP-READ-008: totals a foreign-currency bill in the trip currency, at its booked rate (#2525)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addBudgetItem(trip.id, 'Dinner', 100);
|
||||
// 801.76 USD booked when a euro bought 1.17 dollars: 685.26 EUR of trip money. The
|
||||
// summary used to add the 801.76 to the euros and report 901.76 EUR.
|
||||
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate) VALUES (?, 'accommodation', 'Aparthotel Silver', 801.76, 'USD', 1.17)")
|
||||
.run(trip.id);
|
||||
|
||||
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary.budget.total).toBe(785.26);
|
||||
expect(summary.budget.by_category).toEqual({ food: 100, accommodation: 685.26 });
|
||||
expect(summary.budget.currency).toBe('EUR');
|
||||
});
|
||||
|
||||
it('TRIP-READ-009: totals a trip saved without a currency in euros, the default the rest of the app reads it in (#2525)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
testDb.prepare('UPDATE trips SET currency = NULL WHERE id = ?').run(trip.id);
|
||||
addBudgetItem(trip.id, 'Dinner', 100);
|
||||
// 117.33 USD booked at 1.1733 dollars to the euro is 100 EUR of trip money.
|
||||
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate) VALUES (?, 'transport', 'Taxi', 117.33, 'USD', 1.1733)")
|
||||
.run(trip.id);
|
||||
|
||||
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary.budget.total).toBe(200);
|
||||
expect(summary.budget.by_category).toEqual({ food: 100, transport: 100 });
|
||||
expect(summary.budget.currency).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-READ-004: counts only checked packing items, not the whole list', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addPackingItem(trip.id, 'Socks', 1);
|
||||
@@ -208,7 +238,7 @@ describe('getTripSummary shaping', () => {
|
||||
|
||||
// total and checked come from the same array; if the filter is ever widened the
|
||||
// packing progress the summary reports jumps to 100% while items are still open.
|
||||
const summary = svc.getTripSummary(trip.id, owner.id)!;
|
||||
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary.packing.total).toBe(3);
|
||||
expect(summary.packing.checked).toBe(2);
|
||||
});
|
||||
@@ -254,7 +284,7 @@ describe('bundle shaping', () => {
|
||||
});
|
||||
|
||||
describe('private packing items stay viewer-scoped (#858)', () => {
|
||||
it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", () => {
|
||||
it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: viewer } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
|
||||
@@ -267,7 +297,7 @@ describe('private packing items stay viewer-scoped (#858)', () => {
|
||||
// ONLY thing filtering the list. If either call site loses it, listItems falls
|
||||
// back to the unfiltered query and the surprise the owner is carrying shows up
|
||||
// in the other member's MCP summary and in their offline cache.
|
||||
const asViewer = svc.getTripSummary(trip.id, viewer.id)!;
|
||||
const asViewer = (await svc.getTripSummary(trip.id, viewer.id))!;
|
||||
expect(asViewer.packing.items.map((i: any) => i.name)).toEqual(['Tent']);
|
||||
expect(asViewer.packing.total).toBe(1);
|
||||
|
||||
@@ -276,7 +306,7 @@ describe('private packing items stay viewer-scoped (#858)', () => {
|
||||
|
||||
// The owner still sees their own private item through both paths, so the
|
||||
// assertions above are the filter working, not an empty fixture.
|
||||
expect(svc.getTripSummary(trip.id, owner.id)!.packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']);
|
||||
expect((await svc.getTripSummary(trip.id, owner.id))!.packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']);
|
||||
expect((svc.bundle(String(trip.id), { user_id: owner.id }, owner.id) as any)
|
||||
.packingItems.map((i: any) => i.name)).toEqual(['Ring', 'Tent']);
|
||||
});
|
||||
|
||||
@@ -928,7 +928,7 @@ describe('guest members (#1362)', () => {
|
||||
// ── Folded CRUD SQL (summary / list / create / delete / copy) ─────────────────
|
||||
|
||||
describe('folded trip CRUD', () => {
|
||||
it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', () => {
|
||||
it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
|
||||
@@ -936,7 +936,7 @@ describe('folded trip CRUD', () => {
|
||||
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id);
|
||||
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id);
|
||||
|
||||
const summary = readModelSvc.getTripSummary(trip.id, owner.id)!;
|
||||
const summary = (await readModelSvc.getTripSummary(trip.id, owner.id))!;
|
||||
expect(summary).toBeTruthy();
|
||||
expect((summary.trip as any).id).toBe(trip.id);
|
||||
expect(summary.members.owner.id).toBe(owner.id);
|
||||
@@ -950,7 +950,7 @@ describe('folded trip CRUD', () => {
|
||||
expect(summary.collab_notes).toEqual([]);
|
||||
|
||||
// Missing trips return null instead of throwing.
|
||||
expect(readModelSvc.getTripSummary(99999)).toBeNull();
|
||||
expect(await readModelSvc.getTripSummary(99999)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-SVC-043: list returns owned + shared trips with is_owner, honoring the archived filter', () => {
|
||||
|
||||
@@ -43,6 +43,8 @@ function build(opts: { canEdit?: boolean; cascade?: boolean; stop?: boolean; see
|
||||
),
|
||||
syncBudgetOnCreate: vi.fn(),
|
||||
syncBudgetOnUpdate: vi.fn(),
|
||||
// The price's currency and rate are the service's to resolve; here it passes through.
|
||||
withFrozenRate: vi.fn(async (_tripId: number, entry: unknown) => entry),
|
||||
notifyBookingChange: vi.fn(),
|
||||
// Both guards run before a write now, so the fixture answers for them.
|
||||
referencesOutsideTrip: vi.fn(() => opts.foreign ?? []),
|
||||
|
||||
Reference in New Issue
Block a user