removed unneccesary files
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 24s

This commit is contained in:
2026-03-21 17:30:11 -05:00
parent 952684fc25
commit 9c7f4d5139
93 changed files with 107 additions and 7734 deletions

View File

View File

View File

@@ -1,34 +0,0 @@
const {PrismaClient} = require('@prisma/client');
async function checkAllocations() {
const p = new PrismaClient();
try {
const user = await p.user.findUnique({
where: { email: 'test@skymoney.com' }
});
const income = await p.incomeEvent.findFirst({
where: { userId: user.id },
orderBy: { postedAt: 'desc' },
include: { allocations: true }
});
console.log('\n💵 LATEST INCOME:', Number(income.amountCents)/100);
console.log('\n📊 ALLOCATIONS:');
for (const a of income.allocations) {
if (a.kind === 'fixed') {
const plan = await p.fixedPlan.findUnique({ where: { id: a.toId } });
console.log(' Fixed -', plan.name + ':', Number(a.amountCents)/100);
} else if (a.kind === 'variable') {
const cat = await p.variableCategory.findUnique({ where: { id: a.toId } });
console.log(' Variable -', cat.name + ':', Number(a.amountCents)/100);
}
}
} finally {
await p.$disconnect();
}
}
checkAllocations();

View File

@@ -1,49 +0,0 @@
// Script to check overdue status of test user
const { PrismaClient } = require('@prisma/client');
async function main() {
const prisma = new PrismaClient();
try {
const user = await prisma.user.findUnique({
where: { email: 'test@skymoney.com' }
});
if (!user) {
console.log('❌ Test user not found. Run create-test-user.cjs first.');
return;
}
console.log('✅ Found test user:', user.email);
const plans = await prisma.fixedPlan.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
isOverdue: true,
overdueAmount: true,
overdueSince: true,
},
});
console.log('\n📋 Fixed Plans:');
for (const plan of plans) {
console.log(`\n ${plan.name}:`);
console.log(` Total: $${Number(plan.totalCents) / 100}`);
console.log(` Funded: $${Number(plan.fundedCents) / 100}`);
console.log(` Overdue: ${plan.isOverdue ? 'YES' : 'NO'}`);
if (plan.isOverdue) {
console.log(` Overdue Amount: $${plan.overdueAmount / 100}`);
console.log(` Overdue Since: ${plan.overdueSince}`);
}
}
} finally {
await prisma.$disconnect();
}
}
main();

View File

@@ -1,238 +0,0 @@
/* SkyMoney SDK: zero-dep, Fetch-based, TypeScript-first.
Usage:
import { SkyMoney } from "./sdk";
const api = new SkyMoney({ baseUrl: import.meta.env.VITE_API_URL });
const dash = await api.dashboard.get();
*/
export type TransactionKind = "variable_spend" | "fixed_payment";
export interface OkResponse { ok: true }
export interface ErrorResponse {
ok: false; code: string; message: string; requestId: string;
}
export interface VariableCategory {
id: string;
userId?: string;
name: string;
percent: number; // 0..100
isSavings: boolean;
priority: number;
balanceCents?: number;
}
export interface FixedPlan {
id: string;
userId?: string;
name: string;
totalCents?: number;
fundedCents?: number;
priority: number;
dueOn: string; // ISO
cycleStart?: string;// ISO
}
export interface Transaction {
id: string;
userId?: string;
kind: TransactionKind;
amountCents: number;
occurredAt: string; // ISO
categoryId?: string | null;
planId?: string | null;
}
export interface TransactionList {
items: Transaction[];
page: number;
limit: number;
total: number;
}
export interface DashboardResponse {
totals: {
incomeCents: number;
variableBalanceCents: number;
fixedRemainingCents: number;
};
percentTotal: number;
variableCategories: VariableCategory[];
fixedPlans: FixedPlan[];
recentTransactions: Transaction[];
}
export interface IncomeRequest { amountCents: number; }
export interface AllocationItem {
id: string; name: string; amountCents: number;
}
export interface IncomePreviewResponse {
fixed: AllocationItem[];
variable: AllocationItem[];
unallocatedCents: number;
}
// allocateIncome returns a richer object; tests expect these fields:
export interface IncomeAllocationResponse {
fixedAllocations?: AllocationItem[];
variableAllocations?: AllocationItem[];
remainingUnallocatedCents?: number;
// allow any extra fields without type errors:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[k: string]: any;
}
export type FetchLike = typeof fetch;
export type SDKOptions = {
baseUrl?: string;
fetch?: FetchLike;
requestIdFactory?: () => string; // to set x-request-id if desired
};
function makeQuery(params: Record<string, unknown | undefined>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
sp.set(k, String(v));
}
const s = sp.toString();
return s ? `?${s}` : "";
}
export class SkyMoney {
readonly baseUrl: string;
private readonly f: FetchLike;
private readonly reqId?: () => string;
constructor(opts: SDKOptions = {}) {
this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, "");
this.f = opts.fetch || fetch;
this.reqId = opts.requestIdFactory;
}
private async request<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
body?: unknown,
query?: Record<string, unknown>,
headers?: Record<string, string>
): Promise<T> {
const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`;
const h: Record<string, string> = { ...(headers || {}) };
if (this.reqId) h["x-request-id"] = this.reqId();
const hasBody = body !== undefined && body !== null;
const res = await this.f(url, {
method,
credentials: "include",
headers: {
...(hasBody ? { "content-type": "application/json" } : {}),
...h,
},
body: hasBody ? JSON.stringify(body) : undefined,
});
// Attempt to parse JSON; fall back to text
const text = await res.text();
const data = text ? safeJson(text) : undefined;
if (!res.ok) {
const err = new Error((data as any)?.message || `HTTP ${res.status}`);
(err as any).status = res.status;
(err as any).body = data ?? text;
throw err;
}
return data as T;
}
// ---- Health
health = {
get: () => this.request<{ ok: true }>("GET", "/health"),
db: () => this.request<{ ok: true; nowISO: string; latencyMs: number }>("GET", "/health/db"),
};
// ---- Dashboard
dashboard = {
get: () => this.request<DashboardResponse>("GET", "/dashboard"),
};
// ---- Income
income = {
preview: (amountCents: number) =>
this.request<IncomePreviewResponse>("POST", "/income/preview", { amountCents }),
create: (amountCents: number) =>
this.request<IncomeAllocationResponse>("POST", "/income", { amountCents }),
};
// ---- Transactions
transactions = {
list: (args: {
from?: string; // YYYY-MM-DD
to?: string; // YYYY-MM-DD
kind?: TransactionKind;
q?: string;
page?: number;
limit?: number;
}) =>
this.request<TransactionList>("GET", "/transactions", undefined, args),
create: (payload: {
kind: TransactionKind;
amountCents: number;
occurredAtISO: string;
categoryId?: string;
planId?: string;
}) => this.request<Transaction>("POST", "/transactions", payload),
};
// ---- Variable Categories
variableCategories = {
create: (payload: {
name: string;
percent: number;
isSavings: boolean;
priority: number;
}) => this.request<OkResponse>("POST", "/variable-categories", payload),
update: (id: string, patch: Partial<{
name: string;
percent: number;
isSavings: boolean;
priority: number;
}>) => this.request<OkResponse>("PATCH", `/variable-categories/${encodeURIComponent(id)}`, patch),
delete: (id: string) =>
this.request<OkResponse>("DELETE", `/variable-categories/${encodeURIComponent(id)}`),
};
// ---- Fixed Plans
fixedPlans = {
create: (payload: {
name: string;
totalCents: number;
fundedCents?: number;
priority: number;
dueOn: string; // ISO
cycleStart?: string; // ISO
}) => this.request<OkResponse>("POST", "/fixed-plans", payload),
update: (id: string, patch: Partial<{
name: string;
totalCents: number;
fundedCents: number;
priority: number;
dueOn: string;
cycleStart: string;
}>) => this.request<OkResponse>("PATCH", `/fixed-plans/${encodeURIComponent(id)}`, patch),
delete: (id: string) =>
this.request<OkResponse>("DELETE", `/fixed-plans/${encodeURIComponent(id)}`),
};
}
// ---------- helpers ----------
function safeJson(s: string) {
try { return JSON.parse(s) } catch { return s }
}

View File

@@ -1,135 +0,0 @@
const argon2 = require('argon2');
const { PrismaClient } = require('@prisma/client');
async function createTestUser() {
const prisma = new PrismaClient({
datasourceUrl: 'postgres://app:app@localhost:5432/skymoney'
});
try {
// Delete existing test user if exists
await prisma.user.deleteMany({
where: { email: 'test@skymoney.com' }
});
console.log('✓ Cleaned up old test user');
// Create user
const hash = await argon2.hash('password123');
const user = await prisma.user.create({
data: {
email: 'test@skymoney.com',
passwordHash: hash,
displayName: 'Test User',
timezone: 'America/New_York'
}
});
console.log('✓ Created user:', user.id);
// Create categories (must total 100%)
await prisma.variableCategory.create({
data: {
userId: user.id,
name: 'Groceries',
percent: 50,
balanceCents: 150000n // $1500
}
});
await prisma.variableCategory.create({
data: {
userId: user.id,
name: 'Other',
percent: 50,
balanceCents: 150000n // $1500
}
});
console.log('✓ Created categories (100% total)');
const today = new Date();
today.setHours(0, 0, 0, 0);
// Create 3 overdue bills with different overdue dates (oldest first priority)
// 1. RENT - Overdue 5 days ago (OLDEST = HIGHEST PRIORITY)
const rentOverdue = new Date(today);
rentOverdue.setDate(rentOverdue.getDate() - 5);
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Rent',
totalCents: 150000n, // $1500 total
fundedCents: 100000n, // $1000 funded
currentFundedCents: 100000n,
dueOn: rentOverdue,
cycleStart: rentOverdue,
frequency: 'monthly',
needsFundingThisPeriod: true,
isOverdue: true,
overdueAmount: 50000n, // $500 overdue
overdueSince: rentOverdue
}
});
console.log('✓ Rent: $1500 total, $500 overdue (5 days ago - OLDEST)');
// 2. UTILITIES - Overdue 3 days ago (SECOND PRIORITY)
const utilOverdue = new Date(today);
utilOverdue.setDate(utilOverdue.getDate() - 3);
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Utilities',
totalCents: 20000n, // $200 total
fundedCents: 10000n, // $100 funded
currentFundedCents: 10000n,
dueOn: utilOverdue,
cycleStart: utilOverdue,
frequency: 'monthly',
needsFundingThisPeriod: true,
isOverdue: true,
overdueAmount: 10000n, // $100 overdue
overdueSince: utilOverdue
}
});
console.log('✓ Utilities: $200 total, $100 overdue (3 days ago)');
// 3. PHONE - Overdue 1 day ago (NEWEST = LOWEST PRIORITY)
const phoneOverdue = new Date(today);
phoneOverdue.setDate(phoneOverdue.getDate() - 1);
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Phone',
totalCents: 10000n, // $100 total
fundedCents: 5000n, // $50 funded
currentFundedCents: 5000n,
dueOn: phoneOverdue,
cycleStart: phoneOverdue,
frequency: 'monthly',
needsFundingThisPeriod: true,
isOverdue: true,
overdueAmount: 5000n, // $50 overdue
overdueSince: phoneOverdue
}
});
console.log('✓ Phone: $100 total, $50 overdue (1 day ago - NEWEST)');
console.log('\n✅ Multi-overdue test user ready!');
console.log(' Email: test@skymoney.com');
console.log(' Password: password123');
console.log('\n OVERDUE BILLS (priority order):');
console.log(' 1. Rent: $500 (5 days overdue)');
console.log(' 2. Utilities: $100 (3 days overdue)');
console.log(' 3. Phone: $50 (1 day overdue)');
console.log(' TOTAL OVERDUE: $650');
console.log('\n Test scenarios:');
console.log(' - Post $500 income → Should pay Rent only');
console.log(' - Post $600 income → Should pay Rent ($500) + Utilities ($100)');
console.log(' - Post $700 income → Should pay all 3 overdue bills');
} catch (error) {
console.error('❌ Error:', error.message);
} finally {
await prisma.$disconnect();
}
}
createTestUser();

View File

@@ -1,133 +0,0 @@
// Create test user with MULTIPLE overdue bills
const argon2 = require('argon2');
const { PrismaClient } = require('@prisma/client');
async function main() {
const prisma = new PrismaClient({
datasourceUrl: 'postgres://app:app@localhost:5432/skymoney'
});
try {
const email = 'test@skymoney.com';
const password = 'password123';
// Clean up existing user
await prisma.user.deleteMany({ where: { email } });
console.log('✓ Cleaned up old test user');
// Create user
const passwordHash = await argon2.hash(password);
const user = await prisma.user.create({
data: {
email,
passwordHash,
displayName: 'Test User',
incomeFrequency: 'biweekly',
totalBudgetCents: BigInt(300000), // $3000
timezone: 'America/New_York',
},
});
console.log('✓ Created user:', user.id);
// Create income source
await prisma.incomeEvent.create({
data: {
id: '00000000-0000-0000-0000-000000000001',
userId: user.id,
postedAt: new Date(),
amountCents: BigInt(300000),
note: 'Initial budget',
},
});
console.log('✓ Created income: $3000');
// Create categories
await prisma.variableCategory.createMany({
data: [
{ userId: user.id, name: 'Groceries', percent: 50, priority: 1, balanceCents: BigInt(150000) },
{ userId: user.id, name: 'Other', percent: 50, priority: 2, balanceCents: BigInt(150000) },
],
});
console.log('✓ Created categories (100% total)');
const today = new Date();
today.setHours(6, 0, 0, 0); // 6am today
const threeDaysAgo = new Date(today);
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
const oneWeekAgo = new Date(today);
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
// Create THREE overdue bills with different dates
// 1. Rent - $1500, $1000 funded, $500 overdue (oldest - 7 days ago)
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Rent',
cycleStart: oneWeekAgo,
dueOn: today,
totalCents: BigInt(150000), // $1500
fundedCents: BigInt(100000), // $1000 funded
currentFundedCents: BigInt(100000),
priority: 1,
isOverdue: true,
overdueAmount: BigInt(50000), // $500 overdue
overdueSince: oneWeekAgo,
},
});
// 2. Utilities - $200, $100 funded, $100 overdue (3 days ago)
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Utilities',
cycleStart: threeDaysAgo,
dueOn: today,
totalCents: BigInt(20000), // $200
fundedCents: BigInt(10000), // $100 funded
currentFundedCents: BigInt(10000),
priority: 2,
isOverdue: true,
overdueAmount: BigInt(10000), // $100 overdue
overdueSince: threeDaysAgo,
},
});
// 3. Phone - $100, $50 funded, $50 overdue (today)
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Phone',
cycleStart: today,
dueOn: today,
totalCents: BigInt(10000), // $100
fundedCents: BigInt(5000), // $50 funded
currentFundedCents: BigInt(5000),
priority: 3,
isOverdue: true,
overdueAmount: BigInt(5000), // $50 overdue
overdueSince: today,
},
});
console.log('✓ Created 3 overdue plans:');
console.log(' - Rent: $1500 total, $1000 funded, $500 overdue (7 days ago)');
console.log(' - Utilities: $200 total, $100 funded, $100 overdue (3 days ago)');
console.log(' - Phone: $100 total, $50 funded, $50 overdue (today)');
console.log('\n✅ Test user ready!');
console.log(' Email: test@skymoney.com');
console.log(' Password: password123');
console.log(' Total overdue: $650');
console.log('\n💡 Post $1000 income to see priority order:');
console.log(' 1st: Rent $500 (oldest)');
console.log(' 2nd: Utilities $100');
console.log(' 3rd: Phone $50');
console.log(' Remaining $350 → normal allocation');
} finally {
await prisma.$disconnect();
}
}
main();

View File

@@ -1,91 +0,0 @@
const argon2 = require('argon2');
const { PrismaClient } = require('@prisma/client');
async function createTestUser() {
const prisma = new PrismaClient({
datasourceUrl: 'postgres://app:app@localhost:5432/skymoney'
});
try {
// Delete existing test user if exists
await prisma.user.deleteMany({
where: { email: 'test@skymoney.com' }
});
console.log('✓ Cleaned up old test user');
// Create user
const hash = await argon2.hash('password123');
const user = await prisma.user.create({
data: {
email: 'test@skymoney.com',
passwordHash: hash,
displayName: 'Test User',
timezone: 'America/New_York'
}
});
console.log('✓ Created user:', user.id);
// Create income
await prisma.incomeEvent.create({
data: {
userId: user.id,
amountCents: 300000n, // $3000
postedAt: new Date(),
isScheduledIncome: true
}
});
console.log('✓ Created income: $3000');
// Create categories (must total 100%)
await prisma.variableCategory.create({
data: {
userId: user.id,
name: 'Groceries',
percent: 50,
balanceCents: 150000n // $1500
}
});
await prisma.variableCategory.create({
data: {
userId: user.id,
name: 'Other',
percent: 50,
balanceCents: 0n
}
});
console.log('✓ Created categories (100% total)');
// Create rent bill due today - PARTIALLY FUNDED & OVERDUE
const today = new Date();
today.setHours(0, 0, 0, 0);
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Rent',
totalCents: 150000n, // $1500 total
fundedCents: 100000n, // $1000 funded (partial)
currentFundedCents: 100000n, // $1000 available
dueOn: today,
cycleStart: today,
frequency: 'monthly',
needsFundingThisPeriod: true,
isOverdue: true, // Marked overdue
overdueAmount: 50000n, // $500 outstanding
overdueSince: new Date()
}
});
console.log('✓ Created Rent plan: $1500 total, $1000 funded, $500 overdue');
console.log('\n✅ Test user ready!');
console.log(' Email: test@skymoney.com');
console.log(' Password: password123');
console.log(' Rent: $1500 due today (partially funded, overdue)');
} catch (error) {
console.error('❌ Error:', error.message);
} finally {
await prisma.$disconnect();
}
}
createTestUser();

View File

View File

View File

@@ -1,552 +0,0 @@
openapi: 3.0.3
info:
title: SkyMoney API
version: 0.1.0
description: |
Fastify backend for budgeting/allocations.
Authentication uses secure httpOnly session cookies (Fastify JWT). During tests
or local development you can set `AUTH_DISABLED=1` to use the legacy `x-user-id`
header for impersonation, but production relies on the session cookie.
servers:
- url: http://localhost:8080
tags:
- name: Health
- name: Dashboard
- name: Income
- name: Transactions
- name: VariableCategories
- name: FixedPlans
paths:
/health:
get:
tags: [Health]
summary: Liveness check
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/HealthOk' }
/health/db:
get:
tags: [Health]
summary: DB health + latency
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: DB OK
content:
application/json:
schema: { $ref: '#/components/schemas/DbHealth' }
/dashboard:
get:
tags: [Dashboard]
summary: Aggregated dashboard data
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: Dashboard payload
content:
application/json:
schema: { $ref: '#/components/schemas/DashboardResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/income:
post:
tags: [Income]
summary: Create income event and allocate funds
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/IncomeRequest' }
responses:
'200':
description: Allocation result
content:
application/json:
schema: { $ref: '#/components/schemas/IncomeAllocationResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/income/preview:
post:
tags: [Income]
summary: Preview allocation of a hypothetical income amount
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/IncomeRequest' }
responses:
'200':
description: Preview
content:
application/json:
schema: { $ref: '#/components/schemas/IncomePreviewResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/transactions:
get:
tags: [Transactions]
summary: List transactions with filters and pagination
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: query
name: from
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
description: Inclusive start date (YYYY-MM-DD)
- in: query
name: to
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
description: Inclusive end date (YYYY-MM-DD)
- in: query
name: kind
schema: { $ref: '#/components/schemas/TransactionKind' }
- in: query
name: q
schema: { type: string }
description: Simple search (currently numeric amount match)
- in: query
name: page
schema: { type: integer, minimum: 1, default: 1 }
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
'200':
description: List
content:
application/json:
schema: { $ref: '#/components/schemas/TransactionList' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
post:
tags: [Transactions]
summary: Create a transaction
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/TransactionCreate' }
responses:
'200':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/Transaction' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/variable-categories:
post:
tags: [VariableCategories]
summary: Create a variable category (sum of percents must be 100)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/VariableCategoryCreate' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/variable-categories/{id}:
patch:
tags: [VariableCategories]
summary: Update a variable category (sum of percents must be 100)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/VariableCategoryPatch' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
delete:
tags: [VariableCategories]
summary: Delete a variable category (sum of percents must remain 100)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/fixed-plans:
post:
tags: [FixedPlans]
summary: Create a fixed plan
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/FixedPlanCreate' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/fixed-plans/{id}:
patch:
tags: [FixedPlans]
summary: Update a fixed plan (fundedCents cannot exceed totalCents)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/FixedPlanPatch' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
delete:
tags: [FixedPlans]
summary: Delete a fixed plan
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
components:
parameters:
UserId:
in: header
name: x-user-id
required: false
description: |
Dev/test-only tenant selector when AUTH_DISABLED=1. Production requests rely
on the session cookie instead and should omit this header.
schema: { type: string }
description: Override the stubbed user id for the request.
RequestId:
in: header
name: x-request-id
required: false
schema: { type: string, maxLength: 64 }
description: Custom request id (echoed back by server).
responses:
BadRequest:
description: Validation or guard failed
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
NotFound:
description: Resource not found
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
InternalError:
description: Unexpected server error
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
schemas:
HealthOk:
type: object
properties:
ok: { type: boolean, const: true }
required: [ok]
DbHealth:
type: object
properties:
ok: { type: boolean, const: true }
nowISO: { type: string, format: date-time }
latencyMs: { type: integer, minimum: 0 }
required: [ok, nowISO, latencyMs]
OkResponse:
type: object
properties:
ok: { type: boolean, const: true }
required: [ok]
ErrorResponse:
type: object
properties:
ok: { type: boolean, const: false }
code: { type: string }
message: { type: string }
requestId: { type: string }
required: [ok, code, message, requestId]
VariableCategory:
type: object
properties:
id: { type: string }
userId: { type: string }
name: { type: string }
percent: { type: integer, minimum: 0, maximum: 100 }
isSavings: { type: boolean }
priority: { type: integer, minimum: 0 }
balanceCents:
type: integer
description: Current balance; may be omitted or 0 when not loaded.
required: [id, userId, name, percent, isSavings, priority]
FixedPlan:
type: object
properties:
id: { type: string }
userId: { type: string }
name: { type: string }
totalCents: { type: integer, minimum: 0 }
fundedCents: { type: integer, minimum: 0 }
priority: { type: integer, minimum: 0 }
dueOn: { type: string, format: date-time }
cycleStart: { type: string, format: date-time }
required: [id, userId, name, priority, dueOn]
TransactionKind:
type: string
enum: [variable_spend, fixed_payment]
Transaction:
type: object
properties:
id: { type: string }
userId: { type: string }
kind: { $ref: '#/components/schemas/TransactionKind' }
amountCents: { type: integer, minimum: 0 }
occurredAt: { type: string, format: date-time }
categoryId: { type: string, nullable: true }
planId: { type: string, nullable: true }
required: [id, userId, kind, amountCents, occurredAt]
TransactionList:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Transaction' }
page: { type: integer, minimum: 1 }
limit: { type: integer, minimum: 1, maximum: 100 }
total: { type: integer, minimum: 0 }
required: [items, page, limit, total]
TransactionCreate:
type: object
properties:
kind: { $ref: '#/components/schemas/TransactionKind' }
amountCents: { type: integer, minimum: 1 }
occurredAtISO: { type: string, format: date-time }
categoryId: { type: string, nullable: true }
planId: { type: string, nullable: true }
required: [kind, amountCents, occurredAtISO]
DashboardResponse:
type: object
properties:
totals:
type: object
properties:
incomeCents: { type: integer, minimum: 0 }
variableBalanceCents: { type: integer, minimum: 0 }
fixedRemainingCents: { type: integer, minimum: 0 }
required: [incomeCents, variableBalanceCents, fixedRemainingCents]
percentTotal: { type: integer, minimum: 0, maximum: 100 }
variableCategories:
type: array
items: { $ref: '#/components/schemas/VariableCategory' }
fixedPlans:
type: array
items: { $ref: '#/components/schemas/FixedPlan' }
recentTransactions:
type: array
items: { $ref: '#/components/schemas/Transaction' }
required: [totals, percentTotal, variableCategories, fixedPlans, recentTransactions]
IncomeRequest:
type: object
properties:
amountCents: { type: integer, minimum: 0 }
required: [amountCents]
AllocationItem:
type: object
properties:
id: { type: string }
name: { type: string }
amountCents: { type: integer, minimum: 0 }
required: [id, name, amountCents]
IncomePreviewResponse:
type: object
properties:
fixed:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
variable:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
unallocatedCents: { type: integer, minimum: 0 }
required: [fixed, variable, unallocatedCents]
IncomeAllocationResponse:
type: object
description: >
Shape returned by allocateIncome. Tests expect:
fixedAllocations, variableAllocations, remainingUnallocatedCents.
Additional fields may be present.
properties:
fixedAllocations:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
variableAllocations:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
remainingUnallocatedCents: { type: integer, minimum: 0 }
additionalProperties: true
VariableCategoryCreate:
type: object
properties:
name: { type: string }
percent: { type: integer, minimum: 0, maximum: 100 }
isSavings: { type: boolean }
priority: { type: integer, minimum: 0 }
required: [name, percent, isSavings, priority]
VariableCategoryPatch:
type: object
properties:
name: { type: string }
percent: { type: integer, minimum: 0, maximum: 100 }
isSavings: { type: boolean }
priority: { type: integer, minimum: 0 }
additionalProperties: false
FixedPlanCreate:
type: object
properties:
name: { type: string }
totalCents: { type: integer, minimum: 0 }
fundedCents: { type: integer, minimum: 0 }
priority: { type: integer, minimum: 0 }
dueOn: { type: string, format: date-time }
cycleStart: { type: string, format: date-time }
required: [name, totalCents, priority, dueOn]
FixedPlanPatch:
type: object
properties:
name: { type: string }
totalCents: { type: integer, minimum: 0 }
fundedCents: { type: integer, minimum: 0 }
priority: { type: integer, minimum: 0 }
dueOn: { type: string, format: date-time }
cycleStart: { type: string, format: date-time }
additionalProperties: false

View File

2173
api/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -297,6 +297,7 @@ async function getInputs(
currentFundedCents: true,
dueOn: true,
priority: true,
fundingMode: true,
needsFundingThisPeriod: true,
paymentSchedule: true,
autoPayEnabled: true,
@@ -328,7 +329,8 @@ export function buildPlanStates(
userIncomeType?: string,
isScheduledIncome?: boolean
): PlanState[] {
const timezone = config.timezone;
const timezone = config.timezone ?? "UTC";
const firstIncomeDate = config.firstIncomeDate ?? null;
const freqDays = frequencyDays[config.incomeFrequency];
// Only handle regular income frequencies
@@ -342,7 +344,8 @@ export function buildPlanStates(
const remainingCents = Math.max(0, total - funded);
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
const autoFundEnabled = !!p.autoPayEnabled;
const autoFundEnabled =
!p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual";
// Calculate preliminary crisis status to determine if we should override funding restrictions
// Use timezone-aware date comparison
@@ -357,14 +360,14 @@ export function buildPlanStates(
let isPrelimCrisis = false;
let dueBeforeNextPayday = false;
let daysUntilPayday = 0;
if (isPaymentPlanUser && config.firstIncomeDate) {
const nextPayday = calculateNextPayday(config.firstIncomeDate, config.incomeFrequency, now, timezone);
if (isPaymentPlanUser && firstIncomeDate) {
const nextPayday = calculateNextPayday(firstIncomeDate, config.incomeFrequency, now, timezone);
const normalizedNextPayday = getUserMidnight(timezone, nextPayday);
daysUntilPayday = Math.max(0, Math.ceil((normalizedNextPayday.getTime() - userNow.getTime()) / DAY_MS));
dueBeforeNextPayday = userDueDate.getTime() < normalizedNextPayday.getTime();
}
if (remainingCents >= CRISIS_MINIMUM_CENTS) {
if (isPaymentPlanUser && config.firstIncomeDate) {
if (isPaymentPlanUser && firstIncomeDate) {
isPrelimCrisis = daysUntilDuePrelim < daysUntilPayday && fundedPercent < 90;
} else {
isPrelimCrisis = fundedPercent < 70 && daysUntilDuePrelim <= 14;
@@ -430,10 +433,10 @@ export function buildPlanStates(
// Calculate payment periods more accurately using firstIncomeDate
let cyclesLeft: number;
if (config.firstIncomeDate) {
if (firstIncomeDate) {
// Count actual pay dates between now and due date based on the recurring pattern
// established by firstIncomeDate (pass timezone for correct date handling)
cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, config.firstIncomeDate, config.incomeFrequency, timezone);
cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, firstIncomeDate, config.incomeFrequency, timezone);
} else {
// Fallback to old calculation if firstIncomeDate not set
cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays));
@@ -1377,7 +1380,9 @@ function computeBudgetAllocation(
const availableBudget = inputs.availableBefore;
const totalPool = availableBudget + newIncome;
const eligiblePlans = inputs.plans.filter((plan) => plan.autoPayEnabled);
const eligiblePlans = inputs.plans.filter(
(plan) => !plan.fundingMode || String(plan.fundingMode).toLowerCase() !== "manual"
);
const planStates = buildBudgetPlanStates(eligiblePlans, now, inputs.config.timezone);
// Calculate total remaining needed across all fixed plans
@@ -1505,7 +1510,8 @@ function buildBudgetPlanStates(
const userDueDate = getUserMidnightFromDateOnly(timezone, p.dueOn);
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS));
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
const autoFundEnabled = !!p.autoPayEnabled;
const autoFundEnabled =
!p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual";
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
// For irregular income, crisis mode triggers earlier (14 days)

View File

@@ -196,64 +196,64 @@ export function calculateNextPaymentDate(
timezone: string
): Date {
const next = toZonedTime(currentDate, timezone);
const hours = next.getUTCHours();
const minutes = next.getUTCMinutes();
const seconds = next.getUTCSeconds();
const ms = next.getUTCMilliseconds();
const hours = next.getHours();
const minutes = next.getMinutes();
const seconds = next.getSeconds();
const ms = next.getMilliseconds();
switch (schedule.frequency) {
case "daily":
next.setUTCDate(next.getUTCDate() + 1);
next.setDate(next.getDate() + 1);
break;
case "weekly":
// Move to next occurrence of specified day of week
{
const targetDay = schedule.dayOfWeek ?? 0;
const currentDay = next.getUTCDay();
const currentDay = next.getDay();
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7));
next.setDate(next.getDate() + (daysUntilTarget || 7));
}
break;
case "biweekly":
{
const targetDay = schedule.dayOfWeek ?? next.getUTCDay();
const currentDay = next.getUTCDay();
const targetDay = schedule.dayOfWeek ?? next.getDay();
const currentDay = next.getDay();
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);
next.setDate(next.getDate() + daysUntilTarget);
}
break;
case "monthly":
{
const targetDay = schedule.dayOfMonth ?? next.getUTCDate();
const targetDay = schedule.dayOfMonth ?? next.getDate();
// Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months.
next.setUTCDate(1);
next.setUTCMonth(next.getUTCMonth() + 1);
next.setDate(1);
next.setMonth(next.getMonth() + 1);
const lastDay = getLastDayOfMonth(next);
next.setUTCDate(Math.min(targetDay, lastDay));
next.setDate(Math.min(targetDay, lastDay));
}
break;
case "custom":
{
const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays;
next.setUTCDate(next.getUTCDate() + days);
next.setDate(next.getDate() + days);
}
break;
default:
// Fallback to periodDays
next.setUTCDate(next.getUTCDate() + periodDays);
next.setDate(next.getDate() + periodDays);
}
next.setUTCHours(hours, minutes, seconds, ms);
next.setHours(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();
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
}

View File

@@ -4,8 +4,10 @@ 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);
// Advance by calendar days in the user's local timezone, then normalize
// to local midnight before converting back to UTC for storage.
zoned.setDate(zoned.getDate() + days);
zoned.setHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
};

View File

@@ -1,5 +1,6 @@
import type { FastifyPluginAsync } from "fastify";
import { getUserMidnightFromDateOnly } from "../allocator.js";
import { getUserTimezone } from "../services/user-context.js";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -273,9 +274,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
app.get("/crisis-status", async (req) => {
const userId = req.userId;
const now = new Date();
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const userTimezone = await getUserTimezone(app.prisma, userId);
const { getUserMidnight } = await import("../allocator.js");
const userNow = getUserMidnight(userTimezone, now);

View File

@@ -6,6 +6,7 @@ import {
getUserMidnight,
getUserMidnightFromDateOnly,
} from "../allocator.js";
import { getUserTimezone } from "../services/user-context.js";
type RateLimitRouteOptions = {
config: {
@@ -90,9 +91,7 @@ const fixedPlansRoutes: FastifyPluginAsync<FixedPlansRoutesOptions> = async (
if (!plan) {
return reply.code(404).send({ message: "Plan not found" });
}
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const userTimezone = await getUserTimezone(app.prisma, userId);
await app.prisma.fixedPlan.update({
where: { id: planId },
@@ -662,9 +661,7 @@ const fixedPlansRoutes: FastifyPluginAsync<FixedPlansRoutesOptions> = async (
return reply.code(400).send({ message: "Invalid payload" });
}
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const userTimezone = await getUserTimezone(app.prisma, userId);
const amountMode = parsed.data.amountMode ?? "fixed";
if (amountMode === "estimated" && parsed.data.estimatedCents === undefined) {
@@ -751,9 +748,7 @@ const fixedPlansRoutes: FastifyPluginAsync<FixedPlansRoutesOptions> = async (
}
const id = String((req.params as any).id);
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const userTimezone = await getUserTimezone(app.prisma, userId);
const plan = await app.prisma.fixedPlan.findFirst({
where: { id, userId },

View File

@@ -1,6 +1,7 @@
import type { FastifyPluginAsync, FastifyInstance } from "fastify";
import type { FastifyPluginAsync } from "fastify";
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import { ensureBudgetSessionAvailableSynced } from "../services/budget-session.js";
type RateLimitRouteOptions = {
config: {
@@ -76,52 +77,6 @@ async function assertPercentTotal(
}
}
async function getLatestBudgetSession(app: FastifyInstance, userId: string) {
return app.prisma.budgetSession.findFirst({
where: { userId },
orderBy: { periodStart: "desc" },
});
}
async function ensureBudgetSession(
app: FastifyInstance,
userId: string,
fallbackAvailableCents = 0
) {
const existing = await getLatestBudgetSession(app, userId);
if (existing) return existing;
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0));
return app.prisma.budgetSession.create({
data: {
userId,
periodStart: start,
periodEnd: end,
totalBudgetCents: BigInt(Math.max(0, fallbackAvailableCents)),
allocatedCents: 0n,
fundedCents: 0n,
availableCents: BigInt(Math.max(0, fallbackAvailableCents)),
},
});
}
async function ensureBudgetSessionAvailableSynced(
app: FastifyInstance,
userId: string,
availableCents: number
) {
const normalizedAvailableCents = BigInt(Math.max(0, Math.trunc(availableCents)));
const session = await ensureBudgetSession(app, userId, Number(normalizedAvailableCents));
if ((session.availableCents ?? 0n) === normalizedAvailableCents) return session;
return app.prisma.budgetSession.update({
where: { id: session.id },
data: { availableCents: normalizedAvailableCents },
});
}
const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptions> = async (
app,
opts
@@ -132,15 +87,7 @@ const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptio
return reply.code(400).send({ message: "Invalid payload" });
}
const userId = req.userId;
const userTimezone =
(
await app.prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
})
)?.timezone ?? "America/New_York";
const normalizedName = parsed.data.name.trim().toLowerCase();
void userTimezone;
return await app.prisma.$transaction(async (tx) => {
try {
@@ -169,18 +116,10 @@ const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptio
}
const id = String((req.params as any).id);
const userId = req.userId;
const userTimezone =
(
await app.prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
})
)?.timezone ?? "America/New_York";
const updateData = {
...patch.data,
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
};
void userTimezone;
return await app.prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({
@@ -271,7 +210,7 @@ const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptio
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
});
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
await ensureBudgetSessionAvailableSynced(app, userId, totalBalance);
await ensureBudgetSessionAvailableSynced(app.prisma, userId, totalBalance);
return reply.send({
ok: true,
@@ -296,7 +235,7 @@ const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptio
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
const availableCents = totalBalance;
await ensureBudgetSessionAvailableSynced(app, userId, availableCents);
await ensureBudgetSessionAvailableSynced(app.prisma, userId, availableCents);
const targetMap = new Map<string, number>();
for (const t of parsed.data.targets) {

View File

@@ -10,6 +10,11 @@ import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import { getUserMidnightFromDateOnly } from "./allocator.js";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import {
computeDepositShares,
computeOverdraftShares,
computeWithdrawShares,
} from "./services/category-shares.js";
import healthRoutes from "./routes/health.js";
import sessionRoutes from "./routes/session.js";
import userRoutes from "./routes/user.js";
@@ -429,199 +434,26 @@ function calculateNextDueDate(currentDueDate: Date, frequency: string, timezone:
switch (frequency) {
case "weekly":
zoned.setUTCDate(zoned.getUTCDate() + 7);
zoned.setDate(zoned.getDate() + 7);
break;
case "biweekly":
zoned.setUTCDate(zoned.getUTCDate() + 14);
zoned.setDate(zoned.getDate() + 14);
break;
case "monthly": {
const targetDay = zoned.getUTCDate();
const nextMonth = zoned.getUTCMonth() + 1;
const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12);
const nextMonthIndex = nextMonth % 12;
const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate();
zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay));
const targetDay = zoned.getDate();
zoned.setDate(1);
zoned.setMonth(zoned.getMonth() + 1);
const lastDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate();
zoned.setDate(Math.min(targetDay, lastDay));
break;
}
default:
return base;
}
zoned.setUTCHours(0, 0, 0, 0);
zoned.setHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
}
function jsonBigIntSafe(obj: unknown) {
return JSON.parse(
JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v))
);
}
type PercentCategory = {
id: string;
percent: number;
balanceCents: bigint | null;
};
function computePercentShares(categories: PercentCategory[], amountCents: number) {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (amountCents * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
balanceCents: Number(cat.balanceCents ?? 0n),
share: floored,
frac: raw - floored,
};
});
let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0);
shares
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((s) => {
if (remainder > 0) {
s.share += 1;
remainder -= 1;
}
});
if (shares.some((s) => s.share > s.balanceCents)) {
return { ok: false as const, reason: "insufficient_balances" };
}
return { ok: true as const, shares };
}
function computeWithdrawShares(categories: PercentCategory[], amountCents: number) {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
const working = categories.map((cat) => ({
id: cat.id,
percent: cat.percent,
balanceCents: Number(cat.balanceCents ?? 0n),
share: 0,
}));
let remaining = Math.max(0, Math.floor(amountCents));
let safety = 0;
while (remaining > 0 && safety < 1000) {
safety += 1;
const eligible = working.filter((c) => c.balanceCents > 0 && c.percent > 0);
if (eligible.length === 0) break;
const totalPercent = eligible.reduce((sum, cat) => sum + cat.percent, 0);
if (totalPercent <= 0) break;
const provisional = eligible.map((cat) => {
const raw = (remaining * cat.percent) / totalPercent;
const floored = Math.floor(raw);
return {
id: cat.id,
raw,
floored,
remainder: raw - floored,
};
});
let sumBase = provisional.reduce((sum, p) => sum + p.floored, 0);
let leftovers = remaining - sumBase;
provisional
.slice()
.sort((a, b) => b.remainder - a.remainder)
.forEach((p) => {
if (leftovers > 0) {
p.floored += 1;
leftovers -= 1;
}
});
let allocatedThisRound = 0;
for (const p of provisional) {
const entry = working.find((w) => w.id === p.id);
if (!entry) continue;
const take = Math.min(p.floored, entry.balanceCents);
if (take > 0) {
entry.balanceCents -= take;
entry.share += take;
allocatedThisRound += take;
}
}
remaining -= allocatedThisRound;
if (allocatedThisRound === 0) break;
}
if (remaining > 0) {
return { ok: false as const, reason: "insufficient_balances" };
}
return {
ok: true as const,
shares: working.map((c) => ({ id: c.id, share: c.share })),
};
}
function computeOverdraftShares(categories: PercentCategory[], amountCents: number) {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (amountCents * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
share: floored,
frac: raw - floored,
};
});
let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0);
shares
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((s) => {
if (remainder > 0) {
s.share += 1;
remainder -= 1;
}
});
return { ok: true as const, shares };
}
function computeDepositShares(categories: PercentCategory[], amountCents: number) {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (amountCents * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
share: floored,
frac: raw - floored,
};
});
let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0);
shares
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((s) => {
if (remainder > 0) {
s.share += 1;
remainder -= 1;
}
});
return { ok: true as const, shares };
}
const DEFAULT_VARIABLE_CATEGORIES = [
{ name: "Essentials", percent: 50, priority: 10, isSavings: false },
{ name: "Savings", percent: 30, priority: 20, isSavings: true },

View File

@@ -1,25 +0,0 @@
#!/bin/bash
# Login and save cookie
echo "<22><><EFBFBD> Logging in..."
curl -c cookies.txt -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@skymoney.com","password":"password123"}' > /dev/null 2>&1
# Check current plans
echo "<22><><EFBFBD> Plans BEFORE income:"
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans | jq '.plans[] | {name: .name, funded: (.fundedCents/100), total: (.totalCents/100), isOverdue: .isOverdue, overdueAmt: (.overdueAmount/100)}'
# Post $1000 income
echo -e "\n<><6E><EFBFBD> Posting $1000 income..."
RESULT=$(curl -s -b cookies.txt -X POST http://localhost:8080/api/income \
-H "Content-Type: application/json" \
-d "{\"amountCents\": 100000, \"postedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"note\": \"Test income\"}")
echo "$RESULT" | jq '{overduePaid, fixedAllocations, variableAllocations}'
# Check plans after
echo -e "\n<><6E><EFBFBD> Plans AFTER income:"
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans | jq '.plans[] | {name: .name, funded: (.fundedCents/100), total: (.totalCents/100), isOverdue: .isOverdue, overdueAmt: (.overdueAmount/100)}'
rm -f cookies.txt

View File

@@ -1,228 +0,0 @@
/**
* Test script for monthly income payday calculations with TIMEZONE awareness
* Run with: node test-monthly-income.cjs
*
* This replicates the actual allocator.ts logic including timezone handling
*/
// Simulating date-fns-tz behavior (simplified for testing)
function toZonedTime(date, timezone) {
// For testing, we'll use a simple offset approach
// In real code, this uses proper timezone rules
const utc = date.getTime();
const tzOffset = getTimezoneOffset(timezone, date);
return new Date(utc + tzOffset);
}
function fromZonedTime(date, timezone) {
const tzOffset = getTimezoneOffset(timezone, date);
return new Date(date.getTime() - tzOffset);
}
// Simplified timezone offset (real implementation uses IANA database)
function getTimezoneOffset(timezone, date) {
const offsets = {
'UTC': 0,
'America/New_York': -5 * 60 * 60 * 1000, // EST (ignoring DST for simplicity)
'America/Los_Angeles': -8 * 60 * 60 * 1000, // PST
'Asia/Tokyo': 9 * 60 * 60 * 1000, // JST
};
return offsets[timezone] || 0;
}
function getUserMidnight(timezone, date = new Date()) {
const zonedDate = toZonedTime(date, timezone);
zonedDate.setHours(0, 0, 0, 0);
return fromZonedTime(zonedDate, timezone);
}
const frequencyDays = {
weekly: 7,
biweekly: 14,
monthly: 30, // Not used for monthly anymore
};
function calculateNextPayday(firstIncomeDate, frequency, fromDate = new Date(), timezone = 'UTC') {
const normalizedFrom = getUserMidnight(timezone, fromDate);
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
// Get the target day in the USER'S timezone
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
const targetDay = zonedFirstIncome.getDate();
let iterations = 0;
while (nextPayDate < normalizedFrom) {
if (frequency === 'monthly') {
// Work in user's timezone for month advancement
const zonedPayDate = toZonedTime(nextPayDate, timezone);
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
zonedPayDate.setHours(0, 0, 0, 0);
const newPayDate = fromZonedTime(zonedPayDate, timezone);
nextPayDate.setTime(newPayDate.getTime());
} else {
nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]);
}
iterations++;
}
return { nextPayDate, iterations, targetDay };
}
function countPayPeriodsBetween(startDate, endDate, firstIncomeDate, frequency, timezone = 'UTC') {
let count = 0;
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
const normalizedStart = getUserMidnight(timezone, startDate);
const normalizedEnd = getUserMidnight(timezone, endDate);
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
const targetDay = zonedFirstIncome.getDate();
const advanceByPeriod = () => {
if (frequency === 'monthly') {
const zonedPayDate = toZonedTime(nextPayDate, timezone);
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
zonedPayDate.setHours(0, 0, 0, 0);
const newPayDate = fromZonedTime(zonedPayDate, timezone);
nextPayDate.setTime(newPayDate.getTime());
} else {
nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]);
}
};
while (nextPayDate < normalizedStart) {
advanceByPeriod();
}
while (nextPayDate < normalizedEnd) {
count++;
advanceByPeriod();
}
return Math.max(1, count);
}
// Helper to format dates
const fmt = (d) => d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
const fmtISO = (d) => d.toISOString().split('T')[0];
console.log('═══════════════════════════════════════════════════════════════');
console.log(' MONTHLY INCOME PAYDAY CALCULATION TESTS (Timezone-Aware)');
console.log('═══════════════════════════════════════════════════════════════\n');
// Test 1: Monthly payday on the 15th - America/New_York
console.log('TEST 1: Monthly payday on the 15th (America/New_York)');
console.log('─────────────────────────────────────');
const firstPayday15 = new Date('2025-01-15T05:00:00.000Z'); // Midnight EST = 5am UTC
const today = new Date('2025-12-20T05:00:00.000Z');
const result1 = calculateNextPayday(firstPayday15, 'monthly', today, 'America/New_York');
console.log(`First income (UTC): ${firstPayday15.toISOString()}`);
console.log(`Today (UTC): ${today.toISOString()}`);
console.log(`Target day: ${result1.targetDay}th of month`);
console.log(`Next payday (UTC): ${result1.nextPayDate.toISOString()}`);
console.log(`Iterations: ${result1.iterations}`);
console.log(`✓ Should be Jan 15, 2026 in EST\n`);
// Test 2: Edge case - payday stored as UTC midnight crossing timezone boundary
console.log('TEST 2: Timezone boundary edge case');
console.log('─────────────────────────────────────');
// If user in LA set payday to "15th", it might be stored as 2025-01-15T08:00:00Z (midnight PST)
const firstPaydayLA = new Date('2025-01-15T08:00:00.000Z');
const todayLA = new Date('2025-12-20T08:00:00.000Z');
const resultLA = calculateNextPayday(firstPaydayLA, 'monthly', todayLA, 'America/Los_Angeles');
console.log(`Timezone: America/Los_Angeles`);
console.log(`First income (UTC): ${firstPaydayLA.toISOString()}`);
console.log(`Target day: ${resultLA.targetDay}th of month`);
console.log(`Next payday (UTC): ${resultLA.nextPayDate.toISOString()}`);
console.log(`✓ Target day should be 15, not 14 or 16\n`);
// Test 3: Compare UTC vs timezone-aware for same "15th" payday
console.log('TEST 3: UTC vs Timezone-aware comparison');
console.log('─────────────────────────────────────');
const sameDate = new Date('2025-01-15T00:00:00.000Z'); // Midnight UTC
const fromDate = new Date('2025-06-01T00:00:00.000Z');
const resultUTC = calculateNextPayday(sameDate, 'monthly', fromDate, 'UTC');
const resultEST = calculateNextPayday(sameDate, 'monthly', fromDate, 'America/New_York');
const resultTokyo = calculateNextPayday(sameDate, 'monthly', fromDate, 'Asia/Tokyo');
console.log(`Date stored: ${sameDate.toISOString()}`);
console.log(`From date: ${fromDate.toISOString()}`);
console.log(`UTC target day: ${resultUTC.targetDay} → Next: ${fmtISO(resultUTC.nextPayDate)}`);
console.log(`EST target day: ${resultEST.targetDay} → Next: ${fmtISO(resultEST.nextPayDate)}`);
console.log(`JST target day: ${resultTokyo.targetDay} → Next: ${fmtISO(resultTokyo.nextPayDate)}`);
console.log(`⚠️ Same UTC date shows different "day of month" in different timezones!\n`);
// Test 4: Monthly payday on 31st with day clamping
console.log('TEST 4: Monthly payday on 31st (day clamping)');
console.log('─────────────────────────────────────');
const firstPayday31 = new Date('2025-01-31T05:00:00.000Z');
console.log(`First payday: Jan 31, 2025`);
let tempDate = getUserMidnight('America/New_York', firstPayday31);
console.log(`\nPayday progression:`);
for (let i = 0; i < 6; i++) {
const zoned = toZonedTime(tempDate, 'America/New_York');
console.log(` ${zoned.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`);
// Advance by month
zoned.setMonth(zoned.getMonth() + 1);
const maxDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate();
zoned.setDate(Math.min(31, maxDay));
zoned.setHours(0, 0, 0, 0);
tempDate = fromZonedTime(zoned, 'America/New_York');
}
console.log(`✓ Feb shows 28th (clamped), other months show 31st or 30th\n`);
// Test 5: Count pay periods with timezone
console.log('TEST 5: Count pay periods (timezone-aware)');
console.log('─────────────────────────────────────');
const firstIncome = new Date('2025-01-15T05:00:00.000Z');
const nowDate = new Date('2025-12-20T05:00:00.000Z');
const billDue = new Date('2026-03-01T05:00:00.000Z');
const periodsEST = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'America/New_York');
const periodsUTC = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'UTC');
console.log(`Now: Dec 20, 2025`);
console.log(`Bill due: Mar 1, 2026`);
console.log(`First income: Jan 15, 2025`);
console.log(`Periods (EST): ${periodsEST}`);
console.log(`Periods (UTC): ${periodsUTC}`);
console.log(`✓ Should be 2-3 periods (Jan 15, Feb 15)\n`);
// Test 6: OLD vs NEW comparison (with timezone)
console.log('TEST 6: OLD (30 days) vs NEW (actual month) - with timezone');
console.log('─────────────────────────────────────');
const startDate = new Date('2025-01-15T05:00:00.000Z');
let oldDate = new Date(startDate);
let newResult = calculateNextPayday(startDate, 'monthly', startDate, 'America/New_York');
let newDate = new Date(newResult.nextPayDate);
console.log('Month | OLD (30 days) | NEW (timezone) | Drift');
console.log('──────┼────────────────┼─────────────────┼───────');
for (let i = 0; i < 12; i++) {
oldDate.setDate(oldDate.getDate() + 30);
// For new method, advance one month from previous
const nextFrom = new Date(newDate.getTime() + 24 * 60 * 60 * 1000); // +1 day to get next
newResult = calculateNextPayday(startDate, 'monthly', nextFrom, 'America/New_York');
newDate = newResult.nextPayDate;
const drift = Math.round((oldDate - newDate) / (24 * 60 * 60 * 1000));
console.log(` ${String(i + 1).padStart(2)} | ${fmtISO(oldDate)} | ${fmtISO(newDate)} | ${drift > 0 ? '+' : ''}${drift} days`);
}
console.log('\n✓ NEW method stays on the 15th (in user\'s timezone)!');
console.log('✓ OLD method drifts 5-6 days early after 12 months\n');
console.log('═══════════════════════════════════════════════════════════════');
console.log(' ALL TESTS COMPLETE - Timezone handling verified');
console.log('═══════════════════════════════════════════════════════════════');

View File

@@ -1,41 +0,0 @@
#!/bin/bash
# Test overdue payment via API endpoint
# Login to get token
echo "🔐 Logging in..."
LOGIN_RESPONSE=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@skymoney.com","password":"password123"}')
TOKEN=$(echo $LOGIN_RESPONSE | grep -o '"token":"[^"]*' | cut -d'"' -f4)
if [ -z "$TOKEN" ]; then
echo "❌ Login failed"
exit 1
fi
echo "✅ Logged in successfully"
# Check current state
echo -e "\n📋 Checking current plans..."
curl -s -X GET http://localhost:8080/api/fixed-plans \
-H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}'
# Post $500 income - should pay $500 to overdue (was $1000, now $500 remaining)
echo -e "\n💰 Posting $500 income..."
INCOME_RESPONSE=$(curl -s -X POST http://localhost:8080/api/income \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"amountCents": 50000,
"postedAt": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'",
"note": "Test income for overdue"
}')
echo $INCOME_RESPONSE | jq '.'
# Check state after income
echo -e "\n📋 Checking plans after income..."
curl -s -X GET http://localhost:8080/api/fixed-plans \
-H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}'

View File

@@ -1,133 +0,0 @@
// Script to post test income and verify overdue payment
const { PrismaClient } = require('@prisma/client');
const { randomUUID } = require('crypto');
async function main() {
const prisma = new PrismaClient();
try {
const user = await prisma.user.findUnique({
where: { email: 'test@skymoney.com' }
});
if (!user) {
console.log('❌ Test user not found. Run create-test-user.cjs first.');
return;
}
console.log('✅ Found test user:', user.email);
// Check overdue status BEFORE posting income
const plansBefore = await prisma.fixedPlan.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
isOverdue: true,
overdueAmount: true,
},
});
console.log('\n📋 Plans BEFORE income:');
for (const plan of plansBefore) {
console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`);
}
// Post $1000 income - should pay $500 to overdue first, then allocate $500 normally
const incomeAmount = 100000; // $1000 in cents
console.log(`\n💰 Posting income: $${incomeAmount / 100}`);
const incomeId = randomUUID();
const now = new Date().toISOString();
// Simulate what the allocateIncome function does
const result = await prisma.$transaction(async (tx) => {
await tx.incomeEvent.create({
data: {
id: incomeId,
userId: user.id,
postedAt: now,
amountCents: BigInt(incomeAmount),
note: 'Test income for overdue payment',
},
});
// Find overdue plans
const overduePlans = await tx.fixedPlan.findMany({
where: {
userId: user.id,
isOverdue: true,
overdueAmount: { gt: 0 },
},
orderBy: { overdueSince: 'asc' },
});
console.log(`\n🔍 Found ${overduePlans.length} overdue plan(s)`);
let remaining = incomeAmount;
for (const plan of overduePlans) {
if (remaining <= 0) break;
const overdueAmount = Number(plan.overdueAmount);
const amountToPay = Math.min(overdueAmount, remaining);
console.log(` Paying $${amountToPay / 100} to ${plan.name} (was $${overdueAmount / 100} overdue)`);
// Create allocation
await tx.allocation.create({
data: {
userId: user.id,
kind: 'fixed',
toId: plan.id,
amountCents: BigInt(amountToPay),
incomeId,
},
});
// Update plan
const newOverdueAmount = overdueAmount - amountToPay;
await tx.fixedPlan.update({
where: { id: plan.id },
data: {
fundedCents: (plan.fundedCents ?? 0n) + BigInt(amountToPay),
currentFundedCents: (plan.currentFundedCents ?? plan.fundedCents ?? 0n) + BigInt(amountToPay),
overdueAmount: newOverdueAmount,
isOverdue: newOverdueAmount > 0,
lastFundingDate: new Date(now),
},
});
remaining -= amountToPay;
}
return { remaining };
});
console.log(`\n💵 Remaining after overdue payments: $${result.remaining / 100}`);
// Check overdue status AFTER posting income
const plansAfter = await prisma.fixedPlan.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
isOverdue: true,
overdueAmount: true,
},
});
console.log('\n📋 Plans AFTER income:');
for (const plan of plansAfter) {
console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`);
}
} finally {
await prisma.$disconnect();
}
}
main();

View File

@@ -1,19 +0,0 @@
#!/bin/bash
echo "<22><><EFBFBD> Logging in..."
curl -s -c cookies.txt -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@skymoney.com","password":"password123"}' > /dev/null
echo "<22><><EFBFBD> Plans BEFORE:"
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans
echo -e "\n\n<><6E><EFBFBD> Posting $1000 income..."
curl -s -b cookies.txt -X POST http://localhost:8080/api/income \
-H "Content-Type: application/json" \
-d "{\"amountCents\": 100000, \"postedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"note\": \"Test\"}"
echo -e "\n\n<><6E><EFBFBD> Plans AFTER:"
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans
rm -f cookies.txt

View File

@@ -32,8 +32,8 @@ export async function resetUser(userId: string) {
export async function ensureUser(userId: string) {
await prisma.user.upsert({
where: { id: userId },
update: {},
create: { id: userId, email: `${userId}@demo.local` },
update: { timezone: "UTC" },
create: { id: userId, email: `${userId}@demo.local`, timezone: "UTC" },
});
}

View File

@@ -40,27 +40,28 @@ function calculateNextDueDateLikeServer(
switch (frequency) {
case "weekly":
zoned.setUTCDate(zoned.getUTCDate() + 7);
zoned.setDate(zoned.getDate() + 7);
break;
case "biweekly":
zoned.setUTCDate(zoned.getUTCDate() + 14);
zoned.setDate(zoned.getDate() + 14);
break;
case "monthly": {
const targetDay = zoned.getUTCDate();
const nextMonth = zoned.getUTCMonth() + 1;
const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12);
const nextMonthIndex = nextMonth % 12;
const targetDay = zoned.getDate();
zoned.setDate(1);
zoned.setMonth(zoned.getMonth() + 1);
const lastDay = new Date(
Date.UTC(nextYear, nextMonthIndex + 1, 0)
).getUTCDate();
zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay));
zoned.getFullYear(),
zoned.getMonth() + 1,
0
).getDate();
zoned.setDate(Math.min(targetDay, lastDay));
break;
}
default:
return base;
}
zoned.setUTCHours(0, 0, 0, 0);
zoned.setHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
}

View File

@@ -23,7 +23,8 @@ describe("rolloverFixedPlans", () => {
select: { id: true },
});
const results = await rolloverFixedPlans(prisma, "2025-01-10T00:00:00Z");
// Rollover job only processes after 6 AM in the user's timezone.
const results = await rolloverFixedPlans(prisma, "2025-01-10T10:00:00Z");
const match = results.find((r) => r.planId === plan.id);
expect(match?.cyclesAdvanced).toBe(1);
expect(match?.deficitCents).toBe(4000);
@@ -48,7 +49,8 @@ describe("rolloverFixedPlans", () => {
select: { id: true },
});
const results = await rolloverFixedPlans(prisma, "2025-02-05T00:00:00Z");
// Rollover job only processes after 6 AM in the user's timezone.
const results = await rolloverFixedPlans(prisma, "2025-02-05T10:00:00Z");
const match = results.find((r) => r.planId === plan.id);
expect(match?.cyclesAdvanced).toBe(2);
expect(match?.carryForwardCents).toBe(2000);

View File

@@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
let app: FastifyInstance;
@@ -34,9 +35,12 @@ describe("Variable Categories guard (sum=100)", () => {
});
it("rejects create that would push sum away from 100", async () => {
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ name: "Oops", percent: 10, isSavings: false, priority: 99 });
expect(res.statusCode).toBe(400);
@@ -45,9 +49,12 @@ describe("Variable Categories guard (sum=100)", () => {
it("rejects update that breaks the sum", async () => {
const existing = await prisma.variableCategory.findFirst({ where: { userId: U } });
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.patch(`/variable-categories/${existing!.id}`)
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ percent: 90 });
expect(res.statusCode).toBe(400);

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
let app: FastifyInstance;
@@ -59,10 +60,13 @@ describe("manual rebalance", () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000
const csrf = randomUUID().replace(/-/g, "");
const postRes = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.send({ targets });
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets, forceLowerSavings: true });
expect(postRes.statusCode).toBe(200);
expect(postRes.body?.availableCents).toBe(10_000);
@@ -78,11 +82,14 @@ describe("manual rebalance", () => {
it("rebalances when sums match available", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.send({ targets });
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets, forceLowerSavings: true });
expect(res.statusCode).toBe(200);
expect(res.body?.ok).toBe(true);
@@ -93,10 +100,13 @@ describe("manual rebalance", () => {
it("rejects sum mismatch", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 5000 : 1000 })); // 5000 + 3*1000 = 8000
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets });
expect(res.statusCode).toBe(400);
@@ -108,10 +118,13 @@ describe("manual rebalance", () => {
// savings to 500 (below 20% of 10000 = 2000)
const targets = cats.map((c) => ({ id: c.id, targetCents: c.isSavings ? 500 : 3166 })); // 500 + 3*3166 = 9998 -> adjust
targets[1].targetCents += 2; // total 10000
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets });
expect(res.statusCode).toBe(400);
@@ -120,6 +133,8 @@ describe("manual rebalance", () => {
const resOk = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets, forceLowerSavings: true });
expect(resOk.statusCode).toBe(200);
@@ -129,13 +144,16 @@ describe("manual rebalance", () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 9000 : 333 }));
targets[1].targetCents += 1; // sum 10000
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets });
expect(res.statusCode).toBe(400);
expect(res.body?.code).toBe("OVER_80_PERCENT");
expect(res.body?.code).toBe("OVER_80_CONFIRM_REQUIRED");
});
});