fix(budget): a phone ticket split keeps the share of everyone a receipt line names (#1382)

On the phone, a ticket split where a receipt line named someone who was not
ticked as a participant saved only the ticked people as members. Their share of
the line was dropped, the member amounts no longer added up to total_price, and
the settlement kept a difference nobody could clear. The desktop editor had
been fixed for this in #1382.

Cause: useExpenseForm only added the people named on ticket lines when the
caller passed ticketShareMembers, and only the desktop CostsPanel did. The
phone MCostSheet kept the old behaviour.

Fix: the hook always sends everyone a ticket line names, and the
ticketShareMembers option is gone, so both editors save the same members.

Red test: src/components/Budget/useExpenseForm.test.ts
FE-BUDGET-EXPFORM-021 failed on the unfixed code with
"AssertionError: expected { name: 'Lunch', ... } to match object { ... }":
member_ids was [1] and members held only user 1, without user 2's 6.

Changed assertions: useExpenseForm.test.ts FE-BUDGET-EXPFORM-017 used to
expect the phone run (ticketShareMembers false) to send
members [{ user_id: 1, amount: 6 }] and member_ids [1]; that phone half is
removed and the test now expects every editor to send members for users 1 and
2 with 6 each and member_ids [1, 2].
This commit is contained in:
Maurice
2026-10-09 17:27:52 +02:00
parent e870270fb9
commit 65a657347e
3 changed files with 44 additions and 30 deletions
+1 -1
View File
@@ -1178,7 +1178,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
setCat, setCurrency, setCustomAmounts, setDay, setName, setNote, setPayerId,
setPreviewReceipts: setModalPreviewReceipts, setSplitMode, splitMode, splitShortfall, splitSum, sym, ticketInfo,
ticketItems, toggleParticipant, togglePayer, total, totalNum, tripCur, uploadingReceipt, valid,
} = useExpenseForm({ tripId, base, people, me, editing, prefill, onSaved, ticketShareMembers: true })
} = useExpenseForm({ tripId, base, people, me, editing, prefill, onSaved })
// The custom split typed as percentages instead of amounts (#1709).
const pct = usePercentSplit({ total: totalNum, participants, customAmounts, setCustomAmounts, currency })
@@ -21,7 +21,7 @@ vi.mock('./receiptUploads', () => ({
),
}));
// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-020
// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-021
const PEOPLE: TripMember[] = [
{ id: 1, username: 'alice', avatar_url: null },
@@ -36,7 +36,6 @@ let addToast: ReturnType<typeof vi.fn>;
interface Options {
editing?: BudgetItem | null;
prefill?: ExpensePrefill;
ticketShareMembers?: boolean;
oneSaveAtATime?: boolean;
keepSavingOnSuccess?: boolean;
}
@@ -52,7 +51,6 @@ function setup(options: Options = {}) {
editing: options.editing ?? null,
prefill: options.prefill,
onSaved,
ticketShareMembers: options.ticketShareMembers,
oneSaveAtATime: options.oneSaveAtATime,
keepSavingOnSuccess: options.keepSavingOnSuccess,
})
@@ -464,26 +462,22 @@ describe('useExpenseForm: saving', () => {
expect(result.current.pendingReceiptFiles).toEqual([]);
});
it('FE-BUDGET-EXPFORM-017: a ticket split sends everyone a line names on the desktop, only the ticked on the phone (#1382)', async () => {
const run = async (ticketShareMembers: boolean) => {
const { result } = setup({ ticketShareMembers });
act(() => {
result.current.setName('Lunch');
result.current.setSplitMode('ticket');
result.current.handleAddEmptyItem();
});
const id = result.current.ticketItems[0].id;
act(() => {
result.current.handleUpdateItemName(id, 'Pizza');
result.current.handleUpdateItemPrice(id, '12');
// Bob eats from the line but is not ticked for the expense.
result.current.toggleParticipant(2);
});
await save(result);
return addBudgetItem.mock.calls[addBudgetItem.mock.calls.length - 1][1];
};
const desktop = await run(true);
expect(desktop).toMatchObject({
it('FE-BUDGET-EXPFORM-017: a ticket split sends everyone a line names, ticked or not (#1382)', async () => {
const { result } = setup();
act(() => {
result.current.setName('Lunch');
result.current.setSplitMode('ticket');
result.current.handleAddEmptyItem();
});
const id = result.current.ticketItems[0].id;
act(() => {
result.current.handleUpdateItemName(id, 'Pizza');
result.current.handleUpdateItemPrice(id, '12');
// Bob eats from the line but is not ticked for the expense.
result.current.toggleParticipant(2);
});
await save(result);
expect(addBudgetItem.mock.calls[0][1]).toMatchObject({
members: [
{ user_id: 1, amount: 6 },
{ user_id: 2, amount: 6 },
@@ -492,8 +486,6 @@ describe('useExpenseForm: saving', () => {
total_price: 12,
ticket_json: JSON.stringify({ items: [{ name: 'Pizza', price: '12', parts: [1, 2] }] }),
});
const phone = await run(false);
expect(phone).toMatchObject({ members: [{ user_id: 1, amount: 6 }], member_ids: [1] });
});
it('FE-BUDGET-EXPFORM-018: a failed save says why; a receipt it could not take back is counted', async () => {
@@ -549,4 +541,29 @@ describe('useExpenseForm: saving', () => {
await save(invalid.result);
expect(saveWithReceipts).toHaveBeenCalledTimes(3);
});
it('FE-BUDGET-EXPFORM-021: the phone sends everyone a ticket line names too, so the shares add up to the total (#1382)', async () => {
const { result } = setup({ oneSaveAtATime: true, keepSavingOnSuccess: true });
act(() => {
result.current.setName('Lunch');
result.current.setSplitMode('ticket');
result.current.handleAddEmptyItem();
});
const id = result.current.ticketItems[0].id;
act(() => {
result.current.handleUpdateItemName(id, 'Pizza');
result.current.handleUpdateItemPrice(id, '12');
// Bob eats from the line but is not ticked for the expense.
result.current.toggleParticipant(2);
});
await save(result);
expect(addBudgetItem.mock.calls[0][1]).toMatchObject({
members: [
{ user_id: 1, amount: 6 },
{ user_id: 2, amount: 6 },
],
member_ids: [1, 2],
total_price: 12,
});
});
});
@@ -38,7 +38,6 @@ let ticketItemSeq = 0;
* through the trip store. Both surfaces lay it out their own way.
*
* Where the two save differently today, the surface says which way:
* `ticketShareMembers` (desktop) also sends everyone a ticket line names (#1382);
* `oneSaveAtATime` (phone) ignores a save while one is out; `keepSavingOnSuccess`
* (phone) leaves the form busy after a save that went through, as the sheet closes.
*/
@@ -50,7 +49,6 @@ export function useExpenseForm({
editing,
prefill,
onSaved,
ticketShareMembers = false,
oneSaveAtATime = false,
keepSavingOnSuccess = false,
}: {
@@ -61,7 +59,6 @@ export function useExpenseForm({
editing: BudgetItem | null;
prefill?: ExpensePrefill;
onSaved: () => void;
ticketShareMembers?: boolean;
oneSaveAtATime?: boolean;
keepSavingOnSuccess?: boolean;
}) {
@@ -349,7 +346,7 @@ export function useExpenseForm({
// only the ticked set would drop their share, leaving the member sum short of
// total_price and handing the settlement a difference it can never clear (#1382).
const memberIds =
splitMode === 'ticket' && ticketShareMembers
splitMode === 'ticket'
? [...new Set([...participants, ...Object.keys(ticketInfo.shares).map(Number)])].sort((a, b) => a - b)
: [...participants];
const memberList = memberIds.map((id) => ({