Files
Maurice 9522e4ce4a 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
2026-08-20 17:44:21 +02:00

64 lines
2.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { cleanAmount, cleanAmountText } from '../../../src/utils/formatters';
/**
* Money that has been through an uneven split (#1964).
*
* A total that does not divide evenly is split into parts that are each exact
* to the cent, but adding those parts back in floating point does not return
* the total: 81.61 + 81.60 is 163.20999999999998. The server writes the clean
* value now, so these two are for what is already stored, and for the places
* that print a number as it stands rather than through Intl — the expense form
* when reopened, and the price stamped onto a linked booking.
*/
describe('cleanAmount', () => {
it('gives back the total the parts were meant to add up to', () => {
expect(cleanAmount(81.61 + 81.60)).toBe(163.21);
expect(cleanAmount(33.34 + 33.33 + 33.33)).toBe(100);
});
it('leaves a value that was already clean untouched', () => {
expect(cleanAmount(163.21)).toBe(163.21);
expect(cleanAmount(25)).toBe(25);
expect(cleanAmount(0)).toBe(0);
});
it('keeps a genuine half-cent from being invented or lost', () => {
// Rounding to cents is the point; a third of a cent is not money.
expect(cleanAmount(0.005)).toBe(0.01);
expect(cleanAmount(0.004)).toBe(0);
});
it('handles a negative the same way, since a refund is money too', () => {
expect(cleanAmount(-81.61 - 81.60)).toBe(-163.21);
});
});
describe('cleanAmountText', () => {
it('tidies a numeric price string', () => {
expect(cleanAmountText('163.20999999999998')).toBe('163.21');
expect(cleanAmountText(163.20999999999998)).toBe('163.21');
});
/*
* The booking price field holds whatever the confirmation said. Import writes
* free text into it, so anything that is not a number has to come back out
* exactly as it went in rather than as NaN.
*/
it('passes free text through untouched', () => {
expect(cleanAmountText('included in fare')).toBe('included in fare');
expect(cleanAmountText('EUR 40,00')).toBe('EUR 40,00');
});
it('answers empty for nothing at all', () => {
expect(cleanAmountText(null)).toBe('');
expect(cleanAmountText(undefined)).toBe('');
expect(cleanAmountText('')).toBe('');
});
it('does not turn whitespace into a zero', () => {
expect(cleanAmountText(' ')).toBe(' ');
});
});