Add recurring expense functionality (#263)

* code complete

* Smaller updates

* delete ambitious TODOs (add to PR)

* add transactionality to recurring expense creation

* Remove unnecessary `let`s

* Add default english labels to non-en-US translations

* Accept `es.json` translations

* add condition to ensure links are only modified when applicable
This commit is contained in:
trandall
2025-04-19 12:23:23 -07:00
committed by GitHub
parent 2bced00f82
commit 94c101cf7b
17 changed files with 464 additions and 3 deletions

View File

@@ -137,6 +137,15 @@
"label": "Empfangen von",
"description": "Wähle das Mitglied, das die Einnahme erhalten hat."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Empfangen für",
"description": "Wähle für wen die Einnahme empfangen wurde."

View File

@@ -162,6 +162,15 @@
"label": "Paid by",
"description": "Select the participant who paid the expense."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Paid for",
"description": "Select who the expense was paid for."

View File

@@ -137,6 +137,15 @@
"label": "Recibido por",
"description": "Seleccione el participante que recibió los ingresos."
},
"recurrenceRule": {
"label": "Recurrencia del gasto",
"description": "Seleccione con qué frecuencia debe repetirse el gasto.",
"none": "Ninguno",
"daily": "Diario",
"weekly": "Semanal",
"monthly": "Mensual"
},
"paidFor": {
"title": "Recibido para for",
"description": "Seleccione para quién se recibió el ingreso."

View File

@@ -137,6 +137,15 @@
"label": "Vastaanottaja",
"description": "Valitse kuka vastaanotti tulon."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Tulon jakaminen",
"description": "Valitse kenelle tulo jaetaan."

View File

@@ -137,6 +137,15 @@
"label": "Reçu par",
"description": "Sélectionnez le participant qui a reçu le revenu."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Reçu pour",
"description": "Sélectionnez pour qui le revenu a été reçu."

View File

@@ -137,6 +137,15 @@
"label": "Ricevuto da",
"description": "Seleziona partecipante che ha ricevuto l'entrata."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Ricevuto per",
"description": "Seleziona per chi è stato ricevuta l'entrata."

View File

@@ -137,6 +137,15 @@
"label": "Otrzymane przez",
"description": "Wybierz członka, który otrzymał wpływ."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Otrzymany dla",
"description": "Podaj dla kogo wpływ był przeznaczony."

View File

@@ -128,6 +128,15 @@
"placeholder": "Cina de luni seară",
"description": "Adaugă o descriere pentru venit."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"DateField": {
"label": "Data venitului",
"description": "Adaugă data la care venitul a fost primit."

View File

@@ -137,6 +137,15 @@
"label": "Получивший",
"description": "Выберите участника, который получил этот доход."
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Участники",
"description": "Выберите тех, между кем этот доход будет распределен."

View File

@@ -137,6 +137,15 @@
"label": "Отримав",
"description": "Оберіть учасника, який отримав дохід"
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "Учасники",
"description": "Виберіть тих, між ким цей дохід буде розподілено"

View File

@@ -137,6 +137,15 @@
"label": "接收到",
"description": "选择接收到这笔收入的群组成员。"
},
"recurrenceRule": {
"label": "Expense Recurrence",
"description": "Select how often the expense should repeat.",
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"paidFor": {
"title": "接收给",
"description": "选择收入是为谁而收。"

View File

@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "RecurrenceRule" AS ENUM ('NONE', 'DAILY', 'WEEKLY', 'MONTHLY');
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "recurrenceRule" "RecurrenceRule" DEFAULT 'NONE',
ADD COLUMN "recurringExpenseLinkId" TEXT;
-- CreateTable
CREATE TABLE "RecurringExpenseLink" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"currentFrameExpenseId" TEXT NOT NULL,
"nextExpenseCreatedAt" TIMESTAMP(3),
"nextExpenseDate" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RecurringExpenseLink_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RecurringExpenseLink_currentFrameExpenseId_key" ON "RecurringExpenseLink"("currentFrameExpenseId");
-- CreateIndex
CREATE INDEX "RecurringExpenseLink_groupId_idx" ON "RecurringExpenseLink"("groupId");
-- CreateIndex
CREATE INDEX "RecurringExpenseLink_groupId_nextExpenseCreatedAt_nextExpen_idx" ON "RecurringExpenseLink"("groupId", "nextExpenseCreatedAt", "nextExpenseDate" DESC);
-- AddForeignKey
ALTER TABLE "RecurringExpenseLink" ADD CONSTRAINT "RecurringExpenseLink_currentFrameExpenseId_fkey" FOREIGN KEY ("currentFrameExpenseId") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -55,6 +55,10 @@ model Expense {
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
recurrenceRule RecurrenceRule? @default(NONE)
recurringExpenseLink RecurringExpenseLink?
recurringExpenseLinkId String?
}
model ExpenseDocument {
@@ -73,6 +77,29 @@ enum SplitMode {
BY_AMOUNT
}
model RecurringExpenseLink {
id String @id
groupId String
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
currentFrameExpenseId String @unique
// Note: We do not want to link to the next expense because once it is created, it should be
// treated as it's own independent entity. This means that if a user wants to delete an Expense
// and any prior related recurring expenses, they'll need to delete them one by one.
nextExpenseCreatedAt DateTime?
nextExpenseDate DateTime
@@index([groupId])
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])
}
enum RecurrenceRule {
NONE
DAILY
WEEKLY
MONTHLY
}
model ExpensePaidFor {
expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)

View File

@@ -54,6 +54,7 @@ import { match } from 'ts-pattern'
import { DeletePopup } from '../../../../components/delete-popup'
import { extractCategoryFromTitle } from '../../../../components/expense-form-actions'
import { Textarea } from '../../../../components/ui/textarea'
import { RecurrenceRule } from '@prisma/client'
const enforceCurrencyPattern = (value: string) =>
value
@@ -166,6 +167,10 @@ export function ExpenseForm({
}
return field?.value
}
const getSelectedRecurrenceRule = (field?: { value: string }) => {
return field?.value as RecurrenceRule
}
const defaultSplittingOptions = getDefaultSplittingOptions(group)
const form = useForm<ExpenseFormValues>({
resolver: zodResolver(expenseFormSchema),
@@ -185,6 +190,7 @@ export function ExpenseForm({
isReimbursement: expense.isReimbursement,
documents: expense.documents,
notes: expense.notes ?? '',
recurrenceRule: expense.recurrenceRule,
}
: searchParams.get('reimbursement')
? {
@@ -208,6 +214,7 @@ export function ExpenseForm({
saveDefaultSplittingOptions: false,
documents: [],
notes: '',
recurrenceRule: RecurrenceRule.NONE,
}
: {
title: searchParams.get('title') ?? '',
@@ -235,6 +242,7 @@ export function ExpenseForm({
]
: [],
notes: '',
recurrenceRule: RecurrenceRule.NONE,
},
})
const [isCategoryLoading, setCategoryLoading] = useState(false)
@@ -495,6 +503,43 @@ export function ExpenseForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="recurrenceRule"
render={({ field }) => (
<FormItem className="sm:order-5">
<FormLabel>{t(`${sExpense}.recurrenceRule.label`)}</FormLabel>
<Select
onValueChange={(value) => {
form.setValue('recurrenceRule', value as RecurrenceRule)
}}
defaultValue={getSelectedRecurrenceRule(field)}
>
<SelectTrigger>
<SelectValue placeholder="NONE"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">
{t(`${sExpense}.recurrenceRule.none`)}
</SelectItem>
<SelectItem value="DAILY">
{t(`${sExpense}.recurrenceRule.daily`)}
</SelectItem>
<SelectItem value="WEEKLY">
{t(`${sExpense}.recurrenceRule.weekly`)}
</SelectItem>
<SelectItem value="MONTHLY">
{t(`${sExpense}.recurrenceRule.monthly`)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(`${sExpense}.recurrenceRule.description`)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>

View File

@@ -23,6 +23,7 @@ export async function GET(
paidFor: { select: { participantId: true, shares: true } },
isReimbursement: true,
splitMode: true,
recurrenceRule: true,
},
orderBy: [{ expenseDate: 'asc' }, { createdAt: 'asc' }],
},

View File

@@ -1,6 +1,6 @@
import { prisma } from '@/lib/prisma'
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
import { ActivityType, Expense } from '@prisma/client'
import { ActivityType, Expense, RecurrenceRule, RecurringExpenseLink } from '@prisma/client'
import { nanoid } from 'nanoid'
export function randomId() {
@@ -50,6 +50,13 @@ export async function createExpense(
data: expenseFormValues.title,
})
const isCreateRecurrence = expenseFormValues.recurrenceRule !== RecurrenceRule.NONE
const recurringExpenseLinkPayload = createPayloadForNewRecurringExpenseLink(
expenseFormValues.recurrenceRule as RecurrenceRule,
expenseFormValues.expenseDate,
groupId
)
return prisma.expense.create({
data: {
id: expenseId,
@@ -60,6 +67,16 @@ export async function createExpense(
title: expenseFormValues.title,
paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
recurrenceRule: expenseFormValues.recurrenceRule,
recurringExpenseLink: {
...(isCreateRecurrence
? {
create: recurringExpenseLinkPayload
}
: {}
),
},
paidFor: {
createMany: {
data: expenseFormValues.paidFor.map((paidFor) => ({
@@ -152,6 +169,32 @@ export async function updateExpense(
data: expenseFormValues.title,
})
const isDeleteRecurrenceExpenseLink =
existingExpense.recurrenceRule !== RecurrenceRule.NONE &&
expenseFormValues.recurrenceRule === RecurrenceRule.NONE &&
// Delete the existing RecurrenceExpenseLink only if it has not been acted upon yet
existingExpense.recurringExpenseLink?.nextExpenseCreatedAt === null
const isUpdateRecurrenceExpenseLink = existingExpense.recurrenceRule !== expenseFormValues.recurrenceRule &&
// Update the exisiting RecurrenceExpenseLink only if it has not been acted upon yet
existingExpense.recurringExpenseLink?.nextExpenseCreatedAt === null
const isCreateRecurrenceExpenseLink =
existingExpense.recurrenceRule === RecurrenceRule.NONE &&
expenseFormValues.recurrenceRule !== RecurrenceRule.NONE &&
// Create a new RecurrenceExpenseLink only if one does not already exist for the expense
existingExpense.recurringExpenseLink === null
const newRecurringExpenseLink = createPayloadForNewRecurringExpenseLink(
expenseFormValues.recurrenceRule as RecurrenceRule,
expenseFormValues.expenseDate,
groupId
)
const updatedRecurrenceExpenseLinkNextExpenseDate = calculateNextDate(
expenseFormValues.recurrenceRule as RecurrenceRule,
existingExpense.expenseDate
)
return prisma.expense.update({
where: { id: expenseId },
data: {
@@ -161,6 +204,7 @@ export async function updateExpense(
categoryId: expenseFormValues.category,
paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
recurrenceRule: expenseFormValues.recurrenceRule,
paidFor: {
create: expenseFormValues.paidFor
.filter(
@@ -191,6 +235,23 @@ export async function updateExpense(
),
),
},
recurringExpenseLink: {
...(isCreateRecurrenceExpenseLink
? {
create: newRecurringExpenseLink
}
: {}
),
...(isUpdateRecurrenceExpenseLink
? {
update: {
nextExpenseDate: updatedRecurrenceExpenseLinkNextExpenseDate
}
}
: {}
),
delete: isDeleteRecurrenceExpenseLink,
},
isReimbursement: expenseFormValues.isReimbursement,
documents: {
connectOrCreate: expenseFormValues.documents.map((doc) => ({
@@ -269,6 +330,8 @@ export async function getGroupExpenses(
groupId: string,
options?: { offset?: number; length?: number; filter?: string },
) {
await createRecurringExpenses()
return prisma.expense.findMany({
select: {
amount: true,
@@ -285,6 +348,7 @@ export async function getGroupExpenses(
},
},
splitMode: true,
recurrenceRule: true,
title: true,
_count: { select: { documents: true } },
},
@@ -307,7 +371,7 @@ export async function getGroupExpenseCount(groupId: string) {
export async function getExpense(groupId: string, expenseId: string) {
return prisma.expense.findUnique({
where: { id: expenseId },
include: { paidBy: true, paidFor: true, category: true, documents: true },
include: { paidBy: true, paidFor: true, category: true, documents: true, recurringExpenseLink: true },
})
}
@@ -355,3 +419,194 @@ export async function logActivity(
},
})
}
async function createRecurringExpenses(){
const localDate = new Date(); // Current local date
const utcDateFromLocal = new Date(Date.UTC(
localDate.getUTCFullYear(),
localDate.getUTCMonth(),
localDate.getUTCDate(),
// More precision beyond date is required to ensure that recurring Expenses are created within <most precises unit> of when expected
localDate.getUTCHours(),
localDate.getUTCMinutes(),
));
const recurringExpenseLinksWithExpensesToCreate = await prisma.recurringExpenseLink.findMany({
where: {
nextExpenseCreatedAt: null,
nextExpenseDate: {
lte: utcDateFromLocal
}
},
include: {
currentFrameExpense: {
include: {
paidBy: true,
paidFor: true,
category: true,
documents: true
},
}
}
})
for (const recurringExpenseLink of recurringExpenseLinksWithExpensesToCreate) {
let newExpenseDate = recurringExpenseLink.nextExpenseDate
let currentExpenseRecord = recurringExpenseLink.currentFrameExpense
let currentReccuringExpenseLinkId = recurringExpenseLink.id
while (newExpenseDate < utcDateFromLocal) {
const newExpenseId = randomId()
const newRecurringExpenseLinkId = randomId()
const newRecurringExpenseNextExpenseDate = calculateNextDate(
currentExpenseRecord.recurrenceRule as RecurrenceRule,
newExpenseDate
)
const {
category, paidBy, paidFor, documents,
...destructeredCurrentExpenseRecord
} = currentExpenseRecord
// Use a transacton to ensure that the only one expense is created for the RecurringExpenseLink
// just in case two clients are processing the same RecurringExpenseLink at the same time
const newExpense = await prisma.$transaction(async (transaction) => {
const newExpense = await transaction.expense.create({
data: {
...destructeredCurrentExpenseRecord,
categoryId: currentExpenseRecord.categoryId,
paidById: currentExpenseRecord.paidById,
paidFor: {
createMany: {
data: currentExpenseRecord.paidFor.map((paidFor) => ({
participantId: paidFor.participantId,
shares: paidFor.shares,
})),
},
},
documents: {
connect: currentExpenseRecord.documents.map((documentRecord) => ({
id: documentRecord.id
})),
},
id: newExpenseId,
expenseDate: newExpenseDate,
recurringExpenseLink: {
create: {
groupId: currentExpenseRecord.groupId,
id: newRecurringExpenseLinkId,
nextExpenseDate: newRecurringExpenseNextExpenseDate
}
}
},
// Ensure that the same information is available on the returned record that was created
include: {
paidFor: true,
documents: true,
category: true,
paidBy: true
}
})
// Mark the RecurringExpenseLink as being "completed" since the new Expense was created
// if an expense hasn't been created for this RecurringExpenseLink yet
await transaction.recurringExpenseLink.update({
where: {
id: currentReccuringExpenseLinkId,
nextExpenseCreatedAt: null,
},
data: {
nextExpenseCreatedAt: newExpense.createdAt
},
})
return newExpense
}).catch(() => {
console.error("Failed to created recurringExpense for expenseId: %s", currentExpenseRecord.id)
return null
})
// If the new expense failed to be created, break out of the while-loop
if (newExpense === null) break
// Set the values for the next iteration of the for-loop in case multiple recurring Expenses need to be created
currentExpenseRecord = newExpense
currentReccuringExpenseLinkId = newRecurringExpenseLinkId
newExpenseDate = newRecurringExpenseNextExpenseDate
}
}
}
function createPayloadForNewRecurringExpenseLink(
recurrenceRule: RecurrenceRule,
priorDateToNextRecurrence: Date,
groupId: String,
): RecurringExpenseLink {
const nextExpenseDate = calculateNextDate(
recurrenceRule,
priorDateToNextRecurrence
)
const recurringExpenseLinkId = randomId()
const recurringExpenseLinkPayload = {
id: recurringExpenseLinkId,
groupId: groupId,
nextExpenseDate: nextExpenseDate
}
return recurringExpenseLinkPayload as RecurringExpenseLink
}
// TODO: Modify this function to use a more comprehensive recurrence Rule library like rrule (https://github.com/jkbrzt/rrule)
//
// Current limitations:
// - If a date is intended to be repeated monthly on the 29th, 30th or 31st, it will change to repeating on the smallest
// date that the reccurence has encountered. Ex. If a recurrence is created for Jan 31st on 2025, the recurring expense
// will be created for Feb 28th, March 28, etc. until it is cancelled or fixed
function calculateNextDate(
recurrenceRule: RecurrenceRule,
priorDateToNextRecurrence: Date
): Date {
const nextDate = new Date(priorDateToNextRecurrence)
switch(recurrenceRule) {
case RecurrenceRule.DAILY:
nextDate.setUTCDate(nextDate.getUTCDate() + 1)
break
case RecurrenceRule.WEEKLY:
nextDate.setUTCDate(nextDate.getUTCDate() + 7)
break
case RecurrenceRule.MONTHLY:
const nextYear = nextDate.getUTCFullYear()
const nextMonth = nextDate.getUTCMonth() + 1
let nextDay = nextDate.getUTCDate()
// Reduce the next day until it is within the direct next month
while (!isDateInNextMonth(nextYear, nextMonth, nextDay)) {
nextDay -= 1
}
nextDate.setUTCMonth(nextMonth, nextDay)
break
}
return nextDate
}
function isDateInNextMonth(
utcYear: number,
utcMonth: number,
utcDate: number
): Boolean {
const testDate = new Date(Date.UTC(
utcYear, utcMonth, utcDate
))
// We're not concerned if the year or month changes. We only want to make sure that the date is our target date
if (testDate.getUTCDate() !== utcDate
) {
return false
}
return true
}

View File

@@ -1,4 +1,4 @@
import { SplitMode } from '@prisma/client'
import { SplitMode, RecurrenceRule } from '@prisma/client'
import * as z from 'zod'
export const groupFormSchema = z
@@ -105,6 +105,11 @@ export const expenseFormSchema = z
)
.default([]),
notes: z.string().optional(),
recurrenceRule:z
.enum<RecurrenceRule, [RecurrenceRule, ...RecurrenceRule[]]>(
Object.values(RecurrenceRule) as any
)
.default('NONE'),
})
.superRefine((expense, ctx) => {
let sum = 0