diff options
| author | Sebastien Castiel <sebastien@castiel.me> | 2024-01-08 18:11:11 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-01-08 12:11:11 -0500 |
| commit | 0a8e56f80033ce889b38b01f2287cd6fbbe36dd6 (patch) | |
| tree | 333572d8e30d340288e6bf2388428bf465e928a4 /src/lib | |
| parent | 0fb0c42ff56c9cc66aeb8f875c6389c3d2ace1b9 (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/lib')
| -rw-r--r-- | src/lib/api.ts | 45 | ||||
| -rw-r--r-- | src/lib/balances.ts | 41 | ||||
| -rw-r--r-- | src/lib/schemas.ts | 132 |
3 files changed, 162 insertions, 56 deletions
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> |
