diff --git a/package-lock.json b/package-lock.json index 8ffbc2a..fcfcbfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^3.3.2", "@prisma/client": "5.6.0", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-icons": "^1.3.0", @@ -34,6 +35,7 @@ "react-hook-form": "^7.47.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", + "ts-pattern": "^5.0.6", "uuid": "^9.0.1", "zod": "^3.22.4" }, @@ -627,6 +629,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", @@ -5749,6 +5781,11 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/ts-pattern": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.6.tgz", + "integrity": "sha512-Y+jOjihlFriWzcBjncPCf2/am+Hgz7LtsWs77pWg5vQQKLQj07oNrJryo/wK2G0ndNaoVn2ownFMeoeAuReu3Q==" + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", diff --git a/package.json b/package.json index 7fe84fc..e46173f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "^3.3.2", "@prisma/client": "5.6.0", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-icons": "^1.3.0", @@ -35,6 +36,7 @@ "react-hook-form": "^7.47.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", + "ts-pattern": "^5.0.6", "uuid": "^9.0.1", "zod": "^3.22.4" }, diff --git a/prisma/migrations/20231215203936_add_shares_in_expenses/migration.sql b/prisma/migrations/20231215203936_add_shares_in_expenses/migration.sql new file mode 100644 index 0000000..ecf7a04 --- /dev/null +++ b/prisma/migrations/20231215203936_add_shares_in_expenses/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ExpensePaidFor" ADD COLUMN "shares" INTEGER NOT NULL DEFAULT 1; diff --git a/prisma/migrations/20231215213409_add_split_mode/migration.sql b/prisma/migrations/20231215213409_add_split_mode/migration.sql new file mode 100644 index 0000000..ac36cfc --- /dev/null +++ b/prisma/migrations/20231215213409_add_split_mode/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "SplitMode" AS ENUM ('EVENLY', 'BY_SHARES', 'BY_PERCENTAGE', 'BY_AMOUNT'); + +-- AlterTable +ALTER TABLE "Expense" ADD COLUMN "splitMode" "SplitMode" NOT NULL DEFAULT 'EVENLY'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index edf68f2..fd65754 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,14 +39,23 @@ model Expense { paidFor ExpensePaidFor[] groupId String isReimbursement Boolean @default(false) + splitMode SplitMode @default(EVENLY) createdAt DateTime @default(now()) } +enum SplitMode { + EVENLY + BY_SHARES + BY_PERCENTAGE + BY_AMOUNT +} + model ExpensePaidFor { expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade) expenseId String participantId String + shares Int @default(1) @@id([expenseId, participantId]) } 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,11 +5,16 @@ 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, @@ -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>> @@ -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} - + ( - + Expense title - ( - - Paid by - - - Select the participant who paid the expense. - - - - )} - /> - ( - + Amount
{group.currency} @@ -168,44 +161,82 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { )} /> + ( + + Paid by + + + Select the participant who paid the expense. + + + + )} + /> + + + + + + + Paid for + + + + Select who the expense was paid for. + + + ( - -
- - Paid for - - - - Select who the expense was paid for. - -
+ {group.participants.map(({ id, name }) => ( { return ( - - - { - return checked - ? field.onChange([...field.value, id]) - : field.onChange( - field.value?.filter( - (value) => value !== id, + + + participant === id, + )} + onCheckedChange={(checked) => { + return checked + ? field.onChange([ + ...field.value, + { + participant: id, + shares: '1', + }, + ]) + : field.onChange( + field.value?.filter( + (value) => value.participant !== id, + ), + ) + }} + /> + + + {name} + + + {form.getValues().splitMode !== 'EVENLY' && ( + participant === id, + )}].shares`} + render={() => { + const sharesLabel = ( + + participant === id, ), - ) + })} + > + {match(form.getValues().splitMode) + .with('BY_SHARES', () => <>share(s)) + .with('BY_PERCENTAGE', () => <>%) + .with('BY_AMOUNT', () => ( + <>{group.currency} + )) + .otherwise(() => ( + <> + ))} + + ) + return ( +
+
+ {form.getValues().splitMode === + 'BY_AMOUNT' && sharesLabel} + + + 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} + /> + + {[ + 'BY_SHARES', + 'BY_PERCENTAGE', + ].includes( + form.getValues().splitMode, + ) && sharesLabel} +
+ +
+ ) }} /> -
- - {name} - -
+ )} +
) }} /> @@ -243,26 +369,80 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
)} /> -
- - Creating… : <>Saving…} - > - {isCreate ? <>Create : <>Save} - - {!isCreate && onDelete && ( - - Delete - - )} - + + + + + +
+ ( + + Split mode + + + + + Select how to split the expense. + + + )} + /> +
+
+
+
+ +
+ Creating… : <>Saving…} + > + + {isCreate ? <>Create : <>Save} + + {!isCreate && onDelete && ( + + + Delete + + )} +
) 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 - paidFors.forEach((paidFor, index) => { - if (!balances[paidFor]) - balances[paidFor] = { paid: 0, paidFor: 0, total: 0 } - const dividedAmount = divide( - expense.amount, - paidFors.length, - index === paidFors.length - 1, - ) - balances[paidFor].paidFor += dividedAmount - balances[paidFor].total -= dividedAmount + const totalPaidForShares = paidFors.reduce( + (sum, paidFor) => sum + paidFor.shares, + 0, + ) + let remaining = expense.amount + paidFors.forEach((paidFor, index) => { + 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 = 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 -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( + 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