Skip to content

Conversation

@Uli-Z
Copy link
Contributor

@Uli-Z Uli-Z commented Nov 14, 2025

Summary

  • Fix Rounding drops a cent when splitting evenly #374: Every expense now distributes its full amount, even when minor units do not divide cleanly. Shares are computed with decimal.js, truncated toward zero to whole cents, and any remainder is distributed afterward.
  • Remainder distribution by fractional parts: leftover cents (positive/negative) are allocated one by one to participants with the largest truncated fractional parts; on ties, later participants (by original order) receive the cent first.
  • BY_AMOUNT stays deterministic: if submitted amounts do not sum exactly to the total, the payer gets the difference credited/debited.
  • Balances and CSV export use the centralized calculateShares logic, keeping UI, exports, and summaries consistent.
  • Comprehensive tests cover the new logic and the tie-break rules.
  • The “Your share” preview in the form uses the new calculation logic.

Refactoring Details

This PR goes beyond a simple bug fix and constitutes a significant refactoring of a core piece of the application's business logic.

Before:

  • Fragmented Logic: Expense share calculations were implemented independently and inconsistently in multiple places:
    • The balance calculation in src/lib/balances.ts.
    • The CSV export generation in src/app/groups/[groupId]/expenses/export/csv/route.ts.
    • A simplistic calculateShare helper in src/lib/totals.ts.
  • Inconsistent Behavior: Each implementation had its own way of handling rounding and division, leading to potential bugs and inconsistencies between what users saw in the balance sheet versus what they got in an export.
  • Precision Issues: Calculations were performed using standard JavaScript numbers, making them susceptible to floating-point precision errors.

After:

  • Single Source of Truth: All calculation logic is consolidated in calculateShares (src/lib/totals.ts).
  • Guaranteed Precision: All internal arithmetic uses decimal.js for precise, finance-safe calculations.
  • Centralized & Consistent: balances.ts, export/csv/route.ts, and the form preview all use the central function — the same numbers everywhere.
  • Deterministic & Testable: Clear deterministic remainder distribution with a tie-break rule; a comprehensive test suite (totals.test.ts) locks behavior.

Testing

  • npx jest src/lib/totals.test.ts --runInBand
  • Includes tie-break tests for equal fractions in EVENLY, BY_SHARES, and BY_PERCENTAGE (positive and negative amounts).

Explanation of Remainder Distribution

  • Compute each participant’s exact share using the selected split mode (evenly, by shares, by percentage, by amount).
  • Truncate each exact share toward zero to whole cents. This guarantees the rounded shares never exceed the total amount.
  • Compute the remainder: remainder = totalAmount - sum(truncatedShares).
  • Special case BY_AMOUNT: if the submitted amounts don’t sum to the total, give the entire remainder to the payer (credit or debit).
  • Otherwise, distribute the remainder one cent at a time:
    • For each participant, compute the fractional part magnitude from their exact share (the portion that was cut off by truncation).
    • Order participants by larger fractional parts first. If multiple participants have the same fractional part, later participants (by the original list order) come first.
    • Add +1 cent to the first participant in this order for a positive remainder (or -1 cent for a negative remainder), move to the next participant, and repeat until the remainder is zero.

Examples

Split $1.00 evenly among 3 people (P1, P2, P3)

  • Exact shares: $1.00 ÷ 3 = $0.333333… each.
  • Truncate to cents (toward zero): $0.33, $0.33, $0.33.
  • Remainder: $1.00 − ($0.33 + $0.33 + $0.33) = $0.01.
  • Tie-break on equal fractions: later participants get leftover first.
  • Distribute remainder (one cent):
    • +$0.01 → P3
  • Final amounts: P1 = $0.33, P2 = $0.33, P3 = $0.34 (sum = $1.00).

Split $1.00 between 2 people with shares 2:1 (P1:P2)

  • Exact shares: $1.00 × (2/3) = $0.666666… for P1, and $1.00 × (1/3) = $0.333333… for P2.
  • Truncate to cents (toward zero): P1 = $0.66, P2 = $0.33.
  • Remainder: $1.00 − ($0.66 + $0.33) = $0.01.
  • Who gets the extra cent? The one with the larger fractional part (P1: .0066… vs P2: .0033…).
  • Final amounts: P1 = $0.67, P2 = $0.33 (sum = $1.00).

@Uli-Z Uli-Z force-pushed the fix/refactor-expense-calculation branch from ef34afc to da493dc Compare December 7, 2025 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rounding drops a cent when splitting evenly

1 participant