import { prisma } from '@/lib/prisma' import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas' import { ActivityType, Expense, RecurrenceRule, RecurringExpenseLink, } from '@prisma/client' import { nanoid } from 'nanoid' export function randomId() { return nanoid() } export async function createGroup(groupFormValues: GroupFormValues) { return prisma.group.create({ data: { id: randomId(), name: groupFormValues.name, information: groupFormValues.information, currency: groupFormValues.currency, currencyCode: groupFormValues.currencyCode, participants: { createMany: { data: groupFormValues.participants.map(({ name }) => ({ id: randomId(), name, })), }, }, }, include: { participants: true }, }) } export async function createExpense( expenseFormValues: ExpenseFormValues, groupId: string, participantId?: string, ): Promise { const group = await getGroup(groupId) if (!group) throw new Error(`Invalid group ID: ${groupId}`) for (const participant of [ expenseFormValues.paidBy, ...expenseFormValues.paidFor.map((p) => p.participant), ]) { if (!group.participants.some((p) => p.id === participant)) throw new Error(`Invalid participant ID: ${participant}`) } const expenseId = randomId() await logActivity(groupId, ActivityType.CREATE_EXPENSE, { participantId, expenseId, 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, groupId, expenseDate: expenseFormValues.expenseDate, categoryId: expenseFormValues.category, amount: expenseFormValues.amount, originalAmount: expenseFormValues.originalAmount, originalCurrency: expenseFormValues.originalCurrency, conversionRate: expenseFormValues.conversionRate, title: expenseFormValues.title, paidById: expenseFormValues.paidBy, splitMode: expenseFormValues.splitMode, recurrenceRule: expenseFormValues.recurrenceRule, recurringExpenseLink: { ...(isCreateRecurrence ? { create: recurringExpenseLinkPayload, } : {}), }, paidFor: { createMany: { data: expenseFormValues.paidFor.map((paidFor) => ({ participantId: paidFor.participant, shares: paidFor.shares, })), }, }, isReimbursement: expenseFormValues.isReimbursement, documents: { createMany: { data: expenseFormValues.documents.map((doc) => ({ id: randomId(), url: doc.url, width: doc.width, height: doc.height, })), }, }, notes: expenseFormValues.notes, }, }) } export async function deleteExpense( groupId: string, expenseId: string, participantId?: string, ) { const existingExpense = await getExpense(groupId, expenseId) await logActivity(groupId, ActivityType.DELETE_EXPENSE, { participantId, expenseId, data: existingExpense?.title, }) await prisma.expense.delete({ where: { id: expenseId }, include: { paidFor: true, paidBy: true }, }) } export async function getGroupExpensesParticipants(groupId: string) { const expenses = await getGroupExpenses(groupId) return Array.from( new Set( expenses.flatMap((e) => [ e.paidBy.id, ...e.paidFor.map((pf) => pf.participant.id), ]), ), ) } export async function getGroups(groupIds: string[]) { return ( await prisma.group.findMany({ where: { id: { in: groupIds } }, include: { _count: { select: { participants: true } } }, }) ).map((group) => ({ ...group, createdAt: group.createdAt.toISOString(), })) } export async function updateExpense( groupId: string, expenseId: string, expenseFormValues: ExpenseFormValues, participantId?: string, ) { const group = await getGroup(groupId) if (!group) throw new Error(`Invalid group ID: ${groupId}`) const existingExpense = await getExpense(groupId, expenseId) if (!existingExpense) throw new Error(`Invalid expense ID: ${expenseId}`) for (const participant of [ expenseFormValues.paidBy, ...expenseFormValues.paidFor.map((p) => p.participant), ]) { if (!group.participants.some((p) => p.id === participant)) throw new Error(`Invalid participant ID: ${participant}`) } await logActivity(groupId, ActivityType.UPDATE_EXPENSE, { participantId, expenseId, 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: { expenseDate: expenseFormValues.expenseDate, amount: expenseFormValues.amount, originalAmount: expenseFormValues.originalAmount, originalCurrency: expenseFormValues.originalCurrency, conversionRate: expenseFormValues.conversionRate, title: expenseFormValues.title, categoryId: expenseFormValues.category, paidById: expenseFormValues.paidBy, splitMode: expenseFormValues.splitMode, recurrenceRule: expenseFormValues.recurrenceRule, paidFor: { create: expenseFormValues.paidFor .filter( (p) => !existingExpense.paidFor.some( (pp) => pp.participantId === p.participant, ), ) .map((paidFor) => ({ participantId: paidFor.participant, shares: paidFor.shares, })), update: expenseFormValues.paidFor.map((paidFor) => ({ where: { expenseId_participantId: { expenseId, participantId: paidFor.participant, }, }, data: { shares: paidFor.shares, }, })), deleteMany: existingExpense.paidFor.filter( (paidFor) => !expenseFormValues.paidFor.some( (pf) => pf.participant === paidFor.participantId, ), ), }, recurringExpenseLink: { ...(isCreateRecurrenceExpenseLink ? { create: newRecurringExpenseLink, } : {}), ...(isUpdateRecurrenceExpenseLink ? { update: { nextExpenseDate: updatedRecurrenceExpenseLinkNextExpenseDate, }, } : {}), delete: isDeleteRecurrenceExpenseLink, }, isReimbursement: expenseFormValues.isReimbursement, documents: { connectOrCreate: expenseFormValues.documents.map((doc) => ({ create: doc, where: { id: doc.id }, })), deleteMany: existingExpense.documents .filter( (existingDoc) => !expenseFormValues.documents.some( (doc) => doc.id === existingDoc.id, ), ) .map((doc) => ({ id: doc.id, })), }, notes: expenseFormValues.notes, }, }) } export async function updateGroup( groupId: string, groupFormValues: GroupFormValues, participantId?: string, ) { const existingGroup = await getGroup(groupId) if (!existingGroup) throw new Error('Invalid group ID') await logActivity(groupId, ActivityType.UPDATE_GROUP, { participantId }) return prisma.group.update({ where: { id: groupId }, data: { name: groupFormValues.name, information: groupFormValues.information, currency: groupFormValues.currency, currencyCode: groupFormValues.currencyCode, participants: { deleteMany: existingGroup.participants.filter( (p) => !groupFormValues.participants.some((p2) => p2.id === p.id), ), updateMany: groupFormValues.participants .filter((participant) => participant.id !== undefined) .map((participant) => ({ where: { id: participant.id }, data: { name: participant.name, }, })), createMany: { data: groupFormValues.participants .filter((participant) => participant.id === undefined) .map((participant) => ({ id: randomId(), name: participant.name, })), }, }, }, }) } export async function getGroup(groupId: string) { return prisma.group.findUnique({ where: { id: groupId }, include: { participants: true }, }) } export async function getCategories() { return prisma.category.findMany() } export async function getGroupExpenses( groupId: string, options?: { offset?: number; length?: number; filter?: string }, ) { await createRecurringExpenses() return prisma.expense.findMany({ select: { amount: true, category: true, createdAt: true, expenseDate: true, id: true, isReimbursement: true, paidBy: { select: { id: true, name: true } }, paidFor: { select: { participant: { select: { id: true, name: true } }, shares: true, }, }, splitMode: true, recurrenceRule: true, title: true, _count: { select: { documents: true } }, }, where: { groupId, title: options?.filter ? { contains: options.filter, mode: 'insensitive' } : undefined, }, orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }], skip: options && options.offset, take: options && options.length, }) } export async function getGroupExpenseCount(groupId: string) { return prisma.expense.count({ where: { groupId } }) } export async function getExpense(groupId: string, expenseId: string) { return prisma.expense.findUnique({ where: { id: expenseId }, include: { paidBy: true, paidFor: true, category: true, documents: true, recurringExpenseLink: true, }, }) } export async function getActivities( groupId: string, options?: { offset?: number; length?: number }, ) { const activities = await prisma.activity.findMany({ where: { groupId }, orderBy: [{ time: 'desc' }], skip: options?.offset, take: options?.length, }) const expenseIds = activities .map((activity) => activity.expenseId) .filter(Boolean) const expenses = await prisma.expense.findMany({ where: { groupId, id: { in: expenseIds }, }, }) return activities.map((activity) => ({ ...activity, expense: activity.expenseId !== null ? expenses.find((expense) => expense.id === activity.expenseId) : undefined, })) } export async function logActivity( groupId: string, activityType: ActivityType, extra?: { participantId?: string; expenseId?: string; data?: string }, ) { return prisma.activity.create({ data: { id: randomId(), groupId, activityType, ...extra, }, }) } 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 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 }