diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 688ff6e..1695aab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -298,6 +298,7 @@ model Transaction { description String? notes String? date DateTime + scheduledDate DateTime? isRecurring Boolean @default(false) recurringRuleId String? @map("recurring_rule_id") receipt_url String? @@ -315,6 +316,7 @@ model Transaction { recurringRule RecurringRule? @relation(fields: [recurringRuleId], references: [id], onDelete: SetNull) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([recurringRuleId, scheduledDate]) @@index([userId]) @@index([accountId]) @@index([categoryId]) @@ -340,7 +342,11 @@ model RecurringRule { frequency RecurringFrequency interval Int @default(1) dayOfMonth Int? + semiMonthlyDay Int? dayOfWeek Int? + weekOfMonth Int? + lastDayOfMonth Boolean @default(false) + overrides Json? nextRunAt DateTime lastRunAt DateTime? status RecurringStatus @default(ACTIVE) @@ -642,6 +648,7 @@ enum TransactionType { enum RecurringFrequency { DAILY WEEKLY + SEMI_MONTHLY MONTHLY YEARLY } diff --git a/src/app/(public)/blog/[slug]/page.tsx b/src/app/(public)/blog/[slug]/page.tsx index 3905021..2a6a4b2 100644 --- a/src/app/(public)/blog/[slug]/page.tsx +++ b/src/app/(public)/blog/[slug]/page.tsx @@ -28,7 +28,7 @@ export default function Page() { publishedDate: recent.date ?? "", content: recent.excerpt ? [recent.excerpt] : [], coverImage: recent.image, - } as BlogPost; + }; } } } diff --git a/src/app/(public)/blog/page.tsx b/src/app/(public)/blog/page.tsx index c5979e9..8a6ec93 100644 --- a/src/app/(public)/blog/page.tsx +++ b/src/app/(public)/blog/page.tsx @@ -72,7 +72,7 @@ export default function BlogPage() { imageAlt: p.imageAlt, title: p.title, excerpt: postExcerpts[p.title] ?? "", - href: p.href as string | undefined, + href: p.href, date: postDates[i] ?? "", category: postCategories[i] ?? "Product", })), diff --git a/src/components/forms/accounts/account-form.tsx b/src/components/forms/accounts/account-form.tsx index 93ceb4c..bee66d0 100644 --- a/src/components/forms/accounts/account-form.tsx +++ b/src/components/forms/accounts/account-form.tsx @@ -147,7 +147,7 @@ export function AccountForm({ typeof values.balance === "number" ? String(values.balance) : values.balance, - } as UpdateAccountInput; + }; await updateAccount(payload); toast.success("Account updated"); if (onSubmit) onSubmit(payload); diff --git a/src/components/forms/categories/subcategory-form.tsx b/src/components/forms/categories/subcategory-form.tsx index 15f44e7..328769e 100644 --- a/src/components/forms/categories/subcategory-form.tsx +++ b/src/components/forms/categories/subcategory-form.tsx @@ -81,14 +81,13 @@ export function SubcategoryForm({ type FormValues = CreateSubcategoryInput; const normalize = React.useCallback( - (vals?: SubcategoryFormProps["initialValues"]) => - ({ - name: vals?.name ?? "", - parentId: vals?.parentId ?? undefined, - icon: vals?.icon ?? undefined, - color: vals?.color ?? undefined, - sortOrder: vals?.sortOrder ?? undefined, - }) as Partial, + (vals?: SubcategoryFormProps["initialValues"]) => ({ + name: vals?.name ?? "", + parentId: vals?.parentId ?? undefined, + icon: vals?.icon ?? undefined, + color: vals?.color ?? undefined, + sortOrder: vals?.sortOrder ?? undefined, + }), [], ); @@ -111,7 +110,7 @@ export function SubcategoryForm({ const payload: UpdateSubcategoryInput = { id: initialValues.id, ...values, - } as UpdateSubcategoryInput; + }; await updateSubcategory.mutateAsync(payload); toast.success("Subcategory updated successfully"); if (onSubmit) onSubmit(payload); diff --git a/src/components/forms/transaction/steps/category-step.tsx b/src/components/forms/transaction/steps/category-step.tsx index 720b8db..04556ad 100644 --- a/src/components/forms/transaction/steps/category-step.tsx +++ b/src/components/forms/transaction/steps/category-step.tsx @@ -263,7 +263,7 @@ const CategoryStep = React.memo(function CategoryStep({ - - - - - Daily - Weekly - Monthly - Yearly - - - - - - )} - /> + {/* Frequency select */} + + + Frequency + + + - ( - - - Every - - - field.onChange(Number(e.target.value))} - className="bg-background h-9" + {/* Interval — hidden for bi-weekly and semi-monthly */} + {!isBiweekly && frequency !== "SEMI_MONTHLY" && ( + ( + + + Every + + + field.onChange(Number(e.target.value))} + className="bg-background h-9" + /> + + + + )} + /> + )} + + {/* WEEKLY / BIWEEKLY: day of week */} + {(frequency === "WEEKLY" || isBiweekly) && ( + ( + + + Day of week + + + + + + + )} + /> + )} + + {/* SEMI_MONTHLY: two day pickers */} + {frequency === "SEMI_MONTHLY" && ( + <> + ( + + + First day + + + + field.onChange(Number(e.target.value)) + } + className="bg-background h-9" + /> + + + + )} + /> + ( + + + Second day + + + + field.onChange(Number(e.target.value)) + } + className="bg-background h-9" + /> + + + + )} + /> + + )} + + {/* MONTHLY: mode selector */} + {frequency === "MONTHLY" && ( +
+ + Repeat on + + + handleMonthlyModeChange(v as MonthlyMode) + } + className="space-y-2" + > +
+ + +
+
+ + +
+
+ + +
+
+ + {/* Specific day input */} + {monthlyMode === "specific-day" && ( + ( + + + + field.onChange( + e.target.value + ? Number(e.target.value) + : undefined, + ) + } + className="bg-background h-9" + /> + + + + )} + /> + )} + + {/* Day pattern selects */} + {monthlyMode === "day-pattern" && ( +
+ ( + + + + + + + )} /> - - - - )} - /> + ( + + + + + + + )} + /> +
+ )} +
+ )} + {/* Start Date */} + {/* End Date */} { - setFormData( - (prev) => ({ ...prev, [name]: value as FormData[K] }) as FormData, - ); + setFormData((prev) => ({ ...prev, [name]: value }) as FormData); }; return ( diff --git a/src/components/pages/(protected)/splits/group-detail/expense-form-sheet.tsx b/src/components/pages/(protected)/splits/group-detail/expense-form-sheet.tsx index a27e586..a9fa953 100644 --- a/src/components/pages/(protected)/splits/group-detail/expense-form-sheet.tsx +++ b/src/components/pages/(protected)/splits/group-detail/expense-form-sheet.tsx @@ -150,7 +150,7 @@ export function ExpenseFormSheet({ }, }); - const splitMethod = form.watch("splitMethod") as SplitMethod; + const splitMethod = form.watch("splitMethod"); const amount = form.watch("amount"); const baseParticipants = useMemo(() => buildParticipants(members), [members]); diff --git a/src/components/pages/(protected)/splits/group-detail/spending-radar-chart.tsx b/src/components/pages/(protected)/splits/group-detail/spending-radar-chart.tsx index 443fc04..2ec15ce 100644 --- a/src/components/pages/(protected)/splits/group-detail/spending-radar-chart.tsx +++ b/src/components/pages/(protected)/splits/group-detail/spending-radar-chart.tsx @@ -41,9 +41,9 @@ function SpendingRadarChartInner({ return { radarData: [], radarDataKeys: [], - radarConfig: {} as ChartConfig, + radarConfig: {}, barData: [], - barConfig: {} as ChartConfig, + barConfig: {}, categoryCount: 0, }; } diff --git a/src/components/pages/(public)/blog/content-section.tsx b/src/components/pages/(public)/blog/content-section.tsx index 4ab7cd4..06342c6 100644 --- a/src/components/pages/(public)/blog/content-section.tsx +++ b/src/components/pages/(public)/blog/content-section.tsx @@ -144,10 +144,7 @@ export const ContentSection = ({ if (inline) { return ( - )} - > + {children} ); @@ -174,10 +171,7 @@ export const ContentSection = ({
-                )}
-                >
+                
                   {children}
                 
               
@@ -282,10 +276,7 @@ export const ContentSection = ({ if (isInternal) { return ( - )} - > + {children} @@ -297,7 +288,7 @@ export const ContentSection = ({ target="_blank" rel="noopener noreferrer" className="text-primary underline" - {...(rest as React.AnchorHTMLAttributes)} + {...rest} > {children} @@ -315,7 +306,7 @@ export const ContentSection = ({ src={src} alt={alt ?? ""} className="h-48 w-full rounded-md object-cover" - {...(rest as unknown as Record)} + {...(rest as Record)} /> ); diff --git a/src/components/pages/(public)/home/content-section.tsx b/src/components/pages/(public)/home/content-section.tsx index 3af73d2..4b703d3 100644 --- a/src/components/pages/(public)/home/content-section.tsx +++ b/src/components/pages/(public)/home/content-section.tsx @@ -1,10 +1,6 @@ import { Cpu, Zap } from "lucide-react"; import Image from "next/image"; -import { contentSection as rawContentSection } from "@content/site/home"; -import type { ContentSectionContent } from "@/types/site"; - -const contentSection: ContentSectionContent = - rawContentSection as unknown as ContentSectionContent; +import { contentSection } from "@content/site/home"; export default function ContentSection() { return ( diff --git a/src/constants/recurrence.ts b/src/constants/recurrence.ts new file mode 100644 index 0000000..c94e7c2 --- /dev/null +++ b/src/constants/recurrence.ts @@ -0,0 +1,28 @@ +export const FREQUENCY_OPTIONS = [ + { value: "DAILY", label: "Daily" }, + { value: "WEEKLY", label: "Weekly" }, + { value: "BIWEEKLY", label: "Bi-weekly" }, + { value: "SEMI_MONTHLY", label: "Twice a month" }, + { value: "MONTHLY", label: "Monthly" }, + { value: "YEARLY", label: "Yearly" }, +] as const; + +export const DAYS_OF_WEEK = [ + { value: 0, label: "Sunday" }, + { value: 1, label: "Monday" }, + { value: 2, label: "Tuesday" }, + { value: 3, label: "Wednesday" }, + { value: 4, label: "Thursday" }, + { value: 5, label: "Friday" }, + { value: 6, label: "Saturday" }, +] as const; + +export const WEEK_OF_MONTH_OPTIONS = [ + { value: 1, label: "First" }, + { value: 2, label: "Second" }, + { value: 3, label: "Third" }, + { value: 4, label: "Fourth" }, + { value: 5, label: "Last" }, +] as const; + +export type MonthlyMode = "specific-day" | "day-pattern" | "last-day"; diff --git a/src/lib/inngest/functions/recurring.ts b/src/lib/inngest/functions/recurring.ts index c253003..097175d 100644 --- a/src/lib/inngest/functions/recurring.ts +++ b/src/lib/inngest/functions/recurring.ts @@ -9,7 +9,7 @@ import { calculateNextRunAt } from "@/lib/recurrence"; import { sendEmail } from "@/lib/email"; import { toNum } from "@shared/decimal"; import { RecurringStatus } from "@prisma/client"; -import type { RecurrenceConfig } from "@/types/recurrence"; +import type { RecurrenceConfig, RecurringOverrides } from "@/types/recurrence"; export const processRecurringTransaction = inngest.createFunction( { @@ -34,7 +34,11 @@ export const processRecurringTransaction = inngest.createFunction( frequency: true, interval: true, dayOfMonth: true, + semiMonthlyDay: true, dayOfWeek: true, + weekOfMonth: true, + lastDayOfMonth: true, + overrides: true, startDate: true, endDate: true, nextRunAt: true, @@ -49,14 +53,18 @@ export const processRecurringTransaction = inngest.createFunction( return; } - const runAt = new Date(); + // Use the scheduled date (rule.nextRunAt) as the transaction date, not "now" + const scheduledDate = rule.nextRunAt; const cfg: RecurrenceConfig = { - frequency: rule.frequency as RecurrenceConfig["frequency"], + frequency: rule.frequency, interval: rule.interval ?? 1, dayOfMonth: rule.dayOfMonth ?? undefined, + semiMonthlyDay: rule.semiMonthlyDay ?? undefined, dayOfWeek: rule.dayOfWeek ?? undefined, - // schema guarantees startDate exists + weekOfMonth: rule.weekOfMonth ?? undefined, + lastDayOfMonth: rule.lastDayOfMonth ?? undefined, + overrides: (rule.overrides as RecurringOverrides) ?? undefined, startDate: rule.startDate instanceof Date ? rule.startDate @@ -73,35 +81,60 @@ export const processRecurringTransaction = inngest.createFunction( : undefined, }; - const nextRunAt = calculateNextRunAt(cfg, runAt); + // Compute next from the scheduled date (not "now") to keep the cadence anchored + const nextRunAt = calculateNextRunAt(cfg, scheduledDate); // Atomically create transaction and update rule to prevent duplicates on crash - await db.$transaction(async (tx) => { - const created = await tx.transaction.create({ - data: { - userId: rule.userId, - accountId: rule.accountId, - categoryId: rule.categoryId ?? null, - amount: rule.amount, - type: rule.type, - description: rule.description ?? null, - notes: rule.notes ?? null, - date: runAt, - isRecurring: true, - recurringRuleId: rule.id, - }, - }); + try { + await db.$transaction(async (tx) => { + await tx.transaction.create({ + data: { + userId: rule.userId, + accountId: rule.accountId, + categoryId: rule.categoryId ?? null, + amount: rule.amount, + type: rule.type, + description: rule.description ?? null, + notes: rule.notes ?? null, + date: scheduledDate, + scheduledDate, + isRecurring: true, + recurringRuleId: rule.id, + }, + }); - await tx.recurringRule.update({ - where: { id: rule.id }, - data: { - lastRunAt: runAt, - lastTransactionId: created.id, - status: nextRunAt ? RecurringStatus.ACTIVE : RecurringStatus.ENDED, - ...(nextRunAt && { nextRunAt }), - }, + await tx.recurringRule.update({ + where: { id: rule.id }, + data: { + lastRunAt: new Date(), + status: nextRunAt ? RecurringStatus.ACTIVE : RecurringStatus.ENDED, + ...(nextRunAt && { nextRunAt }), + }, + }); }); - }); + } catch (error: unknown) { + // P2002 = unique constraint violation → duplicate transaction for this scheduledDate + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "P2002" + ) { + logger.warn( + `Duplicate recurring transaction detected for rule ${rule.id}, advancing nextRunAt`, + ); + // Advance the rule so it doesn't get stuck + if (nextRunAt) { + await db.recurringRule.update({ + where: { id: rule.id }, + data: { nextRunAt }, + }); + await enqueueRecurringRun(rule.id, nextRunAt); + } + return; + } + throw error; + } // Schedule the next occurrence, if any if (nextRunAt) { @@ -114,7 +147,7 @@ export const processRecurringTransaction = inngest.createFunction( await sendEmail({ to: userEmail, subject: "Recurring transaction processed", - html: `A recurring transaction for ${rule.description ?? "Transaction"} was processed for ${toNum(rule.amount).toFixed(2)} on ${runAt.toDateString()}.`, + html: `A recurring transaction for ${rule.description ?? "Transaction"} was processed for ${toNum(rule.amount).toFixed(2)} on ${scheduledDate.toDateString()}.`, }); } }, diff --git a/src/lib/recurrence.ts b/src/lib/recurrence.ts index 0a5c043..b233424 100644 --- a/src/lib/recurrence.ts +++ b/src/lib/recurrence.ts @@ -8,7 +8,11 @@ import type { RecurrenceConfig } from "@/types/recurrence"; export function calculateNextRunAt( config: RecurrenceConfig, anchor?: Date | string, + _depth = 0, ): Date | null { + // Guard against infinite recursion from skipped dates + if (_depth > 100) return null; + const interval = config.interval && config.interval > 0 ? config.interval : 1; const base = anchor ?? config.nextRunAt ?? config.startDate; const from = base instanceof Date ? base : new Date(base); @@ -19,38 +23,68 @@ export function calculateNextRunAt( case "DAILY": next = addDays(from, interval); break; + case "WEEKLY": { const desiredDow = typeof config.dayOfWeek === "number" ? config.dayOfWeek : from.getUTCDay(); - // Start from current week boundary and move forward interval weeks, landing on desiredDow const currentDow = from.getUTCDay(); const daysUntil = (7 - currentDow + desiredDow) % 7 || 7; const target = addDays(from, daysUntil); next = addWeeks(target, interval - 1); break; } + + case "SEMI_MONTHLY": { + next = computeSemiMonthly(from, config.dayOfMonth, config.semiMonthlyDay); + break; + } + case "MONTHLY": { - const candidate = addMonths(from, interval); - if (typeof config.dayOfMonth === "number") { - const safeDay = Math.min( - Math.max(config.dayOfMonth, 1), - daysInMonth(candidate.getUTCFullYear(), candidate.getUTCMonth()), + if (config.lastDayOfMonth) { + // Always land on the last day of the target month + const candidate = addMonths(from, interval); + const lastDay = daysInMonth( + candidate.getUTCFullYear(), + candidate.getUTCMonth(), + ); + candidate.setUTCDate(lastDay); + next = candidate; + } else if ( + typeof config.weekOfMonth === "number" && + typeof config.dayOfWeek === "number" + ) { + // Nth weekday pattern (e.g., "2nd Tuesday") + next = computeNthWeekday( + from, + interval, + config.weekOfMonth, + config.dayOfWeek, ); - candidate.setUTCDate(safeDay); + } else { + // Standard monthly with specific day + const candidate = addMonths(from, interval); + if (typeof config.dayOfMonth === "number") { + const safeDay = Math.min( + Math.max(config.dayOfMonth, 1), + daysInMonth(candidate.getUTCFullYear(), candidate.getUTCMonth()), + ); + candidate.setUTCDate(safeDay); + } + next = candidate; } - next = candidate; break; } + case "YEARLY": default: { - const candidate = addYears(from, interval); - next = candidate; + next = addYears(from, interval); break; } } + // Check end date if (config.endDate) { const end = config.endDate instanceof Date @@ -59,9 +93,114 @@ export function calculateNextRunAt( if (next > end) return null; } + // Handle overrides (skipped/rescheduled dates) + if (config.overrides) { + const overrides = config.overrides; + const key = formatDateKey(next); + + // If this date was skipped, recurse to find the next valid date + if (overrides.skipped?.includes(key)) { + return calculateNextRunAt(config, next, _depth + 1); + } + + // If this date was rescheduled, return the new date + if (overrides.rescheduled?.[key]) { + return new Date(overrides.rescheduled[key]); + } + } + return next; } +/** + * Compute the next semi-monthly date. + * Alternates between dayOfMonth (first) and semiMonthlyDay (second). + */ +function computeSemiMonthly( + from: Date, + day1?: number | null, + day2?: number | null, +): Date { + const firstDay = day1 ?? 1; + const secondDay = day2 ?? 15; + + const year = from.getUTCFullYear(); + const month = from.getUTCMonth(); + const currentDate = from.getUTCDate(); + + // Determine the next occurrence: either day2 in current month, or day1 in next month + const maxDaysCurrent = daysInMonth(year, month); + const safeFirst = Math.min(firstDay, maxDaysCurrent); + const safeSecond = Math.min(secondDay, maxDaysCurrent); + + if (currentDate < safeFirst) { + // Next is first day this month + return new Date(Date.UTC(year, month, safeFirst)); + } else if (currentDate < safeSecond) { + // Next is second day this month + return new Date(Date.UTC(year, month, safeSecond)); + } else { + // Next is first day next month + const nextMonth = month + 1; + const nextYear = nextMonth > 11 ? year + 1 : year; + const normalizedMonth = nextMonth % 12; + const maxDaysNext = daysInMonth(nextYear, normalizedMonth); + return new Date( + Date.UTC(nextYear, normalizedMonth, Math.min(firstDay, maxDaysNext)), + ); + } +} + +/** + * Compute the Nth weekday of a target month. + * weekOfMonth: 1-4 = ordinal, 5 = last occurrence of that weekday. + */ +function computeNthWeekday( + from: Date, + interval: number, + weekOfMonth: number, + dayOfWeek: number, +): Date { + const target = addMonths(from, interval); + const year = target.getUTCFullYear(); + const month = target.getUTCMonth(); + + if (weekOfMonth === 5) { + // Last occurrence of dayOfWeek in the month + const lastDay = daysInMonth(year, month); + const lastDate = new Date(Date.UTC(year, month, lastDay)); + const lastDow = lastDate.getUTCDay(); + const diff = (lastDow - dayOfWeek + 7) % 7; + return new Date(Date.UTC(year, month, lastDay - diff)); + } + + // Nth occurrence (1-4) + const firstOfMonth = new Date(Date.UTC(year, month, 1)); + const firstDow = firstOfMonth.getUTCDay(); + const daysUntilTarget = (dayOfWeek - firstDow + 7) % 7; + const nthDay = 1 + daysUntilTarget + (weekOfMonth - 1) * 7; + + // Clamp to month bounds + const maxDays = daysInMonth(year, month); + if (nthDay > maxDays) { + // Fallback to last occurrence if Nth doesn't exist + const lastDate = new Date(Date.UTC(year, month, maxDays)); + const lastDow = lastDate.getUTCDay(); + const diff = (lastDow - dayOfWeek + 7) % 7; + return new Date(Date.UTC(year, month, maxDays - diff)); + } + + return new Date(Date.UTC(year, month, nthDay)); +} + +/** Format a Date as YYYY-MM-DD for override key comparison */ +export function formatDateKey(date: Date): string { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, "0"); + const d = String(date.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + function daysInMonth(year: number, monthIndexZeroBased: number): number { return new Date(Date.UTC(year, monthIndexZeroBased + 1, 0)).getUTCDate(); } diff --git a/src/lib/shared/error.ts b/src/lib/shared/error.ts index 97172cb..5e0002b 100644 --- a/src/lib/shared/error.ts +++ b/src/lib/shared/error.ts @@ -12,12 +12,12 @@ export function toError(err: unknown): Error { function hasMessage(obj: object): obj is { message: string } { return ( "message" in obj && - typeof (obj as { message: unknown }).message === "string" + typeof (obj as Record).message === "string" ); } function hasErrorString(obj: object): obj is { error: string } { return ( - "error" in obj && typeof (obj as { error: unknown }).error === "string" + "error" in obj && typeof (obj as Record).error === "string" ); } diff --git a/src/server/api/routers/accountRouter.ts b/src/server/api/routers/accountRouter.ts index 5a71000..51b1089 100644 --- a/src/server/api/routers/accountRouter.ts +++ b/src/server/api/routers/accountRouter.ts @@ -164,7 +164,7 @@ export const accountRouter = createTRPCRouter({ where: { id: input.id }, select: { id: true, userId: true }, }); - const existing = existingRaw as { id: string; userId: string } | null; + const existing = existingRaw; if (existing?.userId !== userId) throw new TRPCError({ code: "NOT_FOUND", diff --git a/src/server/api/routers/aiRouter.ts b/src/server/api/routers/aiRouter.ts index f51660e..b629347 100644 --- a/src/server/api/routers/aiRouter.ts +++ b/src/server/api/routers/aiRouter.ts @@ -98,7 +98,7 @@ export const aiRouter = createTRPCRouter({ where: { id: input.groupId }, select: { userId: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" }); } return AIService.generateGroupInsights(input.groupId, ctx.user.id); @@ -117,7 +117,7 @@ export const aiRouter = createTRPCRouter({ where: { id: input.groupId }, select: { userId: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" }); } return AIService.suggestSplit( diff --git a/src/server/api/routers/contactRouter.ts b/src/server/api/routers/contactRouter.ts index afae3d1..7285725 100644 --- a/src/server/api/routers/contactRouter.ts +++ b/src/server/api/routers/contactRouter.ts @@ -99,7 +99,7 @@ export const contactRouter = createTRPCRouter({ }, }); - if (!contact || contact.userId !== ctx.user.id) { + if (contact?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Contact not found", @@ -195,7 +195,7 @@ export const contactRouter = createTRPCRouter({ select: { userId: true, email: true, avatarUrl: true }, }); - if (!existing || existing.userId !== ctx.user.id) { + if (existing?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Contact not found", @@ -297,7 +297,7 @@ export const contactRouter = createTRPCRouter({ select: { userId: true }, }); - if (!existing || existing.userId !== ctx.user.id) { + if (existing?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Contact not found", diff --git a/src/server/api/routers/expenseRouter.ts b/src/server/api/routers/expenseRouter.ts index 43172a0..7ed1f9e 100644 --- a/src/server/api/routers/expenseRouter.ts +++ b/src/server/api/routers/expenseRouter.ts @@ -18,7 +18,7 @@ export const expenseRouter = createTRPCRouter({ where: { id: input.groupId }, select: { userId: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -142,7 +142,7 @@ export const expenseRouter = createTRPCRouter({ }, }); - if (!expense || expense.group.userId !== ctx.user.id) { + if (expense?.group.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found", @@ -171,7 +171,7 @@ export const expenseRouter = createTRPCRouter({ where: { id: input.groupId }, select: { userId: true, currency: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -184,7 +184,7 @@ export const expenseRouter = createTRPCRouter({ where: { id: input.categoryId }, select: { userId: true }, }); - if (!category || category.userId !== ctx.user.id) { + if (category?.userId !== ctx.user.id) { throw new TRPCError({ code: "BAD_REQUEST", message: "Category not found", @@ -297,7 +297,7 @@ export const expenseRouter = createTRPCRouter({ }, }); - if (!existing || existing.group.userId !== ctx.user.id) { + if (existing?.group.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found", @@ -358,7 +358,7 @@ export const expenseRouter = createTRPCRouter({ }, }); - if (!expense || expense.group.userId !== ctx.user.id) { + if (expense?.group.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found", diff --git a/src/server/api/routers/groupRouter.ts b/src/server/api/routers/groupRouter.ts index bddfc3a..e8b8b2d 100644 --- a/src/server/api/routers/groupRouter.ts +++ b/src/server/api/routers/groupRouter.ts @@ -111,7 +111,7 @@ export const groupRouter = createTRPCRouter({ }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -213,7 +213,7 @@ export const groupRouter = createTRPCRouter({ select: { userId: true }, }); - if (!existing || existing.userId !== ctx.user.id) { + if (existing?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -250,7 +250,7 @@ export const groupRouter = createTRPCRouter({ where: { id: input.groupId }, select: { userId: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -262,7 +262,7 @@ export const groupRouter = createTRPCRouter({ where: { id: input.contactId }, select: { userId: true }, }); - if (!contact || contact.userId !== ctx.user.id) { + if (contact?.userId !== ctx.user.id) { throw new TRPCError({ code: "BAD_REQUEST", message: "Contact not found", @@ -328,7 +328,7 @@ export const groupRouter = createTRPCRouter({ }, }); - if (!member || member.group.userId !== ctx.user.id) { + if (member?.group.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Member not found", @@ -363,7 +363,7 @@ export const groupRouter = createTRPCRouter({ where: { id: input.id }, select: { userId: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -385,7 +385,7 @@ export const groupRouter = createTRPCRouter({ where: { id: input.id }, select: { userId: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -407,7 +407,7 @@ export const groupRouter = createTRPCRouter({ where: { id: input.id }, select: { userId: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -438,7 +438,7 @@ export const groupRouter = createTRPCRouter({ }, }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -491,7 +491,7 @@ export const groupRouter = createTRPCRouter({ }, }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -543,7 +543,7 @@ export const groupRouter = createTRPCRouter({ }, }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -594,7 +594,7 @@ export const groupRouter = createTRPCRouter({ where: { id: input.id }, select: { userId: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", diff --git a/src/server/api/routers/settlementRouter.ts b/src/server/api/routers/settlementRouter.ts index f289aa6..e058c7f 100644 --- a/src/server/api/routers/settlementRouter.ts +++ b/src/server/api/routers/settlementRouter.ts @@ -15,7 +15,7 @@ export const settlementRouter = createTRPCRouter({ where: { id: input.groupId }, select: { userId: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", @@ -63,7 +63,7 @@ export const settlementRouter = createTRPCRouter({ where: { id: input.groupId }, select: { userId: true, currency: true }, }); - if (!group || group.userId !== ctx.user.id) { + if (group?.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Group not found", diff --git a/src/server/api/routers/transactionRouter.ts b/src/server/api/routers/transactionRouter.ts index ccb07a2..05c43a0 100644 --- a/src/server/api/routers/transactionRouter.ts +++ b/src/server/api/routers/transactionRouter.ts @@ -23,8 +23,11 @@ import { createTransactionSchema, updateTransactionSchema, transactionListInput, + skipOccurrenceSchema, + rescheduleOccurrenceSchema, } from "@/validation/transaction"; import type { RecurrenceInputStrict } from "@/types/transaction"; +import type { RecurringOverrides } from "@/types/recurrence"; export const transactionRouter = createTRPCRouter({ list: protectedProcedure @@ -94,6 +97,12 @@ export const transactionRouter = createTRPCRouter({ recurringRule: { select: { frequency: true, + interval: true, + dayOfMonth: true, + semiMonthlyDay: true, + dayOfWeek: true, + weekOfMonth: true, + lastDayOfMonth: true, nextRunAt: true, }, }, @@ -118,6 +127,12 @@ export const transactionRouter = createTRPCRouter({ recurringRule: t.recurringRule ? { frequency: t.recurringRule.frequency, + interval: t.recurringRule.interval, + dayOfMonth: t.recurringRule.dayOfMonth, + semiMonthlyDay: t.recurringRule.semiMonthlyDay, + dayOfWeek: t.recurringRule.dayOfWeek, + weekOfMonth: t.recurringRule.weekOfMonth, + lastDayOfMonth: t.recurringRule.lastDayOfMonth, nextRunAt: t.recurringRule.nextRunAt.toISOString(), } : null, @@ -157,6 +172,12 @@ export const transactionRouter = createTRPCRouter({ recurringRule: { select: { frequency: true, + interval: true, + dayOfMonth: true, + semiMonthlyDay: true, + dayOfWeek: true, + weekOfMonth: true, + lastDayOfMonth: true, nextRunAt: true, }, }, @@ -172,6 +193,12 @@ export const transactionRouter = createTRPCRouter({ recurringRule: t.recurringRule ? { frequency: t.recurringRule.frequency, + interval: t.recurringRule.interval, + dayOfMonth: t.recurringRule.dayOfMonth, + semiMonthlyDay: t.recurringRule.semiMonthlyDay, + dayOfWeek: t.recurringRule.dayOfWeek, + weekOfMonth: t.recurringRule.weekOfMonth, + lastDayOfMonth: t.recurringRule.lastDayOfMonth, nextRunAt: t.recurringRule.nextRunAt.toISOString(), } : null, @@ -201,15 +228,18 @@ export const transactionRouter = createTRPCRouter({ let newRule: { id: string; nextRunAt?: Date | null } | null = null; if (input.isRecurring && recurrence) { - const startDate = new Date( - (recurrence.startDate ?? initialDate) as string, - ); + const startDate = recurrence.startDate + ? new Date(recurrence.startDate) + : initialDate; const nextRunAt = startDate; const followingRun = calculateNextRunAt({ frequency: recurrence.frequency, interval: recurrence.interval, dayOfMonth: recurrence.dayOfMonth, + semiMonthlyDay: recurrence.semiMonthlyDay, dayOfWeek: recurrence.dayOfWeek, + weekOfMonth: recurrence.weekOfMonth, + lastDayOfMonth: recurrence.lastDayOfMonth, startDate, endDate: recurrence.endDate ? new Date(recurrence.endDate) @@ -233,7 +263,10 @@ export const transactionRouter = createTRPCRouter({ frequency: recurrence.frequency, interval: recurrence.interval ?? 1, dayOfMonth: recurrence.dayOfMonth ?? null, + semiMonthlyDay: recurrence.semiMonthlyDay ?? null, dayOfWeek: recurrence.dayOfWeek ?? null, + weekOfMonth: recurrence.weekOfMonth ?? null, + lastDayOfMonth: recurrence.lastDayOfMonth ?? false, nextRunAt: followingRun ?? nextRunAt, timezone: recurrence.timezone ?? "UTC", status: RecurringStatus.ACTIVE, @@ -402,7 +435,10 @@ export const transactionRouter = createTRPCRouter({ frequency: recurrence.frequency, interval: recurrence.interval, dayOfMonth: recurrence.dayOfMonth, + semiMonthlyDay: recurrence.semiMonthlyDay, dayOfWeek: recurrence.dayOfWeek, + weekOfMonth: recurrence.weekOfMonth, + lastDayOfMonth: recurrence.lastDayOfMonth, startDate, endDate: recurrence.endDate ? new Date(recurrence.endDate) @@ -434,7 +470,10 @@ export const transactionRouter = createTRPCRouter({ frequency: recurrence.frequency, interval: recurrence.interval ?? 1, dayOfMonth: recurrence.dayOfMonth ?? null, + semiMonthlyDay: recurrence.semiMonthlyDay ?? null, dayOfWeek: recurrence.dayOfWeek ?? null, + weekOfMonth: recurrence.weekOfMonth ?? null, + lastDayOfMonth: recurrence.lastDayOfMonth ?? false, nextRunAt: followingRun ?? startDate, status: RecurringStatus.ACTIVE, }, @@ -446,7 +485,10 @@ export const transactionRouter = createTRPCRouter({ frequency: recurrence.frequency, interval: recurrence.interval, dayOfMonth: recurrence.dayOfMonth, + semiMonthlyDay: recurrence.semiMonthlyDay, dayOfWeek: recurrence.dayOfWeek, + weekOfMonth: recurrence.weekOfMonth, + lastDayOfMonth: recurrence.lastDayOfMonth, startDate, endDate: recurrence.endDate ? new Date(recurrence.endDate) @@ -478,7 +520,10 @@ export const transactionRouter = createTRPCRouter({ frequency: recurrence.frequency, interval: recurrence.interval ?? 1, dayOfMonth: recurrence.dayOfMonth ?? null, + semiMonthlyDay: recurrence.semiMonthlyDay ?? null, dayOfWeek: recurrence.dayOfWeek ?? null, + weekOfMonth: recurrence.weekOfMonth ?? null, + lastDayOfMonth: recurrence.lastDayOfMonth ?? false, nextRunAt: followingRun ?? startDate, timezone: recurrence.timezone ?? "UTC", status: RecurringStatus.ACTIVE, @@ -734,12 +779,12 @@ export const transactionRouter = createTRPCRouter({ { id: cat.id, name: cat.name, - type: cat.type as "INCOME" | "EXPENSE" | "TRANSFER", + type: cat.type, }, ...(cat.children?.map((sub) => ({ id: sub.id, name: sub.name, - type: sub.type as "INCOME" | "EXPENSE" | "TRANSFER", + type: sub.type, parentCategoryId: cat.id, })) ?? []), ]); @@ -843,6 +888,64 @@ export const transactionRouter = createTRPCRouter({ count: createdTransactions.length, }; }), + + skipOccurrence: protectedProcedure + .input(skipOccurrenceSchema) + .mutation(async ({ ctx, input }) => { + const prisma = ctx.db; + const rule = await prisma.recurringRule.findUnique({ + where: { id: input.ruleId }, + select: { userId: true, overrides: true }, + }); + if (rule?.userId !== ctx.user.id) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Recurring rule not found", + }); + + const existing = (rule.overrides as RecurringOverrides | null) ?? { + skipped: [], + rescheduled: {}, + }; + if (!existing.skipped.includes(input.date)) { + existing.skipped.push(input.date); + } + + await prisma.recurringRule.update({ + where: { id: input.ruleId }, + data: { overrides: existing }, + }); + + return { success: true }; + }), + + rescheduleOccurrence: protectedProcedure + .input(rescheduleOccurrenceSchema) + .mutation(async ({ ctx, input }) => { + const prisma = ctx.db; + const rule = await prisma.recurringRule.findUnique({ + where: { id: input.ruleId }, + select: { userId: true, overrides: true }, + }); + if (rule?.userId !== ctx.user.id) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Recurring rule not found", + }); + + const existing = (rule.overrides as RecurringOverrides | null) ?? { + skipped: [], + rescheduled: {}, + }; + existing.rescheduled[input.originalDate] = input.newDate; + + await prisma.recurringRule.update({ + where: { id: input.ruleId }, + data: { overrides: existing }, + }); + + return { success: true }; + }), }); export default transactionRouter; diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 2698b4e..c4b80d2 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -104,15 +104,14 @@ export const createTRPCContext = async (opts: { headers: Headers }) => { callIfHelper(authExport.api?.get) ?? null; - const maybeLookupResult: AuthLookupResult | Promise = - maybeLookupResultRaw as AuthLookupResult | Promise; + const maybeLookupResult = maybeLookupResultRaw; const lookupResult = maybeLookupResult && typeof (maybeLookupResult as Promise).then === "function" ? await maybeLookupResult - : (maybeLookupResult as AuthLookupResult); + : maybeLookupResult; // If no session was resolved, log whether a cookie header was present on the request. if (!lookupResult) { diff --git a/src/types/recurrence.ts b/src/types/recurrence.ts index 3bf84fa..936c136 100644 --- a/src/types/recurrence.ts +++ b/src/types/recurrence.ts @@ -1,8 +1,17 @@ +export type RecurringOverrides = { + skipped: string[]; // ISO date strings of skipped occurrences + rescheduled: Record; // original ISO date → new ISO date +}; + export type RecurrenceConfig = { - frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; + frequency: "DAILY" | "WEEKLY" | "SEMI_MONTHLY" | "MONTHLY" | "YEARLY"; interval?: number | null; dayOfMonth?: number | null; + semiMonthlyDay?: number | null; dayOfWeek?: number | null; + weekOfMonth?: number | null; + lastDayOfMonth?: boolean | null; + overrides?: RecurringOverrides | null; startDate: Date | string; endDate?: Date | string | null; nextRunAt?: Date | string | null; diff --git a/src/types/transaction.ts b/src/types/transaction.ts index 4bfa129..e5a8db1 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -10,6 +10,7 @@ export interface Transaction { description?: string | null; notes?: string | null; date: string; // ISO date string + scheduledDate?: string | null; isRecurring: boolean; recurringRuleId?: string | null; receipt_url?: string | null; @@ -25,7 +26,13 @@ export interface Transaction { | "OTHER" | null; recurringRule?: { - frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; + frequency: "DAILY" | "WEEKLY" | "SEMI_MONTHLY" | "MONTHLY" | "YEARLY"; + interval: number; + dayOfMonth?: number | null; + semiMonthlyDay?: number | null; + dayOfWeek?: number | null; + weekOfMonth?: number | null; + lastDayOfMonth: boolean; nextRunAt: string; } | null; createdAt: string; @@ -44,10 +51,14 @@ export interface RecurringRule { startDate: string; endDate?: string | null; timezone?: string | null; - frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; + frequency: "DAILY" | "WEEKLY" | "SEMI_MONTHLY" | "MONTHLY" | "YEARLY"; interval: number; dayOfMonth?: number | null; + semiMonthlyDay?: number | null; dayOfWeek?: number | null; + weekOfMonth?: number | null; + lastDayOfMonth: boolean; + overrides?: Record | null; nextRunAt: string; lastRunAt?: string | null; status: "ACTIVE" | "PAUSED" | "CANCELLED" | "ENDED"; @@ -60,11 +71,14 @@ export interface RecurringRule { * Strict recurrence input type for internal calculation logic */ export type RecurrenceInputStrict = { - frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; + frequency: "DAILY" | "WEEKLY" | "SEMI_MONTHLY" | "MONTHLY" | "YEARLY"; interval?: number; startDate?: string; endDate?: string; timezone?: string; dayOfMonth?: number; + semiMonthlyDay?: number; dayOfWeek?: number; + weekOfMonth?: number; + lastDayOfMonth?: boolean; }; diff --git a/src/validation/transaction.ts b/src/validation/transaction.ts index e3b601f..7959477 100644 --- a/src/validation/transaction.ts +++ b/src/validation/transaction.ts @@ -1,13 +1,58 @@ import { z } from "zod"; -export const recurrenceSchema = z.object({ - frequency: z.enum(["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]), - interval: z.number().int().min(1).default(1), - startDate: z.string(), - endDate: z.string().optional(), - timezone: z.string().optional(), - dayOfMonth: z.number().int().min(1).max(31).optional(), - dayOfWeek: z.number().int().min(0).max(6).optional(), +export const recurrenceSchema = z + .object({ + frequency: z.enum(["DAILY", "WEEKLY", "SEMI_MONTHLY", "MONTHLY", "YEARLY"]), + interval: z.number().int().min(1).default(1), + startDate: z.string(), + endDate: z.string().optional(), + timezone: z.string().optional(), + dayOfMonth: z.number().int().min(1).max(31).optional(), + semiMonthlyDay: z.number().int().min(1).max(31).optional(), + dayOfWeek: z.number().int().min(0).max(6).optional(), + weekOfMonth: z.number().int().min(1).max(5).optional(), + lastDayOfMonth: z.boolean().optional(), + }) + .refine( + (data) => { + if (data.frequency === "SEMI_MONTHLY") { + return ( + typeof data.dayOfMonth === "number" && + typeof data.semiMonthlyDay === "number" + ); + } + return true; + }, + { + message: + "SEMI_MONTHLY frequency requires both dayOfMonth and semiMonthlyDay", + path: ["semiMonthlyDay"], + }, + ) + .refine( + (data) => { + if (typeof data.weekOfMonth === "number") { + return ( + data.frequency === "MONTHLY" && typeof data.dayOfWeek === "number" + ); + } + return true; + }, + { + message: "weekOfMonth requires MONTHLY frequency and dayOfWeek", + path: ["weekOfMonth"], + }, + ); + +export const skipOccurrenceSchema = z.object({ + ruleId: z.string().min(1), + date: z.string().min(1), // ISO date to skip +}); + +export const rescheduleOccurrenceSchema = z.object({ + ruleId: z.string().min(1), + originalDate: z.string().min(1), // ISO date of the original occurrence + newDate: z.string().min(1), // ISO date to reschedule to }); export const createTransactionSchema = z.object({ @@ -78,4 +123,7 @@ export const transactionListInput = z.object({ export type CreateTransactionInput = z.infer; export type UpdateTransactionInput = z.infer; export type RecurrenceInput = z.infer; -// No default export to preserve proper type information for named exports. +export type SkipOccurrenceInput = z.infer; +export type RescheduleOccurrenceInput = z.infer< + typeof rescheduleOccurrenceSchema +>;