From 364857a18cbd412f1f3451f06bb5225f9f3002f4 Mon Sep 17 00:00:00 2001 From: Shobhit Date: Mon, 26 Feb 2024 05:05:44 +0530 Subject: [PATCH] feature: recurring transactions added- migrations, ui, api updated. Resolves issue #5 --- .../migration.sql | 16 +++ prisma/schema.prisma | 12 ++ src/components/expense-form.tsx | 40 ++++++ src/lib/api.ts | 130 +++++++++++++++++- src/lib/schemas.ts | 2 + 5 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20240225042831_create_recurring_transactions/migration.sql diff --git a/prisma/migrations/20240225042831_create_recurring_transactions/migration.sql b/prisma/migrations/20240225042831_create_recurring_transactions/migration.sql new file mode 100644 index 00000000..ab57954c --- /dev/null +++ b/prisma/migrations/20240225042831_create_recurring_transactions/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "RecurringTransactions" ( + "groupId" VARCHAR(255) NOT NULL, + "expenseId" VARCHAR(255) NOT NULL, + "createNextAt" INT NOT NULL, + "lockId" VARCHAR(255) DEFAULT NULL, + + CONSTRAINT "RecurringTransactions_pkey" PRIMARY KEY ("groupId", "expenseId") +); + +CREATE INDEX "idx_recurring_transactions_group_expense_next_create" ON "RecurringTransactions" ("groupId", "expenseId", "createNextAt" DESC); +CREATE UNIQUE INDEX "idx_unq_recurring_transactions_lock_id" ON "RecurringTransactions" ("lockId"); + +-- AlterTable +ALTER TABLE "Expense" ADD COLUMN "recurringDays" TEXT NOT NULL DEFAULT 0, + ADD COLUMN "isArchive" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 394342de..430dd252 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,6 +52,8 @@ model Expense { splitMode SplitMode @default(EVENLY) createdAt DateTime @default(now()) documents ExpenseDocument[] + recurringDays Int @default(0) + isArchive Boolean @default(false) } model ExpenseDocument { @@ -79,3 +81,13 @@ model ExpensePaidFor { @@id([expenseId, participantId]) } + +model RecurringTransactions { + groupId String + expenseId String + createNextAt Int + lockId String? @unique + + @@id([groupId, expenseId]) + @@index([groupId, expenseId, createNextAt(sort: Desc)]) +} \ No newline at end of file diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index af998bb7..b1f430a1 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -163,6 +163,10 @@ export function ExpenseForm({ return field?.value } const defaultSplittingOptions = getDefaultSplittingOptions(group) + + const getRecurringField = (field?: { value: string }) => { + return field?.value + } const form = useForm({ resolver: zodResolver(expenseFormSchema), defaultValues: expense @@ -180,6 +184,8 @@ export function ExpenseForm({ saveDefaultSplittingOptions: false, isReimbursement: expense.isReimbursement, documents: expense.documents, + recurringDays: String(expense.recurringDays), + isArchive: expense.isArchive, } : searchParams.get('reimbursement') ? { @@ -202,6 +208,8 @@ export function ExpenseForm({ splitMode: defaultSplittingOptions.splitMode, saveDefaultSplittingOptions: false, documents: [], + recurringDays: "0", + isArchive: false, } : { title: searchParams.get('title') ?? '', @@ -228,7 +236,10 @@ export function ExpenseForm({ }, ] : [], + recurringDays: "0", + isArchive: false, }, + }) const [isCategoryLoading, setCategoryLoading] = useState(false) @@ -237,6 +248,7 @@ export function ExpenseForm({ return onSubmit(values) } + const recurringDays = [{ "key": "Never","value": "0"}, { "key":"weekly", "value": "7"}, {"key": "fortnightly", "value": "14"}, {"key": "monthly", "value": "30"}, {"key": "bimonthly", "value": "60"}] return (
@@ -400,6 +412,34 @@ export function ExpenseForm({ )} /> + ( + + Recurring Days + + + Select recursive days. + + + + )} + /> diff --git a/src/lib/api.ts b/src/lib/api.ts index 6f8e3893..f4e01c35 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -30,6 +30,7 @@ export async function createGroup(groupFormValues: GroupFormValues) { export async function createExpense( expenseFormValues: ExpenseFormValues, groupId: string, + expenseRef: string|null = null ): Promise { const group = await getGroup(groupId) if (!group) throw new Error(`Invalid group ID: ${groupId}`) @@ -41,13 +42,42 @@ export async function createExpense( if (!group.participants.some((p) => p.id === participant)) throw new Error(`Invalid participant ID: ${participant}`) } - + 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 + } + }) + } + } + } + return prisma.expense.create({ data: { - id: randomId(), + id: expenseId, groupId, - expenseDate: expenseFormValues.expenseDate, + expenseDate: expenseDate, categoryId: expenseFormValues.category, amount: expenseFormValues.amount, title: expenseFormValues.title, @@ -72,6 +102,8 @@ export async function createExpense( })), }, }, + recurringDays: +expenseFormValues.recurringDays, + isArchive: false, }, }) } @@ -241,7 +273,99 @@ export async function getCategories() { } export async function getGroupExpenses(groupId: string) { + const now: number = Math.floor((new Date()).getTime() / 1000) const prisma = await getPrisma() + + let allPendingRecurringTxns = await prisma.recurringTransactions.findMany({ + where: { + groupId, + createNextAt: { + lte: now + }, + lockId: null + } + }) + let ignoreExpenseId = [] + while(allPendingRecurringTxns.length) { + const relatedExpenses = await prisma.expense.findMany({ + where: { + id: { + in: allPendingRecurringTxns.map((rt) => rt.expenseId) + }, + groupId, + }, + include: { + paidFor: { include: { participant: true } }, + paidBy: true, + category: true, + documents: true + }, + }) + 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 + } + }) + } + } + allPendingRecurringTxns = await prisma.recurringTransactions.findMany({ + where: { + groupId, + createNextAt: { + lte: now + }, + expenseId: { + notIn: ignoreExpenseId + }, + lockId: null + } + }) + } return prisma.expense.findMany({ where: { groupId }, include: { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index bd6db3be..0515092c 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -107,6 +107,8 @@ export const expenseFormSchema = z .default('EVENLY'), saveDefaultSplittingOptions: z.boolean(), isReimbursement: z.boolean(), + recurringDays: z.string({ required_error: 'You must select recurring days.' }), + isArchive: z.boolean(), documents: z .array( z.object({