mirror of
https://github.com/spliit-app/spliit.git
synced 2026-03-03 19:46:13 +01:00
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:
@@ -137,6 +137,15 @@
|
|||||||
"label": "Empfangen von",
|
"label": "Empfangen von",
|
||||||
"description": "Wähle das Mitglied, das die Einnahme erhalten hat."
|
"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": {
|
"paidFor": {
|
||||||
"title": "Empfangen für",
|
"title": "Empfangen für",
|
||||||
"description": "Wähle für wen die Einnahme empfangen wurde."
|
"description": "Wähle für wen die Einnahme empfangen wurde."
|
||||||
|
|||||||
@@ -162,6 +162,15 @@
|
|||||||
"label": "Paid by",
|
"label": "Paid by",
|
||||||
"description": "Select the participant who paid the expense."
|
"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": {
|
"paidFor": {
|
||||||
"title": "Paid for",
|
"title": "Paid for",
|
||||||
"description": "Select who the expense was paid for."
|
"description": "Select who the expense was paid for."
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"label": "Recibido por",
|
"label": "Recibido por",
|
||||||
"description": "Seleccione el participante que recibió los ingresos."
|
"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": {
|
"paidFor": {
|
||||||
"title": "Recibido para for",
|
"title": "Recibido para for",
|
||||||
"description": "Seleccione para quién se recibió el ingreso."
|
"description": "Seleccione para quién se recibió el ingreso."
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"label": "Vastaanottaja",
|
"label": "Vastaanottaja",
|
||||||
"description": "Valitse kuka vastaanotti tulon."
|
"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": {
|
"paidFor": {
|
||||||
"title": "Tulon jakaminen",
|
"title": "Tulon jakaminen",
|
||||||
"description": "Valitse kenelle tulo jaetaan."
|
"description": "Valitse kenelle tulo jaetaan."
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"label": "Reçu par",
|
"label": "Reçu par",
|
||||||
"description": "Sélectionnez le participant qui a reçu le revenu."
|
"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": {
|
"paidFor": {
|
||||||
"title": "Reçu pour",
|
"title": "Reçu pour",
|
||||||
"description": "Sélectionnez pour qui le revenu a été reçu."
|
"description": "Sélectionnez pour qui le revenu a été reçu."
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"label": "Ricevuto da",
|
"label": "Ricevuto da",
|
||||||
"description": "Seleziona partecipante che ha ricevuto l'entrata."
|
"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": {
|
"paidFor": {
|
||||||
"title": "Ricevuto per",
|
"title": "Ricevuto per",
|
||||||
"description": "Seleziona per chi è stato ricevuta l'entrata."
|
"description": "Seleziona per chi è stato ricevuta l'entrata."
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"label": "Otrzymane przez",
|
"label": "Otrzymane przez",
|
||||||
"description": "Wybierz członka, który otrzymał wpływ."
|
"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": {
|
"paidFor": {
|
||||||
"title": "Otrzymany dla",
|
"title": "Otrzymany dla",
|
||||||
"description": "Podaj dla kogo wpływ był przeznaczony."
|
"description": "Podaj dla kogo wpływ był przeznaczony."
|
||||||
|
|||||||
@@ -128,6 +128,15 @@
|
|||||||
"placeholder": "Cina de luni seară",
|
"placeholder": "Cina de luni seară",
|
||||||
"description": "Adaugă o descriere pentru venit."
|
"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": {
|
"DateField": {
|
||||||
"label": "Data venitului",
|
"label": "Data venitului",
|
||||||
"description": "Adaugă data la care venitul a fost primit."
|
"description": "Adaugă data la care venitul a fost primit."
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"label": "Получивший",
|
"label": "Получивший",
|
||||||
"description": "Выберите участника, который получил этот доход."
|
"description": "Выберите участника, который получил этот доход."
|
||||||
},
|
},
|
||||||
|
"recurrenceRule": {
|
||||||
|
"label": "Expense Recurrence",
|
||||||
|
"description": "Select how often the expense should repeat.",
|
||||||
|
|
||||||
|
"none": "None",
|
||||||
|
"daily": "Daily",
|
||||||
|
"weekly": "Weekly",
|
||||||
|
"monthly": "Monthly"
|
||||||
|
},
|
||||||
"paidFor": {
|
"paidFor": {
|
||||||
"title": "Участники",
|
"title": "Участники",
|
||||||
"description": "Выберите тех, между кем этот доход будет распределен."
|
"description": "Выберите тех, между кем этот доход будет распределен."
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"label": "Отримав",
|
"label": "Отримав",
|
||||||
"description": "Оберіть учасника, який отримав дохід"
|
"description": "Оберіть учасника, який отримав дохід"
|
||||||
},
|
},
|
||||||
|
"recurrenceRule": {
|
||||||
|
"label": "Expense Recurrence",
|
||||||
|
"description": "Select how often the expense should repeat.",
|
||||||
|
|
||||||
|
"none": "None",
|
||||||
|
"daily": "Daily",
|
||||||
|
"weekly": "Weekly",
|
||||||
|
"monthly": "Monthly"
|
||||||
|
},
|
||||||
"paidFor": {
|
"paidFor": {
|
||||||
"title": "Учасники",
|
"title": "Учасники",
|
||||||
"description": "Виберіть тих, між ким цей дохід буде розподілено"
|
"description": "Виберіть тих, між ким цей дохід буде розподілено"
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"label": "接收到",
|
"label": "接收到",
|
||||||
"description": "选择接收到这笔收入的群组成员。"
|
"description": "选择接收到这笔收入的群组成员。"
|
||||||
},
|
},
|
||||||
|
"recurrenceRule": {
|
||||||
|
"label": "Expense Recurrence",
|
||||||
|
"description": "Select how often the expense should repeat.",
|
||||||
|
|
||||||
|
"none": "None",
|
||||||
|
"daily": "Daily",
|
||||||
|
"weekly": "Weekly",
|
||||||
|
"monthly": "Monthly"
|
||||||
|
},
|
||||||
"paidFor": {
|
"paidFor": {
|
||||||
"title": "接收给",
|
"title": "接收给",
|
||||||
"description": "选择收入是为谁而收。"
|
"description": "选择收入是为谁而收。"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -55,6 +55,10 @@ model Expense {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
documents ExpenseDocument[]
|
documents ExpenseDocument[]
|
||||||
notes String?
|
notes String?
|
||||||
|
|
||||||
|
recurrenceRule RecurrenceRule? @default(NONE)
|
||||||
|
recurringExpenseLink RecurringExpenseLink?
|
||||||
|
recurringExpenseLinkId String?
|
||||||
}
|
}
|
||||||
|
|
||||||
model ExpenseDocument {
|
model ExpenseDocument {
|
||||||
@@ -73,6 +77,29 @@ enum SplitMode {
|
|||||||
BY_AMOUNT
|
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 {
|
model ExpensePaidFor {
|
||||||
expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
|
expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
|
||||||
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
|
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { match } from 'ts-pattern'
|
|||||||
import { DeletePopup } from '../../../../components/delete-popup'
|
import { DeletePopup } from '../../../../components/delete-popup'
|
||||||
import { extractCategoryFromTitle } from '../../../../components/expense-form-actions'
|
import { extractCategoryFromTitle } from '../../../../components/expense-form-actions'
|
||||||
import { Textarea } from '../../../../components/ui/textarea'
|
import { Textarea } from '../../../../components/ui/textarea'
|
||||||
|
import { RecurrenceRule } from '@prisma/client'
|
||||||
|
|
||||||
const enforceCurrencyPattern = (value: string) =>
|
const enforceCurrencyPattern = (value: string) =>
|
||||||
value
|
value
|
||||||
@@ -166,6 +167,10 @@ export function ExpenseForm({
|
|||||||
}
|
}
|
||||||
return field?.value
|
return field?.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSelectedRecurrenceRule = (field?: { value: string }) => {
|
||||||
|
return field?.value as RecurrenceRule
|
||||||
|
}
|
||||||
const defaultSplittingOptions = getDefaultSplittingOptions(group)
|
const defaultSplittingOptions = getDefaultSplittingOptions(group)
|
||||||
const form = useForm<ExpenseFormValues>({
|
const form = useForm<ExpenseFormValues>({
|
||||||
resolver: zodResolver(expenseFormSchema),
|
resolver: zodResolver(expenseFormSchema),
|
||||||
@@ -185,6 +190,7 @@ export function ExpenseForm({
|
|||||||
isReimbursement: expense.isReimbursement,
|
isReimbursement: expense.isReimbursement,
|
||||||
documents: expense.documents,
|
documents: expense.documents,
|
||||||
notes: expense.notes ?? '',
|
notes: expense.notes ?? '',
|
||||||
|
recurrenceRule: expense.recurrenceRule,
|
||||||
}
|
}
|
||||||
: searchParams.get('reimbursement')
|
: searchParams.get('reimbursement')
|
||||||
? {
|
? {
|
||||||
@@ -208,6 +214,7 @@ export function ExpenseForm({
|
|||||||
saveDefaultSplittingOptions: false,
|
saveDefaultSplittingOptions: false,
|
||||||
documents: [],
|
documents: [],
|
||||||
notes: '',
|
notes: '',
|
||||||
|
recurrenceRule: RecurrenceRule.NONE,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
title: searchParams.get('title') ?? '',
|
title: searchParams.get('title') ?? '',
|
||||||
@@ -235,6 +242,7 @@ export function ExpenseForm({
|
|||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
notes: '',
|
notes: '',
|
||||||
|
recurrenceRule: RecurrenceRule.NONE,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [isCategoryLoading, setCategoryLoading] = useState(false)
|
const [isCategoryLoading, setCategoryLoading] = useState(false)
|
||||||
@@ -495,6 +503,43 @@ export function ExpenseForm({
|
|||||||
</FormItem>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function GET(
|
|||||||
paidFor: { select: { participantId: true, shares: true } },
|
paidFor: { select: { participantId: true, shares: true } },
|
||||||
isReimbursement: true,
|
isReimbursement: true,
|
||||||
splitMode: true,
|
splitMode: true,
|
||||||
|
recurrenceRule: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ expenseDate: 'asc' }, { createdAt: 'asc' }],
|
orderBy: [{ expenseDate: 'asc' }, { createdAt: 'asc' }],
|
||||||
},
|
},
|
||||||
|
|||||||
259
src/lib/api.ts
259
src/lib/api.ts
@@ -1,6 +1,6 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
|
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
|
||||||
import { ActivityType, Expense } from '@prisma/client'
|
import { ActivityType, Expense, RecurrenceRule, RecurringExpenseLink } from '@prisma/client'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
|
|
||||||
export function randomId() {
|
export function randomId() {
|
||||||
@@ -50,6 +50,13 @@ export async function createExpense(
|
|||||||
data: expenseFormValues.title,
|
data: expenseFormValues.title,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isCreateRecurrence = expenseFormValues.recurrenceRule !== RecurrenceRule.NONE
|
||||||
|
const recurringExpenseLinkPayload = createPayloadForNewRecurringExpenseLink(
|
||||||
|
expenseFormValues.recurrenceRule as RecurrenceRule,
|
||||||
|
expenseFormValues.expenseDate,
|
||||||
|
groupId
|
||||||
|
)
|
||||||
|
|
||||||
return prisma.expense.create({
|
return prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
id: expenseId,
|
id: expenseId,
|
||||||
@@ -60,6 +67,16 @@ export async function createExpense(
|
|||||||
title: expenseFormValues.title,
|
title: expenseFormValues.title,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
splitMode: expenseFormValues.splitMode,
|
splitMode: expenseFormValues.splitMode,
|
||||||
|
recurrenceRule: expenseFormValues.recurrenceRule,
|
||||||
|
recurringExpenseLink: {
|
||||||
|
...(isCreateRecurrence
|
||||||
|
? {
|
||||||
|
create: recurringExpenseLinkPayload
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
),
|
||||||
|
|
||||||
|
},
|
||||||
paidFor: {
|
paidFor: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: expenseFormValues.paidFor.map((paidFor) => ({
|
data: expenseFormValues.paidFor.map((paidFor) => ({
|
||||||
@@ -152,6 +169,32 @@ export async function updateExpense(
|
|||||||
data: expenseFormValues.title,
|
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({
|
return prisma.expense.update({
|
||||||
where: { id: expenseId },
|
where: { id: expenseId },
|
||||||
data: {
|
data: {
|
||||||
@@ -161,6 +204,7 @@ export async function updateExpense(
|
|||||||
categoryId: expenseFormValues.category,
|
categoryId: expenseFormValues.category,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
splitMode: expenseFormValues.splitMode,
|
splitMode: expenseFormValues.splitMode,
|
||||||
|
recurrenceRule: expenseFormValues.recurrenceRule,
|
||||||
paidFor: {
|
paidFor: {
|
||||||
create: expenseFormValues.paidFor
|
create: expenseFormValues.paidFor
|
||||||
.filter(
|
.filter(
|
||||||
@@ -191,6 +235,23 @@ export async function updateExpense(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
recurringExpenseLink: {
|
||||||
|
...(isCreateRecurrenceExpenseLink
|
||||||
|
? {
|
||||||
|
create: newRecurringExpenseLink
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
),
|
||||||
|
...(isUpdateRecurrenceExpenseLink
|
||||||
|
? {
|
||||||
|
update: {
|
||||||
|
nextExpenseDate: updatedRecurrenceExpenseLinkNextExpenseDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
),
|
||||||
|
delete: isDeleteRecurrenceExpenseLink,
|
||||||
|
},
|
||||||
isReimbursement: expenseFormValues.isReimbursement,
|
isReimbursement: expenseFormValues.isReimbursement,
|
||||||
documents: {
|
documents: {
|
||||||
connectOrCreate: expenseFormValues.documents.map((doc) => ({
|
connectOrCreate: expenseFormValues.documents.map((doc) => ({
|
||||||
@@ -269,6 +330,8 @@ export async function getGroupExpenses(
|
|||||||
groupId: string,
|
groupId: string,
|
||||||
options?: { offset?: number; length?: number; filter?: string },
|
options?: { offset?: number; length?: number; filter?: string },
|
||||||
) {
|
) {
|
||||||
|
await createRecurringExpenses()
|
||||||
|
|
||||||
return prisma.expense.findMany({
|
return prisma.expense.findMany({
|
||||||
select: {
|
select: {
|
||||||
amount: true,
|
amount: true,
|
||||||
@@ -285,6 +348,7 @@ export async function getGroupExpenses(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
splitMode: true,
|
splitMode: true,
|
||||||
|
recurrenceRule: true,
|
||||||
title: true,
|
title: true,
|
||||||
_count: { select: { documents: true } },
|
_count: { select: { documents: true } },
|
||||||
},
|
},
|
||||||
@@ -307,7 +371,7 @@ export async function getGroupExpenseCount(groupId: string) {
|
|||||||
export async function getExpense(groupId: string, expenseId: string) {
|
export async function getExpense(groupId: string, expenseId: string) {
|
||||||
return prisma.expense.findUnique({
|
return prisma.expense.findUnique({
|
||||||
where: { id: expenseId },
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SplitMode } from '@prisma/client'
|
import { SplitMode, RecurrenceRule } from '@prisma/client'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
export const groupFormSchema = z
|
export const groupFormSchema = z
|
||||||
@@ -105,6 +105,11 @@ export const expenseFormSchema = z
|
|||||||
)
|
)
|
||||||
.default([]),
|
.default([]),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
recurrenceRule:z
|
||||||
|
.enum<RecurrenceRule, [RecurrenceRule, ...RecurrenceRule[]]>(
|
||||||
|
Object.values(RecurrenceRule) as any
|
||||||
|
)
|
||||||
|
.default('NONE'),
|
||||||
})
|
})
|
||||||
.superRefine((expense, ctx) => {
|
.superRefine((expense, ctx) => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user