final touches for beta skymoney (at least i think)
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
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),
|
||||
@@ -8,6 +17,9 @@ const NewPlan = z.object({
|
||||
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) });
|
||||
@@ -22,26 +34,81 @@ function validateFunding(total: bigint, funded: bigint) {
|
||||
}
|
||||
}
|
||||
|
||||
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("/api/fixed-plans", async (req, reply) => {
|
||||
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: new Date(parsed.data.dueOn),
|
||||
dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn)),
|
||||
frequency: frequency || null,
|
||||
totalCents: totalBI,
|
||||
fundedCents: fundedBI,
|
||||
cycleStart: new Date(), // required by your schema
|
||||
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 },
|
||||
});
|
||||
@@ -49,12 +116,15 @@ const plugin: FastifyPluginAsync = async (app) => {
|
||||
});
|
||||
|
||||
// UPDATE
|
||||
app.patch("/api/fixed-plans/:id", async (req, reply) => {
|
||||
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" });
|
||||
@@ -63,22 +133,43 @@ const plugin: FastifyPluginAsync = async (app) => {
|
||||
const nextFunded = patch.data.fundedCents !== undefined ? bi(patch.data.fundedCents) : (existing.fundedCents as bigint);
|
||||
validateFunding(nextTotal, nextFunded);
|
||||
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: pid.data.id },
|
||||
// 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: new Date(patch.data.dueOn) } : 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) } : 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("/api/fixed-plans/:id", async (req, reply) => {
|
||||
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() });
|
||||
@@ -86,9 +177,10 @@ const plugin: FastifyPluginAsync = async (app) => {
|
||||
const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } });
|
||||
if (!existing) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
|
||||
await prisma.fixedPlan.delete({ where: { id: pid.data.id } });
|
||||
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;
|
||||
export default plugin;
|
||||
|
||||
Reference in New Issue
Block a user