diff --git a/package-lock.json b/package-lock.json index 52b0e543..c1c46263 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "devDependencies": { "@total-typescript/ts-reset": "^0.5.1", "@types/content-disposition": "^0.5.8", - "@types/node": "^20", + "@types/node": "^20.11.20", "@types/pg": "^8.10.9", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", @@ -3111,10 +3111,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", - "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", - "license": "MIT", + "version": "20.11.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz", + "integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==", "dependencies": { "undici-types": "~5.26.4" } diff --git a/package.json b/package.json index 5706b1ef..d417e72d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "next13-progressbar": "^1.1.1", "openai": "^4.25.0", "pg": "^8.11.3", + "prisma": "^5.7.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.47.0", @@ -53,13 +54,12 @@ "ts-pattern": "^5.0.6", "uuid": "^9.0.1", "vaul": "^0.8.0", - "zod": "^3.22.4", - "prisma": "^5.7.0" + "zod": "^3.22.4" }, "devDependencies": { "@total-typescript/ts-reset": "^0.5.1", "@types/content-disposition": "^0.5.8", - "@types/node": "^20", + "@types/node": "^20.11.20", "@types/pg": "^8.10.9", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", diff --git a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx index 2514a77c..a566b3eb 100644 --- a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx +++ b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx @@ -5,6 +5,7 @@ import { getCategories, getExpense, updateExpense, + archiveExpenseStateUpdate, } from '@/lib/api' import { getRuntimeFeatureFlags } from '@/lib/featureFlags' import { expenseFormSchema } from '@/lib/schemas' @@ -36,7 +37,13 @@ export default async function EditExpensePage({ async function deleteExpenseAction() { 'use server' - await deleteExpense(expenseId) + await deleteExpense(groupId, expenseId) + redirect(`/groups/${groupId}`) + } + + async function archiveExpenseStateUpdateAction(state: boolean) { + 'use server' + await archiveExpenseStateUpdate(expenseId, state) redirect(`/groups/${groupId}`) } @@ -48,6 +55,7 @@ export default async function EditExpensePage({ categories={categories} onSubmit={updateExpenseAction} onDelete={deleteExpenseAction} + onArchive={archiveExpenseStateUpdateAction} runtimeFeatureFlags={await getRuntimeFeatureFlags()} /> diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx index cc908af0..2e2767b5 100644 --- a/src/app/groups/[groupId]/expenses/expense-list.tsx +++ b/src/app/groups/[groupId]/expenses/expense-list.tsx @@ -129,6 +129,11 @@ export function ExpenseList({
+ + + {(expense.isArchive ? "Archived \u00B7 " :"")} + + {expense.title}
diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx index 9dc3ab5e..f30359e0 100644 --- a/src/app/groups/[groupId]/expenses/page.tsx +++ b/src/app/groups/[groupId]/expenses/page.tsx @@ -100,7 +100,7 @@ export default async function GroupExpensesPage({ async function Expenses({ groupId }: { groupId: string }) { const group = await cached.getGroup(groupId) if (!group) notFound() - const expenses = await getGroupExpenses(group.id) + const expenses = (await getGroupExpenses(group.id))// .filter(e => !e.isArchive) return ( >> onSubmit: (values: ExpenseFormValues) => Promise onDelete?: () => Promise + onArchive?: (state: boolean) => Promise runtimeFeatureFlags: RuntimeFeatureFlags } @@ -62,9 +63,11 @@ export function ExpenseForm({ categories, onSubmit, onDelete, + onArchive, runtimeFeatureFlags, }: Props) { const isCreate = expense === undefined + const isArchived = expense?.isArchive const searchParams = useSearchParams() const getSelectedPayer = (field?: { value: string }) => { if (isCreate && typeof window !== 'undefined') { @@ -615,6 +618,28 @@ export function ExpenseForm({ {isCreate ? <>Create : <>Save} + {!isCreate && !isArchived && onArchive && ( + onArchive(true)} + > + + Archive + + )} + {!isCreate && isArchived && onArchive && ( + onArchive(false)} + > + + Unarchive + + )} {!isCreate && onDelete && ( { + setTimeout(resolve, duration) + }) +} export function randomId() { return nanoid() @@ -27,6 +35,134 @@ export async function createGroup(groupFormValues: GroupFormValues) { }) } +export async function acquireLockRecurringTransaction(groupId:string, expenseId:string, lockId: string): Promise { + const prisma = await getPrisma() + let retryCnt = 5 + let getRecTxn:RecurringTransactions[] = [] + while(--retryCnt) { + const existingLock = await prisma.recurringTransactions.findMany({ + where: { + groupId, + expenseId + } + }) + if (!existingLock.length) { + const query = ` + INSERT INTO "RecurringTransactions" + ("groupId", "expenseId", "createNextAt", "lockId") + VALUES + ('${groupId}', '${expenseId}', 0, '${lockId}') + ON CONFLICT("groupId", "expenseId") + DO NOTHING; + ` + await prisma.$queryRawUnsafe(query) + } + await prisma.recurringTransactions.updateMany({ + where: { + groupId, + expenseId, + lockId: null + }, + data: { + lockId + } + }) + getRecTxn = await prisma.recurringTransactions.findMany({ + where: { + expenseId: expenseId, + groupId, + lockId + } + }) + const receivedLockId = getRecTxn?.[0]?.lockId + if (receivedLockId === lockId) break; + await sleep(500); + } + return getRecTxn +} + +export async function releaseLockRecurringTransaction(groupId:string, expenseId:string, lockId: string): Promise{ + const prisma = await getPrisma() + await prisma.recurringTransactions.updateMany({ + where: { + groupId, + expenseId: expenseId, + lockId, + }, + data: { + lockId: null + } + }) +} + +export async function createOrUpdateRecurringTransaction( + expenseFormValues: ExpenseFormValues, + groupId: string, + expenseId: string, + expenseRef: string|null = null +): Promise { + let expenseDate = expenseFormValues.expenseDate + const prisma = await getPrisma() + if (!+expenseFormValues.recurringDays) { + await prisma.recurringTransactions.deleteMany({ + where: { + groupId, + expenseId: expenseRef || expenseId + } + }) + return Promise.resolve(undefined) + } + const lockId:string = randomId(); + const receivedLockId = (await acquireLockRecurringTransaction(groupId, expenseRef || expenseId, lockId))?.[0]?.lockId + let recTxn; + if (!receivedLockId) return recTxn + const epochTime = Math.floor((new Date(expenseDate)).getTime() / 1000) + const nextAt: number = epochTime + (+expenseFormValues.recurringDays * 86400) + if (!isNaN(nextAt)) { + if (expenseRef) { + expenseDate = new Date(nextAt*1000) + await prisma.recurringTransactions.updateMany({ + where: { + groupId, + expenseId: expenseRef, + lockId + }, + data: { + createNextAt: nextAt, + expenseId + } + }) + recTxn = (await prisma.recurringTransactions.findMany({ + where: { + groupId, + expenseId + } + }))?.[0] + } else { + recTxn = await prisma.recurringTransactions.upsert({ + where: { + groupId_expenseId: { + groupId, + expenseId: expenseId + }, + lockId + }, + update: { + createNextAt: nextAt, + }, + create: { + groupId, + expenseId, + createNextAt: nextAt, + lockId + } + }) + } + } + await releaseLockRecurringTransaction(groupId, expenseId, lockId) + return recTxn; +} + export async function createExpense( expenseFormValues: ExpenseFormValues, groupId: string, @@ -45,34 +181,11 @@ export async function createExpense( const expenseId = randomId() const prisma = await getPrisma() let expenseDate = expenseFormValues.expenseDate - if (+expenseFormValues.recurringDays) { - const nextAt: number = Math.floor((new Date(expenseDate)).getTime() / 1000) + (+expenseFormValues.recurringDays * 86400) - if (!isNaN(nextAt)) { - if (expenseRef) { - expenseDate = new Date(nextAt*1000) - await prisma.recurringTransactions.updateMany({ - where: { - groupId, - expenseId: expenseRef - }, - data: { - createNextAt: nextAt, - expenseId - } - }) - } else { - await prisma.recurringTransactions.create({ - data: { - groupId, - expenseId, - createNextAt: nextAt, - lockId: null - } - }) - } - } - } + const recurringTxn = await createOrUpdateRecurringTransaction(expenseFormValues, groupId, expenseId, expenseRef) + if (recurringTxn) { + expenseDate = new Date(recurringTxn.createNextAt*1000) + } return prisma.expense.create({ data: { id: expenseId, @@ -108,14 +221,35 @@ export async function createExpense( }) } -export async function deleteExpense(expenseId: string) { +export async function deleteExpense(groupId: string, expenseId: string) { const prisma = await getPrisma() + const lockId = randomId() + const recurringTxns = await prisma.recurringTransactions.findMany({ + where: {groupId, expenseId} + }) + const receivedRecurringTxn = () => acquireLockRecurringTransaction(groupId, expenseId, lockId) + if (recurringTxns.length && (await receivedRecurringTxn())?.[0]?.lockId) { + await prisma.recurringTransactions.deleteMany({ + where: {groupId, expenseId} + }) + } + await releaseLockRecurringTransaction(groupId, expenseId, lockId) await prisma.expense.delete({ - where: { id: expenseId }, + where: { id: expenseId, groupId }, include: { paidFor: true, paidBy: true }, }) } +export async function archiveExpenseStateUpdate(expenseId: string, state: boolean) { + const prisma = await getPrisma() + await prisma.expense.updateMany({ + where: { id: expenseId }, + data: { + isArchive: state + } + }) +} + export async function getGroupExpensesParticipants(groupId: string) { const expenses = await getGroupExpenses(groupId) return Array.from( @@ -159,7 +293,7 @@ export async function updateExpense( if (!group.participants.some((p) => p.id === participant)) throw new Error(`Invalid participant ID: ${participant}`) } - + await createOrUpdateRecurringTransaction(expenseFormValues, groupId, expenseId, expenseId) const prisma = await getPrisma() return prisma.expense.update({ where: { id: expenseId }, @@ -217,6 +351,8 @@ export async function updateExpense( id: doc.id, })), }, + recurringDays: +expenseFormValues.recurringDays, + isArchive: expenseFormValues.isArchive }, }) } @@ -302,56 +438,27 @@ export async function getGroupExpenses(groupId: string) { }, }) for (let i=0; i ({ - participant: paidFor.participant.id, - shares: paidFor.shares, - })), - isReimbursement: relatedExpenses[i].isReimbursement, - documents: relatedExpenses[i].documents, - recurringDays: String(relatedExpenses[i].recurringDays), - isArchive: relatedExpenses[i].isArchive, - }, groupId, relatedExpenses[i].id); - await prisma.recurringTransactions.updateMany({ - where: { - groupId, - expenseId: newExpense.id, - }, - data: { - lockId: null - } - }) - } + await createExpense({ + expenseDate: relatedExpenses[i].expenseDate, + title: relatedExpenses[i].title, + category: relatedExpenses[i].category?.id || 0, + amount: relatedExpenses[i].amount, + paidBy: relatedExpenses[i].paidBy.id, + splitMode: relatedExpenses[i].splitMode, + paidFor: relatedExpenses[i].paidFor + .map((paidFor) => ({ + participant: paidFor.participant.id, + shares: paidFor.shares, + })), + isReimbursement: relatedExpenses[i].isReimbursement, + documents: relatedExpenses[i].documents, + recurringDays: String(relatedExpenses[i].recurringDays), + isArchive: relatedExpenses[i].isArchive, + }, groupId, relatedExpenses[i].id); } allPendingRecurringTxns = await prisma.recurringTransactions.findMany({ where: { diff --git a/src/lib/balances.ts b/src/lib/balances.ts index aa8d26f4..b4f8d9d2 100644 --- a/src/lib/balances.ts +++ b/src/lib/balances.ts @@ -19,6 +19,7 @@ export function getBalances( const balances: Balances = {} for (const expense of expenses) { + if (expense.isArchive) continue; const paidBy = expense.paidById const paidFors = expense.paidFor