From a89fe672349c3143f1772dd78f39fb2987e484bd Mon Sep 17 00:00:00 2001 From: Jessica1213 Date: Mon, 10 Nov 2025 22:23:12 +0700 Subject: [PATCH 1/4] Ensure the exported data is sorted by the expense date --- src/app/groups/[groupId]/expenses/export/csv/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/groups/[groupId]/expenses/export/csv/route.ts b/src/app/groups/[groupId]/expenses/export/csv/route.ts index dfb92c7b5..5bc60032b 100644 --- a/src/app/groups/[groupId]/expenses/export/csv/route.ts +++ b/src/app/groups/[groupId]/expenses/export/csv/route.ts @@ -36,6 +36,7 @@ export async function GET( currencyCode: true, expenses: { select: { + createdAt: true, expenseDate: true, title: true, category: { select: { name: true } }, @@ -48,6 +49,7 @@ export async function GET( isReimbursement: true, splitMode: true, }, + orderBy: [{ expenseDate: 'asc' }, { createdAt: 'asc' }], }, participants: { select: { id: true, name: true } }, }, @@ -101,7 +103,6 @@ export async function GET( ] const currency = getCurrencyFromGroup(group) - const expenses = group.expenses.map((expense) => ({ date: formatDate(expense.expenseDate), title: expense.title, From 337cd8c62d93a22b056fb83fdebfd20cd70d573d Mon Sep 17 00:00:00 2001 From: Jessica1213 Date: Mon, 10 Nov 2025 23:43:27 +0700 Subject: [PATCH 2/4] Display the paying user for each expense in CSV export --- .../[groupId]/expenses/export/csv/route.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/app/groups/[groupId]/expenses/export/csv/route.ts b/src/app/groups/[groupId]/expenses/export/csv/route.ts index 5bc60032b..3a7d60563 100644 --- a/src/app/groups/[groupId]/expenses/export/csv/route.ts +++ b/src/app/groups/[groupId]/expenses/export/csv/route.ts @@ -72,16 +72,17 @@ export async function GET( - Conversion rate: The rate used to convert the amount. - Is Reimbursement: Whether the expense is a reimbursement or not. - Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount). + - Paid By: The paying user - UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user). Example Table: - +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+ - | Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | User A | User B | - +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+ - | 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | 2500 | -2500 | - +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+ - | 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | -80000 | -17264.09 | - +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+ + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+----------+--------+-----------+ + | Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | Paid By | User A | User B | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+----------+--------+-----------+ + | 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | User A | 2500 | -2500 | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+----------+--------+-----------+ + | 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | User B | -80000 | -17264.09 | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+----------+--------+-----------+ */ @@ -96,6 +97,7 @@ export async function GET( { label: 'Conversion rate', value: 'conversionRate' }, { label: 'Is Reimbursement', value: 'isReimbursement' }, { label: 'Split mode', value: 'splitMode' }, + { label: 'Paid By', value: 'paidBy'}, ...group.participants.map((participant) => ({ label: participant.name, value: participant.name, @@ -103,6 +105,9 @@ export async function GET( ] const currency = getCurrencyFromGroup(group) + + const participantIdNameMap = Object.fromEntries(group.participants.map(p => [p.id, p.name])) as Record + const expenses = group.expenses.map((expense) => ({ date: formatDate(expense.expenseDate), title: expense.title, @@ -119,6 +124,7 @@ export async function GET( conversionRate: expense.conversionRate ? expense.conversionRate.toString() : null, + paidBy: participantIdNameMap[expense.paidById], isReimbursement: expense.isReimbursement ? 'Yes' : 'No', splitMode: splitModeLabel[expense.splitMode], ...Object.fromEntries( From 80d70adab03539d690d0f023457e9d69f4c35ef6 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:11:50 +0100 Subject: [PATCH 3/4] feat: switch csv export to saldo format --- .../[groupId]/expenses/export/csv/route.ts | 151 +++++++++++------- 1 file changed, 92 insertions(+), 59 deletions(-) diff --git a/src/app/groups/[groupId]/expenses/export/csv/route.ts b/src/app/groups/[groupId]/expenses/export/csv/route.ts index 3a7d60563..3e79cd16f 100644 --- a/src/app/groups/[groupId]/expenses/export/csv/route.ts +++ b/src/app/groups/[groupId]/expenses/export/csv/route.ts @@ -10,7 +10,7 @@ const splitModeLabel = { BY_SHARES: 'Unevenly – By shares', BY_PERCENTAGE: 'Unevenly – By percentage', BY_AMOUNT: 'Unevenly – By amount', -} +} as const function formatDate(isoDateString: Date): string { const date = new Date(isoDateString) @@ -70,19 +70,18 @@ export async function GET( - Original cost: The amount spent in the original currency. - Original currency: The currency the amount was originally spent in. - Conversion rate: The rate used to convert the amount. - - Is Reimbursement: Whether the expense is a reimbursement or not. - Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount). - Paid By: The paying user - - UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user). + - UserA, UserB: Per-participant saldo for this expense (payer advances vs owed amount). Saldos per row sum to 0. Example Table: - +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+----------+--------+-----------+ - | Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | Paid By | User A | User B | - +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+----------+--------+-----------+ - | 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | User A | 2500 | -2500 | - +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+----------+--------+-----------+ - | 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | User B | -80000 | -17264.09 | - +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+----------+--------+-----------+ + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+----------------------+----------+--------+-----------+ + | Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Split mode | Paid By | User A | User B | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+----------------------+----------+--------+-----------+ + | 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | Evenly | User A | 2500 | -2500 | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+----------------------+----------+--------+-----------+ + | 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | Unevenly - By amount | User B | -80000 | -17264.09 | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+----------------------+----------+--------+-----------+ */ @@ -95,9 +94,8 @@ export async function GET( { label: 'Original cost', value: 'originalAmount' }, { label: 'Original currency', value: 'originalCurrency' }, { label: 'Conversion rate', value: 'conversionRate' }, - { label: 'Is Reimbursement', value: 'isReimbursement' }, { label: 'Split mode', value: 'splitMode' }, - { label: 'Paid By', value: 'paidBy'}, + { label: 'Paid By', value: 'paidBy' }, ...group.participants.map((participant) => ({ label: participant.name, value: participant.name, @@ -106,53 +104,88 @@ export async function GET( const currency = getCurrencyFromGroup(group) - const participantIdNameMap = Object.fromEntries(group.participants.map(p => [p.id, p.name])) as Record - - const expenses = group.expenses.map((expense) => ({ - date: formatDate(expense.expenseDate), - title: expense.title, - categoryName: expense.category?.name || '', - currency: group.currencyCode ?? group.currency, - amount: formatAmountAsDecimal(expense.amount, currency), - originalAmount: expense.originalAmount - ? formatAmountAsDecimal( - expense.originalAmount, - getCurrency(expense.originalCurrency), - ) - : null, - originalCurrency: expense.originalCurrency, - conversionRate: expense.conversionRate - ? expense.conversionRate.toString() - : null, - paidBy: participantIdNameMap[expense.paidById], - isReimbursement: expense.isReimbursement ? 'Yes' : 'No', - splitMode: splitModeLabel[expense.splitMode], - ...Object.fromEntries( - group.participants.map((participant) => { - const { totalShares, participantShare } = expense.paidFor.reduce( - (acc, { participantId, shares }) => { - acc.totalShares += shares - if (participantId === participant.id) { - acc.participantShare = shares - } - return acc - }, - { totalShares: 0, participantShare: 0 }, - ) - - const isPaidByParticipant = expense.paidById === participant.id - const participantAmountShare = +formatAmountAsDecimal( - (expense.amount / totalShares) * participantShare, - currency, - ) - - return [ - participant.name, - participantAmountShare * (isPaidByParticipant ? 1 : -1), - ] - }), - ), - })) + const participantIdNameMap = Object.fromEntries( + group.participants.map((p) => [p.id, p.name]), + ) as Record + + const expenses = group.expenses.map((expense) => { + const normalizedAmount = Number(expense.amount) + const normalizedOriginalAmount = expense.originalAmount + ? Number(expense.originalAmount) + : null + const normalizedConversionRate = expense.conversionRate + ? Number(expense.conversionRate) + : null + + // Total amount of the expense in major currency units (e.g. cents -> dollars) + // This is used to compute the payer's net saldo for this single expense. + const totalAmount = +formatAmountAsDecimal(normalizedAmount, currency) + // Map of participantId -> shares for quick lookups when building saldos. + const shareByParticipant = Object.fromEntries( + expense.paidFor.map(({ participantId, shares }) => [ + participantId, + shares, + ]), + ) as Record + const totalShares = expense.paidFor.reduce( + (sum, { shares }) => sum + shares, + 0, + ) + + return { + date: formatDate(expense.expenseDate), + title: expense.title, + categoryName: expense.category?.name || '', + currency: group.currencyCode ?? group.currency, + // Costs should not count reimbursements as spending in CSV totals + amount: formatAmountAsDecimal( + expense.isReimbursement ? 0 : normalizedAmount, + currency, + ), + originalAmount: normalizedOriginalAmount + ? formatAmountAsDecimal( + normalizedOriginalAmount, + getCurrency(expense.originalCurrency), + ) + : null, + originalCurrency: expense.originalCurrency, + conversionRate: normalizedConversionRate + ? normalizedConversionRate.toString() + : null, + paidBy: participantIdNameMap[expense.paidById], + splitMode: splitModeLabel[expense.splitMode], + // For every participant we export the *saldo* (net effect) of this single expense: + // - For the paying participant (paidBy): + // saldo = totalAmount - participantShareAmount + // -> how much they effectively advance for others. + // - For all other participants: + // saldo = -participantShareAmount + // -> how much they owe to the payer for this expense. + // + // The sum of all participant saldos for a given expense is always 0, + // which makes the CSV easy to aggregate in tools like Excel. + ...Object.fromEntries( + group.participants.map((participant) => { + const participantShare = shareByParticipant[participant.id] ?? 0 + + const participantShareAmount = + totalShares === 0 + ? 0 + : +formatAmountAsDecimal( + (normalizedAmount / totalShares) * participantShare, + currency, + ) + + const isPaidByParticipant = expense.paidById === participant.id + const saldo = isPaidByParticipant + ? totalAmount - participantShareAmount + : -participantShareAmount + + return [participant.name, saldo] + }), + ), + } + }) const json2csvParser = new Parser({ fields }) const csv = json2csvParser.parse(expenses) From c7d7401954c9049202845c6ed4edbe31b1121c2e Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:57:34 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(csv):=20avoid=20rounding=20drift=20by?= =?UTF-8?q?=20allocating=20shares=20in=20minor=20units=20with=20last-share?= =?UTF-8?q?=20remainder\n\nPreviously,=20participant=20shares=20were=20com?= =?UTF-8?q?puted=20in=20major=20units=20and=20rounded=20per-participant=20?= =?UTF-8?q?before=20saldo=20calculation.=20For=20amounts=20like=201.01=20s?= =?UTF-8?q?plit=20across=202=20people,=20this=20caused=20mismatches=20(e.g?= =?UTF-8?q?.=20-0.51=20and=20+0.50)=20where=20per=E2=80=91row=20saldos=20d?= =?UTF-8?q?id=20not=20add=20up=20due=20to=20early=20rounding.\n\nThis=20ch?= =?UTF-8?q?ange=20mirrors=20app=20behaviour:=20compute=20shares=20in=20min?= =?UTF-8?q?or=20units=20(cents),=20distribute=20floor(amount*share/total)?= =?UTF-8?q?=20incrementally=20and=20assign=20the=20remainder=20to=20the=20?= =?UTF-8?q?last=20relevant=20participant.=20Saldos=20are=20derived=20from?= =?UTF-8?q?=20these=20integer=20shares=20and=20only=20formatted=20at=20the?= =?UTF-8?q?=20end.=20This=20fixes=20the=20erroneous=20rounding=20assignmen?= =?UTF-8?q?t=20and=20keeps=20totals=20consistent.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[groupId]/expenses/export/csv/route.ts | 78 ++++++++++++------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/src/app/groups/[groupId]/expenses/export/csv/route.ts b/src/app/groups/[groupId]/expenses/export/csv/route.ts index 3e79cd16f..102e55e19 100644 --- a/src/app/groups/[groupId]/expenses/export/csv/route.ts +++ b/src/app/groups/[groupId]/expenses/export/csv/route.ts @@ -127,8 +127,22 @@ export async function GET( shares, ]), ) as Record - const totalShares = expense.paidFor.reduce( - (sum, { shares }) => sum + shares, + // Normalize shares based on split mode to mirror app logic + const isEvenly = expense.splitMode === 'EVENLY' + const normalizedSharesByParticipant: Record = {} + for (const p of group.participants) { + if (isEvenly) { + normalizedSharesByParticipant[p.id] = expense.paidFor.some( + (pf) => pf.participantId === p.id, + ) + ? 1 + : 0 + } else { + normalizedSharesByParticipant[p.id] = shareByParticipant[p.id] ?? 0 + } + } + const totalShares = Object.values(normalizedSharesByParticipant).reduce( + (sum, v) => sum + v, 0, ) @@ -154,36 +168,40 @@ export async function GET( : null, paidBy: participantIdNameMap[expense.paidById], splitMode: splitModeLabel[expense.splitMode], - // For every participant we export the *saldo* (net effect) of this single expense: - // - For the paying participant (paidBy): - // saldo = totalAmount - participantShareAmount - // -> how much they effectively advance for others. - // - For all other participants: - // saldo = -participantShareAmount - // -> how much they owe to the payer for this expense. - // - // The sum of all participant saldos for a given expense is always 0, - // which makes the CSV easy to aggregate in tools like Excel. - ...Object.fromEntries( - group.participants.map((participant) => { - const participantShare = shareByParticipant[participant.id] ?? 0 - - const participantShareAmount = - totalShares === 0 - ? 0 - : +formatAmountAsDecimal( - (normalizedAmount / totalShares) * participantShare, - currency, - ) - + // For every participant we export the saldo (net effect) of this single expense. + // Compute participant shares in minor units first to avoid rounding drift. + ...(() => { + const entries: [string, number][] = [] + // Determine ordered list of participants that actually have shares + const participantsWithShares = group.participants + .map((p, idx) => ({ p, idx })) + .filter(({ p }) => (normalizedSharesByParticipant[p.id] ?? 0) > 0) + .map(({ p }) => p.id) + + let remaining = normalizedAmount // minor units remaining to allocate + group.participants.forEach((participant) => { + const shares = normalizedSharesByParticipant[participant.id] ?? 0 + let shareMinor = 0 + if (totalShares > 0 && shares > 0) { + const isLast = + participant.id === + participantsWithShares[participantsWithShares.length - 1] + if (isLast) { + shareMinor = remaining + } else { + shareMinor = Math.floor((normalizedAmount * shares) / totalShares) + remaining -= shareMinor + } + } + const shareMajor = +formatAmountAsDecimal(shareMinor, currency) const isPaidByParticipant = expense.paidById === participant.id const saldo = isPaidByParticipant - ? totalAmount - participantShareAmount - : -participantShareAmount - - return [participant.name, saldo] - }), - ), + ? totalAmount - shareMajor + : -shareMajor + entries.push([participant.name, saldo]) + }) + return Object.fromEntries(entries) + })(), } })