From b4a953fcdf5625783389d62702502e28a76e98f4 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Tue, 3 Dec 2024 21:46:32 +1100 Subject: [PATCH] add edit expense (#150) * add edit expense * add edit expense changes * fix build * fix edit not working on exact match * set share to zero * fix deleting user on edit not working * fix auto load to expense page * make split share nice --- .../migration.sql | 5 + prisma/schema.prisma | 3 + src/components/AddExpense/AddExpensePage.tsx | 40 +- .../AddExpense/SplitTypeSection.tsx | 5 +- src/components/AddExpense/UserInput.tsx | 17 +- src/components/Expense/ExpensePage.tsx | 7 + src/components/Friend/Settleup.tsx | 2 +- src/pages/add.tsx | 74 +++- .../[friendId]/expenses/[expenseId].tsx | 20 +- src/pages/expenses/[expenseId].tsx | 18 +- .../groups/[groupId]/expenses/[expenseId].tsx | 15 +- src/server/api/routers/group.ts | 64 ++- src/server/api/routers/user.ts | 64 ++- .../api/services/notificationService.ts | 90 ++++ src/server/api/services/splitService.ts | 392 +++++++++++++----- src/store/addStore.ts | 120 +++++- 16 files changed, 747 insertions(+), 189 deletions(-) create mode 100644 prisma/migrations/20241116203000_add_updated_by_for_expense/migration.sql create mode 100644 src/server/api/services/notificationService.ts diff --git a/prisma/migrations/20241116203000_add_updated_by_for_expense/migration.sql b/prisma/migrations/20241116203000_add_updated_by_for_expense/migration.sql new file mode 100644 index 0000000..c671e90 --- /dev/null +++ b/prisma/migrations/20241116203000_add_updated_by_for_expense/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Expense" ADD COLUMN "updatedBy" INTEGER; + +-- AddForeignKey +ALTER TABLE "Expense" ADD CONSTRAINT "Expense_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 66dc2f3..aa8912f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,6 +62,7 @@ model User { paidExpenses Expense[] @relation("PaidByUser") addedExpenses Expense[] @relation("AddedByUser") deletedExpenses Expense[] @relation("DeletedByUser") + updatedExpenses Expense[] @relation("UpdatedByUser") } model VerificationToken { @@ -149,10 +150,12 @@ model Expense { groupId Int? deletedAt DateTime? deletedBy Int? + updatedBy Int? group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade) addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade) deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade) + updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull) expenseParticipants ExpenseParticipant[] expenseNotes ExpenseNote[] diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 4c00463..469156d 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -106,13 +106,13 @@ const categories = { }, }; -export const AddExpensePage: React.FC<{ +export const AddOrEditExpensePage: React.FC<{ isStorageConfigured: boolean; enableSendingInvites: boolean; -}> = ({ isStorageConfigured, enableSendingInvites }) => { + expenseId?: string; +}> = ({ isStorageConfigured, enableSendingInvites, expenseId }) => { const [date, setDate] = React.useState(new Date()); const [open, setOpen] = React.useState(false); - const [amtStr, setAmountStr] = React.useState(''); const showFriends = useAddExpenseStore((s) => s.showFriends); const amount = useAddExpenseStore((s) => s.amount); @@ -122,13 +122,20 @@ export const AddExpensePage: React.FC<{ const category = useAddExpenseStore((s) => s.category); const description = useAddExpenseStore((s) => s.description); const isFileUploading = useAddExpenseStore((s) => s.isFileUploading); + const amtStr = useAddExpenseStore((s) => s.amountStr); - const { setCurrency, setCategory, setDescription, setAmount, resetState } = useAddExpenseStore( - (s) => s.actions, - ); + const { + setCurrency, + setCategory, + setDescription, + setAmount, + setAmountStr, + resetState, + setSplitScreenOpen, + } = useAddExpenseStore((s) => s.actions); - const addExpenseMutation = api.user.addExpense.useMutation(); - const addGroupExpenseMutation = api.group.addExpense.useMutation(); + const addExpenseMutation = api.user.addOrEditExpense.useMutation(); + const addGroupExpenseMutation = api.group.addOrEditExpense.useMutation(); const updateProfile = api.user.updateUserDetail.useMutation(); const router = useRouter(); @@ -140,11 +147,17 @@ export const AddExpensePage: React.FC<{ } function addExpense() { - const { group, paidBy, splitType, fileKey } = useAddExpenseStore.getState(); + const { group, paidBy, splitType, fileKey, canSplitScreenClosed } = + useAddExpenseStore.getState(); if (!paidBy) { return; } + if (!canSplitScreenClosed) { + setSplitScreenOpen(true); + return; + } + if (group) { addGroupExpenseMutation.mutate( { @@ -161,12 +174,13 @@ export const AddExpensePage: React.FC<{ category, fileKey, expenseDate: date, + expenseId, }, { onSuccess: (d) => { if (d) { router - .push(`/groups/${group.id}/expenses/${d?.id}`) + .push(`/groups/${group.id}/expenses/${d?.id ?? expenseId}`) .then(() => resetState()) .catch(console.error); } @@ -176,6 +190,7 @@ export const AddExpensePage: React.FC<{ } else { addExpenseMutation.mutate( { + expenseId, name: description, currency, amount, @@ -191,10 +206,9 @@ export const AddExpensePage: React.FC<{ }, { onSuccess: (d) => { - resetState(); if (participants[1] && d) { router - .push(`/balances/${participants[1]?.id}/expenses/${d?.id}`) + .push(`expenses/${d?.id ?? expenseId}`) .then(() => resetState()) .catch(console.error); } @@ -237,7 +251,7 @@ export const AddExpensePage: React.FC<{ Save {' '} - + {showFriends || (participants.length === 1 && !group) ? ( ) : ( diff --git a/src/components/AddExpense/SplitTypeSection.tsx b/src/components/AddExpense/SplitTypeSection.tsx index 8478d4b..daec515 100644 --- a/src/components/AddExpense/SplitTypeSection.tsx +++ b/src/components/AddExpense/SplitTypeSection.tsx @@ -13,8 +13,9 @@ export const SplitTypeSection: React.FC = () => { const currentUser = useAddExpenseStore((s) => s.currentUser); const canSplitScreenClosed = useAddExpenseStore((s) => s.canSplitScreenClosed); const splitType = useAddExpenseStore((s) => s.splitType); + const splitScreenOpen = useAddExpenseStore((s) => s.splitScreenOpen); - const { setPaidBy } = useAddExpenseStore((s) => s.actions); + const { setPaidBy, setSplitScreenOpen } = useAddExpenseStore((s) => s.actions); return (
@@ -63,6 +64,8 @@ export const SplitTypeSection: React.FC = () => { dismissible={false} actionTitle="Save" actionDisabled={!canSplitScreenClosed} + open={splitScreenOpen} + onOpenChange={(open) => setSplitScreenOpen(open)} > diff --git a/src/components/AddExpense/UserInput.tsx b/src/components/AddExpense/UserInput.tsx index 7f990fb..afd4548 100644 --- a/src/components/AddExpense/UserInput.tsx +++ b/src/components/AddExpense/UserInput.tsx @@ -4,7 +4,9 @@ import { z } from 'zod'; import { api } from '~/utils/api'; import Router from 'next/router'; -export const UserInput: React.FC = () => { +export const UserInput: React.FC<{ + isEditing?: boolean; +}> = ({ isEditing }) => { const { setNameOrEmail, removeLastParticipant, @@ -85,17 +87,20 @@ export const UserInput: React.FC = () => { 1 - ? 'Add more friends' - : 'Search friends, groups or add email' + isEditing && !!group + ? 'Cannot change group while editing' + : group + ? 'Press delete to remove group' + : participants.length > 1 + ? 'Add more friends' + : 'Search friends, groups or add email' } value={nameOrEmail} onChange={(e) => setNameOrEmail(e.target.value)} onKeyDown={handleKeyDown} className="min-w-[100px] flex-grow bg-transparent outline-none placeholder:text-sm focus:ring-0" autoFocus + disabled={isEditing && !!group} />
); diff --git a/src/components/Expense/ExpensePage.tsx b/src/components/Expense/ExpensePage.tsx index a4d84f1..ccc8f7a 100644 --- a/src/components/Expense/ExpensePage.tsx +++ b/src/components/Expense/ExpensePage.tsx @@ -18,6 +18,7 @@ type ExpenseDetailsProps = { addedByUser: User; paidByUser: User; deletedByUser: User | null; + updatedByUser: User | null; }; storagePublicUrl?: string; }; @@ -42,6 +43,12 @@ const ExpenseDetails: React.FC = ({ user, expense, storageP {!isSameDay(expense.expenseDate, expense.createdAt) ? (

{format(expense.expenseDate, 'dd MMM yyyy')}

) : null} + {expense.updatedByUser ? ( +

+ Edited by {expense.updatedByUser?.name ?? expense.updatedByUser?.email} on{' '} + {format(expense.updatedAt, 'dd MMM yyyy')} +

+ ) : null} {expense.deletedByUser ? (

Deleted by {expense.deletedByUser.name ?? expense.addedByUser.email} on{' '} diff --git a/src/components/Friend/Settleup.tsx b/src/components/Friend/Settleup.tsx index b37c74f..40023ff 100644 --- a/src/components/Friend/Settleup.tsx +++ b/src/components/Friend/Settleup.tsx @@ -30,7 +30,7 @@ export const SettleUp: React.FC<{ setAmount(toFixedNumber(Math.abs(balance.amount)).toString()); } - const addExpenseMutation = api.user.addExpense.useMutation(); + const addExpenseMutation = api.user.addOrEditExpense.useMutation(); const utils = api.useUtils(); function saveExpense() { diff --git a/src/pages/add.tsx b/src/pages/add.tsx index 1322118..da3bed8 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -1,13 +1,14 @@ import Head from 'next/head'; import { useRouter } from 'next/router'; import React, { useEffect } from 'react'; -import { AddExpensePage } from '~/components/AddExpense/AddExpensePage'; +import { AddOrEditExpensePage } from '~/components/AddExpense/AddExpensePage'; import MainLayout from '~/components/Layout/MainLayout'; import { env } from '~/env'; import { isStorageConfigured } from '~/server/storage'; -import { useAddExpenseStore } from '~/store/addStore'; +import { calculateSplitShareBasedOnAmount, useAddExpenseStore } from '~/store/addStore'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; +import { toFixedNumber, toInteger } from '~/utils/numbers'; // 🧾 @@ -15,9 +16,17 @@ const AddPage: NextPageWithUser<{ isStorageConfigured: boolean; enableSendingInvites: boolean; }> = ({ user, isStorageConfigured, enableSendingInvites }) => { - const { setCurrentUser, setGroup, setParticipants, setCurrency } = useAddExpenseStore( - (s) => s.actions, - ); + const { + setCurrentUser, + setGroup, + setParticipants, + setCurrency, + setAmount, + setDescription, + setPaidBy, + setAmountStr, + setSplitType, + } = useAddExpenseStore((s) => s.actions); const currentUser = useAddExpenseStore((s) => s.currentUser); useEffect(() => { @@ -32,11 +41,11 @@ const AddPage: NextPageWithUser<{ }, []); const router = useRouter(); - const { friendId, groupId } = router.query; + const { friendId, groupId, expenseId } = router.query; const _groupId = parseInt(groupId as string); const _friendId = parseInt(friendId as string); - + const _expenseId = expenseId as string; const groupQuery = api.group.getGroupDetails.useQuery( { groupId: _groupId }, { enabled: !!_groupId }, @@ -47,6 +56,11 @@ const AddPage: NextPageWithUser<{ { enabled: !!_friendId }, ); + const expenseQuery = api.user.getExpenseDetails.useQuery( + { expenseId: _expenseId }, + { enabled: !!_expenseId, refetchOnWindowFocus: false }, + ); + useEffect(() => { // Set group if (groupId && !groupQuery.isLoading && groupQuery.data && currentUser) { @@ -69,16 +83,56 @@ const AddPage: NextPageWithUser<{ // eslint-disable-next-line react-hooks/exhaustive-deps }, [friendId, friendQuery.isLoading, friendQuery.data, currentUser]); + useEffect(() => { + if (_expenseId && expenseQuery.data) { + console.log( + 'expenseQuery.data 123', + expenseQuery.data.expenseParticipants, + expenseQuery.data.splitType, + calculateSplitShareBasedOnAmount( + toFixedNumber(expenseQuery.data.amount), + expenseQuery.data.expenseParticipants.map((ep) => ({ + ...ep.user, + amount: toFixedNumber(ep.amount), + })), + expenseQuery.data.splitType, + expenseQuery.data.paidByUser, + ), + ); + expenseQuery.data.group && setGroup(expenseQuery.data.group); + setParticipants( + calculateSplitShareBasedOnAmount( + toFixedNumber(expenseQuery.data.amount), + expenseQuery.data.expenseParticipants.map((ep) => ({ + ...ep.user, + amount: toFixedNumber(ep.amount), + })), + expenseQuery.data.splitType, + expenseQuery.data.paidByUser, + ), + ); + setCurrency(expenseQuery.data.currency); + setAmountStr(toFixedNumber(expenseQuery.data.amount).toString()); + setDescription(expenseQuery.data.name); + setPaidBy(expenseQuery.data.paidByUser); + setAmount(toFixedNumber(expenseQuery.data.amount)); + setSplitType(expenseQuery.data.splitType); + useAddExpenseStore.setState({ showFriends: false }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [_expenseId, expenseQuery.data]); + return ( <> Add Expense - {currentUser ? ( - ) : (

@@ -93,8 +147,6 @@ AddPage.auth = true; export default AddPage; export async function getServerSideProps() { - console.log('isStorageConfigured', isStorageConfigured()); - return { props: { isStorageConfigured: !!isStorageConfigured(), diff --git a/src/pages/balances/[friendId]/expenses/[expenseId].tsx b/src/pages/balances/[friendId]/expenses/[expenseId].tsx index 6ce1939..1bc9184 100644 --- a/src/pages/balances/[friendId]/expenses/[expenseId].tsx +++ b/src/pages/balances/[friendId]/expenses/[expenseId].tsx @@ -3,11 +3,12 @@ import MainLayout from '~/components/Layout/MainLayout'; import { api } from '~/utils/api'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { ChevronLeftIcon, Trash, Trash2 } from 'lucide-react'; +import { ChevronLeftIcon, PencilIcon, Trash, Trash2 } from 'lucide-react'; import ExpenseDetails from '~/components/Expense/ExpensePage'; import { DeleteExpense } from '~/components/Expense/DeleteExpense'; import { type NextPageWithUser } from '~/types'; import { env } from '~/env'; +import { Button } from '~/components/ui/button'; const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ user, @@ -35,11 +36,18 @@ const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ } actions={ - +
+ + + + +
} > {expenseQuery.data ? ( diff --git a/src/pages/expenses/[expenseId].tsx b/src/pages/expenses/[expenseId].tsx index dd57597..826a206 100644 --- a/src/pages/expenses/[expenseId].tsx +++ b/src/pages/expenses/[expenseId].tsx @@ -5,11 +5,12 @@ import { type User } from '@prisma/client'; import { api } from '~/utils/api'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { ChevronLeftIcon } from 'lucide-react'; +import { ChevronLeftIcon, PencilIcon } from 'lucide-react'; import ExpenseDetails from '~/components/Expense/ExpensePage'; import { DeleteExpense } from '~/components/Expense/DeleteExpense'; import { type NextPageWithUser } from '~/types'; import { env } from 'process'; +import { Button } from '~/components/ui/button'; const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ user, @@ -35,7 +36,20 @@ const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({

Expense details

} - actions={!expenseQuery.data?.deletedBy ? : null} + actions={ +
+ {!expenseQuery.data?.deletedBy ? ( +
+ + + + +
+ ) : null} +
+ } > {expenseQuery.data ? ( = ({ user, @@ -38,7 +39,17 @@ const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ } actions={ - +
+ + + + +
} > {expenseQuery.data ? ( diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts index f7c06d1..228ce62 100644 --- a/src/server/api/routers/group.ts +++ b/src/server/api/routers/group.ts @@ -2,7 +2,7 @@ import { SplitType } from '@prisma/client'; import { z } from 'zod'; import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; -import { createGroupExpense, deleteExpense } from '../services/splitService'; +import { createGroupExpense, deleteExpense, editExpense } from '../services/splitService'; import { TRPCError } from '@trpc/server'; import { nanoid } from 'nanoid'; @@ -116,7 +116,7 @@ export const groupRouter = createTRPCRouter({ return group; }), - addExpense: groupProcedure + addOrEditExpense: groupProcedure .input( z.object({ paidBy: z.number(), @@ -135,23 +135,56 @@ export const groupRouter = createTRPCRouter({ participants: z.array(z.object({ userId: z.number(), amount: z.number() })), fileKey: z.string().optional(), expenseDate: z.date().optional(), + expenseId: z.string().optional(), }), ) .mutation(async ({ input, ctx }) => { + if (input.expenseId) { + const expenseParticipant = await db.expenseParticipant.findUnique({ + where: { + expenseId_userId: { + expenseId: input.expenseId, + userId: ctx.session.user.id, + }, + }, + }); + + if (!expenseParticipant) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You are not the participant of the expense', + }); + } + } + try { - const expense = await createGroupExpense( - input.groupId, - input.paidBy, - input.name, - input.category, - input.amount, - input.splitType, - input.currency, - input.participants, - ctx.session.user.id, - input.expenseDate ?? new Date(), - input.fileKey, - ); + const expense = input.expenseId + ? await editExpense( + input.expenseId, + input.paidBy, + input.name, + input.category, + input.amount, + input.splitType, + input.currency, + input.participants, + ctx.session.user.id, + input.expenseDate ?? new Date(), + input.fileKey, + ) + : await createGroupExpense( + input.groupId, + input.paidBy, + input.name, + input.category, + input.amount, + input.splitType, + input.currency, + input.participants, + ctx.session.user.id, + input.expenseDate ?? new Date(), + input.fileKey, + ); return expense; } catch (error) { @@ -198,6 +231,7 @@ export const groupRouter = createTRPCRouter({ addedByUser: true, paidByUser: true, deletedByUser: true, + updatedByUser: true, }, }); diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 01f24cb..bffdd09 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -5,6 +5,7 @@ import { db } from '~/server/db'; import { addUserExpense, deleteExpense, + editExpense, getCompleteFriendsDetails, getCompleteGroupDetails, importGroupFromSplitwise, @@ -139,7 +140,7 @@ export const userRouter = createTRPCRouter({ return user; }), - addExpense: protectedProcedure + addOrEditExpense: protectedProcedure .input( z.object({ paidBy: z.number(), @@ -158,22 +159,57 @@ export const userRouter = createTRPCRouter({ participants: z.array(z.object({ userId: z.number(), amount: z.number() })), fileKey: z.string().optional(), expenseDate: z.date().optional(), + expenseId: z.string().optional(), }), ) .mutation(async ({ input, ctx }) => { + if (input.expenseId) { + const expenseParticipant = await db.expenseParticipant.findUnique({ + where: { + expenseId_userId: { + expenseId: input.expenseId, + userId: ctx.session.user.id, + }, + }, + }); + + console.log('expenseParticipant', expenseParticipant); + + if (!expenseParticipant) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You are not the participant of the expense', + }); + } + } + try { - const expense = await addUserExpense( - input.paidBy, - input.name, - input.category, - input.amount, - input.splitType, - input.currency, - input.participants, - ctx.session.user.id, - input.expenseDate ?? new Date(), - input.fileKey, - ); + const expense = input.expenseId + ? await editExpense( + input.expenseId, + input.paidBy, + input.name, + input.category, + input.amount, + input.splitType, + input.currency, + input.participants, + ctx.session.user.id, + input.expenseDate ?? new Date(), + input.fileKey, + ) + : await addUserExpense( + input.paidBy, + input.name, + input.category, + input.amount, + input.splitType, + input.currency, + input.participants, + ctx.session.user.id, + input.expenseDate ?? new Date(), + input.fileKey, + ); return expense; } catch (error) { @@ -266,6 +302,8 @@ export const userRouter = createTRPCRouter({ addedByUser: true, paidByUser: true, deletedByUser: true, + updatedByUser: true, + group: true, }, }); diff --git a/src/server/api/services/notificationService.ts b/src/server/api/services/notificationService.ts new file mode 100644 index 0000000..c67f086 --- /dev/null +++ b/src/server/api/services/notificationService.ts @@ -0,0 +1,90 @@ +import { SplitType } from '@prisma/client'; +import { db } from '~/server/db'; +import { pushNotification } from '~/server/notification'; +import { toFixedNumber } from '~/utils/numbers'; + +export async function sendExpensePushNotification(expenseId: string) { + const expense = await db.expense.findUnique({ + where: { + id: expenseId, + }, + select: { + paidBy: true, + amount: true, + currency: true, + addedBy: true, + name: true, + deletedBy: true, + splitType: true, + deletedByUser: { + select: { + name: true, + email: true, + }, + }, + expenseParticipants: { + select: { + userId: true, + }, + }, + paidByUser: { + select: { + name: true, + email: true, + }, + }, + addedByUser: { + select: { + name: true, + email: true, + }, + }, + updatedByUser: { + select: { + name: true, + email: true, + }, + }, + }, + }); + + if (!expense) { + return; + } + + const participants = expense.deletedBy + ? expense.expenseParticipants.map((p) => p.userId).filter((e) => e !== expense.deletedBy) + : expense.expenseParticipants.map((p) => p.userId).filter((e) => e !== expense.addedBy); + + const subscriptions = await db.pushNotification.findMany({ + where: { + userId: { + in: participants, + }, + }, + }); + + const pushData = expense.deletedBy + ? { + title: `${expense.deletedByUser?.name ?? expense.deletedByUser?.email}`, + message: `Deleted ${expense.name}`, + } + : expense.updatedByUser + ? { + title: `${expense.updatedByUser.name ?? expense.updatedByUser.email}`, + message: `Updated ${expense.name} ${expense.currency} ${toFixedNumber(expense.amount)}`, + } + : expense.splitType === SplitType.SETTLEMENT + ? { + title: `${expense.addedByUser.name ?? expense.addedByUser.email}`, + message: `${expense.paidByUser.name ?? expense.paidByUser.email} settled up ${expense.currency} ${toFixedNumber(expense.amount)}`, + } + : { + title: `${expense.addedByUser.name ?? expense.addedByUser.email}`, + message: `${expense.paidByUser.name ?? expense.paidByUser.email} paid ${expense.currency} ${toFixedNumber(expense.amount)} for ${expense.name}`, + }; + + const pushNotifications = subscriptions.map((s) => pushNotification(s.subscription, pushData)); + + await Promise.all(pushNotifications); +} diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index ff12417..66fbd21 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -1,10 +1,9 @@ -import { SplitType, type User } from '@prisma/client'; -import exp from 'constants'; +import { type Expense, type SplitType, type User } from '@prisma/client'; import { nanoid } from 'nanoid'; import { db } from '~/server/db'; -import { pushNotification } from '~/server/notification'; import { type SplitwiseGroup, type SplitwiseUser } from '~/types'; import { toFixedNumber, toInteger } from '~/utils/numbers'; +import { sendExpensePushNotification } from './notificationService'; export async function joinGroup(userId: number, publicGroupId: string) { const group = await db.group.findUnique({ @@ -295,54 +294,6 @@ export async function addUserExpense( return result[0]; } -async function updateGroupExpenseForIfBalanceIsZero( - userId: number, - friendIds: Array, - currency: string, -) { - console.log('Checking for users with 0 balance to reflect in group'); - const balances = await db.balance.findMany({ - where: { - userId, - currency, - friendId: { - in: friendIds, - }, - amount: 0, - }, - }); - - console.log('Total balances needs to be updated:', balances.length); - - if (balances.length) { - await db.groupBalance.updateMany({ - where: { - userId, - firendId: { - in: friendIds, - }, - currency, - }, - data: { - amount: 0, - }, - }); - - await db.groupBalance.updateMany({ - where: { - userId: { - in: friendIds, - }, - firendId: userId, - currency, - }, - data: { - amount: 0, - }, - }); - } -} - export async function deleteExpense(expenseId: string, deletedBy: number) { const expense = await db.expense.findUnique({ where: { @@ -479,79 +430,307 @@ export async function deleteExpense(expenseId: string, deletedBy: number) { sendExpensePushNotification(expenseId).catch(console.error); } -export async function sendExpensePushNotification(expenseId: string) { +export async function editExpense( + expenseId: string, + paidBy: number, + name: string, + category: string, + amount: number, + splitType: SplitType, + currency: string, + participants: { userId: number; amount: number }[], + currentUserId: number, + expenseDate: Date, + fileKey?: string, +) { const expense = await db.expense.findUnique({ - where: { - id: expenseId, + where: { id: expenseId }, + include: { + expenseParticipants: true, }, - select: { - paidBy: true, - amount: true, - currency: true, - addedBy: true, - name: true, - deletedBy: true, - splitType: true, - deletedByUser: { - select: { - name: true, - email: true, + }); + + if (!expense) { + throw new Error('Expense not found'); + } + + const operations = []; + + // First reverse all existing balances + for (const participant of expense.expenseParticipants) { + if (participant.userId === expense.paidBy) { + continue; + } + + operations.push( + db.balance.update({ + where: { + userId_currency_friendId: { + userId: expense.paidBy, + currency: expense.currency, + friendId: participant.userId, + }, }, - }, - expenseParticipants: { - select: { - userId: true, + data: { + amount: { + increment: participant.amount, + }, }, - }, - paidByUser: { - select: { - name: true, - email: true, + }), + ); + + operations.push( + db.balance.update({ + where: { + userId_currency_friendId: { + userId: participant.userId, + currency: expense.currency, + friendId: expense.paidBy, + }, }, + data: { + amount: { + decrement: participant.amount, + }, + }, + }), + ); + + // Reverse group balances if it's a group expense + if (expense.groupId) { + operations.push( + db.groupBalance.update({ + where: { + groupId_currency_firendId_userId: { + groupId: expense.groupId, + currency: expense.currency, + userId: expense.paidBy, + firendId: participant.userId, + }, + }, + data: { + amount: { + increment: participant.amount, + }, + }, + }), + ); + + operations.push( + db.groupBalance.update({ + where: { + groupId_currency_firendId_userId: { + groupId: expense.groupId, + currency: expense.currency, + userId: participant.userId, + firendId: expense.paidBy, + }, + }, + data: { + amount: { + decrement: participant.amount, + }, + }, + }), + ); + } + } + + // Delete existing participants + operations.push( + db.expenseParticipant.deleteMany({ + where: { + expenseId, }, - addedByUser: { - select: { - name: true, - email: true, + }), + ); + + // Update expense with new details and create new participants + operations.push( + db.expense.update({ + where: { id: expenseId }, + data: { + paidBy, + name, + category, + amount: toInteger(amount), + splitType, + currency, + expenseParticipants: { + create: participants.map((participant) => ({ + userId: participant.userId, + amount: toInteger(participant.amount), + })), }, + fileKey, + expenseDate, + updatedBy: currentUserId, }, - }, - }); + }), + ); - if (!expense) { - return; - } + // Add new balances + participants.forEach((participant) => { + if (participant.userId === paidBy) { + return; + } - const participants = expense.deletedBy - ? expense.expenseParticipants.map((p) => p.userId).filter((e) => e !== expense.deletedBy) - : expense.expenseParticipants.map((p) => p.userId).filter((e) => e !== expense.addedBy); + operations.push( + db.balance.upsert({ + where: { + userId_currency_friendId: { + userId: paidBy, + currency, + friendId: participant.userId, + }, + }, + create: { + userId: paidBy, + currency, + friendId: participant.userId, + amount: -toInteger(participant.amount), + }, + update: { + amount: { + increment: -toInteger(participant.amount), + }, + }, + }), + ); - const subscriptions = await db.pushNotification.findMany({ + operations.push( + db.balance.upsert({ + where: { + userId_currency_friendId: { + userId: participant.userId, + currency, + friendId: paidBy, + }, + }, + create: { + userId: participant.userId, + currency, + friendId: paidBy, + amount: toInteger(participant.amount), + }, + update: { + amount: { + increment: toInteger(participant.amount), + }, + }, + }), + ); + + // Add new group balances if it's a group expense + if (expense.groupId) { + operations.push( + db.groupBalance.upsert({ + where: { + groupId_currency_firendId_userId: { + groupId: expense.groupId, + currency, + userId: paidBy, + firendId: participant.userId, + }, + }, + create: { + amount: -toInteger(participant.amount), + groupId: expense.groupId, + currency, + userId: paidBy, + firendId: participant.userId, + }, + update: { + amount: { + increment: -toInteger(participant.amount), + }, + }, + }), + ); + + operations.push( + db.groupBalance.upsert({ + where: { + groupId_currency_firendId_userId: { + groupId: expense.groupId, + currency, + userId: participant.userId, + firendId: paidBy, + }, + }, + create: { + amount: toInteger(participant.amount), + groupId: expense.groupId, + currency, + userId: participant.userId, + firendId: paidBy, + }, + update: { + amount: { + increment: toInteger(participant.amount), + }, + }, + }), + ); + } + }); + + await db.$transaction(operations); + await updateGroupExpenseForIfBalanceIsZero( + paidBy, + participants.map((p) => p.userId), + currency, + ); + sendExpensePushNotification(expenseId).catch(console.error); + return { id: expenseId }; // Return the updated expense +} + +async function updateGroupExpenseForIfBalanceIsZero( + userId: number, + friendIds: Array, + currency: string, +) { + console.log('Checking for users with 0 balance to reflect in group'); + const balances = await db.balance.findMany({ where: { - userId: { - in: participants, + userId, + currency, + friendId: { + in: friendIds, }, + amount: 0, }, }); - const pushData = expense.deletedBy - ? { - title: `${expense.deletedByUser?.name ?? expense.deletedByUser?.email}`, - message: `Deleted ${expense.name}`, - } - : expense.splitType === SplitType.SETTLEMENT - ? { - title: `${expense.addedByUser.name ?? expense.addedByUser.email}`, - message: `${expense.paidByUser.name ?? expense.paidByUser.email} settled up ${expense.currency} ${toFixedNumber(expense.amount)}`, - } - : { - title: `${expense.addedByUser.name ?? expense.addedByUser.email}`, - message: `${expense.paidByUser.name ?? expense.paidByUser.email} paid ${expense.currency} ${toFixedNumber(expense.amount)} for ${expense.name}`, - }; + console.log('Total balances needs to be updated:', balances.length); - const pushNotifications = subscriptions.map((s) => pushNotification(s.subscription, pushData)); + if (balances.length) { + await db.groupBalance.updateMany({ + where: { + userId, + firendId: { + in: friendIds, + }, + currency, + }, + data: { + amount: 0, + }, + }); - await Promise.all(pushNotifications); + await db.groupBalance.updateMany({ + where: { + userId: { + in: friendIds, + }, + firendId: userId, + currency, + }, + data: { + amount: 0, + }, + }); + } } export async function getCompleteFriendsDetails(userId: number) { @@ -763,7 +942,6 @@ export async function importGroupFromSplitwise( splitwiseUserMap[member.id.toString()] = member; } } - console.log('splitwiseUserMap', splitwiseUserMap); const users = await createUsersFromSplitwise(Object.values(splitwiseUserMap)); diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 1cb2e72..693ed7a 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -6,6 +6,7 @@ export type Participant = User & { amount?: number; splitShare?: number }; interface AddExpenseState { amount: number; + amountStr: string; currentUser: User | undefined; splitType: SplitType; group: Group | undefined; @@ -19,8 +20,10 @@ interface AddExpenseState { isFileUploading: boolean; fileKey?: string; canSplitScreenClosed: boolean; + splitScreenOpen: boolean; actions: { setAmount: (amount: number) => void; + setAmountStr: (amountStr: string) => void; setSplitType: (splitType: SplitType) => void; setGroup: (group: Group | undefined) => void; addOrUpdateParticipant: (user: Participant) => void; @@ -36,11 +39,13 @@ interface AddExpenseState { setFileUploading: (isFileUploading: boolean) => void; setFileKey: (fileKey: string) => void; resetState: () => void; + setSplitScreenOpen: (splitScreenOpen: boolean) => void; }; } export const useAddExpenseStore = create()((set) => ({ amount: 0, + amountStr: '', splitType: SplitType.EQUAL, group: undefined, participants: [], @@ -54,6 +59,7 @@ export const useAddExpenseStore = create()((set) => ({ isFileUploading: false, fileKey: undefined, canSplitScreenClosed: true, + splitScreenOpen: false, actions: { setAmount: (amount) => set((s) => { @@ -66,6 +72,7 @@ export const useAddExpenseStore = create()((set) => ({ return { amount, participants, canSplitScreenClosed }; }), + setAmountStr: (amountStr) => set({ amountStr }), setSplitType: (splitType) => set((state) => { return { @@ -83,7 +90,7 @@ export const useAddExpenseStore = create()((set) => ({ if (userIndex !== -1) { participants[userIndex] = user; } else { - participants.push({ ...user, splitShare: 1 }); + participants.push({ ...user, splitShare: state.splitType === SplitType.EQUAL ? 1 : 0 }); } return { ...calculateParticipantSplit(state.amount, participants, state.splitType, state.paidBy), @@ -91,7 +98,10 @@ export const useAddExpenseStore = create()((set) => ({ }), setParticipants: (_participants) => set((state) => { - const participants = _participants.map((p) => ({ ...p, splitShare: 1 })); + const participants = _participants.map((p) => ({ + splitShare: state.splitType === SplitType.EQUAL ? 1 : 0, + ...p, + })); return { splitType: SplitType.EQUAL, ...calculateParticipantSplit(state.amount, participants, SplitType.EQUAL, state.paidBy), @@ -158,7 +168,7 @@ export const useAddExpenseStore = create()((set) => ({ setDescription: (description) => set({ description }), setFileUploading: (isFileUploading) => set({ isFileUploading }), setFileKey: (fileKey) => set({ fileKey }), - resetState: () => + resetState: () => { set((s) => ({ amount: 0, participants: s.currentUser ? [s.currentUser] : [], @@ -167,7 +177,10 @@ export const useAddExpenseStore = create()((set) => ({ category: 'general', splitType: SplitType.EQUAL, group: undefined, - })), + amountStr: '', + })); + }, + setSplitScreenOpen: (splitScreenOpen) => set({ splitScreenOpen }), }, })); @@ -207,14 +220,14 @@ export function calculateParticipantSplit( amount: ((p.splitShare ?? 0) * amount) / totalShare, })); break; - case SplitType.EXACT: - const totalSplitShare = participants.reduce((acc, p) => acc + (p.splitShare ?? 0), 0); - - const epsilon = 0.01; - canSplitScreenClosed = Math.abs(amount - totalSplitShare) < epsilon; - - updatedParticipants = participants.map((p) => ({ ...p, amount: p.splitShare ?? 0 })); - break; + case SplitType.EXACT: + const totalSplitShare = participants.reduce((acc, p) => acc + (p.splitShare ?? 0), 0); + + const epsilon = 0.01; + canSplitScreenClosed = Math.abs(amount - totalSplitShare) < epsilon; + + updatedParticipants = participants.map((p) => ({ ...p, amount: p.splitShare ?? 0 })); + break; case SplitType.ADJUSTMENT: const totalAdjustment = participants.reduce((acc, p) => acc + (p.splitShare ?? 0), 0); if (totalAdjustment > amount) { @@ -237,3 +250,86 @@ export function calculateParticipantSplit( return { participants: updatedParticipants, canSplitScreenClosed }; } + +export function calculateSplitShareBasedOnAmount( + amount: number, + participants: Array, + splitType: SplitType, + paidBy?: User, +) { + let updatedParticipants = [...participants]; + + console.log('calculateSplitShareBasedOnAmount', amount, participants, splitType); + + switch (splitType) { + case SplitType.EQUAL: + // For equal split, split share should be amount/participants or 0 if amount is 0 + updatedParticipants = participants.map((p) => ({ + ...p, + splitShare: p.amount === 0 ? 0 : 1, + })); + break; + + case SplitType.PERCENTAGE: + // Convert amounts back to percentages + updatedParticipants = participants.map((p) => ({ + ...p, + splitShare: + amount === 0 + ? 0 + : paidBy?.id !== p.id + ? (Math.abs(p.amount ?? 0) / amount) * 100 + : (Math.abs(amount - (p.amount ?? 0)) / amount) * 100, + })); + break; + + case SplitType.SHARE: + // Convert amounts back to shares + const shares = participants.map((p) => + p.id === paidBy?.id + ? Math.abs(amount - (p.amount ?? 0)) / amount + : Math.abs(p.amount ?? 0) / amount, + ); + + // Find the minimum share value + const minShare = Math.min(...shares); + + // Calculate multiplier to make minimum share equal to 1 + const multiplier = minShare !== 0 ? 1 / minShare : 1; + + updatedParticipants = participants.map((p) => ({ + ...p, + splitShare: + (amount === 0 + ? 0 + : paidBy?.id !== p.id + ? Math.abs(p.amount ?? 0) / amount + : Math.abs(amount - (p.amount ?? 0)) / amount) * multiplier, + })); + break; + + case SplitType.EXACT: + // For exact, split share is the absolute amount + updatedParticipants = participants.map((p) => ({ + ...p, + splitShare: + paidBy?.id !== p.id ? Math.abs(p.amount ?? 0) : Math.abs(amount - (p.amount ?? 0)), + })); + break; + + case SplitType.ADJUSTMENT: + // For adjustment, split share is the difference from equal share + updatedParticipants = participants.map((p) => ({ + ...p, + splitShare: + amount === 0 + ? 0 + : paidBy?.id !== p.id + ? Math.abs(p.amount ?? 0) + : Math.abs(amount - (p.amount ?? 0)), + })); + break; + } + + return updatedParticipants; +}