🐛 add env to useDb and apply prettier
tinyedge/deploy Deployed by TinyEdge

This commit is contained in:
2026-08-07 13:28:26 +02:00
parent 67727f96ab
commit cbe1b42f6e
54 changed files with 5492 additions and 2406 deletions
+10 -10
View File
@@ -1,19 +1,19 @@
export type ParticipantId = number
export type ParticipantId = number;
export interface Participant {
id: ParticipantId
name: string
id: ParticipantId;
name: string;
}
export interface Expense {
id: number
amountCents: number
payerId: ParticipantId
participantIds: ParticipantId[]
id: number;
amountCents: number;
payerId: ParticipantId;
participantIds: ParticipantId[];
}
export interface Settlement {
fromId: ParticipantId
toId: ParticipantId
amountCents: number
fromId: ParticipantId;
toId: ParticipantId;
amountCents: number;
}
+48 -39
View File
@@ -1,48 +1,54 @@
import { describe, expect, it } from 'vitest'
import type { Expense, Participant } from '../types'
import { calculateBalances } from './balances'
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)
];
const { netBalances, settlements } = calculateBalances(
participants,
expenses,
);
expect(netBalances).toEqual({ 1: 600, 2: -300, 3: -300 })
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)
})
);
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 })
})
];
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([])
})
];
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[] = [
@@ -50,21 +56,24 @@ describe('calculateBalances', () => {
// 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 }])
})
];
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 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([])
})
];
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[] = [
@@ -72,18 +81,18 @@ describe('calculateBalances', () => {
{ 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)
];
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 })
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)
})
})
expect(settlements.length).toBeLessThanOrEqual(3);
const total = settlements.reduce((sum, s) => sum + s.amountCents, 0);
expect(total).toBe(600);
});
});
+37 -30
View File
@@ -1,63 +1,70 @@
import type { Expense, Participant, ParticipantId, Settlement } from '../types'
import { computeSplit } from './split'
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> = {}
): { netBalances: Record<ParticipantId, number>; settlements: Settlement[] } {
const netBalances: Record<ParticipantId, number> = {};
for (const participant of participants) {
netBalances[participant.id] = 0
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)
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 id = Number(participantId);
netBalances[id] = (netBalances[id] ?? 0) - share;
}
}
const settlements = simplifyDebts(netBalances)
const settlements = simplifyDebts(netBalances);
return { netBalances, settlements }
return { netBalances, settlements };
}
function simplifyDebts(netBalances: Record<ParticipantId, number>): Settlement[] {
const creditors: { id: ParticipantId, amount: number }[] = []
const debtors: { id: ParticipantId, amount: number }[] = []
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 })
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)
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
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)
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 })
settlements.push({
fromId: debtor.id,
toId: creditor.id,
amountCents: amount,
});
}
creditor.amount -= amount
debtor.amount -= amount
creditor.amount -= amount;
debtor.amount -= amount;
if (creditor.amount === 0) ci++
if (debtor.amount === 0) di++
if (creditor.amount === 0) ci++;
if (debtor.amount === 0) di++;
}
return settlements
return settlements;
}
+11 -11
View File
@@ -1,18 +1,18 @@
import { describe, expect, it } from 'vitest'
import { formatCurrency, fromCents, toCents } from './money'
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)
})
expect(toCents(12.34)).toBe(1234);
expect(toCents(5)).toBe(500);
});
it('converts cents back to amounts', () => {
expect(fromCents(1234)).toBe(12.34)
})
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')
})
})
expect(formatCurrency(1234, 'EUR')).toContain('12');
expect(formatCurrency(1234, 'EUR')).toContain('34');
});
});
+6 -3
View File
@@ -1,11 +1,14 @@
export function toCents(amount: number): number {
return Math.round(amount * 100)
return Math.round(amount * 100);
}
export function fromCents(cents: number): number {
return cents / 100
return cents / 100;
}
export function formatCurrency(cents: number, currency: string): string {
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(fromCents(cents))
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
}).format(fromCents(cents));
}
+13 -13
View File
@@ -1,24 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isValidPaymentLink } from './paymentLink'
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)
})
expect(isValidPaymentLink('https://paypal.me/janedoe')).toBe(true);
});
it('accepts an http link', () => {
expect(isValidPaymentLink('http://paypal.me/janedoe')).toBe(true)
})
expect(isValidPaymentLink('http://paypal.me/janedoe')).toBe(true);
});
it('rejects an empty string', () => {
expect(isValidPaymentLink('')).toBe(false)
})
expect(isValidPaymentLink('')).toBe(false);
});
it('rejects a javascript: URI', () => {
expect(isValidPaymentLink('javascript:alert(1)')).toBe(false)
})
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)
})
})
expect(isValidPaymentLink('not a link')).toBe(false);
});
});
+5 -6
View File
@@ -1,11 +1,10 @@
export function isValidPaymentLink(value: string): boolean {
if (!value.trim()) return false
if (!value.trim()) return false;
try {
const url = new URL(value)
return url.protocol === 'https:' || url.protocol === 'http:'
}
catch {
return false
const url = new URL(value);
return url.protocol === 'https:' || url.protocol === 'http:';
} catch {
return false;
}
}
+8 -8
View File
@@ -1,13 +1,13 @@
import { describe, expect, it } from 'vitest'
import { generateSlug } from './slug'
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}$/)
})
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)
})
})
const slugs = new Set(Array.from({ length: 20 }, () => generateSlug()));
expect(slugs.size).toBeGreaterThan(1);
});
});
+67 -15
View File
@@ -1,23 +1,75 @@
import { customAlphabet } from 'nanoid'
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',
]
'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',
]
'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)
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()}`
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
return `${adjective}-${noun}-${nanoid()}`;
}
+17 -17
View File
@@ -1,30 +1,30 @@
import { describe, expect, it } from 'vitest'
import { computeSplit } from './split'
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 })
})
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)
})
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 })
})
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 })
})
expect(computeSplit(1234, [9])).toEqual({ 9: 1234 });
});
it('handles a zero amount', () => {
expect(computeSplit(0, [1, 2])).toEqual({ 1: 0, 2: 0 })
})
expect(computeSplit(0, [1, 2])).toEqual({ 1: 0, 2: 0 });
});
it('throws when given no participants', () => {
expect(() => computeSplit(1000, [])).toThrow()
})
})
expect(() => computeSplit(1000, [])).toThrow();
});
});
+12 -9
View File
@@ -1,21 +1,24 @@
import type { ParticipantId } from '../types'
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> {
export function computeSplit(
amountCents: number,
participantIds: ParticipantId[],
): Record<ParticipantId, number> {
if (participantIds.length === 0) {
throw new Error('computeSplit requires at least one participant')
throw new Error('computeSplit requires at least one participant');
}
const base = Math.floor(amountCents / participantIds.length)
const remainder = amountCents - base * participantIds.length
const base = Math.floor(amountCents / participantIds.length);
const remainder = amountCents - base * participantIds.length;
const shares: Record<ParticipantId, number> = {}
const shares: Record<ParticipantId, number> = {};
participantIds.forEach((id, index) => {
shares[id] = base + (index < remainder ? 1 : 0)
})
return shares
shares[id] = base + (index < remainder ? 1 : 0);
});
return shares;
}