Files
SkyMoney/api/src/routes/payday.ts
Ricearoni1245 a8e5443b0d
All checks were successful
Deploy / deploy (push) Successful in 1m31s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 26s
phase 7: income, payday. and budget handling routes simplified and compacted
2026-03-17 22:05:17 -05:00

141 lines
4.0 KiB
TypeScript

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<PaydayRoutesOptions> = async (app, opts) => {
const logDebug = (message: string, data?: Record<string, unknown>) => {
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;