🎉 init
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
export type ParticipantId = number
|
||||
|
||||
export interface Participant {
|
||||
id: ParticipantId
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Expense {
|
||||
id: number
|
||||
amountCents: number
|
||||
payerId: ParticipantId
|
||||
participantIds: ParticipantId[]
|
||||
}
|
||||
|
||||
export interface Settlement {
|
||||
fromId: ParticipantId
|
||||
toId: ParticipantId
|
||||
amountCents: number
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Expense, Participant } from '../types'
|
||||
import { calculateBalances } from './balances'
|
||||
|
||||
const participants: Participant[] = [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
{ id: 3, name: 'Carol' },
|
||||
]
|
||||
|
||||
describe('calculateBalances', () => {
|
||||
it('nets an equal split expense among all participants', () => {
|
||||
const expenses: Expense[] = [
|
||||
{ id: 1, amountCents: 900, payerId: 1, participantIds: [1, 2, 3] },
|
||||
]
|
||||
const { netBalances, settlements } = calculateBalances(participants, expenses)
|
||||
|
||||
expect(netBalances).toEqual({ 1: 600, 2: -300, 3: -300 })
|
||||
expect(settlements).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ fromId: 2, toId: 1, amountCents: 300 },
|
||||
{ fromId: 3, toId: 1, amountCents: 300 },
|
||||
]),
|
||||
)
|
||||
expect(settlements).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handles an expense split among a subset of participants', () => {
|
||||
const expenses: Expense[] = [
|
||||
{ id: 1, amountCents: 1000, payerId: 2, participantIds: [2, 3] },
|
||||
]
|
||||
const { netBalances } = calculateBalances(participants, expenses)
|
||||
expect(netBalances).toEqual({ 1: 0, 2: 500, 3: -500 })
|
||||
})
|
||||
|
||||
it('produces a zero-sum, fully-settled group when expenses cancel out', () => {
|
||||
const expenses: Expense[] = [
|
||||
{ id: 1, amountCents: 900, payerId: 1, participantIds: [1, 2, 3] },
|
||||
{ id: 2, amountCents: 900, payerId: 2, participantIds: [1, 2, 3] },
|
||||
{ id: 3, amountCents: 900, payerId: 3, participantIds: [1, 2, 3] },
|
||||
]
|
||||
const { netBalances, settlements } = calculateBalances(participants, expenses)
|
||||
expect(Object.values(netBalances).every(v => v === 0)).toBe(true)
|
||||
expect(settlements).toEqual([])
|
||||
})
|
||||
|
||||
it('folds in a settlement payment recorded as a one-person split expense', () => {
|
||||
const expenses: Expense[] = [
|
||||
{ id: 1, amountCents: 900, payerId: 1, participantIds: [1, 2, 3] },
|
||||
// Bob (2) settles 300 with Alice (1): payerId is who pays, the sole
|
||||
// participant is who receives it.
|
||||
{ id: 2, amountCents: 300, payerId: 2, participantIds: [1] },
|
||||
]
|
||||
const { netBalances, settlements } = calculateBalances(participants, expenses)
|
||||
expect(netBalances).toEqual({ 1: 300, 2: 0, 3: -300 })
|
||||
expect(settlements).toEqual([{ fromId: 3, toId: 1, amountCents: 300 }])
|
||||
})
|
||||
|
||||
it('handles a single-participant Pot (self-paid expense nets to zero)', () => {
|
||||
const solo: Participant[] = [{ id: 1, name: 'Alice' }]
|
||||
const expenses: Expense[] = [
|
||||
{ id: 1, amountCents: 500, payerId: 1, participantIds: [1] },
|
||||
]
|
||||
const { netBalances, settlements } = calculateBalances(solo, expenses)
|
||||
expect(netBalances).toEqual({ 1: 0 })
|
||||
expect(settlements).toEqual([])
|
||||
})
|
||||
|
||||
it('produces a minimal number of settlements for a multi-person scenario', () => {
|
||||
const four: Participant[] = [
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 2, name: 'B' },
|
||||
{ id: 3, name: 'C' },
|
||||
{ id: 4, name: 'D' },
|
||||
]
|
||||
// A pays 400 split 4 ways (each owes 100), B pays 800 split 4 ways (each owes 200).
|
||||
const expenses: Expense[] = [
|
||||
{ id: 1, amountCents: 400, payerId: 1, participantIds: [1, 2, 3, 4] },
|
||||
{ id: 2, amountCents: 800, payerId: 2, participantIds: [1, 2, 3, 4] },
|
||||
]
|
||||
const { netBalances, settlements } = calculateBalances(four, expenses)
|
||||
// A: +400-100-200=100, B: +800-100-200=500, C: -300, D: -300
|
||||
expect(netBalances).toEqual({ 1: 100, 2: 500, 3: -300, 4: -300 })
|
||||
// 2 creditors, 2 debtors -> minimal settlement count is at most 3 (n-1 participants with nonzero balance)
|
||||
expect(settlements.length).toBeLessThanOrEqual(3)
|
||||
const total = settlements.reduce((sum, s) => sum + s.amountCents, 0)
|
||||
expect(total).toBe(600)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Expense, Participant, ParticipantId, Settlement } from '../types'
|
||||
import { computeSplit } from './split'
|
||||
|
||||
export function calculateBalances(
|
||||
participants: Participant[],
|
||||
expenses: Expense[],
|
||||
): { netBalances: Record<ParticipantId, number>, settlements: Settlement[] } {
|
||||
const netBalances: Record<ParticipantId, number> = {}
|
||||
for (const participant of participants) {
|
||||
netBalances[participant.id] = 0
|
||||
}
|
||||
|
||||
// A settlement payment (fromId pays toId) is folded in as a regular
|
||||
// expense: payerId = fromId, participantIds = [toId]. The math is
|
||||
// identical to a one-person split, so no separate handling is needed here.
|
||||
for (const expense of expenses) {
|
||||
netBalances[expense.payerId] = (netBalances[expense.payerId] ?? 0) + expense.amountCents
|
||||
const shares = computeSplit(expense.amountCents, expense.participantIds)
|
||||
for (const [participantId, share] of Object.entries(shares)) {
|
||||
const id = Number(participantId)
|
||||
netBalances[id] = (netBalances[id] ?? 0) - share
|
||||
}
|
||||
}
|
||||
|
||||
const settlements = simplifyDebts(netBalances)
|
||||
|
||||
return { netBalances, settlements }
|
||||
}
|
||||
|
||||
function simplifyDebts(netBalances: Record<ParticipantId, number>): Settlement[] {
|
||||
const creditors: { id: ParticipantId, amount: number }[] = []
|
||||
const debtors: { id: ParticipantId, amount: number }[] = []
|
||||
|
||||
for (const [id, amount] of Object.entries(netBalances)) {
|
||||
if (amount > 0) creditors.push({ id: Number(id), amount })
|
||||
else if (amount < 0) debtors.push({ id: Number(id), amount: -amount })
|
||||
}
|
||||
|
||||
creditors.sort((a, b) => b.amount - a.amount)
|
||||
debtors.sort((a, b) => b.amount - a.amount)
|
||||
|
||||
const settlements: Settlement[] = []
|
||||
let ci = 0
|
||||
let di = 0
|
||||
|
||||
while (ci < creditors.length && di < debtors.length) {
|
||||
const creditor = creditors[ci]
|
||||
const debtor = debtors[di]
|
||||
const amount = Math.min(creditor.amount, debtor.amount)
|
||||
|
||||
if (amount > 0) {
|
||||
settlements.push({ fromId: debtor.id, toId: creditor.id, amountCents: amount })
|
||||
}
|
||||
|
||||
creditor.amount -= amount
|
||||
debtor.amount -= amount
|
||||
|
||||
if (creditor.amount === 0) ci++
|
||||
if (debtor.amount === 0) di++
|
||||
}
|
||||
|
||||
return settlements
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatCurrency, fromCents, toCents } from './money'
|
||||
|
||||
describe('money helpers', () => {
|
||||
it('converts amounts to cents', () => {
|
||||
expect(toCents(12.34)).toBe(1234)
|
||||
expect(toCents(5)).toBe(500)
|
||||
})
|
||||
|
||||
it('converts cents back to amounts', () => {
|
||||
expect(fromCents(1234)).toBe(12.34)
|
||||
})
|
||||
|
||||
it('formats cents as a localized currency string', () => {
|
||||
expect(formatCurrency(1234, 'EUR')).toContain('12')
|
||||
expect(formatCurrency(1234, 'EUR')).toContain('34')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export function toCents(amount: number): number {
|
||||
return Math.round(amount * 100)
|
||||
}
|
||||
|
||||
export function fromCents(cents: number): number {
|
||||
return cents / 100
|
||||
}
|
||||
|
||||
export function formatCurrency(cents: number, currency: string): string {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(fromCents(cents))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isValidPaymentLink } from './paymentLink'
|
||||
|
||||
describe('isValidPaymentLink', () => {
|
||||
it('accepts an https PayPal.me link', () => {
|
||||
expect(isValidPaymentLink('https://paypal.me/janedoe')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an http link', () => {
|
||||
expect(isValidPaymentLink('http://paypal.me/janedoe')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an empty string', () => {
|
||||
expect(isValidPaymentLink('')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a javascript: URI', () => {
|
||||
expect(isValidPaymentLink('javascript:alert(1)')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a value that is not a URL at all', () => {
|
||||
expect(isValidPaymentLink('not a link')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export function isValidPaymentLink(value: string): boolean {
|
||||
if (!value.trim()) return false
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'https:' || url.protocol === 'http:'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateSlug } from './slug'
|
||||
|
||||
describe('generateSlug', () => {
|
||||
it('produces an adjective-noun-suffix slug', () => {
|
||||
expect(generateSlug()).toMatch(/^[a-z]+-[a-z]+-[a-z0-9]{6}$/)
|
||||
})
|
||||
|
||||
it('produces distinct slugs across calls', () => {
|
||||
const slugs = new Set(Array.from({ length: 20 }, () => generateSlug()))
|
||||
expect(slugs.size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
const ADJECTIVES = [
|
||||
'drunken', 'sleepy', 'grumpy', 'jolly', 'quiet', 'brave', 'clever', 'eager',
|
||||
'fuzzy', 'gentle', 'happy', 'icy', 'jumpy', 'kind', 'lively', 'mighty',
|
||||
'noisy', 'orange', 'proud', 'quick', 'rusty', 'silly', 'tidy', 'upbeat',
|
||||
'vivid', 'witty', 'zesty', 'bold', 'calm', 'dizzy',
|
||||
]
|
||||
|
||||
const NOUNS = [
|
||||
'wizard', 'tiger', 'otter', 'falcon', 'penguin', 'dragon', 'panda', 'raven',
|
||||
'walrus', 'yeti', 'badger', 'comet', 'ember', 'fox', 'goose', 'heron',
|
||||
'igloo', 'jackal', 'koala', 'lemur', 'mango', 'newt', 'oasis', 'puffin',
|
||||
'quokka', 'robin', 'sloth', 'toucan', 'unicorn', 'viper',
|
||||
]
|
||||
|
||||
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 6)
|
||||
|
||||
export function generateSlug(): string {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
return `${adjective}-${noun}-${nanoid()}`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeSplit } from './split'
|
||||
|
||||
describe('computeSplit', () => {
|
||||
it('divides evenly when amount divides cleanly', () => {
|
||||
expect(computeSplit(1000, [1, 2, 4])).toEqual({ 1: 334, 2: 333, 4: 333 })
|
||||
})
|
||||
|
||||
it('sums back exactly to the original amount', () => {
|
||||
const shares = computeSplit(1001, [1, 2, 3, 4, 5, 6, 7])
|
||||
const total = Object.values(shares).reduce((sum, share) => sum + share, 0)
|
||||
expect(total).toBe(1001)
|
||||
})
|
||||
|
||||
it('distributes remainder cents one-each to the first participants in order', () => {
|
||||
expect(computeSplit(10, [1, 2, 3])).toEqual({ 1: 4, 2: 3, 3: 3 })
|
||||
})
|
||||
|
||||
it('gives the full amount to a single participant', () => {
|
||||
expect(computeSplit(1234, [9])).toEqual({ 9: 1234 })
|
||||
})
|
||||
|
||||
it('handles a zero amount', () => {
|
||||
expect(computeSplit(0, [1, 2])).toEqual({ 1: 0, 2: 0 })
|
||||
})
|
||||
|
||||
it('throws when given no participants', () => {
|
||||
expect(() => computeSplit(1000, [])).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ParticipantId } from '../types'
|
||||
|
||||
/**
|
||||
* Splits amountCents equally among participantIds. Any remainder cents (from
|
||||
* integer division) go one-each to the first participants in the given
|
||||
* order, so the shares always sum exactly back to amountCents.
|
||||
*/
|
||||
export function computeSplit(amountCents: number, participantIds: ParticipantId[]): Record<ParticipantId, number> {
|
||||
if (participantIds.length === 0) {
|
||||
throw new Error('computeSplit requires at least one participant')
|
||||
}
|
||||
|
||||
const base = Math.floor(amountCents / participantIds.length)
|
||||
const remainder = amountCents - base * participantIds.length
|
||||
|
||||
const shares: Record<ParticipantId, number> = {}
|
||||
participantIds.forEach((id, index) => {
|
||||
shares[id] = base + (index < remainder ? 1 : 0)
|
||||
})
|
||||
return shares
|
||||
}
|
||||
Reference in New Issue
Block a user