final touches for beta skymoney (at least i think)
This commit is contained in:
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());
|
||||
Reference in New Issue
Block a user