final touches for beta skymoney (at least i think)
This commit is contained in:
1728
api/src/allocator.ts
1728
api/src/allocator.ts
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,62 @@
|
||||
// api/src/env.ts
|
||||
import { z } from "zod";
|
||||
|
||||
const BoolFromEnv = z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((val) => {
|
||||
if (typeof val === "boolean") return val;
|
||||
const normalized = val.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1";
|
||||
});
|
||||
|
||||
const Env = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(8080),
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
|
||||
// Comma-separated list of allowed origins; empty => allow all (dev)
|
||||
CORS_ORIGIN: z.string().optional(),
|
||||
|
||||
// 🔹 New: rate-limit knobs (have defaults so typing is happy)
|
||||
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200),
|
||||
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
|
||||
JWT_SECRET: z.string().min(32),
|
||||
COOKIE_SECRET: z.string().min(32),
|
||||
COOKIE_DOMAIN: z.string().optional(),
|
||||
AUTH_DISABLED: BoolFromEnv.optional().default(false),
|
||||
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
|
||||
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
|
||||
});
|
||||
|
||||
export const env = Env.parse({
|
||||
const rawEnv = {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PORT: process.env.PORT,
|
||||
HOST: process.env.HOST,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
CORS_ORIGIN: "http://localhost:5173,http://127.0.0.1:5173",
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
|
||||
});
|
||||
JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me",
|
||||
COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
|
||||
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
|
||||
AUTH_DISABLED: process.env.AUTH_DISABLED,
|
||||
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
|
||||
};
|
||||
|
||||
const parsed = Env.parse(rawEnv);
|
||||
|
||||
if (parsed.NODE_ENV === "production") {
|
||||
if (!parsed.CORS_ORIGIN) {
|
||||
throw new Error("CORS_ORIGIN must be set in production.");
|
||||
}
|
||||
if (rawEnv.AUTH_DISABLED && parsed.AUTH_DISABLED) {
|
||||
throw new Error("AUTH_DISABLED cannot be enabled in production.");
|
||||
}
|
||||
if (parsed.SEED_DEFAULT_BUDGET) {
|
||||
throw new Error("SEED_DEFAULT_BUDGET must be disabled in production.");
|
||||
}
|
||||
if (parsed.JWT_SECRET.includes("dev-jwt-secret-change-me")) {
|
||||
throw new Error("JWT_SECRET must be set to a strong value in production.");
|
||||
}
|
||||
if (parsed.COOKIE_SECRET.includes("dev-cookie-secret-change-me")) {
|
||||
throw new Error("COOKIE_SECRET must be set to a strong value in production.");
|
||||
}
|
||||
}
|
||||
|
||||
export const env = parsed;
|
||||
|
||||
259
api/src/jobs/auto-payments.ts
Normal file
259
api/src/jobs/auto-payments.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type PaymentSchedule = {
|
||||
frequency: "monthly" | "weekly" | "biweekly" | "daily" | "custom";
|
||||
dayOfMonth?: number; // For monthly (1-31)
|
||||
dayOfWeek?: number; // For weekly/biweekly (0=Sunday, 6=Saturday)
|
||||
everyNDays?: number; // For custom cadence (fallback to periodDays)
|
||||
minFundingPercent: number; // 0-100, minimum funding required before auto-pay
|
||||
};
|
||||
|
||||
export type AutoPaymentReport = {
|
||||
planId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
paymentAmountCents: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
retryCount: number;
|
||||
nextRetryDate?: string;
|
||||
};
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
/**
|
||||
* Check if auto-payment should run for a user based on their timezone
|
||||
* Auto-payment runs at 9 AM in the user's timezone
|
||||
*/
|
||||
function shouldProcessPaymentForUser(userTimezone: string, asOf: Date): boolean {
|
||||
const zonedTime = toZonedTime(asOf, userTimezone);
|
||||
const hour = zonedTime.getHours();
|
||||
// Process if we're past 9 AM in user's timezone
|
||||
return hour >= 9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process auto-scheduled payments for fixed plans
|
||||
*/
|
||||
export async function processAutoPayments(
|
||||
prisma: PrismaClient,
|
||||
asOfInput?: Date | string,
|
||||
{ dryRun = false }: { dryRun?: boolean } = {}
|
||||
): Promise<AutoPaymentReport[]> {
|
||||
const asOf = asOfInput ? new Date(asOfInput) : new Date();
|
||||
|
||||
// Find plans with auto-payments enabled and due for payment
|
||||
const candidates = await prisma.fixedPlan.findMany({
|
||||
where: {
|
||||
autoPayEnabled: true,
|
||||
nextPaymentDate: { lte: asOf },
|
||||
paymentSchedule: { not: Prisma.DbNull },
|
||||
},
|
||||
orderBy: { nextPaymentDate: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
totalCents: true,
|
||||
fundedCents: true,
|
||||
currentFundedCents: true,
|
||||
paymentSchedule: true,
|
||||
nextPaymentDate: true,
|
||||
lastAutoPayment: true,
|
||||
maxRetryAttempts: true,
|
||||
periodDays: true,
|
||||
user: {
|
||||
select: {
|
||||
timezone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reports: AutoPaymentReport[] = [];
|
||||
|
||||
for (const plan of candidates) {
|
||||
// Check if it's time for auto-payment in this user's timezone
|
||||
const userTimezone = plan.user.timezone ?? "America/New_York";
|
||||
if (!shouldProcessPaymentForUser(userTimezone, asOf)) {
|
||||
if (!isProd) {
|
||||
console.log(
|
||||
`[auto-payment] Skipping plan ${plan.id} for user ${plan.userId} - not yet 9 AM in ${userTimezone}`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const schedule = plan.paymentSchedule as PaymentSchedule | null;
|
||||
if (!schedule) continue;
|
||||
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
|
||||
const total = Number(plan.totalCents ?? 0n);
|
||||
const fundingPercent = total > 0 ? (funded / total) * 100 : 0;
|
||||
const minFunding = schedule.minFundingPercent ?? 100;
|
||||
|
||||
// Check if plan meets minimum funding requirement
|
||||
if (fundingPercent < minFunding) {
|
||||
reports.push({
|
||||
planId: plan.id,
|
||||
userId: plan.userId,
|
||||
name: plan.name,
|
||||
paymentAmountCents: 0,
|
||||
success: false,
|
||||
error: `Insufficient funding: ${fundingPercent.toFixed(1)}% < ${minFunding}%`,
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
// Schedule next retry (1 day later)
|
||||
const nextRetry = new Date(asOf.getTime() + DAY_MS);
|
||||
if (!dryRun) {
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: { nextPaymentDate: nextRetry },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate payment amount (use full funded amount)
|
||||
const paymentAmount = funded;
|
||||
|
||||
try {
|
||||
if (!dryRun) {
|
||||
// Create the payment transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Create fixed_payment transaction
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: plan.userId,
|
||||
kind: "fixed_payment",
|
||||
amountCents: BigInt(paymentAmount),
|
||||
occurredAt: asOf,
|
||||
planId: plan.id,
|
||||
note: `Auto-payment (${schedule.frequency})`,
|
||||
isAutoPayment: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Update plan funding
|
||||
await tx.fixedPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: {
|
||||
fundedCents: BigInt(funded - paymentAmount),
|
||||
currentFundedCents: BigInt(funded - paymentAmount),
|
||||
lastAutoPayment: asOf,
|
||||
nextPaymentDate: calculateNextPaymentDate(asOf, schedule, plan.periodDays, userTimezone),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
reports.push({
|
||||
planId: plan.id,
|
||||
userId: plan.userId,
|
||||
name: plan.name,
|
||||
paymentAmountCents: paymentAmount,
|
||||
success: true,
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
reports.push({
|
||||
planId: plan.id,
|
||||
userId: plan.userId,
|
||||
name: plan.name,
|
||||
paymentAmountCents: paymentAmount,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
// Schedule retry (1 hour later)
|
||||
const nextRetry = new Date(asOf.getTime() + 60 * 60 * 1000);
|
||||
if (!dryRun) {
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: { nextPaymentDate: nextRetry },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next payment date based on schedule
|
||||
*/
|
||||
export function calculateNextPaymentDate(
|
||||
currentDate: Date,
|
||||
schedule: PaymentSchedule,
|
||||
periodDays: number,
|
||||
timezone: string
|
||||
): Date {
|
||||
const next = toZonedTime(currentDate, timezone);
|
||||
const hours = next.getUTCHours();
|
||||
const minutes = next.getUTCMinutes();
|
||||
const seconds = next.getUTCSeconds();
|
||||
const ms = next.getUTCMilliseconds();
|
||||
|
||||
switch (schedule.frequency) {
|
||||
case "daily":
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
break;
|
||||
|
||||
case "weekly":
|
||||
// Move to next occurrence of specified day of week
|
||||
{
|
||||
const targetDay = schedule.dayOfWeek ?? 0;
|
||||
const currentDay = next.getUTCDay();
|
||||
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
||||
next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7));
|
||||
}
|
||||
break;
|
||||
|
||||
case "biweekly":
|
||||
{
|
||||
const targetDay = schedule.dayOfWeek ?? next.getUTCDay();
|
||||
const currentDay = next.getUTCDay();
|
||||
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
||||
// ensure at least one full week gap to make it biweekly
|
||||
daysUntilTarget = daysUntilTarget === 0 ? 14 : daysUntilTarget + 7;
|
||||
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
|
||||
}
|
||||
break;
|
||||
|
||||
case "monthly":
|
||||
{
|
||||
const targetDay = schedule.dayOfMonth ?? next.getUTCDate();
|
||||
// Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months.
|
||||
next.setUTCDate(1);
|
||||
next.setUTCMonth(next.getUTCMonth() + 1);
|
||||
const lastDay = getLastDayOfMonth(next);
|
||||
next.setUTCDate(Math.min(targetDay, lastDay));
|
||||
}
|
||||
break;
|
||||
|
||||
case "custom":
|
||||
{
|
||||
const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays;
|
||||
next.setUTCDate(next.getUTCDate() + days);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Fallback to periodDays
|
||||
next.setUTCDate(next.getUTCDate() + periodDays);
|
||||
}
|
||||
|
||||
next.setUTCHours(hours, minutes, seconds, ms);
|
||||
return fromZonedTime(next, timezone);
|
||||
}
|
||||
|
||||
function getLastDayOfMonth(date: Date): number {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
|
||||
}
|
||||
129
api/src/jobs/rollover.ts
Normal file
129
api/src/jobs/rollover.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||
import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js";
|
||||
|
||||
const addDaysInTimezone = (date: Date, days: number, timezone: string) => {
|
||||
const zoned = toZonedTime(date, timezone);
|
||||
zoned.setUTCDate(zoned.getUTCDate() + days);
|
||||
zoned.setUTCHours(0, 0, 0, 0);
|
||||
return fromZonedTime(zoned, timezone);
|
||||
};
|
||||
|
||||
export type RolloverReport = {
|
||||
planId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
cyclesAdvanced: number;
|
||||
deficitCents: number;
|
||||
carryForwardCents: number;
|
||||
nextDueOn: string;
|
||||
};
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
/**
|
||||
* Check if rollover should run for a user based on their timezone
|
||||
* Rollover runs at 6 AM in the user's timezone
|
||||
*/
|
||||
function shouldProcessRolloverForUser(userTimezone: string, asOf: Date): boolean {
|
||||
const zonedTime = toZonedTime(asOf, userTimezone);
|
||||
const hour = zonedTime.getHours();
|
||||
// Process if we're past 6 AM in user's timezone
|
||||
return hour >= 6;
|
||||
}
|
||||
|
||||
export async function rolloverFixedPlans(
|
||||
prisma: PrismaClient,
|
||||
asOfInput?: Date | string,
|
||||
{ dryRun = false }: { dryRun?: boolean } = {}
|
||||
): Promise<RolloverReport[]> {
|
||||
const asOf = asOfInput ? new Date(asOfInput) : new Date();
|
||||
|
||||
// First, get all candidate plans with user timezone
|
||||
const candidates = await prisma.fixedPlan.findMany({
|
||||
where: {
|
||||
autoRollover: true,
|
||||
periodDays: { gt: 0 },
|
||||
dueOn: { lte: asOf },
|
||||
},
|
||||
orderBy: { dueOn: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
dueOn: true,
|
||||
cycleStart: true,
|
||||
periodDays: true,
|
||||
totalCents: true,
|
||||
fundedCents: true,
|
||||
user: {
|
||||
select: {
|
||||
timezone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reports: RolloverReport[] = [];
|
||||
|
||||
for (const plan of candidates) {
|
||||
// Check if it's time for rollover in this user's timezone
|
||||
const userTimezone = plan.user.timezone ?? "America/New_York";
|
||||
if (!shouldProcessRolloverForUser(userTimezone, asOf)) {
|
||||
if (!isProd) {
|
||||
console.log(
|
||||
`[rollover] Skipping plan ${plan.id} for user ${plan.userId} - not yet 6 AM in ${userTimezone}`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const asOfUser = getUserMidnight(userTimezone, asOf);
|
||||
let dueOn = getUserMidnightFromDateOnly(userTimezone, plan.dueOn);
|
||||
let cycleStart = getUserMidnightFromDateOnly(userTimezone, plan.cycleStart);
|
||||
let funded = plan.fundedCents ?? 0n;
|
||||
const total = plan.totalCents ?? 0n;
|
||||
const period = Math.max(plan.periodDays ?? 30, 1);
|
||||
let cycles = 0;
|
||||
let finalDeficit = 0n;
|
||||
let finalCarry = 0n;
|
||||
|
||||
while (dueOn <= asOfUser) {
|
||||
const deficit = funded < total ? total - funded : 0n;
|
||||
const carry = funded > total ? funded - total : 0n;
|
||||
finalDeficit = deficit;
|
||||
finalCarry = carry;
|
||||
funded = carry;
|
||||
cycleStart = dueOn;
|
||||
dueOn = addDaysInTimezone(dueOn, period, userTimezone);
|
||||
cycles += 1;
|
||||
}
|
||||
|
||||
if (cycles === 0) continue;
|
||||
|
||||
reports.push({
|
||||
planId: plan.id,
|
||||
userId: plan.userId,
|
||||
name: plan.name,
|
||||
cyclesAdvanced: cycles,
|
||||
deficitCents: Number(finalDeficit),
|
||||
carryForwardCents: Number(finalCarry),
|
||||
nextDueOn: dueOn.toISOString(),
|
||||
});
|
||||
|
||||
if (!dryRun) {
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: {
|
||||
fundedCents: funded,
|
||||
cycleStart,
|
||||
dueOn,
|
||||
lastRollover: asOf,
|
||||
needsFundingThisPeriod: true, // Reset flag for new cycle
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import fp from "fastify-plugin";
|
||||
import { prisma } from "../prisma.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
userId: string;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async (app) => {
|
||||
app.addHook("onRequest", async (req) => {
|
||||
// Dev-only stub: use header if provided, else default
|
||||
const hdr = req.headers["x-user-id"];
|
||||
req.userId = typeof hdr === "string" && hdr.trim() ? hdr.trim() : "demo-user-1";
|
||||
|
||||
// Ensure the user exists (avoids FK P2003 on first write)
|
||||
await prisma.user.upsert({
|
||||
where: { id: req.userId },
|
||||
update: {},
|
||||
create: { id: req.userId, email: `${req.userId}@demo.local` },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -1,82 +1,98 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { previewAllocation, previewIrregularAllocation } from "../allocator.js";
|
||||
|
||||
const Body = z.object({ amountCents: z.number().int().nonnegative() });
|
||||
const Body = z.object({
|
||||
amountCents: z.number().int().nonnegative(),
|
||||
occurredAtISO: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export default async function incomePreviewRoutes(app: FastifyInstance) {
|
||||
type PlanPreview = {
|
||||
id: string;
|
||||
name: string;
|
||||
dueOn: Date;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
remainingCents: number;
|
||||
daysUntilDue: number;
|
||||
allocatedThisRun: number;
|
||||
isCrisis: boolean;
|
||||
};
|
||||
|
||||
type PreviewResult = {
|
||||
fixedAllocations: Array<{ fixedPlanId: string; amountCents: number; source?: string }>;
|
||||
variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>;
|
||||
planStatesAfter: PlanPreview[];
|
||||
availableBudgetAfterCents: number;
|
||||
remainingUnallocatedCents: number;
|
||||
crisis: { active: boolean; plans: Array<{ id: string; name: string; remainingCents: number; daysUntilDue: number; priority: number; allocatedCents: number }> };
|
||||
};
|
||||
|
||||
app.post("/income/preview", async (req, reply) => {
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
|
||||
|
||||
const userId = req.userId;
|
||||
let remaining = Math.max(0, parsed.data.amountCents | 0);
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { incomeType: true, fixedExpensePercentage: true },
|
||||
});
|
||||
|
||||
const [plans, cats] = await Promise.all([
|
||||
app.prisma.fixedPlan.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
|
||||
select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true },
|
||||
}),
|
||||
app.prisma.variableCategory.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
|
||||
}),
|
||||
]);
|
||||
let result: PreviewResult;
|
||||
|
||||
// Fixed pass
|
||||
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
for (const p of plans) {
|
||||
if (remaining <= 0) break;
|
||||
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
|
||||
const need = Number(needBig > 0n ? needBig : 0n);
|
||||
if (need <= 0) continue;
|
||||
const give = Math.min(need, remaining);
|
||||
fixed.push({ id: p.id, name: p.name, amountCents: give });
|
||||
remaining -= give;
|
||||
if (user?.incomeType === "irregular") {
|
||||
const rawResult = await previewIrregularAllocation(
|
||||
app.prisma,
|
||||
userId,
|
||||
parsed.data.amountCents,
|
||||
user.fixedExpensePercentage ?? 40,
|
||||
parsed.data.occurredAtISO
|
||||
);
|
||||
result = {
|
||||
fixedAllocations: rawResult.fixedAllocations,
|
||||
variableAllocations: rawResult.variableAllocations,
|
||||
planStatesAfter: rawResult.planStatesAfter,
|
||||
availableBudgetAfterCents: rawResult.availableBudgetCents,
|
||||
remainingUnallocatedCents: rawResult.remainingBudgetCents,
|
||||
crisis: rawResult.crisis,
|
||||
};
|
||||
} else {
|
||||
const rawResult = await previewAllocation(
|
||||
app.prisma,
|
||||
userId,
|
||||
parsed.data.amountCents,
|
||||
parsed.data.occurredAtISO
|
||||
);
|
||||
result = {
|
||||
fixedAllocations: rawResult.fixedAllocations,
|
||||
variableAllocations: rawResult.variableAllocations,
|
||||
planStatesAfter: rawResult.planStatesAfter,
|
||||
availableBudgetAfterCents: rawResult.availableBudgetAfterCents,
|
||||
remainingUnallocatedCents: rawResult.remainingUnallocatedCents,
|
||||
crisis: rawResult.crisis,
|
||||
};
|
||||
}
|
||||
|
||||
// Variable pass — largest remainder with savings-first tiebreak
|
||||
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
if (remaining > 0 && cats.length > 0) {
|
||||
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
|
||||
const normCats =
|
||||
totalPercent === 100
|
||||
? cats
|
||||
: cats.map((c) => ({
|
||||
...c,
|
||||
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
|
||||
}));
|
||||
const fixedPreview = result.planStatesAfter.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
dueOn: p.dueOn.toISOString(),
|
||||
totalCents: p.totalCents,
|
||||
fundedCents: p.fundedCents,
|
||||
remainingCents: p.remainingCents,
|
||||
daysUntilDue: p.daysUntilDue,
|
||||
allocatedThisRun: p.allocatedThisRun,
|
||||
isCrisis: p.isCrisis,
|
||||
}));
|
||||
|
||||
const base: number[] = new Array(normCats.length).fill(0);
|
||||
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
|
||||
let sumBase = 0;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const exact = (remaining * c.percent) / 100;
|
||||
const floor = Math.floor(exact);
|
||||
base[idx] = floor;
|
||||
sumBase += floor;
|
||||
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
|
||||
});
|
||||
|
||||
let leftovers = remaining - sumBase;
|
||||
tie.sort((a, b) => {
|
||||
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first
|
||||
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const give = base[idx] || 0;
|
||||
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
|
||||
});
|
||||
|
||||
remaining = leftovers;
|
||||
}
|
||||
|
||||
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
|
||||
return {
|
||||
fixedAllocations: result.fixedAllocations,
|
||||
variableAllocations: result.variableAllocations,
|
||||
fixedPreview,
|
||||
availableBudgetAfterCents: result.availableBudgetAfterCents,
|
||||
crisis: result.crisis,
|
||||
unallocatedCents: result.remainingUnallocatedCents,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// api/src/routes/transactions.ts
|
||||
import fp from "fastify-plugin";
|
||||
import { z } from "zod";
|
||||
import { getUserDateRangeFromDateOnly } from "../allocator.js";
|
||||
|
||||
const Query = z.object({
|
||||
from: z
|
||||
@@ -19,10 +20,13 @@ const Query = z.object({
|
||||
|
||||
export default fp(async function transactionsRoute(app) {
|
||||
app.get("/transactions", async (req, reply) => {
|
||||
const userId =
|
||||
typeof req.userId === "string"
|
||||
? req.userId
|
||||
: String(req.userId ?? "demo-user-1");
|
||||
if (typeof req.userId !== "string") {
|
||||
return reply.code(401).send({ message: "Unauthorized" });
|
||||
}
|
||||
const userId = req.userId;
|
||||
const userTimezone =
|
||||
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
|
||||
"America/New_York";
|
||||
|
||||
const parsed = Query.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
@@ -34,9 +38,7 @@ export default fp(async function transactionsRoute(app) {
|
||||
|
||||
const where: any = { userId };
|
||||
if (from || to) {
|
||||
where.occurredAt = {};
|
||||
if (from) where.occurredAt.gte = new Date(`${from}T00:00:00.000Z`);
|
||||
if (to) where.occurredAt.lte = new Date(`${to}T23:59:59.999Z`);
|
||||
where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, from, to);
|
||||
}
|
||||
if (kind) where.kind = kind;
|
||||
|
||||
|
||||
@@ -13,6 +13,39 @@ const NewCat = z.object({
|
||||
const PatchCat = NewCat.partial();
|
||||
const IdParam = z.object({ id: z.string().min(1) });
|
||||
|
||||
function computeBalanceTargets(
|
||||
categories: Array<{ id: string; percent: number }>,
|
||||
totalBalance: number
|
||||
) {
|
||||
const percentTotal = categories.reduce((sum, c) => sum + (c.percent || 0), 0);
|
||||
if (percentTotal <= 0) {
|
||||
return { ok: false as const, reason: "no_percent" };
|
||||
}
|
||||
|
||||
const targets = categories.map((cat) => {
|
||||
const raw = (totalBalance * cat.percent) / percentTotal;
|
||||
const floored = Math.floor(raw);
|
||||
return {
|
||||
id: cat.id,
|
||||
target: floored,
|
||||
frac: raw - floored,
|
||||
};
|
||||
});
|
||||
|
||||
let remainder = totalBalance - targets.reduce((sum, t) => sum + t.target, 0);
|
||||
targets
|
||||
.slice()
|
||||
.sort((a, b) => b.frac - a.frac)
|
||||
.forEach((t) => {
|
||||
if (remainder > 0) {
|
||||
t.target += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true as const, targets };
|
||||
}
|
||||
|
||||
async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: string) {
|
||||
const g = await tx.variableCategory.groupBy({
|
||||
by: ["userId"],
|
||||
@@ -20,66 +53,135 @@ async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: strin
|
||||
_sum: { percent: true },
|
||||
});
|
||||
const sum = g[0]?._sum.percent ?? 0;
|
||||
if (sum !== 100) {
|
||||
const err: any = new Error(`Percents must sum to 100 (got ${sum}).`);
|
||||
|
||||
// Allow partial states during onboarding (< 100%), but enforce exact 100% when sum would be >= 100%
|
||||
if (sum > 100) {
|
||||
const err = new Error(`Percents cannot exceed 100 (got ${sum}).`) as any;
|
||||
err.statusCode = 400;
|
||||
err.code = "PERCENT_TOTAL_NOT_100";
|
||||
err.code = "PERCENT_TOTAL_OVER_100";
|
||||
throw err;
|
||||
}
|
||||
|
||||
// For now, allow partial completion during onboarding
|
||||
// The frontend will ensure 100% total before finishing onboarding
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginAsync = async (app) => {
|
||||
// CREATE
|
||||
app.post("/api/variable-categories", async (req, reply) => {
|
||||
app.post("/variable-categories", async (req, reply) => {
|
||||
const userId = req.userId;
|
||||
const body = NewCat.safeParse(req.body);
|
||||
if (!body.success) return reply.status(400).send({ error: "INVALID_BODY", details: body.error.flatten() });
|
||||
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const rec = await tx.variableCategory.create({
|
||||
data: { ...body.data, userId },
|
||||
const normalizedName = body.data.name.trim().toLowerCase();
|
||||
try {
|
||||
const result = await prisma.variableCategory.create({
|
||||
data: { ...body.data, userId, name: normalizedName },
|
||||
select: { id: true },
|
||||
});
|
||||
await assertPercentTotal100(tx, userId);
|
||||
return rec;
|
||||
});
|
||||
|
||||
return reply.status(201).send(created);
|
||||
return reply.status(201).send(result);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
return reply.status(400).send({ error: 'DUPLICATE_NAME', message: `Category name '${body.data.name}' already exists` });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// UPDATE
|
||||
app.patch("/api/variable-categories/:id", async (req, reply) => {
|
||||
app.patch("/variable-categories/:id", async (req, reply) => {
|
||||
const userId = req.userId;
|
||||
const pid = IdParam.safeParse(req.params);
|
||||
const patch = PatchCat.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() });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
|
||||
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
await tx.variableCategory.update({ where: { id: pid.data.id }, data: patch.data });
|
||||
await assertPercentTotal100(tx, userId);
|
||||
const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
|
||||
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
|
||||
const updateData = {
|
||||
...patch.data,
|
||||
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
|
||||
};
|
||||
const updated = await prisma.variableCategory.updateMany({
|
||||
where: { id: pid.data.id, userId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
|
||||
// DELETE
|
||||
app.delete("/api/variable-categories/:id", async (req, reply) => {
|
||||
app.delete("/variable-categories/: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() });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
|
||||
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
await tx.variableCategory.delete({ where: { id: pid.data.id } });
|
||||
await assertPercentTotal100(tx, userId);
|
||||
const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
|
||||
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
|
||||
const deleted = await prisma.variableCategory.deleteMany({
|
||||
where: { id: pid.data.id, userId },
|
||||
});
|
||||
if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
|
||||
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// REBALANCE balances based on current percents
|
||||
app.post("/variable-categories/rebalance", async (req, reply) => {
|
||||
const userId = req.userId;
|
||||
const categories = await prisma.variableCategory.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, percent: true, balanceCents: true },
|
||||
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
if (categories.length === 0) {
|
||||
return reply.send({ ok: true, applied: false });
|
||||
}
|
||||
|
||||
const hasNegative = categories.some(
|
||||
(c) => Number(c.balanceCents ?? 0n) < 0
|
||||
);
|
||||
if (hasNegative) {
|
||||
return reply.code(400).send({
|
||||
ok: false,
|
||||
code: "NEGATIVE_BALANCE",
|
||||
message: "Cannot rebalance while a category has a negative balance.",
|
||||
});
|
||||
}
|
||||
|
||||
const totalBalance = categories.reduce(
|
||||
(sum, c) => sum + Number(c.balanceCents ?? 0n),
|
||||
0
|
||||
);
|
||||
if (totalBalance <= 0) {
|
||||
return reply.send({ ok: true, applied: false });
|
||||
}
|
||||
|
||||
const targetResult = computeBalanceTargets(categories, totalBalance);
|
||||
if (!targetResult.ok) {
|
||||
return reply.code(400).send({
|
||||
ok: false,
|
||||
code: "NO_PERCENT",
|
||||
message: "No percent totals available to rebalance.",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
targetResult.targets.map((t) =>
|
||||
prisma.variableCategory.update({
|
||||
where: { id: t.id },
|
||||
data: { balanceCents: BigInt(t.target) },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return reply.send({ ok: true, applied: true, totalBalance });
|
||||
});
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
export default plugin;
|
||||
|
||||
73
api/src/scripts/manage-plan.ts
Normal file
73
api/src/scripts/manage-plan.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed: Record<string, string | boolean> = {};
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--")) {
|
||||
const [key, value] = arg.slice(2).split("=");
|
||||
parsed[key] = value === undefined ? true : value;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
const planId = args["planId"] as string | undefined;
|
||||
|
||||
if (!planId) {
|
||||
const plans = await prisma.fixedPlan.findMany({
|
||||
orderBy: { dueOn: "asc" },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
dueOn: true,
|
||||
cycleStart: true,
|
||||
periodDays: true,
|
||||
totalCents: true,
|
||||
fundedCents: true,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
plans,
|
||||
(_k, v) => (typeof v === "bigint" ? v.toString() : v),
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
if (args["dueOn"]) data.dueOn = new Date(String(args["dueOn"]));
|
||||
if (args["cycleStart"]) data.cycleStart = new Date(String(args["cycleStart"]));
|
||||
if (args["periodDays"]) data.periodDays = Number(args["periodDays"]);
|
||||
if (args["fundedCents"]) data.fundedCents = BigInt(args["fundedCents"]);
|
||||
if (args["totalCents"]) data.totalCents = BigInt(args["totalCents"]);
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
console.log("No fields provided to update.");
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await prisma.fixedPlan.update({
|
||||
where: { id: planId },
|
||||
data,
|
||||
select: { id: true, name: true, dueOn: true, cycleStart: true, fundedCents: true },
|
||||
});
|
||||
console.log("Updated plan:", updated);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
50
api/src/scripts/run-rollover.ts
Normal file
50
api/src/scripts/run-rollover.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { rolloverFixedPlans } from "../jobs/rollover.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed: Record<string, string | boolean> = {};
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--")) {
|
||||
const [key, value] = arg.slice(2).split("=");
|
||||
if (value === undefined) {
|
||||
parsed[key] = true;
|
||||
} else {
|
||||
parsed[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
const asOfRaw = (args["asOf"] as string | undefined) ?? undefined;
|
||||
const asOf = asOfRaw ? new Date(asOfRaw) : new Date();
|
||||
const dryRun = Boolean(args["dry-run"] ?? args["dryRun"]);
|
||||
|
||||
const results = await rolloverFixedPlans(prisma, asOf, { dryRun });
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
asOf: asOf.toISOString(),
|
||||
dryRun,
|
||||
processed: results.length,
|
||||
results,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
274
api/src/scripts/setup-frontend-test-user.ts
Normal file
274
api/src/scripts/setup-frontend-test-user.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function setupFrontendTestUser() {
|
||||
console.log("\n🔧 Setting up frontend test user...\n");
|
||||
|
||||
const email = "test@skymoney.com";
|
||||
const password = "password123";
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Delete existing test user if exists
|
||||
await prisma.user.deleteMany({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
displayName: "Test User",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
totalBudgetCents: 200000n, // $2,000
|
||||
firstIncomeDate: today,
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created user: ${user.email}`);
|
||||
console.log(` User ID: ${user.id}`);
|
||||
console.log(` Password: ${password}\n`);
|
||||
|
||||
// Create variable categories
|
||||
const savings = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Savings",
|
||||
percent: 30,
|
||||
balanceCents: 60000n, // $600
|
||||
isSavings: true,
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const groceries = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Groceries",
|
||||
percent: 40,
|
||||
balanceCents: 80000n, // $800
|
||||
isSavings: false,
|
||||
priority: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const entertainment = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Entertainment",
|
||||
percent: 30,
|
||||
balanceCents: 60000n, // $600
|
||||
isSavings: false,
|
||||
priority: 3,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created variable categories:`);
|
||||
console.log(` - Savings: 30% ($600 balance)`);
|
||||
console.log(` - Groceries: 40% ($800 balance)`);
|
||||
console.log(` - Entertainment: 30% ($600 balance)\n`);
|
||||
|
||||
// Create fixed plans
|
||||
|
||||
// Scenario 1: Rent - DUE TODAY, fully funded (test payment reconciliation)
|
||||
const dueToday = new Date(today);
|
||||
const rent = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Rent",
|
||||
totalCents: 150000n, // $1,500
|
||||
fundedCents: 150000n, // Fully funded
|
||||
currentFundedCents: 150000n,
|
||||
dueOn: dueToday,
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: false,
|
||||
needsFundingThisPeriod: false,
|
||||
priority: 10,
|
||||
cycleStart: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created Rent plan:`);
|
||||
console.log(` - Total: $1,500`);
|
||||
console.log(` - Funded: $1,500 (100%)`);
|
||||
console.log(` - Due: TODAY (${dueToday.toDateString()})`);
|
||||
console.log(` - Status: Ready for payment reconciliation!\n`);
|
||||
|
||||
// Scenario 2: Car Insurance - DUE TODAY, partially funded (test final funding attempt)
|
||||
const carInsurance = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Car Insurance",
|
||||
totalCents: 40000n, // $400
|
||||
fundedCents: 25000n, // $250 funded
|
||||
currentFundedCents: 25000n,
|
||||
dueOn: dueToday,
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: false,
|
||||
needsFundingThisPeriod: false,
|
||||
priority: 20,
|
||||
cycleStart: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created Car Insurance plan:`);
|
||||
console.log(` - Total: $400`);
|
||||
console.log(` - Funded: $250 (62.5%)`);
|
||||
console.log(` - Due: TODAY (${dueToday.toDateString()})`);
|
||||
console.log(` - Status: Will test final funding attempt!\n`);
|
||||
|
||||
// Scenario 3: Phone Bill - Not due yet, partially funded
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
const phoneBill = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Phone Bill",
|
||||
totalCents: 8000n, // $80
|
||||
fundedCents: 5000n, // $50 funded
|
||||
currentFundedCents: 5000n,
|
||||
dueOn: nextWeek,
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: false,
|
||||
needsFundingThisPeriod: true,
|
||||
priority: 30,
|
||||
cycleStart: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created Phone Bill plan:`);
|
||||
console.log(` - Total: $80`);
|
||||
console.log(` - Funded: $50 (62.5%)`);
|
||||
console.log(` - Due: Next week (${nextWeek.toDateString()})\n`);
|
||||
|
||||
// Create income event and allocations
|
||||
const incomeEvent = await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: 200000n, // $2,000
|
||||
postedAt: new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
|
||||
isScheduledIncome: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created income event:`);
|
||||
console.log(` - Amount: $2,000`);
|
||||
console.log(` - Posted: 2 days ago\n`);
|
||||
|
||||
// Create allocations (showing where the money went)
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: rent.id,
|
||||
amountCents: 150000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: carInsurance.id,
|
||||
amountCents: 25000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: phoneBill.id,
|
||||
amountCents: 5000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: savings.id,
|
||||
amountCents: 6000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: groceries.id,
|
||||
amountCents: 8000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: entertainment.id,
|
||||
amountCents: 6000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created allocations (total $2,000 distributed)\n`);
|
||||
|
||||
// Calculate available budget
|
||||
const totalIncome = 200000;
|
||||
const totalAllocated = 150000 + 25000 + 5000 + 6000 + 8000 + 6000;
|
||||
const availableBudget = totalIncome - totalAllocated;
|
||||
|
||||
console.log(`📊 Budget Summary:`);
|
||||
console.log(` - Total Income: $2,000`);
|
||||
console.log(` - Total Allocated: $${totalAllocated / 100}`);
|
||||
console.log(` - Available Budget: $${availableBudget / 100}\n`);
|
||||
|
||||
console.log(`🎯 TEST SCENARIOS:\n`);
|
||||
console.log(`1️⃣ RENT - Payment Reconciliation Modal:`);
|
||||
console.log(` - Navigate to Dashboard`);
|
||||
console.log(` - Should see "Rent" with 100% funded, due TODAY`);
|
||||
console.log(` - Modal should ask: "Was the full amount ($1,500) paid?"`);
|
||||
console.log(` - Test: Click "Yes, Full Amount" → Should create transaction & rollover\n`);
|
||||
|
||||
console.log(`2️⃣ CAR INSURANCE - Attempt Final Funding:`);
|
||||
console.log(` - Should see "Car Insurance" with 62.5% funded, due TODAY`);
|
||||
console.log(` - Modal should attempt to fund from available budget first`);
|
||||
console.log(` - Available: $0, Needed: $150`);
|
||||
console.log(` - Should mark as OVERDUE with $150 remaining`);
|
||||
console.log(` - Modal should show: "Could not fully fund. $250/$400 funded."`);
|
||||
console.log(` - Test: Click "Partial: $100" → Should refund $150 to available\n`);
|
||||
|
||||
console.log(`3️⃣ PHONE BILL - Regular funding:`);
|
||||
console.log(` - Due next week (not yet showing payment modal)`);
|
||||
console.log(` - Add income to test allocation to overdue bills\n`);
|
||||
|
||||
console.log(`📧 LOGIN CREDENTIALS:`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${password}\n`);
|
||||
|
||||
console.log(`🌐 Frontend URL: http://localhost:5174\n`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
setupFrontendTestUser()
|
||||
.catch((e) => {
|
||||
console.error("❌ Error:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
266
api/src/scripts/test-dashboard-edge.ts
Normal file
266
api/src/scripts/test-dashboard-edge.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
import { addDays, startOfDay } from "date-fns";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const PASSWORD = "password";
|
||||
|
||||
type Scenario = {
|
||||
name: string;
|
||||
timezone: string;
|
||||
incomeFrequency: "weekly" | "biweekly" | "monthly";
|
||||
firstIncomeDate: Date;
|
||||
incomeCents: number;
|
||||
variableCats: Array<{ name: string; percent: number; isSavings?: boolean }>;
|
||||
fixedPlans: Array<{
|
||||
name: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
dueOn: Date;
|
||||
frequency: "monthly" | "weekly" | "biweekly";
|
||||
autoPayEnabled?: boolean;
|
||||
paymentSchedule?: Record<string, any>;
|
||||
isOverdue?: boolean;
|
||||
overdueAmount?: number;
|
||||
overdueSince?: Date | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function zonedStartOfDay(date: Date, timeZone: string) {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const getPart = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||||
const year = Number(getPart("year"));
|
||||
const month = Number(getPart("month"));
|
||||
const day = Number(getPart("day"));
|
||||
const utcMidnight = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
|
||||
const tzDateStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(utcMidnight);
|
||||
const [mdy, hms] = tzDateStr.split(", ");
|
||||
const [mm, dd, yyyy] = mdy.split("/");
|
||||
const [hh, mi, ss] = hms.split(":");
|
||||
const tzAsUtc = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(mi), Number(ss));
|
||||
const offsetMs = tzAsUtc - utcMidnight.getTime();
|
||||
return new Date(utcMidnight.getTime() - offsetMs);
|
||||
}
|
||||
|
||||
async function createScenario(s: Scenario) {
|
||||
const timestamp = Date.now();
|
||||
const email = `test-dashboard-${s.name.toLowerCase().replace(/\s+/g, "-")}-${timestamp}@test.com`;
|
||||
const hashed = await argon2.hash(PASSWORD);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: hashed,
|
||||
timezone: s.timezone,
|
||||
incomeType: "regular",
|
||||
incomeFrequency: s.incomeFrequency,
|
||||
firstIncomeDate: s.firstIncomeDate,
|
||||
totalBudgetCents: BigInt(0),
|
||||
},
|
||||
});
|
||||
|
||||
const cats = [];
|
||||
for (const cat of s.variableCats) {
|
||||
const created = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: cat.name,
|
||||
percent: cat.percent,
|
||||
isSavings: !!cat.isSavings,
|
||||
balanceCents: 0n,
|
||||
},
|
||||
});
|
||||
cats.push(created);
|
||||
}
|
||||
|
||||
// Seed income and allocate to variable balances
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: BigInt(s.incomeCents),
|
||||
postedAt: startOfDay(new Date()),
|
||||
note: "Seed income",
|
||||
},
|
||||
});
|
||||
|
||||
const catTotalPercent = s.variableCats.reduce((sum, c) => sum + c.percent, 0);
|
||||
for (const cat of cats) {
|
||||
const percent = s.variableCats.find((c) => c.name === cat.name)?.percent ?? 0;
|
||||
const share = Math.floor((s.incomeCents * percent) / catTotalPercent);
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: cat.id,
|
||||
amountCents: BigInt(share),
|
||||
},
|
||||
});
|
||||
await prisma.variableCategory.update({
|
||||
where: { id: cat.id },
|
||||
data: { balanceCents: BigInt(share) },
|
||||
});
|
||||
}
|
||||
|
||||
for (const plan of s.fixedPlans) {
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: plan.name,
|
||||
totalCents: BigInt(plan.totalCents),
|
||||
fundedCents: BigInt(plan.fundedCents),
|
||||
currentFundedCents: BigInt(plan.fundedCents),
|
||||
cycleStart: startOfDay(new Date()),
|
||||
dueOn: plan.dueOn,
|
||||
frequency: plan.frequency,
|
||||
autoPayEnabled: plan.autoPayEnabled ?? true,
|
||||
paymentSchedule: plan.paymentSchedule ?? { frequency: plan.frequency, minFundingPercent: 100 },
|
||||
isOverdue: plan.isOverdue ?? false,
|
||||
overdueAmount: BigInt(plan.overdueAmount ?? 0),
|
||||
overdueSince: plan.overdueSince ?? null,
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { user, email };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const today = startOfDay(new Date());
|
||||
const tomorrow = addDays(today, 1);
|
||||
const laToday = zonedStartOfDay(new Date(), "America/Los_Angeles");
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: "Multi Due Same Day",
|
||||
timezone: "America/Chicago",
|
||||
incomeFrequency: "weekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
incomeCents: 150000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 30, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Misc", percent: 30 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{ name: "Rent", totalCents: 120000, fundedCents: 0, dueOn: today, frequency: "monthly" },
|
||||
{ name: "Phone", totalCents: 8000, fundedCents: 0, dueOn: today, frequency: "monthly" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Due+Overdue",
|
||||
timezone: "America/Chicago",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
incomeCents: 80000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 20, isSavings: true },
|
||||
{ name: "Food", percent: 50 },
|
||||
{ name: "Gas", percent: 30 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Insurance",
|
||||
totalCents: 50000,
|
||||
fundedCents: 20000,
|
||||
dueOn: addDays(today, -2),
|
||||
frequency: "monthly",
|
||||
isOverdue: true,
|
||||
overdueAmount: 30000,
|
||||
overdueSince: addDays(today, -2),
|
||||
},
|
||||
{
|
||||
name: "Utilities",
|
||||
totalCents: 10000,
|
||||
fundedCents: 0,
|
||||
dueOn: today,
|
||||
frequency: "monthly",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Income+Due Same Day",
|
||||
timezone: "America/Los_Angeles",
|
||||
incomeFrequency: "weekly",
|
||||
firstIncomeDate: laToday,
|
||||
incomeCents: 120000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 30, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Misc", percent: 30 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{ name: "Car", totalCents: 40000, fundedCents: 0, dueOn: laToday, frequency: "monthly" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Fully Funded Due",
|
||||
timezone: "America/Chicago",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
incomeCents: 60000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 20, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Misc", percent: 40 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{ name: "Internet", totalCents: 12000, fundedCents: 12000, dueOn: today, frequency: "monthly" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Partial Available",
|
||||
timezone: "America/New_York",
|
||||
incomeFrequency: "weekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
incomeCents: 20000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 20, isSavings: true },
|
||||
{ name: "Food", percent: 50 },
|
||||
{ name: "Gas", percent: 30 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{ name: "Subscription", totalCents: 25000, fundedCents: 0, dueOn: today, frequency: "monthly" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
console.log("\n=== Dashboard Edge Test Setup ===\n");
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { user, email } = await createScenario(scenario);
|
||||
console.log(`Scenario: ${scenario.name}`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${PASSWORD}`);
|
||||
console.log(` User ID: ${user.id}`);
|
||||
console.log(` Timezone: ${scenario.timezone}`);
|
||||
console.log(` Income: regular ${scenario.incomeFrequency}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("Use these users to validate dashboard edge flows.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
204
api/src/scripts/test-early-funding.ts
Normal file
204
api/src/scripts/test-early-funding.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Test Script: Early Funding Feature
|
||||
*
|
||||
* This script helps test the complete payment → early funding → refunding cycle
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx src/scripts/test-early-funding.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { randomUUID } from "crypto";
|
||||
import argon2 from "argon2";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("\n=== 🧪 Early Funding Feature Test ===\n");
|
||||
|
||||
// Step 1: Create test user with payment plan
|
||||
const userId = randomUUID();
|
||||
const email = `test-early-funding-${Date.now()}@test.com`;
|
||||
const password = "password";
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
console.log("📝 Step 1: Creating test user with payment plan...");
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
email,
|
||||
passwordHash,
|
||||
displayName: "Test User",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: new Date("2025-12-20"), // Next payday Dec 20
|
||||
timezone: "America/Chicago",
|
||||
totalBudgetCents: 300000n, // $3,000 monthly budget
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created user: ${email} (${userId})`);
|
||||
console.log(` Income: Biweekly, Next payday: Dec 20, 2025\n`);
|
||||
|
||||
// Step 1.5: Create a variable category (needed for hasBudgetSetup)
|
||||
await prisma.variableCategory.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
name: "Savings",
|
||||
percent: 100, // 100% of variable budget
|
||||
priority: 1,
|
||||
isSavings: true,
|
||||
},
|
||||
});
|
||||
console.log("💾 Created Savings category (100% variable budget)\n");
|
||||
|
||||
// Step 2: Create recurring rent bill
|
||||
const rentId = randomUUID();
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id: rentId,
|
||||
userId,
|
||||
name: "Rent",
|
||||
totalCents: 150000, // $1,500
|
||||
fundedCents: 0n,
|
||||
currentFundedCents: 0n,
|
||||
dueOn: new Date("2025-12-28"), // Due Dec 28
|
||||
cycleStart: new Date("2025-12-01"),
|
||||
frequency: "monthly",
|
||||
periodDays: 30,
|
||||
priority: 1,
|
||||
autoRollover: true,
|
||||
needsFundingThisPeriod: true, // Should be funded
|
||||
paymentSchedule: {
|
||||
frequency: "monthly",
|
||||
dayOfMonth: 28,
|
||||
minFundingPercent: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log("🏠 Step 2: Created Rent bill");
|
||||
console.log(` Amount: $1,500 | Due: Dec 28, 2025`);
|
||||
console.log(` Status: needsFundingThisPeriod = true\n`);
|
||||
|
||||
// Step 3: Fund the rent (simulate payday)
|
||||
const incomeId = randomUUID();
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
id: incomeId,
|
||||
userId,
|
||||
amountCents: 150000n, // $1,500
|
||||
postedAt: new Date("2025-12-17"),
|
||||
isScheduledIncome: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: rentId },
|
||||
data: {
|
||||
fundedCents: 150000n,
|
||||
currentFundedCents: 150000n,
|
||||
needsFundingThisPeriod: false, // Fully funded, no longer needs funding
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "fixed",
|
||||
toId: rentId,
|
||||
amountCents: 150000n,
|
||||
incomeId,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("💰 Step 3: Funded rent with Dec 17 paycheck");
|
||||
console.log(` Funded: $1,500 / $1,500 (100%)`);
|
||||
console.log(` Status: needsFundingThisPeriod = false\n`);
|
||||
|
||||
console.log("=" .repeat(70));
|
||||
console.log("\n🎯 TEST SCENARIOS:\n");
|
||||
|
||||
console.log("📍 SCENARIO A: User opts for EARLY FUNDING");
|
||||
console.log("-".repeat(70));
|
||||
console.log("1. User sees modal: 'Start Funding Early?'");
|
||||
console.log("2. User clicks: 'Yes, Start Now'");
|
||||
console.log("3. API call: PATCH /fixed-plans/{rentId}/early-funding");
|
||||
console.log(" Body: { enableEarlyFunding: true }");
|
||||
console.log("4. Database: needsFundingThisPeriod = true");
|
||||
console.log("5. Next paycheck (Dec 20): Rent gets funded ✅");
|
||||
console.log("");
|
||||
|
||||
console.log("📍 SCENARIO B: User opts to WAIT");
|
||||
console.log("-".repeat(70));
|
||||
console.log("1. User sees modal: 'Start Funding Early?'");
|
||||
console.log("2. User clicks: 'Wait Until Rollover'");
|
||||
console.log("3. No API call made");
|
||||
console.log("4. Database: needsFundingThisPeriod = false (unchanged)");
|
||||
console.log("5. Next paycheck (Dec 20): Rent NOT funded ❌");
|
||||
console.log("6. Jan 28 rollover: needsFundingThisPeriod = true");
|
||||
console.log("7. Paycheck after Jan 28: Rent gets funded ✅");
|
||||
console.log("");
|
||||
|
||||
console.log("=" .repeat(70));
|
||||
console.log("\n🧪 MANUAL TESTING STEPS:\n");
|
||||
|
||||
console.log("1️⃣ Login as: " + email);
|
||||
console.log("2️⃣ Go to Spend page");
|
||||
console.log("3️⃣ Select 'Pay Fixed Expense'");
|
||||
console.log("4️⃣ Select 'Rent' plan");
|
||||
console.log("5️⃣ Enter amount: $1,500");
|
||||
console.log("6️⃣ Click 'Record'");
|
||||
console.log("7️⃣ Modal should appear: 'Start Funding Early?'");
|
||||
console.log("8️⃣ Test both buttons:\n");
|
||||
|
||||
console.log(" Option A: Click 'Yes, Start Now'");
|
||||
console.log(" → Check DB: needsFundingThisPeriod should be TRUE");
|
||||
console.log(" → Add income: $1,500 on Dec 20");
|
||||
console.log(" → Verify: Rent receives allocation\n");
|
||||
|
||||
console.log(" Option B: Click 'Wait Until Rollover'");
|
||||
console.log(" → Check DB: needsFundingThisPeriod should be FALSE");
|
||||
console.log(" → Add income: $1,500 on Dec 20");
|
||||
console.log(" → Verify: Rent receives NO allocation");
|
||||
console.log(" → Rollover runs Jan 28");
|
||||
console.log(" → Add income after Jan 28");
|
||||
console.log(" → Verify: Rent receives allocation\n");
|
||||
|
||||
console.log("=" .repeat(70));
|
||||
console.log("\n📊 DATABASE VERIFICATION COMMANDS:\n");
|
||||
|
||||
console.log("-- Check plan status");
|
||||
console.log(`SELECT name, "fundedCents", "totalCents", "dueOn", "needsFundingThisPeriod"`);
|
||||
console.log(`FROM "FixedPlan" WHERE id = '${rentId}';`);
|
||||
console.log("");
|
||||
|
||||
console.log("-- Check last allocation");
|
||||
console.log(`SELECT kind, "amountCents", "createdAt"`);
|
||||
console.log(`FROM "Allocation" WHERE "userId" = '${userId}'`);
|
||||
console.log(`ORDER BY "createdAt" DESC LIMIT 5;`);
|
||||
console.log("");
|
||||
|
||||
console.log("-- Check transactions");
|
||||
console.log(`SELECT kind, "amountCents", "occurredAt", note`);
|
||||
console.log(`FROM "Transaction" WHERE "userId" = '${userId}'`);
|
||||
console.log(`ORDER BY "occurredAt" DESC;`);
|
||||
console.log("");
|
||||
|
||||
console.log("=" .repeat(70));
|
||||
console.log("\n✅ Test user created successfully!");
|
||||
console.log(`📧 Email: ${email}`);
|
||||
console.log(`🔑 Password: ${password}`);
|
||||
console.log(`🆔 User ID: ${userId}`);
|
||||
console.log(`🏠 Rent Plan ID: ${rentId}`);
|
||||
console.log("");
|
||||
console.log("🚀 Ready to test in the UI!");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error("❌ Error:", err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
182
api/src/scripts/test-final-funding.ts
Normal file
182
api/src/scripts/test-final-funding.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Test script for attempt-final-funding endpoint
|
||||
*
|
||||
* This tests the logic that runs when a payment modal opens:
|
||||
* 1. If bill not fully funded -> attempts to pull from available budget
|
||||
* 2. If available budget can cover -> fully funds it
|
||||
* 3. If available budget insufficient -> takes all available, marks overdue
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { allocateIncome } from "../allocator.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const testEmail = `test-final-funding-${Date.now()}@test.com`;
|
||||
console.log(`\n🧪 Testing attempt-final-funding endpoint with user: ${testEmail}\n`);
|
||||
|
||||
// 1. Create test user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: testEmail,
|
||||
displayName: "Test Final Funding",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created test user: ${user.id}`);
|
||||
|
||||
// 2. Create variable categories (for available budget calculation)
|
||||
await prisma.variableCategory.createMany({
|
||||
data: [
|
||||
{ userId: user.id, name: "Essentials", percent: 50, priority: 10, isSavings: false, balanceCents: 0n },
|
||||
{ userId: user.id, name: "Savings", percent: 30, priority: 20, isSavings: true, balanceCents: 0n },
|
||||
{ userId: user.id, name: "Fun", percent: 20, priority: 30, isSavings: false, balanceCents: 0n },
|
||||
],
|
||||
});
|
||||
console.log(`✅ Created variable categories`);
|
||||
|
||||
// 3. Create test rent plan ($1,500, due tomorrow)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const rentPlan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Rent",
|
||||
totalCents: 150000n, // $1,500
|
||||
fundedCents: 100000n, // $1,000 already funded
|
||||
currentFundedCents: 100000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date(),
|
||||
dueOn: tomorrow,
|
||||
fundingMode: "auto-on-deposit",
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created rent plan: $1,500 total, $1,000 funded, $500 remaining`);
|
||||
|
||||
// 4. Add initial income to simulate funded amount
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: 200000n, // $2,000 income
|
||||
postedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Allocate to rent to simulate the $1,000 funded
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: rentPlan.id,
|
||||
amountCents: 100000n, // $1,000 to rent
|
||||
},
|
||||
});
|
||||
|
||||
// Allocate rest to variable categories to simulate spending/allocation
|
||||
const essentials = await prisma.variableCategory.findFirst({ where: { userId: user.id, name: "Essentials" } });
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: essentials!.id,
|
||||
amountCents: 70000n, // $700
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created income event: $2,000`);
|
||||
console.log(` Allocated: $1,000 to rent, $700 to essentials`);
|
||||
console.log(` Available budget: $300\n`);
|
||||
|
||||
// 5. TEST CASE 1: Available budget ($300) < Remaining ($500)
|
||||
console.log("📋 TEST CASE 1: Partial funding from available budget");
|
||||
console.log(" Remaining needed: $500");
|
||||
console.log(" Available budget: $300");
|
||||
console.log(" Expected: Take all $300, mark overdue with $200\n");
|
||||
|
||||
const response1 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-user-id": user.id,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result1 = await response1.json();
|
||||
console.log("Response:", JSON.stringify(result1, null, 2));
|
||||
|
||||
if (result1.status === "overdue" && result1.fundedCents === 130000 && result1.overdueAmount === 20000) {
|
||||
console.log("✅ TEST 1 PASSED: Correctly funded $300 and marked $200 overdue\n");
|
||||
} else {
|
||||
console.log("❌ TEST 1 FAILED: Unexpected result\n");
|
||||
}
|
||||
|
||||
// 6. TEST CASE 2: Available budget can fully fund
|
||||
// Add more income to create sufficient available budget
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: 50000n, // $500 more income
|
||||
postedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(`💰 Added $500 more income`);
|
||||
console.log(` New available budget: $500`);
|
||||
console.log(` Remaining on rent: $200 (overdue)\n`);
|
||||
|
||||
console.log("📋 TEST CASE 2: Full funding from available budget");
|
||||
console.log(" Remaining needed: $200");
|
||||
console.log(" Available budget: $500");
|
||||
console.log(" Expected: Fund full $200, clear overdue\n");
|
||||
|
||||
const response2 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-user-id": user.id,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result2 = await response2.json();
|
||||
console.log("Response:", JSON.stringify(result2, null, 2));
|
||||
|
||||
if (result2.status === "fully_funded" && result2.fundedCents === 150000 && result2.isOverdue === false) {
|
||||
console.log("✅ TEST 2 PASSED: Correctly fully funded and cleared overdue\n");
|
||||
} else {
|
||||
console.log("❌ TEST 2 FAILED: Unexpected result\n");
|
||||
}
|
||||
|
||||
// 7. TEST CASE 3: Already fully funded
|
||||
console.log("📋 TEST CASE 3: Already fully funded");
|
||||
console.log(" Expected: No changes, return fully_funded status\n");
|
||||
|
||||
const response3 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-user-id": user.id,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result3 = await response3.json();
|
||||
console.log("Response:", JSON.stringify(result3, null, 2));
|
||||
|
||||
if (result3.status === "fully_funded" && result3.fundedCents === 150000) {
|
||||
console.log("✅ TEST 3 PASSED: Correctly identified as fully funded\n");
|
||||
} else {
|
||||
console.log("❌ TEST 3 FAILED: Unexpected result\n");
|
||||
}
|
||||
|
||||
console.log("\n🎉 All tests completed!");
|
||||
console.log(`\nTest user: ${testEmail}`);
|
||||
console.log(`User ID: ${user.id}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
199
api/src/scripts/test-onboarding-edge.ts
Normal file
199
api/src/scripts/test-onboarding-edge.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
import { addDays, startOfDay } from "date-fns";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const PASSWORD = "password";
|
||||
|
||||
type Scenario = {
|
||||
name: string;
|
||||
timezone: string;
|
||||
incomeType: "regular" | "irregular";
|
||||
incomeFrequency: "weekly" | "biweekly" | "monthly" | null;
|
||||
firstIncomeDate: Date | null;
|
||||
fixedPlans: Array<{
|
||||
name: string;
|
||||
totalCents: number;
|
||||
dueOn: Date;
|
||||
frequency?: "weekly" | "biweekly" | "monthly" | "one-time";
|
||||
autoPayEnabled?: boolean;
|
||||
paymentSchedule?: Record<string, any> | null;
|
||||
}>;
|
||||
variableCategories: Array<{
|
||||
name: string;
|
||||
percent: number;
|
||||
isSavings?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function createScenario(s: Scenario) {
|
||||
const timestamp = Date.now();
|
||||
const email = `test-onboarding-${s.name.toLowerCase().replace(/\s+/g, "-")}-${timestamp}@test.com`;
|
||||
const hashed = await argon2.hash(PASSWORD);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: hashed,
|
||||
timezone: s.timezone,
|
||||
incomeType: s.incomeType,
|
||||
incomeFrequency: s.incomeFrequency ?? undefined,
|
||||
firstIncomeDate: s.firstIncomeDate ?? undefined,
|
||||
totalBudgetCents: 100000, // $1,000 placeholder
|
||||
},
|
||||
});
|
||||
|
||||
for (const cat of s.variableCategories) {
|
||||
await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: cat.name,
|
||||
percent: cat.percent,
|
||||
isSavings: !!cat.isSavings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const plan of s.fixedPlans) {
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: plan.name,
|
||||
totalCents: plan.totalCents,
|
||||
fundedCents: 0,
|
||||
currentFundedCents: 0,
|
||||
cycleStart: new Date().toISOString(),
|
||||
dueOn: plan.dueOn.toISOString(),
|
||||
frequency: plan.frequency ?? null,
|
||||
autoPayEnabled: !!plan.autoPayEnabled,
|
||||
paymentSchedule: plan.paymentSchedule ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { user, email };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const today = startOfDay(new Date());
|
||||
const tomorrow = addDays(today, 1);
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: "Timezone Tomorrow",
|
||||
timezone: "America/Los_Angeles",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "weekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Rent",
|
||||
totalCents: 120000,
|
||||
dueOn: addDays(today, 29),
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
|
||||
},
|
||||
],
|
||||
variableCategories: [
|
||||
{ name: "Savings A", percent: 20, isSavings: true },
|
||||
{ name: "Savings B", percent: 10, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Gas", percent: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Month End",
|
||||
timezone: "America/Chicago",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "monthly",
|
||||
firstIncomeDate: new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 31)),
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Insurance",
|
||||
totalCents: 50000,
|
||||
dueOn: new Date(Date.UTC(today.getUTCFullYear(), 0, 31)),
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule: { frequency: "monthly", dayOfMonth: 31, minFundingPercent: 100 },
|
||||
},
|
||||
],
|
||||
variableCategories: [
|
||||
{ name: "Savings", percent: 25, isSavings: true },
|
||||
{ name: "Food", percent: 50 },
|
||||
{ name: "Misc", percent: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Irregular No Auto",
|
||||
timezone: "America/New_York",
|
||||
incomeType: "irregular",
|
||||
incomeFrequency: null,
|
||||
firstIncomeDate: null,
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Subscription",
|
||||
totalCents: 1200,
|
||||
dueOn: addDays(today, 14),
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: false,
|
||||
paymentSchedule: null,
|
||||
},
|
||||
],
|
||||
variableCategories: [
|
||||
{ name: "Savings", percent: 30, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Entertainment", percent: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Due Today Underfunded",
|
||||
timezone: "America/Chicago",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Phone",
|
||||
totalCents: 25000,
|
||||
dueOn: today,
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
|
||||
},
|
||||
],
|
||||
variableCategories: [
|
||||
{ name: "Savings", percent: 20, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Gas", percent: 20 },
|
||||
{ name: "Misc", percent: 20 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
console.log("\n=== Onboarding Edge Test Setup ===\n");
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { user, email } = await createScenario(scenario);
|
||||
console.log(`Scenario: ${scenario.name}`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${PASSWORD}`);
|
||||
console.log(` User ID: ${user.id}`);
|
||||
console.log(` Timezone: ${scenario.timezone}`);
|
||||
console.log(` Income: ${scenario.incomeType} ${scenario.incomeFrequency ?? ""}`.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("Use these users to verify onboarding edge cases and dashboard follow-up behavior.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
206
api/src/scripts/test-overdue-reconciliation.ts
Normal file
206
api/src/scripts/test-overdue-reconciliation.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Test script for Payment Reconciliation System with Overdue tracking
|
||||
*
|
||||
* Tests:
|
||||
* 1. Full payment → Rollover
|
||||
* 2. Partial payment → Refund + Mark overdue
|
||||
* 3. No payment → Mark overdue
|
||||
* 4. Overdue priority in next income allocation
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const timestamp = Date.now();
|
||||
const email = `test-overdue-${timestamp}@test.com`;
|
||||
const password = "testpassword123";
|
||||
|
||||
console.log("🧪 Testing Payment Reconciliation System");
|
||||
console.log("========================================\n");
|
||||
|
||||
// 1. Create test user
|
||||
console.log("1️⃣ Creating test user...");
|
||||
const passwordHash = await argon2.hash(password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
displayName: "Overdue Test User",
|
||||
incomeFrequency: "biweekly",
|
||||
incomeType: "regular",
|
||||
timezone: "America/New_York",
|
||||
firstIncomeDate: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(`✅ User created: ${email}\n`);
|
||||
|
||||
// 2. Create fixed plan (Rent)
|
||||
console.log("2️⃣ Creating Rent plan ($1,500)...");
|
||||
const rent = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Rent",
|
||||
totalCents: 150000n, // $1,500
|
||||
fundedCents: 100000n, // $1,000 funded
|
||||
currentFundedCents: 100000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date(),
|
||||
dueOn: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), // 5 days from now
|
||||
fundingMode: "auto-on-deposit",
|
||||
frequency: "monthly",
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Rent plan created (ID: ${rent.id})`);
|
||||
console.log(` Funded: $1,000 / $1,500\n`);
|
||||
|
||||
// 3. Create variable categories
|
||||
console.log("3️⃣ Creating variable categories...");
|
||||
await prisma.variableCategory.createMany({
|
||||
data: [
|
||||
{ userId: user.id, name: "Essentials", percent: 50, priority: 10, balanceCents: 50000n },
|
||||
{ userId: user.id, name: "Savings", percent: 30, priority: 20, isSavings: true, balanceCents: 30000n },
|
||||
{ userId: user.id, name: "Fun", percent: 20, priority: 30, balanceCents: 20000n },
|
||||
],
|
||||
});
|
||||
console.log("✅ Variable categories created\n");
|
||||
|
||||
// 4. Record initial income to establish available budget
|
||||
console.log("4️⃣ Recording initial income ($2,000)...");
|
||||
const income1 = await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
postedAt: new Date(),
|
||||
amountCents: 200000n,
|
||||
note: "Initial income",
|
||||
},
|
||||
});
|
||||
|
||||
// Create allocations for the funded amount
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: rent.id,
|
||||
amountCents: 100000n,
|
||||
incomeId: income1.id,
|
||||
},
|
||||
});
|
||||
console.log("✅ Income recorded + $1,000 allocated to Rent\n");
|
||||
|
||||
// 5. TEST SCENARIO A: Partial Payment
|
||||
console.log("🧪 TEST SCENARIO A: Partial Payment ($1,000 paid out of $1,500)");
|
||||
console.log("-----------------------------------------------------------");
|
||||
|
||||
const partialPayment = await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
occurredAt: new Date(),
|
||||
kind: "fixed_payment",
|
||||
amountCents: 100000n, // Only paid $1,000
|
||||
planId: rent.id,
|
||||
note: "Partial payment test",
|
||||
},
|
||||
});
|
||||
|
||||
const rentAfterPartial = await prisma.fixedPlan.findUnique({
|
||||
where: { id: rent.id },
|
||||
});
|
||||
|
||||
console.log(`✅ Partial payment recorded: $${Number(partialPayment.amountCents) / 100}`);
|
||||
console.log(` Plan status:`);
|
||||
console.log(` - fundedCents: $${Number(rentAfterPartial?.fundedCents) / 100}`);
|
||||
console.log(` - isOverdue: ${rentAfterPartial?.isOverdue}`);
|
||||
console.log(` - overdueAmount: $${Number(rentAfterPartial?.overdueAmount ?? 0n) / 100}\n`);
|
||||
|
||||
// 6. TEST SCENARIO B: Overdue Priority in Allocation
|
||||
console.log("🧪 TEST SCENARIO B: Next Income Should Prioritize Overdue");
|
||||
console.log("--------------------------------------------------------");
|
||||
|
||||
console.log("📥 Posting new income ($500) - using direct allocator...");
|
||||
|
||||
// Use direct allocator function instead of API
|
||||
const { allocateIncome } = await import("../allocator.js");
|
||||
const allocationResult = await allocateIncome(
|
||||
prisma,
|
||||
user.id,
|
||||
50000, // $500
|
||||
new Date().toISOString(),
|
||||
"test-income-2",
|
||||
"Test income after overdue",
|
||||
true // isScheduledIncome
|
||||
);
|
||||
|
||||
console.log("✅ Income allocated");
|
||||
console.log(` Fixed allocations:`, JSON.stringify(allocationResult.fixedAllocations, null, 2));
|
||||
|
||||
const rentAfterIncome = await prisma.fixedPlan.findUnique({
|
||||
where: { id: rent.id },
|
||||
});
|
||||
|
||||
console.log(` Rent plan after allocation:`);
|
||||
console.log(` - overdueAmount: $${Number(rentAfterIncome?.overdueAmount ?? 0n) / 100}`);
|
||||
console.log(` - fundedCents: $${Number(rentAfterIncome?.fundedCents) / 100}\n`);
|
||||
|
||||
// 7. TEST SCENARIO C: Mark as Unpaid
|
||||
console.log("🧪 TEST SCENARIO C: Mark Bill as Unpaid");
|
||||
console.log("---------------------------------------");
|
||||
|
||||
// Create another plan to test mark-unpaid
|
||||
const utilities = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Utilities",
|
||||
totalCents: 20000n, // $200
|
||||
fundedCents: 15000n, // $150 funded
|
||||
currentFundedCents: 15000n,
|
||||
priority: 20,
|
||||
cycleStart: new Date(),
|
||||
dueOn: new Date(), // Due today
|
||||
fundingMode: "auto-on-deposit",
|
||||
frequency: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
// Mark as unpaid directly via Prisma
|
||||
const fundedAmount = Number(utilities.currentFundedCents);
|
||||
const totalAmount = Number(utilities.totalCents);
|
||||
const remainingBalance = totalAmount - fundedAmount;
|
||||
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: utilities.id },
|
||||
data: {
|
||||
isOverdue: true,
|
||||
overdueAmount: BigInt(Math.max(0, remainingBalance)),
|
||||
overdueSince: new Date(),
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ Marked utilities as unpaid");
|
||||
console.log(` Remaining balance: $${remainingBalance / 100}`);
|
||||
|
||||
const utilitiesAfter = await prisma.fixedPlan.findUnique({
|
||||
where: { id: utilities.id },
|
||||
});
|
||||
console.log(` - isOverdue: ${utilitiesAfter?.isOverdue}`);
|
||||
console.log(` - overdueAmount: $${Number(utilitiesAfter?.overdueAmount ?? 0n) / 100}`);
|
||||
console.log(` - overdueSince: ${utilitiesAfter?.overdueSince}\n`);
|
||||
|
||||
console.log("✅ All tests completed successfully!");
|
||||
console.log("\n📊 Final State:");
|
||||
console.log(` Test user: ${email}`);
|
||||
console.log(` User ID: ${user.id}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("❌ Test failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
289
api/src/scripts/test-payment-flow.ts
Normal file
289
api/src/scripts/test-payment-flow.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
import { addDays, startOfDay } from "date-fns";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("\n=== 🧪 Payment Flow Test Setup ===\n");
|
||||
|
||||
const timestamp = Date.now();
|
||||
const email = `test-payment-flow-${timestamp}@test.com`;
|
||||
const password = "password";
|
||||
const hashedPassword = await argon2.hash(password);
|
||||
|
||||
// Calculate dates
|
||||
const today = startOfDay(new Date());
|
||||
const nextPayday = addDays(today, 3); // Dec 20
|
||||
const rentDue = addDays(today, 11); // Dec 28
|
||||
|
||||
console.log("📝 Step 1: Creating test user...");
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: hashedPassword,
|
||||
timezone: "America/Los_Angeles",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: nextPayday.toISOString(),
|
||||
totalBudgetCents: 300000, // $3,000 total budget
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created user: ${email} (${user.id})`);
|
||||
console.log(` Total Budget: $3,000 | Next payday: ${nextPayday.toLocaleDateString()}\n`);
|
||||
|
||||
console.log("💾 Step 2: Creating budget categories...");
|
||||
|
||||
// Create variable categories
|
||||
const savingsCategory = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Savings",
|
||||
percent: 20,
|
||||
isSavings: true,
|
||||
},
|
||||
});
|
||||
|
||||
const groceriesCategory = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Groceries",
|
||||
percent: 50,
|
||||
isSavings: false,
|
||||
},
|
||||
});
|
||||
|
||||
const entertainmentCategory = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Entertainment",
|
||||
percent: 30,
|
||||
isSavings: false,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created 3 variable categories`);
|
||||
console.log(` - Savings: 20%`);
|
||||
console.log(` - Groceries: 50%`);
|
||||
console.log(` - Entertainment: 30%\n`);
|
||||
|
||||
console.log("🏠 Step 3: Creating fixed expense (Rent)...");
|
||||
const rentPlan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Rent",
|
||||
totalCents: 150000, // $1,500
|
||||
fundedCents: 75000, // $750 (50% funded)
|
||||
cycleStart: today.toISOString(),
|
||||
dueOn: rentDue.toISOString(),
|
||||
frequency: "monthly",
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created Rent bill`);
|
||||
console.log(` Amount: $1,500 | Funded: $750 (50%)`);
|
||||
console.log(` Due: ${rentDue.toLocaleDateString()}`);
|
||||
console.log(` Status: needsFundingThisPeriod = true\n`);
|
||||
|
||||
console.log("💰 Step 4: Adding income to create available budget...");
|
||||
|
||||
// Add income event (not transaction - backend checks IncomeEvent table!)
|
||||
const incomeAmount = 200000; // $2,000
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: incomeAmount,
|
||||
postedAt: today,
|
||||
note: "Test paycheck",
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate allocations (simplified - in real flow this is done by allocator)
|
||||
// Rent needs $750 more, gets funded
|
||||
// Variable gets: $2,000 - $750 = $1,250
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: rentPlan.id,
|
||||
amountCents: 75000, // Fund the remaining $750
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: rentPlan.id },
|
||||
data: {
|
||||
fundedCents: 150000, // Now 100% funded
|
||||
currentFundedCents: 150000, // Dashboard reads from this
|
||||
},
|
||||
});
|
||||
|
||||
// Variable allocation: $1,250
|
||||
const variableAmount = 125000;
|
||||
const savingsAmount = Math.floor(variableAmount * 0.20); // $250
|
||||
const groceriesAmount = Math.floor(variableAmount * 0.50); // $625
|
||||
const entertainmentAmount = Math.floor(variableAmount * 0.30); // $375
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: savingsCategory.id,
|
||||
amountCents: savingsAmount,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: groceriesCategory.id,
|
||||
amountCents: groceriesAmount,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: entertainmentCategory.id,
|
||||
amountCents: entertainmentAmount,
|
||||
},
|
||||
});
|
||||
|
||||
// Update variable category balances (dashboard reads from these)
|
||||
await prisma.variableCategory.update({
|
||||
where: { id: savingsCategory.id },
|
||||
data: { balanceCents: savingsAmount },
|
||||
});
|
||||
|
||||
await prisma.variableCategory.update({
|
||||
where: { id: groceriesCategory.id },
|
||||
data: { balanceCents: groceriesAmount },
|
||||
});
|
||||
|
||||
await prisma.variableCategory.update({
|
||||
where: { id: entertainmentCategory.id },
|
||||
data: { balanceCents: entertainmentAmount },
|
||||
});
|
||||
|
||||
console.log(`✅ Recorded income: $${(incomeAmount / 100).toFixed(2)}`);
|
||||
console.log(` Rent funded: +$750 → 100% complete`);
|
||||
console.log(` Variable budget: $1,250`);
|
||||
console.log(` - Savings: $250 (20%)`);
|
||||
console.log(` - Groceries: $625 (50%)`);
|
||||
console.log(` - Entertainment: $375 (30%)\n`);
|
||||
|
||||
console.log("======================================================================\n");
|
||||
console.log("🎯 TEST SCENARIOS:\n");
|
||||
|
||||
console.log("📍 SCENARIO 1: Pay Rent from fundedCents + available");
|
||||
console.log("----------------------------------------------------------------------");
|
||||
console.log("Current state:");
|
||||
console.log(" - Rent: $1,500 total, $1,500 funded (100%)");
|
||||
console.log(" - Available budget: $1,250 (all in variable categories)");
|
||||
console.log("");
|
||||
console.log("Action: Pay Rent $1,500");
|
||||
console.log("Expected behavior:");
|
||||
console.log(" ✅ Takes $1,500 from fundedCents");
|
||||
console.log(" ✅ Takes $0 from available");
|
||||
console.log(" ✅ Modal appears: 'Start funding early?'");
|
||||
console.log(" ✅ No confirmation needed (not depleting variable)\n");
|
||||
|
||||
console.log("📍 SCENARIO 2: Pay more than funded (triggers confirmation)");
|
||||
console.log("----------------------------------------------------------------------");
|
||||
console.log("Setup: Reset rent to $500 funded");
|
||||
console.log("");
|
||||
console.log("Action: Pay Rent $1,500");
|
||||
console.log("Expected behavior:");
|
||||
console.log(" ✅ Takes $500 from fundedCents");
|
||||
console.log(" ✅ Needs $1,000 from available ($1,250 total)");
|
||||
console.log(" ⚠️ Would deplete 80% of variable balance");
|
||||
console.log(" ⚠️ CONFIRMATION_REQUIRED modal appears");
|
||||
console.log(" ✅ User confirms → payment succeeds");
|
||||
console.log(" ✅ Negative allocation tracks -$1,000 from variable\n");
|
||||
|
||||
console.log("📍 SCENARIO 3: Add income that brings bill to 100%");
|
||||
console.log("----------------------------------------------------------------------");
|
||||
console.log("Setup: Create new bill '$400 Car Insurance' with $0 funded");
|
||||
console.log("");
|
||||
console.log("Action: Record income $500");
|
||||
console.log("Expected behavior:");
|
||||
console.log(" ✅ Allocator funds Car Insurance: $400");
|
||||
console.log(" ✅ Bill reaches 100% funded");
|
||||
console.log(" ✅ Modal appears: 'Start funding early for Car Insurance?'");
|
||||
console.log(" ✅ Remaining $100 goes to variable\n");
|
||||
|
||||
// Create the car insurance bill for testing scenario 3
|
||||
console.log("🚗 Creating Car Insurance bill for Scenario 3...");
|
||||
const carInsurance = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Car Insurance",
|
||||
totalCents: 40000, // $400
|
||||
fundedCents: 0,
|
||||
cycleStart: today.toISOString(),
|
||||
dueOn: addDays(today, 15).toISOString(),
|
||||
frequency: "monthly",
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created Car Insurance: $400, $0 funded\n`);
|
||||
|
||||
console.log("======================================================================\n");
|
||||
console.log("🧪 MANUAL TESTING INSTRUCTIONS:\n");
|
||||
|
||||
console.log("1️⃣ Login at http://localhost:5174");
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${password}\n`);
|
||||
|
||||
console.log("2️⃣ SCENARIO 1: Test normal payment");
|
||||
console.log(" → Go to Spend page");
|
||||
console.log(" → Pay 'Rent' $1,500");
|
||||
console.log(" → Should see early funding modal only (no warning)\n");
|
||||
|
||||
console.log("3️⃣ SCENARIO 2: Test confirmation modal");
|
||||
console.log(" → First, manually reset rent funded amount to $500:");
|
||||
console.log(` UPDATE "FixedPlan" SET "fundedCents" = 50000 WHERE id = '${rentPlan.id}';`);
|
||||
console.log(" → Pay 'Rent' $1,500");
|
||||
console.log(" → Should see CONFIRMATION modal first");
|
||||
console.log(" → Confirm payment");
|
||||
console.log(" → Then see early funding modal\n");
|
||||
|
||||
console.log("4️⃣ SCENARIO 3: Test income modal");
|
||||
console.log(" → Go to Income page");
|
||||
console.log(" → Record income $500");
|
||||
console.log(" → Should see early funding modal for 'Car Insurance'\n");
|
||||
|
||||
console.log("======================================================================\n");
|
||||
console.log("📊 DATABASE VERIFICATION:\n");
|
||||
|
||||
console.log("-- Check rent status");
|
||||
console.log(`SELECT name, "fundedCents", "totalCents", "needsFundingThisPeriod"`);
|
||||
console.log(`FROM "FixedPlan" WHERE id = '${rentPlan.id}';\n`);
|
||||
|
||||
console.log("-- Check available budget (should decrease after payment from available)");
|
||||
console.log(`SELECT kind, "categoryId", "amountCents"`);
|
||||
console.log(`FROM "Allocation" WHERE "userId" = '${user.id}'`);
|
||||
console.log(`ORDER BY "createdAt" DESC LIMIT 10;\n`);
|
||||
|
||||
console.log("-- Check transactions");
|
||||
console.log(`SELECT kind, "amountCents", "planId", note`);
|
||||
console.log(`FROM "Transaction" WHERE "userId" = '${user.id}'`);
|
||||
console.log(`ORDER BY "occurredAt" DESC;\n`);
|
||||
|
||||
console.log("======================================================================\n");
|
||||
console.log("✅ Test user created successfully!");
|
||||
console.log(`📧 Email: ${email}`);
|
||||
console.log(`🔑 Password: ${password}`);
|
||||
console.log(`🆔 User ID: ${user.id}`);
|
||||
console.log(`🏠 Rent ID: ${rentPlan.id}`);
|
||||
console.log(`🚗 Car Insurance ID: ${carInsurance.id}`);
|
||||
console.log("\n🚀 Ready to test all payment scenarios!\n");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
145
api/src/scripts/test-timezone-jobs.ts
Normal file
145
api/src/scripts/test-timezone-jobs.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Test script to simulate running scheduled jobs at different times/timezones
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx src/scripts/test-timezone-jobs.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { rolloverFixedPlans } from "../jobs/rollover.js";
|
||||
import { processAutoPayments } from "../jobs/auto-payments.js";
|
||||
import { toZonedTime } from "date-fns-tz";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function checkMidnight(date: Date | null, timezone: string, label: string) {
|
||||
if (!date) return { ok: true, label, reason: "missing" };
|
||||
const zoned = toZonedTime(date, timezone);
|
||||
const isMidnight =
|
||||
zoned.getHours() === 0 &&
|
||||
zoned.getMinutes() === 0 &&
|
||||
zoned.getSeconds() === 0 &&
|
||||
zoned.getMilliseconds() === 0;
|
||||
return {
|
||||
ok: isMidnight,
|
||||
label,
|
||||
zoned: zoned.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("\n=== Timezone Job Testing ===\n");
|
||||
|
||||
// Get test user
|
||||
const userId = process.argv[2];
|
||||
if (!userId) {
|
||||
console.error("Usage: npx tsx src/scripts/test-timezone-jobs.ts <userId>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, timezone: true, firstIncomeDate: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error(`User ${userId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Testing for user: ${user.email}`);
|
||||
const userTimezone = user.timezone ?? "America/New_York";
|
||||
console.log(`User timezone: ${userTimezone}\n`);
|
||||
|
||||
const plans = await prisma.fixedPlan.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, name: true, dueOn: true, nextPaymentDate: true, cycleStart: true },
|
||||
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
|
||||
});
|
||||
|
||||
console.log("=== DATE NORMALIZATION CHECKS (local midnight expected) ===\n");
|
||||
const checks = [
|
||||
checkMidnight(user.firstIncomeDate ?? null, userTimezone, "user.firstIncomeDate"),
|
||||
];
|
||||
plans.forEach((plan) => {
|
||||
checks.push(checkMidnight(plan.dueOn, userTimezone, `plan:${plan.name}:dueOn`));
|
||||
checks.push(checkMidnight(plan.cycleStart, userTimezone, `plan:${plan.name}:cycleStart`));
|
||||
if (plan.nextPaymentDate) {
|
||||
checks.push(checkMidnight(plan.nextPaymentDate, userTimezone, `plan:${plan.name}:nextPaymentDate`));
|
||||
}
|
||||
});
|
||||
|
||||
let hasIssues = false;
|
||||
for (const check of checks) {
|
||||
if (!check.ok) {
|
||||
hasIssues = true;
|
||||
console.log(`❌ ${check.label} not at local midnight (${check.zoned})`);
|
||||
}
|
||||
}
|
||||
if (!hasIssues) {
|
||||
console.log("✅ All date-only fields are stored at local midnight.\n");
|
||||
} else {
|
||||
console.log("\n⚠️ Some date-only fields are not normalized to local midnight.\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
// Test different UTC times to see when jobs would run
|
||||
const testTimes = [
|
||||
"2025-12-17T00:00:00Z", // Midnight UTC
|
||||
"2025-12-17T06:00:00Z", // 6 AM UTC
|
||||
"2025-12-17T12:00:00Z", // Noon UTC
|
||||
"2025-12-17T18:00:00Z", // 6 PM UTC
|
||||
"2025-12-17T23:00:00Z", // 11 PM UTC
|
||||
];
|
||||
|
||||
console.log("=== ROLLOVER JOB (should run at 6 AM user time) ===\n");
|
||||
for (const utcTime of testTimes) {
|
||||
const asOf = new Date(utcTime);
|
||||
const userTime = toZonedTime(asOf, userTimezone);
|
||||
const userHour = userTime.getHours();
|
||||
const shouldRun = userHour >= 6;
|
||||
|
||||
console.log(`UTC: ${utcTime}`);
|
||||
console.log(` User time: ${userTime.toLocaleString('en-US', { timeZone: userTimezone })}`);
|
||||
console.log(` User hour: ${userHour}`);
|
||||
console.log(` Would run: ${shouldRun ? '✅ YES' : '❌ NO (before 6 AM)'}\n`);
|
||||
}
|
||||
|
||||
console.log("\n=== AUTO-PAYMENT JOB (should run at 9 AM user time) ===\n");
|
||||
for (const utcTime of testTimes) {
|
||||
const asOf = new Date(utcTime);
|
||||
const userTime = toZonedTime(asOf, userTimezone);
|
||||
const userHour = userTime.getHours();
|
||||
const shouldRun = userHour >= 9;
|
||||
|
||||
console.log(`UTC: ${utcTime}`);
|
||||
console.log(` User time: ${userTime.toLocaleString('en-US', { timeZone: userTimezone })}`);
|
||||
console.log(` User hour: ${userHour}`);
|
||||
console.log(` Would run: ${shouldRun ? '✅ YES' : '❌ NO (before 9 AM)'}\n`);
|
||||
}
|
||||
|
||||
// Actually test rollover with dry-run
|
||||
console.log("\n=== TESTING ROLLOVER (DRY RUN) ===\n");
|
||||
const rolloverTestTime = "2025-12-17T22:00:00Z"; // 7 AM Tokyo time (should run)
|
||||
console.log(`Testing at: ${rolloverTestTime}`);
|
||||
const rolloverResults = await rolloverFixedPlans(prisma, rolloverTestTime, { dryRun: true });
|
||||
console.log(`Plans found for rollover: ${rolloverResults.length}`);
|
||||
if (rolloverResults.length > 0) {
|
||||
console.log("Plans:", rolloverResults.map(r => ({ name: r.name, cycles: r.cyclesAdvanced })));
|
||||
}
|
||||
|
||||
// Actually test auto-payment with dry-run
|
||||
console.log("\n=== TESTING AUTO-PAYMENT (DRY RUN) ===\n");
|
||||
const paymentTestTime = "2025-12-18T01:00:00Z"; // 10 AM Tokyo time (should run)
|
||||
console.log(`Testing at: ${paymentTestTime}`);
|
||||
const paymentResults = await processAutoPayments(prisma, paymentTestTime, { dryRun: true });
|
||||
console.log(`Plans found for auto-payment: ${paymentResults.length}`);
|
||||
if (paymentResults.length > 0) {
|
||||
console.log("Plans:", paymentResults.map(r => ({ name: r.name, success: r.success, error: r.error })));
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
3545
api/src/server.ts
3545
api/src/server.ts
File diff suppressed because it is too large
Load Diff
46
api/src/worker/auto-payments.ts
Normal file
46
api/src/worker/auto-payments.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import cron from "node-cron";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { processAutoPayments } from "../jobs/auto-payments.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Default: run every 15 minutes to catch users at their local 9 AM across all timezones
|
||||
// The job itself filters to only process users where local time >= 9 AM
|
||||
const schedule =
|
||||
process.env.AUTO_PAYMENT_SCHEDULE_CRON && typeof process.env.AUTO_PAYMENT_SCHEDULE_CRON === "string"
|
||||
? process.env.AUTO_PAYMENT_SCHEDULE_CRON
|
||||
: "*/15 * * * *";
|
||||
|
||||
async function runOnce() {
|
||||
const asOf = new Date();
|
||||
try {
|
||||
const reports = await processAutoPayments(prisma, asOf, { dryRun: false });
|
||||
console.log(
|
||||
`[auto-payments] ${asOf.toISOString()} processed=${reports.length} successful=${reports.filter(r => r.success).length}`
|
||||
);
|
||||
|
||||
// Log any failures
|
||||
const failures = reports.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.warn(`[auto-payments] ${failures.length} failed payments.`);
|
||||
} else {
|
||||
console.warn(
|
||||
`[auto-payments] ${failures.length} failed payments:`,
|
||||
failures.map(f => `${f.name}: ${f.error}`).join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[auto-payments] job failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[auto-payments-worker] starting cron ${schedule}`);
|
||||
cron.schedule(schedule, () => {
|
||||
runOnce().catch((err) => console.error("[auto-payments] schedule error", err));
|
||||
});
|
||||
|
||||
if (process.env.RUN_ONCE === "1") {
|
||||
runOnce().finally(() => process.exit(0));
|
||||
}
|
||||
39
api/src/worker/rollover.ts
Normal file
39
api/src/worker/rollover.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import cron from "node-cron";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { rolloverFixedPlans } from "../jobs/rollover.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Default: run every 15 minutes to catch users at their local 6 AM across all timezones
|
||||
// The job itself filters to only process users where local time >= 6 AM
|
||||
const schedule =
|
||||
process.env.ROLLOVER_SCHEDULE_CRON && typeof process.env.ROLLOVER_SCHEDULE_CRON === "string"
|
||||
? process.env.ROLLOVER_SCHEDULE_CRON
|
||||
: "*/15 * * * *";
|
||||
|
||||
async function runOnce() {
|
||||
const asOf = new Date();
|
||||
try {
|
||||
const results = await rolloverFixedPlans(prisma, asOf, { dryRun: false });
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.log(`[rollover] ${asOf.toISOString()} processed=${results.length}`);
|
||||
} else {
|
||||
console.log(
|
||||
`[rollover] ${asOf.toISOString()} processed=${results.length} ids=${results
|
||||
.map((r) => r.planId)
|
||||
.join(",")}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[rollover] job failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[rollover-worker] starting cron ${schedule}`);
|
||||
cron.schedule(schedule, () => {
|
||||
runOnce().catch((err) => console.error("[rollover] schedule error", err));
|
||||
});
|
||||
|
||||
if (process.env.RUN_ONCE === "1") {
|
||||
runOnce().finally(() => process.exit(0));
|
||||
}
|
||||
Reference in New Issue
Block a user