File size: 5,231 Bytes
0545b15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa363c5
 
0545b15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa363c5
0545b15
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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 };
}