Spaces:
Sleeping
Sleeping
assafvayner HF Staff
fix(photos,forms): own-property content types, dimension bounds, VP8L signature, trimmed inputs
fa363c5 | 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 }; | |
| } | |