Skip to content

Commit

Permalink
feature: recurring transactions added- migrations, ui, api updated. R…
Browse files Browse the repository at this point in the history
…esolves issue spliit-app#5
  • Loading branch information
neonshobhit committed Feb 25, 2024
1 parent 2af0660 commit 10263bb
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)])
}
41 changes: 40 additions & 1 deletion src/components/expense-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export function ExpenseForm({
}
return field?.value
}

const getRecurringField = (field?: { value: string }) => {
return field?.value
}
const form = useForm<ExpenseFormValues>({
resolver: zodResolver(expenseFormSchema),
defaultValues: expense
Expand All @@ -91,6 +95,8 @@ export function ExpenseForm({
splitMode: expense.splitMode,
isReimbursement: expense.isReimbursement,
documents: expense.documents,
recurringDays: String(expense.recurringDays),
isArchive: expense.isArchive,
}
: searchParams.get('reimbursement')
? {
Expand All @@ -109,6 +115,8 @@ export function ExpenseForm({
isReimbursement: true,
splitMode: 'EVENLY',
documents: [],
recurringDays: "0",
isArchive: false,
}
: {
title: searchParams.get('title') ?? '',
Expand Down Expand Up @@ -137,10 +145,13 @@ export function ExpenseForm({
},
]
: [],
recurringDays: "0",
isArchive: false,
},

})
const [isCategoryLoading, setCategoryLoading] = useState(false)

const recurringDays = [{ "key": "Never","value": "0"}, { "key":"weekly", "value": "7"}, {"key": "fortnightly", "value": "14"}, {"key": "monthly", "value": "30"}, {"key": "bimonthly", "value": "60"}]
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
Expand Down Expand Up @@ -299,6 +310,34 @@ export function ExpenseForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="recurringDays"
render={({ field }) => (
<FormItem className="sm:order-5">
<FormLabel>Recurring Days</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={getRecurringField(field)}
>
<SelectTrigger>
<SelectValue placeholder="Never" />
</SelectTrigger>
<SelectContent>
{recurringDays.map(({ key, value }) => (
<SelectItem key={key} value={value}>
{key}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select recursive days.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>

Expand Down
130 changes: 127 additions & 3 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export async function createGroup(groupFormValues: GroupFormValues) {
export async function createExpense(
expenseFormValues: ExpenseFormValues,
groupId: string,
expenseRef: string|null = null
): Promise<Expense> {
const group = await getGroup(groupId)
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
Expand All @@ -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,
Expand All @@ -72,6 +102,8 @@ export async function createExpense(
})),
},
},
recurringDays: +expenseFormValues.recurringDays,
isArchive: false,
},
})
}
Expand Down Expand Up @@ -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<relatedExpenses.length; ++i) {
const lockId = randomId()
if (now < new Date(relatedExpenses[i].expenseDate).getTime()/1000 + (+relatedExpenses[i].recurringDays * 86400)) {
ignoreExpenseId.push(relatedExpenses[i].id)
continue
}
await prisma.recurringTransactions.updateMany({
where: {
groupId,
expenseId: relatedExpenses[i].id,
lockId: null
},
data: {
lockId
}
})
const getRecTxn = await prisma.recurringTransactions.findMany({
where: {
expenseId: relatedExpenses[i].id,
groupId,
lockId
}
})
if (getRecTxn.length) {
const newExpense = 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);
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: {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export const expenseFormSchema = z
)
.default('EVENLY'),
isReimbursement: z.boolean(),
recurringDays: z.string({ required_error: 'You must select recurring days.' }),
isArchive: z.boolean(),
documents: z
.array(
z.object({
Expand Down

0 comments on commit 10263bb

Please sign in to comment.