splitwise / src /lib /server /forms /expense-form.ts
assafvayner's picture
assafvayner HF Staff
fix(photos,forms): own-property content types, dimension bounds, VP8L signature, trimmed inputs
fa363c5
Raw
History Blame Contribute Delete
5.23 kB
import { isValidDate } from '../../domain/schemas';
import { bpToInput, centsToInput, parseAmountToCents, parsePercentToBp } from '../../domain/money';
import { resolveShares, SplitError } from '../../domain/split';
import type { Expense, ExpenseBody, Split, SplitMode } from '../../domain/types';
import type { BuildResult, ExpenseField, ExpenseFormValues, FieldErrors } from '../../forms/types';
import { bool, str, strList } from './form-utils';
export type { BuildResult, ExpenseField, ExpenseFormValues, FieldErrors };
const MODES: SplitMode[] = ['equal', 'percent', 'exact'];
export function emptyExpenseForm(viewerId: string, memberIds: string[], today: string): ExpenseFormValues {
return { description: '', amount: '', date: today, paidBy: memberIds.includes(viewerId) ? viewerId : memberIds[0] ?? '', mode: 'equal', participants: [...memberIds], pct: {}, exact: {}, notes: '', removePhoto: false };
}
export function readExpenseForm(form: FormData, memberIds: string[]): ExpenseFormValues {
const known = new Set(memberIds);
const modeRaw = str(form, 'mode');
const pct: Record<string, string> = {};
const exact: Record<string, string> = {};
for (const id of memberIds) {
const p = str(form, `pct_${id}`);
const x = str(form, `exact_${id}`);
if (p !== '') pct[id] = p;
if (x !== '') exact[id] = x;
}
return {
description: str(form, 'description').trim(),
amount: str(form, 'amount').trim(),
date: str(form, 'date').trim(),
paidBy: str(form, 'paidBy'),
mode: (MODES as string[]).includes(modeRaw) ? (modeRaw as SplitMode) : 'equal',
participants: [...new Set(strList(form, 'participants').filter((id) => known.has(id)))],
pct,
exact,
notes: str(form, 'notes').trim(),
removePhoto: bool(form, 'removePhoto')
};
}
export function expenseToFormValues(e: Expense): ExpenseFormValues {
const pct: Record<string, string> = {};
const exact: Record<string, string> = {};
if (e.split.mode === 'percent') for (const [id, bp] of Object.entries(e.split.bp)) pct[id] = bpToInput(bp);
if (e.split.mode === 'exact') for (const [id, c] of Object.entries(e.split.cents)) exact[id] = centsToInput(c);
const participants = e.split.mode === 'equal' ? [...e.split.participants] : Object.keys(e.split.mode === 'percent' ? e.split.bp : e.split.cents);
return { description: e.description, amount: centsToInput(e.amountCents), date: e.date, paidBy: e.paidBy, mode: e.split.mode, participants, pct, exact, notes: e.notes ?? '', removePhoto: false };
}
export interface BuildOptions {
activeMemberIds: string[];
currency: string;
}
/** Validates form values against the active member list and produces an ExpenseBody (without photo). */
export function buildExpenseBody(v: ExpenseFormValues, opts: BuildOptions): BuildResult<ExpenseBody, ExpenseField> {
const errors: FieldErrors = {};
const active = new Set(opts.activeMemberIds);
const description = v.description.trim();
if (description.length === 0 || description.length > 120) errors.description = 'Description is required (max 120 characters)';
const amountCents = parseAmountToCents(v.amount);
if (amountCents === null) errors.amount = 'Enter a positive amount with at most 2 decimals';
if (!isValidDate(v.date)) errors.date = 'Enter a valid date';
if (!active.has(v.paidBy)) errors.paidBy = 'Choose who paid';
if (v.participants.length === 0) errors.participants = 'Select at least one participant';
else if (v.participants.some((id) => !active.has(id))) errors.participants = 'A selected participant is not an active member';
if (v.notes.length > 2000) errors.form = 'Notes are too long (max 2000 characters)';
let split: Split | null = null;
if (!errors.participants) {
if (v.mode === 'equal') {
split = { mode: 'equal', participants: [...v.participants].sort() };
} else if (v.mode === 'percent') {
const bp: Record<string, number> = {};
for (const id of v.participants) {
const parsed = parsePercentToBp(v.pct[id] ?? '');
if (parsed === null) {
errors.shares = 'Enter a percentage between 0 and 100 for every participant';
break;
}
bp[id] = parsed;
}
if (!errors.shares) split = { mode: 'percent', bp };
} else {
const cents: Record<string, number> = {};
for (const id of v.participants) {
const parsed = parseAmountToCents(v.exact[id] ?? '');
if (parsed === null) {
errors.shares = 'Enter a positive amount for every participant';
break;
}
cents[id] = parsed;
}
if (!errors.shares) split = { mode: 'exact', cents };
}
}
let shares: Record<string, number> = {};
if (split && amountCents !== null && !errors.shares) {
try {
shares = resolveShares(amountCents, split);
} catch (e) {
if (e instanceof SplitError) errors[(e.field === 'amount' ? 'amount' : e.field === 'participants' ? 'participants' : 'shares') as ExpenseField] = e.message;
else throw e;
}
}
if (Object.keys(errors).length > 0 || !split || amountCents === null) return { ok: false, errors };
const body: ExpenseBody = { description, amountCents, currency: opts.currency, date: v.date, paidBy: v.paidBy, split, shares };
const trimmedNotes = v.notes.trim();
if (trimmedNotes.length > 0) body.notes = trimmedNotes;
return { ok: true, body };
}