aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorSebastien Castiel <sebastien@castiel.me>2024-01-08 18:11:11 +0100
committerGitHub <noreply@github.com>2024-01-08 12:11:11 -0500
commit0a8e56f80033ce889b38b01f2287cd6fbbe36dd6 (patch)
tree333572d8e30d340288e6bf2388428bf465e928a4 /src
parent0fb0c42ff56c9cc66aeb8f875c6389c3d2ace1b9 (diff)
Add splitmode and shares to expenses (#11)
* Add splitmode and shares to expenses * Update balances based on shares * Change field size * Form validation * Redesign expense form * Split unevenly by amount
Diffstat (limited to 'src')
-rw-r--r--src/components/expense-form.tsx390
-rw-r--r--src/components/ui/collapsible.tsx11
-rw-r--r--src/lib/api.ts45
-rw-r--r--src/lib/balances.ts41
-rw-r--r--src/lib/schemas.ts132
5 files changed, 458 insertions, 161 deletions
diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx
index bdee9d0..af3a923 100644
--- a/src/components/expense-form.tsx
+++ b/src/components/expense-form.tsx
@@ -5,12 +5,17 @@ import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
- CardFooter,
+ CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible'
+import {
Form,
FormControl,
FormDescription,
@@ -29,9 +34,12 @@ import {
} from '@/components/ui/select'
import { getExpense, getGroup } from '@/lib/api'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
+import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod'
+import { Save, Trash2 } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
+import { match } from 'ts-pattern'
export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -50,7 +58,11 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
title: expense.title,
amount: String(expense.amount / 100) as unknown as number, // hack
paidBy: expense.paidById,
- paidFor: expense.paidFor.map(({ participantId }) => participantId),
+ paidFor: expense.paidFor.map(({ participantId, shares }) => ({
+ participant: participantId,
+ shares: String(shares / 100) as unknown as number,
+ })),
+ splitMode: expense.splitMode,
isReimbursement: expense.isReimbursement,
}
: searchParams.get('reimbursement')
@@ -60,10 +72,20 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
(Number(searchParams.get('amount')) || 0) / 100,
) as unknown as number, // hack
paidBy: searchParams.get('from') ?? undefined,
- paidFor: [searchParams.get('to') ?? undefined],
+ paidFor: [
+ searchParams.get('to')
+ ? { participant: searchParams.get('to')! }
+ : undefined,
+ ],
isReimbursement: true,
}
- : { title: '', amount: 0, paidFor: [], isReimbursement: false },
+ : {
+ title: '',
+ amount: 0,
+ paidFor: [],
+ isReimbursement: false,
+ splitMode: 'EVENLY',
+ },
})
return (
@@ -75,12 +97,12 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
{isCreate ? <>Create expense</> : <>Edit expense</>}
</CardTitle>
</CardHeader>
- <CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-6">
+ <CardContent className="grid sm:grid-cols-2 gap-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
- <FormItem className="order-1">
+ <FormItem className="">
<FormLabel>Expense title</FormLabel>
<FormControl>
<Input
@@ -99,38 +121,9 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
<FormField
control={form.control}
- name="paidBy"
- render={({ field }) => (
- <FormItem className="order-3 sm:order-2">
- <FormLabel>Paid by</FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select a participant" />
- </SelectTrigger>
- <SelectContent>
- {group.participants.map(({ id, name }) => (
- <SelectItem key={id} value={id}>
- {name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormDescription>
- Select the participant who paid the expense.
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
name="amount"
render={({ field }) => (
- <FormItem className="order-2 sm:order-3">
+ <FormItem className="sm:order-3">
<FormLabel>Amount</FormLabel>
<div className="flex items-baseline gap-2">
<span>{group.currency}</span>
@@ -170,42 +163,80 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
<FormField
control={form.control}
+ name="paidBy"
+ render={({ field }) => (
+ <FormItem className="sm:order-5">
+ <FormLabel>Paid by</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select a participant" />
+ </SelectTrigger>
+ <SelectContent>
+ {group.participants.map(({ id, name }) => (
+ <SelectItem key={id} value={id}>
+ {name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ Select the participant who paid the expense.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ <Card className="mt-4">
+ <CardHeader>
+ <CardTitle className="flex justify-between">
+ <span>Paid for</span>
+ <Button
+ variant="link"
+ type="button"
+ className="-my-2 -mx-4"
+ onClick={() => {
+ const paidFor = form.getValues().paidFor
+ const allSelected =
+ paidFor.length === group.participants.length
+ const newPaidFor = allSelected
+ ? []
+ : group.participants.map((p) => ({
+ participant: p.id,
+ shares:
+ paidFor.find((pfor) => pfor.participant === p.id)
+ ?.shares ?? ('1' as unknown as number),
+ }))
+ form.setValue('paidFor', newPaidFor, {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: true,
+ })
+ }}
+ >
+ {form.getValues().paidFor.length ===
+ group.participants.length ? (
+ <>Select none</>
+ ) : (
+ <>Select all</>
+ )}
+ </Button>
+ </CardTitle>
+ <CardDescription>
+ Select who the expense was paid for.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
name="paidFor"
render={() => (
- <FormItem className="order-5">
- <div className="mb-4">
- <FormLabel>
- Paid for
- <Button
- variant="link"
- type="button"
- className="-m-2"
- onClick={() => {
- const paidFor = form.getValues().paidFor
- const allSelected =
- paidFor.length === group.participants.length
- const newPairFor = allSelected
- ? []
- : group.participants.map((p) => p.id)
- form.setValue('paidFor', newPairFor, {
- shouldDirty: true,
- shouldTouch: true,
- shouldValidate: true,
- })
- }}
- >
- {form.getValues().paidFor.length ===
- group.participants.length ? (
- <>Select none</>
- ) : (
- <>Select all</>
- )}
- </Button>
- </FormLabel>
- <FormDescription>
- Select who the expense was paid for.
- </FormDescription>
- </div>
+ <FormItem className="sm:order-4 row-span-2 space-y-0">
{group.participants.map(({ id, name }) => (
<FormField
key={id}
@@ -213,28 +244,123 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
name="paidFor"
render={({ field }) => {
return (
- <FormItem
- key={id}
- className="flex flex-row items-start space-x-3 space-y-0"
+ <div
+ data-id={`${id}/${form.getValues().splitMode}/${
+ group.currency
+ }`}
+ className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
>
- <FormControl>
- <Checkbox
- checked={field.value?.includes(id)}
- onCheckedChange={(checked) => {
- return checked
- ? field.onChange([...field.value, id])
- : field.onChange(
- field.value?.filter(
- (value) => value !== id,
+ <FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value?.some(
+ ({ participant }) => participant === id,
+ )}
+ onCheckedChange={(checked) => {
+ return checked
+ ? field.onChange([
+ ...field.value,
+ {
+ participant: id,
+ shares: '1',
+ },
+ ])
+ : field.onChange(
+ field.value?.filter(
+ (value) => value.participant !== id,
+ ),
+ )
+ }}
+ />
+ </FormControl>
+ <FormLabel className="text-sm font-normal flex-1">
+ {name}
+ </FormLabel>
+ </FormItem>
+ {form.getValues().splitMode !== 'EVENLY' && (
+ <FormField
+ name={`paidFor[${field.value.findIndex(
+ ({ participant }) => participant === id,
+ )}].shares`}
+ render={() => {
+ const sharesLabel = (
+ <span
+ className={cn('text-sm', {
+ 'text-muted': !field.value?.some(
+ ({ participant }) =>
+ participant === id,
),
- )
+ })}
+ >
+ {match(form.getValues().splitMode)
+ .with('BY_SHARES', () => <>share(s)</>)
+ .with('BY_PERCENTAGE', () => <>%</>)
+ .with('BY_AMOUNT', () => (
+ <>{group.currency}</>
+ ))
+ .otherwise(() => (
+ <></>
+ ))}
+ </span>
+ )
+ return (
+ <div>
+ <div className="flex gap-1 items-baseline">
+ {form.getValues().splitMode ===
+ 'BY_AMOUNT' && sharesLabel}
+ <FormControl>
+ <Input
+ key={String(
+ !field.value?.some(
+ ({ participant }) =>
+ participant === id,
+ ),
+ )}
+ className="text-base w-[80px] -my-2"
+ type="number"
+ disabled={
+ !field.value?.some(
+ ({ participant }) =>
+ participant === id,
+ )
+ }
+ value={
+ field.value?.find(
+ ({ participant }) =>
+ participant === id,
+ )?.shares
+ }
+ onChange={(event) =>
+ field.onChange(
+ field.value.map((p) =>
+ p.participant === id
+ ? {
+ participant: id,
+ shares:
+ event.target.value,
+ }
+ : p,
+ ),
+ )
+ }
+ inputMode="numeric"
+ step={1}
+ />
+ </FormControl>
+ {[
+ 'BY_SHARES',
+ 'BY_PERCENTAGE',
+ ].includes(
+ form.getValues().splitMode,
+ ) && sharesLabel}
+ </div>
+ <FormMessage className="float-right" />
+ </div>
+ )
}}
/>
- </FormControl>
- <FormLabel className="text-sm font-normal">
- {name}
- </FormLabel>
- </FormItem>
+ )}
+ </div>
)
}}
/>
@@ -243,26 +369,80 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
</FormItem>
)}
/>
+
+ <Collapsible className="mt-5">
+ <CollapsibleTrigger asChild>
+ <Button variant="link" className="-mx-4">
+ Advanced splitting options…
+ </Button>
+ </CollapsibleTrigger>
+ <CollapsibleContent>
+ <div className="grid sm:grid-cols-2 gap-6 pt-3">
+ <FormField
+ control={form.control}
+ name="splitMode"
+ render={({ field }) => (
+ <FormItem className="sm:order-2">
+ <FormLabel>Split mode</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={(value) => {
+ form.setValue('splitMode', value as any, {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: true,
+ })
+ }}
+ defaultValue={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EVENLY">Evenly</SelectItem>
+ <SelectItem value="BY_SHARES">
+ Unevenly – By shares
+ </SelectItem>
+ <SelectItem value="BY_PERCENTAGE">
+ Unevenly – By percentage
+ </SelectItem>
+ <SelectItem value="BY_AMOUNT">
+ Unevenly – By amount
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormDescription>
+ Select how to split the expense.
+ </FormDescription>
+ </FormItem>
+ )}
+ />
+ </div>
+ </CollapsibleContent>
+ </Collapsible>
</CardContent>
+ </Card>
- <CardFooter className="gap-2">
- <SubmitButton
- loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
+ <div className="flex mt-4 gap-2">
+ <SubmitButton
+ loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
+ >
+ <Save className="w-4 h-4 mr-2" />
+ {isCreate ? <>Create</> : <>Save</>}
+ </SubmitButton>
+ {!isCreate && onDelete && (
+ <AsyncButton
+ type="button"
+ variant="destructive"
+ loadingContent="Deleting…"
+ action={onDelete}
>
- {isCreate ? <>Create</> : <>Save</>}
- </SubmitButton>
- {!isCreate && onDelete && (
- <AsyncButton
- type="button"
- variant="destructive"
- loadingContent="Deleting…"
- action={onDelete}
- >
- Delete
- </AsyncButton>
- )}
- </CardFooter>
- </Card>
+ <Trash2 className="w-4 h-4 mr-2" />
+ Delete
+ </AsyncButton>
+ )}
+ </div>
</form>
</Form>
)
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000..9fa4894
--- /dev/null
+++ b/src/components/ui/collapsible.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+const Collapsible = CollapsiblePrimitive.Root
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
+
+const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 98419d4..586a354 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -36,7 +36,7 @@ export async function createExpense(
for (const participant of [
expenseFormValues.paidBy,
- ...expenseFormValues.paidFor,
+ ...expenseFormValues.paidFor.map((p) => p.participant),
]) {
if (!group.participants.some((p) => p.id === participant))
throw new Error(`Invalid participant ID: ${participant}`)
@@ -50,10 +50,12 @@ export async function createExpense(
amount: expenseFormValues.amount,
title: expenseFormValues.title,
paidById: expenseFormValues.paidBy,
+ splitMode: expenseFormValues.splitMode,
paidFor: {
createMany: {
data: expenseFormValues.paidFor.map((paidFor) => ({
- participantId: paidFor,
+ participantId: paidFor.participant,
+ shares: paidFor.shares,
})),
},
},
@@ -84,12 +86,14 @@ export async function getGroupExpensesParticipants(groupId: string) {
export async function getGroups(groupIds: string[]) {
const prisma = await getPrisma()
- return (await prisma.group.findMany({
- where: { id: { in: groupIds } },
- include: { _count: { select: { participants: true } } },
- })).map(group => ({
+ return (
+ await prisma.group.findMany({
+ where: { id: { in: groupIds } },
+ include: { _count: { select: { participants: true } } },
+ })
+ ).map((group) => ({
...group,
- createdAt: group.createdAt.toISOString()
+ createdAt: group.createdAt.toISOString(),
}))
}
@@ -106,7 +110,7 @@ export async function updateExpense(
for (const participant of [
expenseFormValues.paidBy,
- ...expenseFormValues.paidFor,
+ ...expenseFormValues.paidFor.map((p) => p.participant),
]) {
if (!group.participants.some((p) => p.id === participant))
throw new Error(`Invalid participant ID: ${participant}`)
@@ -119,17 +123,34 @@ export async function updateExpense(
amount: expenseFormValues.amount,
title: expenseFormValues.title,
paidById: expenseFormValues.paidBy,
+ splitMode: expenseFormValues.splitMode,
paidFor: {
- connectOrCreate: expenseFormValues.paidFor.map((paidFor) => ({
+ create: expenseFormValues.paidFor
+ .filter(
+ (p) =>
+ !existingExpense.paidFor.some(
+ (pp) => pp.participantId === p.participant,
+ ),
+ )
+ .map((paidFor) => ({
+ participantId: paidFor.participant,
+ shares: paidFor.shares,
+ })),
+ update: expenseFormValues.paidFor.map((paidFor) => ({
where: {
- expenseId_participantId: { expenseId, participantId: paidFor },
+ expenseId_participantId: {
+ expenseId,
+ participantId: paidFor.participant,
+ },
+ },
+ data: {
+ shares: paidFor.shares,
},
- create: { participantId: paidFor },
})),
deleteMany: existingExpense.paidFor.filter(
(paidFor) =>
!expenseFormValues.paidFor.some(
- (pf) => pf === paidFor.participantId,
+ (pf) => pf.participant === paidFor.participantId,
),
),
},
diff --git a/src/lib/balances.ts b/src/lib/balances.ts
index 77f4622..0bcd915 100644
--- a/src/lib/balances.ts
+++ b/src/lib/balances.ts
@@ -1,5 +1,6 @@
import { getGroupExpenses } from '@/lib/api'
import { Participant } from '@prisma/client'
+import { match } from 'ts-pattern'
export type Balances = Record<
Participant['id'],
@@ -19,34 +20,42 @@ export function getBalances(
for (const expense of expenses) {
const paidBy = expense.paidById
- const paidFors = expense.paidFor.map((p) => p.participantId)
+ const paidFors = expense.paidFor
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
balances[paidBy].paid += expense.amount
balances[paidBy].total += expense.amount
+
+ const totalPaidForShares = paidFors.reduce(
+ (sum, paidFor) => sum + paidFor.shares,
+ 0,
+ )
+ let remaining = expense.amount
paidFors.forEach((paidFor, index) => {
- if (!balances[paidFor])
- balances[paidFor] = { paid: 0, paidFor: 0, total: 0 }
+ if (!balances[paidFor.participantId])
+ balances[paidFor.participantId] = { paid: 0, paidFor: 0, total: 0 }
+
+ const isLast = index === paidFors.length - 1
+
+ const [shares, totalShares] = match(expense.splitMode)
+ .with('EVENLY', () => [1, paidFors.length])
+ .with('BY_SHARES', () => [paidFor.shares, totalPaidForShares])
+ .with('BY_PERCENTAGE', () => [paidFor.shares, totalPaidForShares])
+ .with('BY_AMOUNT', () => [paidFor.shares, totalPaidForShares])
+ .exhaustive()
- const dividedAmount = divide(
- expense.amount,
- paidFors.length,
- index === paidFors.length - 1,
- )
- balances[paidFor].paidFor += dividedAmount
- balances[paidFor].total -= dividedAmount
+ const dividedAmount = isLast
+ ? remaining
+ : Math.floor((expense.amount * shares) / totalShares)
+ remaining -= dividedAmount
+ balances[paidFor.participantId].paidFor += dividedAmount
+ balances[paidFor.participantId].total -= dividedAmount
})
}
return balances
}
-function divide(total: number, count: number, isLast: boolean): number {
- if (!isLast) return Math.floor(total / count)
-
- return total - divide(total, count, false) * (count - 1)
-}
-
export function getSuggestedReimbursements(
balances: Balances,
): Reimbursement[] {
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index 0bebae6..9b7d428 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -1,3 +1,4 @@
+import { SplitMode } from '@prisma/client'
import * as z from 'zod'
export const groupFormSchema = z
@@ -38,36 +39,111 @@ export const groupFormSchema = z
export type GroupFormValues = z.infer<typeof groupFormSchema>
-export const expenseFormSchema = z.object({
- title: z
- .string({ required_error: 'Please enter a title.' })
- .min(2, 'Enter at least two characters.'),
- amount: z
- .union(
- [
- z.number(),
- z.string().transform((value, ctx) => {
- const valueAsNumber = Number(value)
- if (Number.isNaN(valueAsNumber))
+export const expenseFormSchema = z
+ .object({
+ title: z
+ .string({ required_error: 'Please enter a title.' })
+ .min(2, 'Enter at least two characters.'),
+ amount: z
+ .union(
+ [
+ z.number(),
+ z.string().transform((value, ctx) => {
+ const valueAsNumber = Number(value)
+ if (Number.isNaN(valueAsNumber))
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Invalid number.',
+ })
+ return Math.round(valueAsNumber * 100)
+ }),
+ ],
+ { required_error: 'You must enter an amount.' },
+ )
+ .refine((amount) => amount >= 1, 'The amount must be higher than 0.01.')
+ .refine(
+ (amount) => amount <= 10_000_000_00,
+ 'The amount must be lower than 10,000,000.',
+ ),
+ paidBy: z.string({ required_error: 'You must select a participant.' }),
+ paidFor: z
+ .array(
+ z.object({
+ participant: z.string(),
+ shares: z.union([
+ z.number(),
+ z.string().transform((value, ctx) => {
+ const valueAsNumber = Number(value)
+ if (Number.isNaN(valueAsNumber))
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Invalid number.',
+ })
+ return Math.round(valueAsNumber * 100)
+ }),
+ ]),
+ }),
+ )
+ .min(1, 'The expense must be paid for at least one participant.')
+ .superRefine((paidFor, ctx) => {
+ let sum = 0
+ for (const { shares } of paidFor) {
+ sum += shares
+ if (shares < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: 'Invalid number.',
+ message: 'All shares must be higher than 0.',
})
- return Math.round(valueAsNumber * 100)
- }),
- ],
- { required_error: 'You must enter an amount.' },
- )
- .refine((amount) => amount >= 1, 'The amount must be higher than 0.01.')
- .refine(
- (amount) => amount <= 10_000_000_00,
- 'The amount must be lower than 10,000,000.',
- ),
- paidBy: z.string({ required_error: 'You must select a participant.' }),
- paidFor: z
- .array(z.string())
- .min(1, 'The expense must be paid for at least one participant.'),
- isReimbursement: z.boolean(),
-})
+ }
+ }
+ }),
+ splitMode: z
+ .enum<SplitMode, [SplitMode, ...SplitMode[]]>(
+ Object.values(SplitMode) as any,
+ )
+ .default('EVENLY'),
+ isReimbursement: z.boolean(),
+ })
+ .superRefine((expense, ctx) => {
+ let sum = 0
+ for (const { shares } of expense.paidFor) {
+ sum +=
+ typeof shares === 'number' ? shares : Math.round(Number(shares) * 100)
+ }
+ switch (expense.splitMode) {
+ case 'EVENLY':
+ break // noop
+ case 'BY_SHARES':
+ break // noop
+ case 'BY_AMOUNT': {
+ if (sum !== expense.amount) {
+ const detail =
+ sum < expense.amount
+ ? `${((expense.amount - sum) / 100).toFixed(2)} missing`
+ : `${((sum - expense.amount) / 100).toFixed(2)} surplus`
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Sum of amounts must equal the expense amount (${detail}).`,
+ path: ['paidFor'],
+ })
+ }
+ break
+ }
+ case 'BY_PERCENTAGE': {
+ if (sum !== 10000) {
+ const detail =
+ sum < 10000
+ ? `${((10000 - sum) / 100).toFixed(0)}% missing`
+ : `${((sum - 10000) / 100).toFixed(0)}% surplus`
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Sum of percentages must equal 100 (${detail})`,
+ path: ['paidFor'],
+ })
+ }
+ break
+ }
+ }
+ })
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>