import type { FastifyPluginAsync } from "fastify"; import { Prisma } from "@prisma/client"; import { z } from "zod"; import { fromZonedTime } from "date-fns-tz"; import { getUserMidnight } from "../allocator.js"; type RateLimitRouteOptions = { config: { rateLimit: { max: number; timeWindow: number; keyGenerator?: (req: any) => string; }; }; }; type PaydayRoutesOptions = { mutationRateLimit: RateLimitRouteOptions; isProd: boolean; }; const paydayRoutes: FastifyPluginAsync = async (app, opts) => { const logDebug = (message: string, data?: Record) => { if (!opts.isProd) { app.log.info(data ?? {}, message); } }; app.get("/payday/status", async (req, reply) => { const userId = req.userId; const Query = z.object({ debugNow: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), }); const query = Query.safeParse(req.query); logDebug("Payday status check started", { userId }); const [user, paymentPlansCount] = await Promise.all([ app.prisma.user.findUnique({ where: { id: userId }, select: { incomeType: true, incomeFrequency: true, firstIncomeDate: true, pendingScheduledIncome: true, timezone: true, }, }), app.prisma.fixedPlan.count({ where: { userId, paymentSchedule: { not: Prisma.DbNull }, }, }), ]); if (!user) { if (!opts.isProd) { app.log.warn({ userId }, "User not found"); } return reply.code(404).send({ message: "User not found" }); } logDebug("Payday user data retrieved", { userId, incomeType: user.incomeType, incomeFrequency: user.incomeFrequency, firstIncomeDate: user.firstIncomeDate?.toISOString(), pendingScheduledIncome: user.pendingScheduledIncome, paymentPlansCount, }); const hasPaymentPlans = paymentPlansCount > 0; const isRegularUser = user.incomeType === "regular"; if (!isRegularUser || !hasPaymentPlans || !user.firstIncomeDate) { logDebug("Payday check skipped - not applicable", { userId, isRegularUser, hasPaymentPlans, hasFirstIncomeDate: !!user.firstIncomeDate, }); return { shouldShowOverlay: false, pendingScheduledIncome: false, nextPayday: null, }; } const { calculateNextPayday, isWithinPaydayWindow } = await import("../allocator.js"); const userTimezone = user.timezone || "America/New_York"; const debugNow = query.success ? query.data.debugNow : undefined; const now = debugNow ? fromZonedTime(new Date(`${debugNow}T00:00:00`), userTimezone) : new Date(); const nextPayday = calculateNextPayday( user.firstIncomeDate, user.incomeFrequency, now, userTimezone ); const isPayday = isWithinPaydayWindow(now, nextPayday, 0, userTimezone); const dayStart = getUserMidnight(userTimezone, now); const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000 - 1); const scheduledIncomeToday = await app.prisma.incomeEvent.findFirst({ where: { userId, isScheduledIncome: true, postedAt: { gte: dayStart, lte: dayEnd, }, }, select: { id: true }, }); logDebug("Payday calculation complete", { userId, now: now.toISOString(), firstIncomeDate: user.firstIncomeDate.toISOString(), nextPayday: nextPayday.toISOString(), isPayday, pendingScheduledIncome: user.pendingScheduledIncome, scheduledIncomeToday: !!scheduledIncomeToday, shouldShowOverlay: isPayday && !scheduledIncomeToday, }); return { shouldShowOverlay: isPayday && !scheduledIncomeToday, pendingScheduledIncome: !scheduledIncomeToday, nextPayday: nextPayday.toISOString(), }; }); app.post("/payday/dismiss", opts.mutationRateLimit, async (_req, _reply) => { return { ok: true }; }); }; export default paydayRoutes;