141 lines
4.0 KiB
TypeScript
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;
|