187 lines
7.8 KiB
TypeScript
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;
|