A Bill Splitter Where Unit and Rounding Are Orthogonal (They Usually Aren't)
Real-world bill splitting never stops at "total รท people." The organizer picks a rounding mode, picks a unit (round everyone up to the nearest $5? the nearest $1?), and then cares about the leftover / shortfall. Most splitter apps conflate these. This one treats rounding mode and unit as independent axes and gets it right.
When I'm organizing dinner for four, "$34.87 each" is not a number anyone wants to hear. What I actually want is "$35 each" or "$40 each" โ and I want to know whether that covers the bill or leaves a shortfall. That's two separate decisions: how to round (ceiling / floor / nearest) and to what unit ($1 / $5 / $10 / nearest hundred).
๐ Live demo: https://sen.ltd/portfolio/warikan/
๐ฆ GitHub: https://github.com/sen-ltd/warikan
Three modes (even, ratio, weighted), three rounding modes, configurable unit, and a live "total collected / difference" readout so you know whether you're overcharging or undercharging. Vanilla JS, zero deps, no build. 70 lines of logic, 12 tests.
The roundTo(value, unit, mode) helper
The single observation worth taking away: rounding mode and rounding unit are orthogonal. You might want "ceiling to nearest dollar" or "round to nearest five dollars" โ any mode ร any unit. Fold them into one helper:
function roundTo(value, unit, mode) {
const n = value / unit
let rounded
if (mode === 'floor') rounded = Math.floor(n)
else if (mode === 'round') rounded = Math.round(n)
else rounded = Math.ceil(n) // default: ceil
return rounded * unit
}
Divide by unit, apply the chosen rounding, multiply back. Splitting $34.87 and wanting nearest-$5-ceiling: 34.87 / 5 = 6.974 โ ceil = 7 โ ร 5 = 35. You could cram this into Math.ceil(value / unit) * unit inline, but then you'd repeat the expression three times and the UI's "choose rounding" dropdown becomes awkward.
The default is ceiling. It's the safe default for bill splitting โ better to have a small surplus than to come up short. When the organizer is covering the gap, they'd rather know they have $2 extra than $2 missing.
Returning diff is what makes the UX good
Here's the part most splitter apps miss. If you only show "$35/person," the user immediately does mental math: "4 people ร $35 = $140... the bill was $134.87... so I'm $5.13 over." That computation should happen in the tool:
export function splitEvenly(total, count, opts = {}) {
if (!Number.isFinite(total) || total < 0) return { error: 'invalid total' }
if (!Number.isInteger(count) || count <= 0) return { error: 'invalid count' }
const rounding = opts.rounding ?? 'ceil'
const unit = opts.unit ?? 1
const per = roundTo(total / count, unit, rounding)
const totalCollected = per * count
const diff = totalCollected - total
return { per, count, total, totalCollected, diff, rounding, unit }
}
The UI then shows three lines:
$35 per person
Total collected: $140
Diff: +$5.13
Positive diff = surplus (green), negative diff = shortfall (red). The organizer's calculus โ "do I owe or am I owed?" โ is answered at a glance without any mental arithmetic.
Weighted splits via normalization
For scenarios like "splitter group has mixed contributions" (bigger eaters, varying ages, etc.), take a weights array and normalize to the total:
export function splitWeighted(total, weights, opts = {}) {
const sum = weights.reduce((a, b) => a + b, 0)
const shares = weights.map((w) => roundTo((total * w) / sum, unit, rounding))
const totalCollected = shares.reduce((a, b) => a + b, 0)
const diff = totalCollected - total
return { shares, weights: weights.slice(), total, totalCollected, diff, rounding, unit }
}
Normalization means [1, 1, 1.5, 2] and [2, 2, 3, 4] produce identical output. Users can think in "$1 buckets" or in percentages โ the tool doesn't care. The sum is just a divisor.
The "ratio" mode is a one-liner on top of this:
export function splitByRatio(total, countA, countB, ratio, opts = {}) {
const weights = [
...Array(countA).fill(ratio),
...Array(countB).fill(1),
]
return splitWeighted(total, weights, opts)
}
"3 people at 1.5ร and 2 people at 1ร" becomes [1.5, 1.5, 1.5, 1, 1], then straight into splitWeighted. The three UI modes all funnel into the same core function, which is the usual sign that your abstractions are in roughly the right place.
Input validation: fail fast, return errors
Number inputs from a web form can be anything โ NaN, Infinity, negative, non-integer, zero. Guard at the top of every function:
if (!Number.isFinite(total) || total < 0) return { error: 'invalid total' }
if (!Number.isInteger(count) || count <= 0) return { error: 'invalid count' }
Number.isFinite handles both NaN and Infinity. Number.isInteger rejects fractional counts. The count <= 0 check is critical โ without it, division by zero produces Infinity, which would then propagate into the UI and visually break the layout.
Weighted mode has its own guard:
if (weights.some((w) => !Number.isFinite(w) || w <= 0)) {
return { error: 'weights must be positive numbers' }
}
Returning { error } from a pure function, instead of throwing, makes the UI logic cleaner. The render code is if (result.error) showError(result.error); else showResult(result); โ no try/catch, no uncaught exceptions, data flow stays explicit.
Tests
12 cases on node --test, covering each rounding mode ร each unit ร each mode combination:
test('evenly splits 10000 between 4', () => {
const r = splitEvenly(10000, 4)
assert.equal(r.per, 2500)
assert.equal(r.diff, 0)
})
test('ceil rounding when 10000 split by 3', () => {
const r = splitEvenly(10000, 3, { rounding: 'ceil' })
assert.equal(r.per, 3334)
assert.equal(r.diff, 2) // 3334 * 3 = 10002
})
test('unit 100 rounds up to nearest 100', () => {
const r = splitEvenly(14200, 4, { rounding: 'ceil', unit: 100 })
assert.equal(r.per, 3600)
})
test('weighted 1:1:1.5:2 sums to total', () => {
const r = splitWeighted(10000, [1, 1, 1.5, 2])
assert.equal(r.shares.length, 4)
assert.equal(r.shares.reduce((a, b) => a + b, 0), r.totalCollected)
})
test('count=0 returns error', () => {
assert.ok(splitEvenly(1000, 0).error)
})
A "scale-invariance" test โ multiplying every weight by 2 should give the same result โ catches normalization bugs that random-number tests would miss.
Series
This is entry #11 in my 100+ public portfolio series.
- ๐ฆ Repo: https://github.com/sen-ltd/warikan
- ๐ Live: https://sen.ltd/portfolio/warikan/
- ๐ข Company: https://sen.ltd/
"Warikan" is the Japanese word for splitting a bill at a restaurant, usually with the organizer rounding and absorbing any small difference.

Top comments (0)