Skip to content

Commit eb22c9e

Browse files
committed
fix(csv): avoid rounding drift by allocating shares in minor units with last-share remainder\n\nPreviously, participant shares were computed in major units and rounded per-participant before saldo calculation. For amounts like 1.01 split across 2 people, this caused mismatches (e.g. -0.51 and +0.50) where per‑row saldos did not add up due to early rounding.\n\nThis change mirrors app behaviour: compute shares in minor units (cents), distribute floor(amount*share/total) incrementally and assign the remainder to the last relevant participant. Saldos are derived from these integer shares and only formatted at the end. This fixes the erroneous rounding assignment and keeps totals consistent.
1 parent 7bbd936 commit eb22c9e

1 file changed

Lines changed: 48 additions & 30 deletions

File tree

  • src/app/groups/[groupId]/expenses/export/csv

src/app/groups/[groupId]/expenses/export/csv/route.ts

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,22 @@ export async function GET(
126126
shares,
127127
]),
128128
) as Record<string, number>
129-
const totalShares = expense.paidFor.reduce(
130-
(sum, { shares }) => sum + shares,
129+
// Normalize shares based on split mode to mirror app logic
130+
const isEvenly = expense.splitMode === 'EVENLY'
131+
const normalizedSharesByParticipant: Record<string, number> = {}
132+
for (const p of group.participants) {
133+
if (isEvenly) {
134+
normalizedSharesByParticipant[p.id] = expense.paidFor.some(
135+
(pf) => pf.participantId === p.id,
136+
)
137+
? 1
138+
: 0
139+
} else {
140+
normalizedSharesByParticipant[p.id] = shareByParticipant[p.id] ?? 0
141+
}
142+
}
143+
const totalShares = Object.values(normalizedSharesByParticipant).reduce(
144+
(sum, v) => sum + v,
131145
0,
132146
)
133147

@@ -153,36 +167,40 @@ export async function GET(
153167
: null,
154168
paidBy: participantIdNameMap[expense.paidById],
155169
splitMode: splitModeLabel[expense.splitMode],
156-
// For every participant we export the *saldo* (net effect) of this single expense:
157-
// - For the paying participant (paidBy):
158-
// saldo = totalAmount - participantShareAmount
159-
// -> how much they effectively advance for others.
160-
// - For all other participants:
161-
// saldo = -participantShareAmount
162-
// -> how much they owe to the payer for this expense.
163-
//
164-
// The sum of all participant saldos for a given expense is always 0,
165-
// which makes the CSV easy to aggregate in tools like Excel.
166-
...Object.fromEntries(
167-
group.participants.map((participant) => {
168-
const participantShare = shareByParticipant[participant.id] ?? 0
169-
170-
const participantShareAmount =
171-
totalShares === 0
172-
? 0
173-
: +formatAmountAsDecimal(
174-
(normalizedAmount / totalShares) * participantShare,
175-
currency,
176-
)
177-
170+
// For every participant we export the saldo (net effect) of this single expense.
171+
// Compute participant shares in minor units first to avoid rounding drift.
172+
...(() => {
173+
const entries: [string, number][] = []
174+
// Determine ordered list of participants that actually have shares
175+
const participantsWithShares = group.participants
176+
.map((p, idx) => ({ p, idx }))
177+
.filter(({ p }) => (normalizedSharesByParticipant[p.id] ?? 0) > 0)
178+
.map(({ p }) => p.id)
179+
180+
let remaining = normalizedAmount // minor units remaining to allocate
181+
group.participants.forEach((participant) => {
182+
const shares = normalizedSharesByParticipant[participant.id] ?? 0
183+
let shareMinor = 0
184+
if (totalShares > 0 && shares > 0) {
185+
const isLast =
186+
participant.id ===
187+
participantsWithShares[participantsWithShares.length - 1]
188+
if (isLast) {
189+
shareMinor = remaining
190+
} else {
191+
shareMinor = Math.floor((normalizedAmount * shares) / totalShares)
192+
remaining -= shareMinor
193+
}
194+
}
195+
const shareMajor = +formatAmountAsDecimal(shareMinor, currency)
178196
const isPaidByParticipant = expense.paidById === participant.id
179197
const saldo = isPaidByParticipant
180-
? totalAmount - participantShareAmount
181-
: -participantShareAmount
182-
183-
return [participant.name, saldo]
184-
}),
185-
),
198+
? totalAmount - shareMajor
199+
: -shareMajor
200+
entries.push([participant.name, saldo])
201+
})
202+
return Object.fromEntries(entries)
203+
})(),
186204
}
187205
})
188206

0 commit comments

Comments
 (0)