fix(budget): keep an uneven split from corrupting the expense total

163.21 across two people is 81.61 and 81.60. Both are exact to the cent — the
client splits properly — but the server discarded the total it was sent and
re-derived it by adding those two as doubles, which lands on
163.20999999999998. That is the number that went into the row.

It looked like a display quirk because everything routed through Intl came
out right; only the places that print a number as it stands showed it. The
stored value was wrong all along, which is why reopening the expense showed
it back, and why the price stamped onto a linked booking carried it too.

The domain already had this rule and followed it everywhere else — the
settlement maths converts to whole cents before it adds anything. The payer
sum was the one arithmetic still done in euros, in both the create and the
update path, so it now goes through the same rule.

The client side is what makes rows that are already stored wrong stop
looking wrong: the expense form and the mobile cost sheet seed their total
from a cent-clean value, and the three panels that print a booking's price
verbatim tidy it when it parses as a number. Free text an import wrote there
passes through untouched rather than becoming NaN.

Closes #1964
This commit is contained in:
Maurice
2026-08-20 17:25:51 +02:00
committed by Maurice
parent 68bf5316ad
commit 9522e4ce4a
9 changed files with 214 additions and 14 deletions
+25 -4
View File
@@ -52,6 +52,23 @@ export function splitLegacyTicketNote(
* converted(Σ). `factor === 1` (the display currency IS the trip currency, the
* common case) is the identity and never touches a float.
*/
/**
* Add money in whole cents, not in floats (#1964).
*
* A total that does not divide evenly is split into parts that are each exact
* to the cent — 163.21 across two people is 81.61 and 81.60 — but adding those
* two doubles gives 163.20999999999998, and that is what got written back over
* the clean total the client sent. It then surfaced anywhere the number is
* printed without Intl doing the rounding: the expense form when reopened, the
* mobile cost sheet, and the price stamped onto a linked booking.
*
* This is the same rule the settlement maths in this file already follows
* (toTripCents), applied to the one arithmetic that had been left in euros.
*/
function sumMoney(amounts: number[]): number {
return amounts.reduce((a, v) => a + Math.round(v * 100), 0) / 100;
}
function allocateDisplayCents(cents: number[], factor: number): number[] {
if (factor === 1) return [...cents];
const exact = cents.map(c => c * factor);
@@ -145,12 +162,13 @@ export class BudgetService {
this.db.run('DELETE FROM budget_item_payers WHERE budget_item_id = ?', itemId);
const insert = this.db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)');
const known = this.rosterMemberIds(tripId, payers.map(p => p.user_id));
let total = 0;
const accepted: number[] = [];
for (const p of payers) {
if (!(p.amount > 0) || !known.has(p.user_id)) continue;
insert.run(itemId, p.user_id, p.amount);
total += p.amount;
accepted.push(p.amount);
}
const total = sumMoney(accepted);
this.db.run('UPDATE budget_items SET total_price = ? WHERE id = ?', total, itemId);
return total;
}
@@ -348,7 +366,7 @@ export class BudgetService {
// total_price is derived from explicit payers when given; otherwise the caller
// value (planning entries, or a bill no one has paid yet).
const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0);
const payerTotal = sumMoney((data.payers || []).filter(p => p.amount > 0).map(p => p.amount));
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
const knownMembers = data.members ? this.rosterMemberIds(tripId, data.members.map(m => m.user_id)) : null;
@@ -1013,7 +1031,10 @@ export class BudgetService {
);
if (!reservation) return;
const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
meta.price = String(totalPrice);
// Cent-clean, so a booking never inherits float noise from the expense
// it is linked to — and so a row stamped before #1964 heals on the next
// edit. The panels print this string as it stands.
meta.price = String(Math.round(totalPrice * 100) / 100);
this.db.run('UPDATE reservations SET metadata = ? WHERE id = ?', JSON.stringify(meta), reservation.id);
const updatedRes = this.db.get('SELECT * FROM reservations WHERE id = ?', reservation.id);
this.realtime.broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, socketId);