Files
SkyMoney/api/src/routes/fixed-plans.ts

187 lines
7.8 KiB
TypeScript

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;