final touches for beta skymoney (at least i think)

This commit is contained in:
2026-01-18 00:00:44 -06:00
parent 4eae966f96
commit f4f0ae5df2
161 changed files with 26016 additions and 1966 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View 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
View 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;
}

View File

@@ -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` },
});
});
});

View File

@@ -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;

View File

@@ -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,
};
});
}
}

View File

@@ -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;

View File

@@ -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;

View 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();
});

View 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();
});

View 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();
});

View 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();
});

View 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());

View 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());

View 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();
});

View 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();
});

View 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());

View 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());

File diff suppressed because it is too large Load Diff

View 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));
}

View 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));
}