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 = {}; const exact: Record = {}; 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 = {}; const exact: Record = {}; 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 { 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 = {}; 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 = {}; 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 = {}; 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 }; }