import { FastifyPluginAsync } from "fastify"; import { z } from "zod"; import { prisma } from "../prisma.js"; import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; const PaymentSchedule = z.object({ frequency: z.enum(["monthly", "weekly", "biweekly", "daily"]), dayOfMonth: z.number().int().min(1).max(31).optional(), dayOfWeek: z.number().int().min(0).max(6).optional(), minFundingPercent: z.number().min(0).max(100).default(100), }); const NewPlan = z.object({ name: z.string().min(1).max(120), totalCents: z.number().int().min(0), fundedCents: z.number().int().min(0).default(0), priority: z.number().int().min(0).max(10_000), dueOn: z.string().datetime(), // ISO frequency: z.enum(["one-time", "weekly", "biweekly", "monthly"]).optional(), autoPayEnabled: z.boolean().default(false), paymentSchedule: PaymentSchedule.optional(), }); const PatchPlan = NewPlan.partial(); const IdParam = z.object({ id: z.string().min(1) }); const bi = (n: number | bigint | undefined) => BigInt(n ?? 0); function validateFunding(total: bigint, funded: bigint) { if (funded > total) { const err: any = new Error("fundedCents must be ≤ totalCents"); err.statusCode = 400; err.code = "FUNDED_GT_TOTAL"; throw err; } } function calculateNextPaymentDate(dueDate: Date, schedule: any, timezone: string): Date { const base = getUserMidnightFromDateOnly(timezone, dueDate); const next = toZonedTime(base, timezone); switch (schedule.frequency) { case "daily": next.setUTCDate(next.getUTCDate() + 1); break; case "weekly": { const targetDay = schedule.dayOfWeek ?? 0; const currentDay = next.getUTCDay(); const daysUntilTarget = (targetDay - currentDay + 7) % 7; next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7)); break; } case "monthly": { const targetDay = schedule.dayOfMonth ?? next.getUTCDate(); const nextMonth = next.getUTCMonth() + 1; const nextYear = next.getUTCFullYear() + Math.floor(nextMonth / 12); const nextMonthIndex = nextMonth % 12; const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate(); next.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay)); break; } default: next.setUTCDate(next.getUTCDate() + 30); // fallback } next.setUTCHours(0, 0, 0, 0); return fromZonedTime(next, timezone); } const plugin: FastifyPluginAsync = async (app) => { // CREATE app.post("/fixed-plans", async (req, reply) => { const userId = req.userId; const parsed = NewPlan.safeParse(req.body); if (!parsed.success) return reply.status(400).send({ error: "INVALID_BODY", details: parsed.error.flatten() }); const userTimezone = (await prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? "America/New_York"; const totalBI = bi(parsed.data.totalCents); const fundedBI = bi(parsed.data.fundedCents); validateFunding(totalBI, fundedBI); // Calculate next payment date if auto-pay is enabled const nextPaymentDate = parsed.data.autoPayEnabled && parsed.data.paymentSchedule ? calculateNextPaymentDate(new Date(parsed.data.dueOn), parsed.data.paymentSchedule, userTimezone) : null; // Extract frequency from explicit field or paymentSchedule let frequency = parsed.data.frequency; if (!frequency && parsed.data.paymentSchedule?.frequency) { const scheduleFreq = parsed.data.paymentSchedule.frequency; if (scheduleFreq === "monthly" || scheduleFreq === "weekly" || scheduleFreq === "biweekly") { frequency = scheduleFreq; } } const rec = await prisma.fixedPlan.create({ data: { userId, name: parsed.data.name, priority: parsed.data.priority, dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn)), frequency: frequency || null, totalCents: totalBI, fundedCents: fundedBI, currentFundedCents: fundedBI, cycleStart: getUserMidnight(userTimezone, new Date()), // required by your schema autoPayEnabled: parsed.data.autoPayEnabled ?? false, paymentSchedule: parsed.data.paymentSchedule || undefined, nextPaymentDate, lastFundingDate: fundedBI > 0 ? new Date() : null, }, select: { id: true }, }); return reply.status(201).send(rec); }); // UPDATE app.patch("/fixed-plans/:id", async (req, reply) => { const userId = req.userId; const pid = IdParam.safeParse(req.params); const patch = PatchPlan.safeParse(req.body); if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() }); if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() }); const userTimezone = (await prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? "America/New_York"; const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } }); if (!existing) return reply.status(404).send({ error: "NOT_FOUND" }); const nextTotal = patch.data.totalCents !== undefined ? bi(patch.data.totalCents) : (existing.totalCents as bigint); const nextFunded = patch.data.fundedCents !== undefined ? bi(patch.data.fundedCents) : (existing.fundedCents as bigint); validateFunding(nextTotal, nextFunded); // Calculate next payment date if auto-pay settings changed const nextPaymentDate = (patch.data.autoPayEnabled !== undefined || patch.data.paymentSchedule !== undefined) ? ((patch.data.autoPayEnabled ?? existing.autoPayEnabled) && (patch.data.paymentSchedule ?? existing.paymentSchedule)) ? calculateNextPaymentDate( patch.data.dueOn ? new Date(patch.data.dueOn) : existing.dueOn, patch.data.paymentSchedule ?? existing.paymentSchedule, userTimezone ) : null : undefined; const updated = await prisma.fixedPlan.updateMany({ where: { id: pid.data.id, userId }, data: { ...(patch.data.name !== undefined ? { name: patch.data.name } : null), ...(patch.data.priority !== undefined ? { priority: patch.data.priority } : null), ...(patch.data.dueOn !== undefined ? { dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.dueOn)) } : null), ...(patch.data.totalCents !== undefined ? { totalCents: bi(patch.data.totalCents) } : null), ...(patch.data.fundedCents !== undefined ? { fundedCents: bi(patch.data.fundedCents), currentFundedCents: bi(patch.data.fundedCents), lastFundingDate: new Date(), } : null), ...(patch.data.autoPayEnabled !== undefined ? { autoPayEnabled: patch.data.autoPayEnabled } : null), ...(patch.data.paymentSchedule !== undefined ? { paymentSchedule: patch.data.paymentSchedule } : null), ...(nextPaymentDate !== undefined ? { nextPaymentDate } : null), }, }); if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" }); return reply.send({ ok: true }); }); // DELETE app.delete("/fixed-plans/:id", async (req, reply) => { const userId = req.userId; const pid = IdParam.safeParse(req.params); if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() }); const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } }); if (!existing) return reply.status(404).send({ error: "NOT_FOUND" }); const deleted = await prisma.fixedPlan.deleteMany({ where: { id: pid.data.id, userId } }); if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" }); return reply.send({ ok: true }); }); }; export default plugin;