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

@@ -22,7 +22,7 @@ COOKIE_SECRET= PYjozZs+CxkU+In/FX/EI/5SB5ETAEw2AzCAF+G4Zgc=
# Leave unset in local dev so host-only cookie is used.
# COOKIE_DOMAIN=
AUTH_DISABLED=false
ALLOW_INSECURE_AUTH_FOR_DEV=false
ALLOW_INSECURE_AUTH_FOR_DEV=true
SEED_DEFAULT_BUDGET=false
BREAK_GLASS_VERIFY_ENABLED=true

View File

@@ -1,8 +0,0 @@
{
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore",
"less.lint.unknownAtRules": "ignore",
"cSpell.words": [
"skymoney"
]
}

View File

@@ -1,46 +0,0 @@
# Security Forgot Password Controls
## Implemented Controls
- Public entry points:
- `POST /auth/forgot-password/request`
- `POST /auth/forgot-password/confirm`
- Enumeration resistance:
- Request endpoint always returns `200` with a generic success message.
- No account existence signal for unknown/unverified emails.
- Verified-account gate:
- Reset tokens are issued only when `emailVerified=true`.
- Token security:
- Reset links contain `uid` and raw `token` in query params.
- Server stores only `SHA-256(token)`.
- Token type is `password_reset`.
- Token must match `uid`, be unused, and be unexpired.
- Token is consumed once (`usedAt`) and cannot be reused.
- Session invalidation:
- Added `User.passwordChangedAt`.
- JWT auth middleware rejects tokens with `iat <= passwordChangedAt`.
- Reset and authenticated password-change both set `passwordChangedAt`.
- Abuse controls:
- Request endpoint: per-IP + email-fingerprint route keying.
- Confirm endpoint: per-IP + uid-fingerprint route keying.
- Endpoint-specific rate limits via env config.
- Logging hygiene:
- Structured security events for request/email/confirm outcomes.
- No plaintext password or raw token in logs.
- Misconfiguration resilience:
- Email send failures do not leak through API response shape.
- Generic response is preserved if SMTP is unavailable.
## Environment Settings
- `PASSWORD_RESET_TTL_MINUTES` (default `30`)
- `PASSWORD_RESET_RATE_LIMIT_PER_MINUTE` (default `5`)
- `PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE` (default `10`)
## Operational Verification
1. Verify `/auth/forgot-password/request` always returns the same JSON for unknown, unverified, and verified addresses.
2. Verify only verified users get `EmailToken(type=password_reset)` rows.
3. Verify `confirm` succeeds once and fails on replay.
4. Verify pre-reset session cookies fail on protected routes after successful reset.
5. Verify security logs contain `auth.password_reset.*` events and no raw token values.

View File

@@ -1,121 +0,0 @@
# Timezone-Aware Job Scheduling
## How It Works
### Cron Schedule (Every 15 Minutes)
```
*/15 * * * * → Runs at: 00:00, 00:15, 00:30, 00:45, 01:00, 01:15, ...
```
### Each Run:
1. **Query** all candidate plans (across ALL users, all timezones)
2. **For each plan**, check: "Is it past target hour in THIS user's timezone?"
3. **If yes** → process the plan
4. **If no** → skip until next run (15 min later)
## Real World Example: Rollover at 6 AM Local Time
### Timeline Across Timezones (Dec 17, 2025)
| UTC Time | Los Angeles (UTC-8) | New York (UTC-5) | London (UTC+0) | Tokyo (UTC+9) | Action |
|----------|---------------------|------------------|----------------|---------------|--------|
| 21:00 Dec 16 | 1:00 PM Dec 16 | 4:00 PM Dec 16 | 9:00 PM Dec 16 | **6:00 AM Dec 17** | ✅ Process Tokyo users |
| 00:00 Dec 17 | 4:00 PM Dec 16 | 7:00 PM Dec 16 | 12:00 AM Dec 17 | 9:00 AM Dec 17 | (Tokyo already done) |
| 06:00 Dec 17 | 10:00 PM Dec 16 | 1:00 AM Dec 17 | **6:00 AM Dec 17** | 3:00 PM Dec 17 | ✅ Process London users |
| 11:00 Dec 17 | 3:00 AM Dec 17 | **6:00 AM Dec 17** | 11:00 AM Dec 17 | 8:00 PM Dec 17 | ✅ Process NYC users |
| 14:00 Dec 17 | **6:00 AM Dec 17** | 9:00 AM Dec 17 | 2:00 PM Dec 17 | 11:00 PM Dec 17 | ✅ Process LA users |
### Processing Window
- **With 15-min cron**: Users processed within **0-15 minutes** after their local 6 AM
- **With hourly cron**: Users processed within **0-60 minutes** after their local 6 AM
- **With 5-min cron**: Users processed within **0-5 minutes** after their local 6 AM
## Why This Approach?
### ✅ Advantages
1. **No per-user scheduling** needed - single cron handles all users
2. **Automatic timezone handling** - works for any timezone without config
3. **Scalable** - adding users doesn't increase job complexity
4. **Self-correcting** - if a job misses a run, next run catches it
### ⚠️ Considerations
1. **Small delay** - Users processed within 15 min (not exactly at 6:00 AM)
2. **Query overhead** - Queries all candidate plans every 15 min
3. **Database filtering** - Good indexes on `dueOn` and `nextPaymentDate` are important
### 🔄 Alternative Approach (Not Implemented)
Store each user's next run time as UTC timestamp:
```sql
nextRolloverAt = '2025-12-17T21:00:00Z' -- for Tokyo user's 6 AM
```
Then query: `WHERE nextRolloverAt <= NOW()`
**Trade-offs:**
- ✅ Exact timing - no delay
- ✅ More efficient query - index on single timestamp column
- ❌ More complex - need to update nextRolloverAt after each run
- ❌ DST complications - need to recalculate when timezone rules change
## Configuration
### Environment Variables
```bash
# Rollover: default = every 15 minutes
ROLLOVER_SCHEDULE_CRON="*/15 * * * *"
# Auto-payment: default = every 15 minutes
AUTO_PAYMENT_SCHEDULE_CRON="*/15 * * * *"
# For high-precision (every 5 minutes):
ROLLOVER_SCHEDULE_CRON="*/5 * * * *"
# For lower load (hourly):
ROLLOVER_SCHEDULE_CRON="0 * * * *"
```
### Cron Format
```
* * * * *
│ │ │ │ │
│ │ │ │ └─ Day of week (0-7, both 0 and 7 = Sunday)
│ │ │ └─── Month (1-12)
│ │ └───── Day of month (1-31)
│ └─────── Hour (0-23)
└───────── Minute (0-59)
Examples:
*/15 * * * * → Every 15 minutes
0 * * * * → Every hour at :00
0 6 * * * → Once daily at 6 AM UTC
*/5 * * * * → Every 5 minutes
```
## Testing
### Test Specific Timezone
```bash
# 1. Change user timezone
docker compose exec -T postgres psql -U app -d skymoney -c \
"UPDATE \"User\" SET timezone = 'Asia/Tokyo' WHERE id = 'user-id';"
# 2. Run test script
npx tsx src/scripts/test-timezone-jobs.ts user-id
```
### Simulate Specific UTC Time
```typescript
import { rolloverFixedPlans } from "./src/jobs/rollover.js";
// Simulate running at 21:00 UTC (= 6 AM Tokyo)
await rolloverFixedPlans(prisma, "2025-12-17T21:00:00Z", { dryRun: true });
```
### Test Different Timezones
```bash
# Tokyo (UTC+9) - 6 AM = 21:00 UTC previous day
npx tsx -e "import { rolloverFixedPlans } from './src/jobs/rollover.js'; ..."
# Los Angeles (UTC-8) - 6 AM = 14:00 UTC same day
# London (UTC+0) - 6 AM = 06:00 UTC same day
# New York (UTC-5) - 6 AM = 11:00 UTC same day
```

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

View File

@@ -1,55 +0,0 @@
Stack trace:
Frame Function Args
0007FFFF4F30 000210060304 (0007FFFF5138, 0007FFFFCE00, 000000000002, 0007FFFFDC10) msys-2.0.dll+0x20304
000000084002 00021006237D (000000000064, 000700000000, 000000000298, 000000000000) msys-2.0.dll+0x2237D
0007FFFF5640 0002100C1394 (000000000000, 000000000068, 000000000000, 0006FFFFFFB7) msys-2.0.dll+0x81394
0007FFFF5AE0 0002100C1C19 (0007FFFF5810, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x81C19
0007FFFF5AE0 00021006A1DE (000000004000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A1DE
0007FFFF5AE0 00021006A4A9 (000000000000, 0007FFFF5B88, 0007FFFF5D64, 0000FFFFFFFF) msys-2.0.dll+0x2A4A9
0007FFFF5AE0 000210193F2B (000000000000, 0007FFFF5B88, 0007FFFF5D64, 0000FFFFFFFF) msys-2.0.dll+0x153F2B
0007FFFF5AE0 00010042DB65 (000A00000004, 0007FFFF5D74, 00010040DD62, 000000000000) bash.exe+0x2DB65
0000FFFFFFFF 00010043C4F8 (0000000000C2, 000000000000, 000A001FF4B0, 000A000888B0) bash.exe+0x3C4F8
000000000070 00010043E6BE (000000000000, 000000000001, 000000000000, 0007FFFF5D70) bash.exe+0x3E6BE
000000000070 000100441B06 (000700000001, 000A00000000, 0007FFFF5E60, 000000000000) bash.exe+0x41B06
000000000070 000100441D36 (000100000000, 000A00000000, 0007FFFF5F3C, 0007FFFF5F38) bash.exe+0x41D36
000000000001 000100443783 (00010000001F, 000000000001, 000A001FF310, 000A001FD9D0) bash.exe+0x43783
0000FFFFFFFF 0001004195CA (00010046F680, 000000000000, 0000FFFFFFFF, 000A001FD9D0) bash.exe+0x195CA
000A001FF8E0 00010041AC6A (00000000001F, 000000000000, 000A001FD500, 000000000000) bash.exe+0x1AC6A
000A001FF8E0 00010041CD1B (000100000000, 000000000000, 000200000000, 000A001FF8E0) bash.exe+0x1CD1B
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FF8E0) bash.exe+0x179C7
00000000015B 00010041AC6A (000000000000, 000000000000, 000A001FD500, 000000000000) bash.exe+0x1AC6A
00000000015B 0001004180FB (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x180FB
000A001FD530 00010041C54A (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x1C54A
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x179C7
000A001FD4E0 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A
000A001FD4E0 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD4E0) bash.exe+0x1C50F
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD4E0) bash.exe+0x179C7
000A001FD490 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A
000A001FD490 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD490) bash.exe+0x1C50F
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD490) bash.exe+0x179C7
000A001FD440 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A
000A001FD440 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD440) bash.exe+0x1C50F
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD440) bash.exe+0x179C7
000A001FD3F0 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A
000A001FD3F0 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD3F0) bash.exe+0x1C50F
End of stack trace (more stack frames may be present)
Loaded modules:
000100400000 bash.exe
7FFF25310000 ntdll.dll
7FFF247D0000 KERNEL32.DLL
7FFF22E40000 KERNELBASE.dll
7FFF24010000 USER32.dll
7FFF22BD0000 win32u.dll
7FFF246C0000 GDI32.dll
000210040000 msys-2.0.dll
7FFF22CA0000 gdi32full.dll
7FFF22C00000 msvcp_win.dll
7FFF22A20000 ucrtbase.dll
7FFF23330000 advapi32.dll
7FFF249F0000 msvcrt.dll
7FFF24A90000 sechost.dll
7FFF24DA0000 RPCRT4.dll
7FFF229A0000 bcrypt.dll
7FFF221F0000 CRYPTBASE.DLL
7FFF23140000 bcryptPrimitives.dll
7FFF25180000 IMM32.DLL

View File

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJiOGEzN2U1Ny01M2QyLTQyMGYtOWYzZi1lYTdiNmEyM2JmNjgiLCJpYXQiOjE3NjM4NzEwMjl9.2H5loEHfxq33U1D5kS_Jt43CIoq_kKwKeHUTjjXQFzI

View File

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmODE5NzlmYS1mZTQwLTQxOTUtOGMwNC0xZWE2OWQ1NjRmZDQiLCJpYXQiOjE3NjM4NzEwOTl9.Jj4PzUGZuW2Eg-8vt1uJkLIcdDe5ghWFDCcvGYRHjSY

View File

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2ZWQzMTNhMS05MTNiLTRmYzEtYTFiZC0wODhmOWFlY2FhNGUiLCJpYXQiOjE3NjM4NzE0MjN9.hMpW1tyRd-f5P6WuIm-56-PbdVo_7D9reYYYtcSgVn0

View File

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhNjEzMDZhZi05Mzg3LTQ3MzYtODYxYS0zODA5M2RhYzYwZmYiLCJpYXQiOjE3NjM4NzE2Mzl9.g_RurcGYjyJitJRLaPiVM5vAk5Nc94u05k5Rc83lV5k

View File

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3MWQxZjc1My1hYTI3LTQxOTEtYWIxNS05NjNjYWZlNjE2MGEiLCJpYXQiOjE3NjM4NzE1MDJ9.djJEIX3xz_typgayo_FcT8lvpWGXOjv8FLalmaPojQg

View File

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhNjEzMDZhZi05Mzg3LTQ3MzYtODYxYS0zODA5M2RhYzYwZmYiLCJpYXQiOjE3NjM4NzE2NTV9.K5pjWe9iUDawB7yrLNoQywvAmjgv33D2LTa6T50FKCY

View File

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyYmFhNzUwMS1iMzVkLTQyN2EtYTRlMy0wNTg0ZDczN2Y4MmYiLCJpYXQiOjE3NjM4NzE0NzN9.LpnakXIqy6brb_I59fFBTPhzyeZ6NoFpCiSvRQla-BE

View File

@@ -1,61 +0,0 @@
# API Phase 1 Move Log
Date: 2026-03-15
Scope: Move low-risk endpoints out of `api/src/server.ts` into dedicated route modules.
## Route Registration Changes
- Registered session routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1518)
- Registered user routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1523)
- Registered health routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1524)
## Endpoint Movements
1. `GET /health`
- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:11)
- References:
- [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:10)
- [security-misconfiguration.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/security-misconfiguration.test.ts:29)
2. `GET /health/db` (non-production only)
- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:14)
- References:
- [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:15)
3. `GET /auth/session`
- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:40)
- References:
- [useAuthSession.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useAuthSession.ts:23)
- [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:120)
4. `POST /app/update-notice/ack`
- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:77)
- References:
- [App.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/App.tsx:33)
5. `PATCH /me`
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:50)
- References:
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:394)
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:115)
6. `PATCH /me/password`
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:71)
- References:
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:148)
- [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:57)
7. `PATCH /me/income-frequency`
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:112)
- References:
- Currently no frontend direct calls found by static search.
8. `PATCH /user/config`
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:135)
- References:
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:408)
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:171)
## Notes
- `server.ts` endpoint blocks for the above routes were removed to prevent duplicate registration.
- Existing path contracts were preserved (same method + path).
- `openPaths` and auth/CSRF hook behavior remain unchanged.

View File

@@ -1,75 +0,0 @@
# API Phase 2 Move Log
Date: 2026-03-16
Scope: Move `auth` + `account` endpoints out of `api/src/server.ts` into a dedicated route module.
## Route Registration Changes
- Registered auth/account routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:902)
- New route module: [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:111)
## Endpoint Movements
1. `POST /auth/register`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:115)
- References:
- [RegisterPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/RegisterPage.tsx:74)
- [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:54)
2. `POST /auth/login`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:169)
- References:
- [LoginPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/LoginPage.tsx:55)
- [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:49)
3. `POST /auth/logout`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:266)
- References:
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:215)
- [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:53)
4. `POST /auth/verify`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:278)
- References:
- [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:43)
- [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:93)
5. `POST /auth/verify/resend`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:336)
- References:
- [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:65)
- [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:40)
6. `POST /auth/forgot-password/request`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:391)
- References:
- [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:23)
- [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:45)
7. `POST /auth/forgot-password/confirm`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:471)
- References:
- [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:31)
- [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:110)
8. `POST /account/delete-request`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:537)
- References:
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:251)
- [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:67)
9. `POST /account/confirm-delete`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:596)
- References:
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:270)
- [access-control.account-delete.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/access-control.account-delete.test.ts:60)
10. `POST /auth/refresh`
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:677)
- References:
- [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:26)
- [cryptographic-failures.runtime.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/cryptographic-failures.runtime.test.ts:71)
## Notes
- `server.ts` auth/account endpoint blocks were removed to prevent duplicate registration.
- Existing path contracts were preserved (same method + path + response shapes).
- Existing auth helpers (`issueEmailToken`, cooldown checks, security logging, lockout tracking) are still sourced from `server.ts` and injected into the route module.

View File

@@ -1,65 +0,0 @@
# API Phase 3 Move Log
Date: 2026-03-16
Scope: Move `variable-categories` endpoints out of `api/src/server.ts` into a dedicated route module.
## Route Registration Changes
- Added variable-categories route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:18)
- Registered variable-categories routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:934)
- New canonical route module: [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:125)
- Removed inline variable-categories route block from `server.ts` to avoid duplicate endpoint registration.
## Endpoint Movements
1. `POST /variable-categories`
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:129)
- References:
- [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:12)
- [variable-categories.guard.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.guard.test.ts:38)
2. `PATCH /variable-categories/:id`
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:165)
- References:
- [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:13)
- [variable-categories.guard.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.guard.test.ts:49)
3. `DELETE /variable-categories/:id`
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:202)
- References:
- [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:14)
- [CategoriesSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/CategoriesSettings.tsx:360)
4. `POST /variable-categories/rebalance`
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:219)
- References:
- [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:15)
- [CategoriesSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/CategoriesSettings.tsx:367)
5. `GET /variable-categories/manual-rebalance`
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:266)
- References:
- [rebalance.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/rebalance.ts:24)
- [variable-categories.manual-rebalance.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.manual-rebalance.test.ts:54)
6. `POST /variable-categories/manual-rebalance`
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:283)
- References:
- [rebalance.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/rebalance.ts:25)
- [RebalancePage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/RebalancePage.tsx:148)
- [variable-categories.manual-rebalance.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.manual-rebalance.test.ts:63)
## Helper Ownership in Phase 3
- Kept variable-category-specific helpers local to the route module:
- `assertPercentTotal`
- `getLatestBudgetSession`
- `ensureBudgetSession`
- `ensureBudgetSessionAvailableSynced`
- Reused shared server helper by injection:
- `computeDepositShares` injected from `server.ts` registration.
- Reused shared mutation throttling by injection:
- `mutationRateLimit` injected from `server.ts` registration.
## Follow-ups To Revisit
1. Consolidate budget-session sync helpers into a shared service (`api/src/services/budget-session.ts`) once Phase 4 starts.
2. Standardize response error envelopes for variable-category routes (`message` vs `ok/code/message`) to reduce client branching.
3. Recheck `variable-categories.manual-rebalance.test.ts` over-80 error-code expectation versus current confirm-style behavior to keep tests aligned with product policy.

View File

@@ -1,57 +0,0 @@
# API Phase 4 Move Log
Date: 2026-03-17
Scope: Move `transactions` endpoints out of `api/src/server.ts` into a dedicated route module.
## Route Registration Changes
- Added transactions route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:19)
- Registered transactions routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:938)
- New canonical route module: [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:38)
- Removed inline transactions route blocks from `server.ts` to avoid duplicate registration:
- `POST /transactions` block
- `GET/PATCH/DELETE /transactions` block
## Endpoint Movements
1. `POST /transactions`
- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:42)
- References:
- [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:34)
- [payment-rollover.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/payment-rollover.test.ts:42)
- [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:139)
2. `GET /transactions`
- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:465)
- References:
- [useTransactionsQuery.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactionsQuery.tsx:41)
- [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/transactions.ts:27)
- [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:71)
3. `PATCH /transactions/:id`
- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:601)
- References:
- [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:63)
- [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:189)
4. `DELETE /transactions/:id`
- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:659)
- References:
- [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:75)
- [SpendPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/SpendPage.tsx:296)
## Helper Ownership in Phase 4
- Shared helper injection from `server.ts`:
- `mutationRateLimit`
- `computeDepositShares`
- `computeWithdrawShares`
- `computeOverdraftShares`
- `calculateNextDueDate`
- `toBig`
- `parseCurrencyToCents`
- Route-local helper:
- `isDate` query validator
## Follow-ups To Revisit
1. Split `POST /transactions` into smaller internal handlers (`variable_spend` vs `fixed_payment`) after endpoint migration phases complete.
2. Replace broad `any` shapes in transaction where-clause/order composition with a typed query builder to reduce regression risk.
3. Reconcile test expectations for plan overdraft behavior (`OVERDRAFT_PLAN` vs current fixed-payment reconciliation semantics) before behavior changes in later phases.

View File

@@ -1,121 +0,0 @@
# API Phase 5 Move Log
Date: 2026-03-17
Scope: Move `fixed-plans` endpoints out of `api/src/server.ts` into a dedicated route module.
## Route Registration Changes
- Added fixed-plans route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:20)
- Registered fixed-plans routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:948)
- New canonical route module: [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71)
- Removed inline fixed-plans route blocks from `server.ts` to avoid duplicate registration:
- `PATCH /fixed-plans/:id/early-funding`
- `POST /fixed-plans/:id/attempt-final-funding`
- `PATCH /fixed-plans/:id/mark-unpaid`
- `POST /fixed-plans/:id/fund-from-available`
- `POST /fixed-plans/:id/catch-up-funding`
- `POST /fixed-plans`
- `PATCH /fixed-plans/:id`
- `DELETE /fixed-plans/:id`
- `POST /fixed-plans/:id/true-up-actual`
- `GET /fixed-plans/due`
- `POST /fixed-plans/:id/pay-now`
## Endpoint Movements
1. `PATCH /fixed-plans/:id/early-funding`
- Original: `server.ts` line 1414
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71)
- References:
- [EarlyPaymentPromptModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyPaymentPromptModal.tsx:34)
- [EarlyFundingModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyFundingModal.tsx:19)
2. `POST /fixed-plans/:id/attempt-final-funding`
- Original: `server.ts` line 1475
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:131)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:58)
- [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:219)
3. `PATCH /fixed-plans/:id/mark-unpaid`
- Original: `server.ts` line 1635
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:287)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:84)
4. `POST /fixed-plans/:id/fund-from-available`
- Original: `server.ts` line 1674
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:325)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:95)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:461)
5. `POST /fixed-plans/:id/catch-up-funding`
- Original: `server.ts` line 1828
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:478)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:106)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:512)
6. `POST /fixed-plans`
- Original: `server.ts` line 2036
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:659)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:39)
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:449)
- [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:43)
7. `PATCH /fixed-plans/:id`
- Original: `server.ts` line 2122
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:747)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:41)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:502)
8. `DELETE /fixed-plans/:id`
- Original: `server.ts` line 2239
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:866)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:42)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:443)
9. `POST /fixed-plans/:id/true-up-actual`
- Original: `server.ts` line 2285
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:911)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:108)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:549)
- [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:79)
10. `GET /fixed-plans/due`
- Original: `server.ts` line 2429
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1054)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:45)
- [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:363)
11. `POST /fixed-plans/:id/pay-now`
- Original: `server.ts` line 2495
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1118)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:77)
- [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:649)
- [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:170)
## Helper Ownership in Phase 5
- Shared helper injection from `server.ts`:
- `mutationRateLimit`
- `computeDepositShares`
- `computeWithdrawShares`
- `calculateNextDueDate`
- `toBig`
- Route-local helper:
- `DAY_MS` constant for date-window computations
- `PlanBody` / `PlanAmountMode` zod schemas
## Verification
1. Build
- `cd api && npm run build`
2. Focused tests
- `cd api && npm run test -- tests/fixed-plans.estimated-true-up.test.ts tests/payment-rollover.test.ts tests/transactions.test.ts`
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suites skipped/failed before endpoint assertions.

View File

@@ -1,100 +0,0 @@
# API Phase 6 Move Log
Date: 2026-03-17
Scope: Move `income`, `budget`, and `payday` endpoints out of `api/src/server.ts` into dedicated route modules.
## Route Registration Changes
- Added income route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:21)
- Added payday route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:22)
- Added budget route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:23)
- Registered income routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:946)
- Registered payday routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:949)
- Registered budget routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:953)
- Removed inline route blocks from `server.ts` to avoid duplicate registration:
- `POST /income`
- `GET /income/history`
- `POST /income/preview`
- `GET /payday/status`
- `POST /payday/dismiss`
- `POST /budget/allocate`
- `POST /budget/fund`
- `POST /budget/reconcile`
## Endpoint Movements
1. `POST /income`
- Original: `server.ts` line 1382
- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:31)
- References:
- [useIncome.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncome.ts:27)
- [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:71)
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:479)
- [income.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/income.test.ts:19)
- [income.integration.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/income.integration.test.ts:59)
2. `GET /income/history`
- Original: `server.ts` line 1421
- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:78)
- References:
- [useIncomeHistory.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncomeHistory.ts:15)
3. `POST /income/preview`
- Original: `server.ts` line 1459
- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:115)
- References:
- [useIncomePreview.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncomePreview.ts:16)
4. `GET /payday/status`
- Original: `server.ts` line 1483
- Moved to [payday.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/payday.ts:29)
- References:
- [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:35)
5. `POST /payday/dismiss`
- Original: `server.ts` line 1586
- Moved to [payday.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/payday.ts:135)
- References:
- [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:54)
6. `POST /budget/allocate`
- Original: `server.ts` line 1597
- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:43)
- References:
- [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:58)
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:8)
7. `POST /budget/fund`
- Original: `server.ts` line 1624
- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:69)
- References:
- [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:65)
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:473)
8. `POST /budget/reconcile`
- Original: `server.ts` line 1657
- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:98)
- References:
- [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:72)
- [ReconcileSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/ReconcileSettings.tsx:8)
## Helper Ownership in Phase 6
- Shared helper injection from `server.ts`:
- `mutationRateLimit`
- `computeDepositShares`
- `computeWithdrawShares`
- `isProd` flag for environment-sensitive logging
- Route-local schemas/helpers:
- `income.ts`: `AllocationOverrideSchema`
- `budget.ts`: `BudgetBody`, `ReconcileBody`
- `payday.ts`: local debug logger and query schema
## Notes
- Removed legacy unregistered `api/src/routes/income-preview.ts` to avoid duplicate endpoint logic drift.
## Verification
1. Build
- `cd api && npm run build`
2. Focused tests
- `cd api && npm run test -- tests/income.test.ts tests/income.integration.test.ts`
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suites skipped/failed before endpoint assertions.

View File

@@ -1,48 +0,0 @@
# API Phase 7 Move Log
Date: 2026-03-17
Scope: Move dashboard read endpoints out of `api/src/server.ts` into a dedicated route module.
## Route Registration Changes
- Added dashboard route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:24)
- Registered dashboard routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:944)
- New canonical route module: [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:24)
- Removed inline dashboard route blocks from `server.ts` to avoid duplicate registration:
- `GET /dashboard`
- `GET /crisis-status`
## Endpoint Movements
1. `GET /dashboard`
- Original: `server.ts` line 1081
- Moved to [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:24)
- References:
- [useDashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useDashboard.ts:85)
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:172)
- [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:37)
- [security-logging-monitoring-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/security-logging-monitoring-failures.test.ts:48)
2. `GET /crisis-status`
- Original: `server.ts` line 1330
- Moved to [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:273)
- References:
- No direct web/api wrapper references currently found via repo search.
- Endpoint remains available for API consumers and future UI wiring.
## Helper Ownership in Phase 7
- Route-local helpers in `dashboard.ts`:
- `monthKey`
- `monthLabel`
- `buildMonthBuckets`
- `DAY_MS`
- Reused allocator date helpers:
- static `getUserMidnightFromDateOnly`
- dynamic import of `getUserMidnight` and `calculateNextPayday` for parity with pre-move logic
## Verification
1. Build
- `cd api && npm run build`
2. Focused tests
- `cd api && npm run test -- tests/auth.routes.test.ts`
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suite skipped/failed before endpoint assertions.

View File

@@ -1,70 +0,0 @@
# API Phase 8 Move Log
Date: 2026-03-17
Scope: Move `admin` and `site-access` endpoints out of `api/src/server.ts` into dedicated route modules.
## Route Registration Changes
- Added site-access route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:24)
- Added admin route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:25)
- Registered site-access routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:946)
- Registered admin routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:960)
- New canonical route modules:
- [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:29)
- [admin.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/admin.ts:10)
- Removed inline route blocks from `server.ts` to avoid duplicate registration:
- `GET /site-access/status`
- `POST /site-access/unlock`
- `POST /site-access/lock`
- `POST /admin/rollover`
## Endpoint Movements
1. `GET /site-access/status`
- Original: `server.ts` line 946
- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:30)
- References:
- [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:10)
- [BetaGate.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/BetaGate.tsx:20)
- [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:22)
2. `POST /site-access/unlock`
- Original: `server.ts` line 957
- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:41)
- References:
- [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:14)
- [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:40)
3. `POST /site-access/lock`
- Original: `server.ts` line 994
- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:78)
- References:
- [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:18)
- [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:59)
4. `POST /admin/rollover`
- Original: `server.ts` line 1045
- Moved to [admin.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/admin.ts:11)
- References:
- [access-control.admin-rollover.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/access-control.admin-rollover.test.ts:44)
## Helper Ownership in Phase 8
- Shared helper injection from `server.ts`:
- `authRateLimit`
- `mutationRateLimit`
- `hasSiteAccessBypass`
- `safeEqual`
- `isInternalClientIp`
- runtime config flags and cookie settings (`UNDER_CONSTRUCTION`, break-glass, cookie domain/secure, etc.)
- Route-local helpers/schemas:
- `site-access.ts`: unlock payload schema
- `admin.ts`: rollover payload schema
- Retained in `server.ts` by design for global hook behavior:
- site-access bypass token derivation and onRequest maintenance-mode enforcement
## Verification
1. Build
- `cd api && npm run build`
2. Focused tests
- `cd api && npm run test -- tests/access-control.admin-rollover.test.ts tests/security-misconfiguration.test.ts`
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suite skipped/failed before endpoint assertions.

View File

@@ -1,168 +0,0 @@
# API Refactor Lightweight Plan
## Goal
Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves.
Current state (2026-03-17):
- `server.ts` still holds most business routes, but Phases 1-8 are complete.
- Completed move logs:
- `docs/api-phase1-move-log.md`
- `docs/api-phase2-move-log.md`
- `docs/api-phase3-move-log.md`
- `docs/api-phase4-move-log.md`
- `docs/api-phase5-move-log.md`
- `docs/api-phase6-move-log.md`
- `docs/api-phase7-move-log.md`
- `docs/api-phase8-move-log.md`
## Refactor Guardrails
1. Keep route behavior identical while moving code.
2. Move one domain at a time; do not mix domains in one PR.
3. Do not redesign architecture (no repositories/DI/container work).
4. Extract only duplicated logic into shared helpers/services.
5. Preserve global hooks and plugin registration in `server.ts` until final cleanup.
6. Keep response shapes stable (`ok`, `code`, `message`, etc.) during moves.
7. Require tests to pass after each move phase.
## Canonical Source Rule
`server.ts` is the canonical route logic right now.
When moving a domain:
1. Copy current logic from `server.ts` into the domain route module.
2. Register the module.
3. Remove the original block from `server.ts`.
4. Confirm no duplicate route registrations remain.
## Shared Helpers (Phase 0)
Create and/or finish these helpers to keep endpoint moves lightweight:
1. `api/src/services/user-context.ts`
- `getUserTimezone(...)`
- Removes repeated user timezone lookup logic.
2. `api/src/services/category-shares.ts`
- `computePercentShares(...)`
- `computeWithdrawShares(...)`
- `computeOverdraftShares(...)`
- `computeDepositShares(...)`
- Centralizes repeated category-share math.
3. `api/src/services/budget-session.ts`
- `getLatestBudgetSession(...)`
- `ensureBudgetSession(...)`
- `ensureBudgetSessionAvailableSynced(...)`
- Prevents session/value drift bugs.
4. `api/src/services/api-errors.ts`
- Standard typed error object and response body builder.
- Keeps endpoint error handling consistent.
## Progress Snapshot
Completed:
1. Phase 1: low-risk endpoints (`health`, `session`, `me`, `user/config`).
2. Phase 2: `auth` + `account` endpoints.
3. Phase 3: `variable-categories` endpoints.
4. Phase 4: `transactions` endpoints.
5. Phase 5: `fixed-plans` endpoints.
6. Phase 6: `income`, `budget`, `payday` endpoints.
7. Phase 7: `dashboard` + `crisis-status`.
8. Phase 8: `admin` + site access endpoints.
Remaining:
1. Phase 9: final cleanup and helper consolidation.
## Remaining Plan (Detailed)
### Phase 5: Fixed Plans Domain
Move these routes out of `server.ts` into `api/src/routes/fixed-plans.ts` (or split module if needed):
1. `PATCH /fixed-plans/:id/early-funding`
2. `POST /fixed-plans/:id/attempt-final-funding`
3. `PATCH /fixed-plans/:id/mark-unpaid`
4. `POST /fixed-plans/:id/fund-from-available`
5. `POST /fixed-plans/:id/catch-up-funding`
6. `POST /fixed-plans`
7. `PATCH /fixed-plans/:id`
8. `DELETE /fixed-plans/:id`
9. `POST /fixed-plans/:id/true-up-actual`
10. `GET /fixed-plans/due`
11. `POST /fixed-plans/:id/pay-now`
Primary risk:
- Payment/funding workflows are tightly coupled with available budget math and rollover rules.
Test focus:
- `api/tests/fixed-plans*.test.ts`
- `api/tests/payment-rollover.test.ts`
### Phase 6: Income, Budget, Payday Domain
Move these routes into a dedicated module (e.g., `api/src/routes/budget-income.ts`):
1. `POST /income`
2. `GET /income/history`
3. `POST /income/preview`
4. `POST /budget/allocate`
5. `POST /budget/fund`
6. `POST /budget/reconcile`
7. `GET /payday/status`
8. `POST /payday/dismiss`
Primary risk:
- Budget-session synchronization and allocator side effects.
Test focus:
- Income/budget allocation tests
- Any tests asserting payday status/dismiss behavior
### Phase 7: Dashboard Read Domain
Move read endpoints into `api/src/routes/dashboard.ts`:
1. `GET /dashboard`
2. `GET /crisis-status`
Primary risk:
- Derived numbers diverging between dashboard and rebalance/session APIs.
Test focus:
- Dashboard API contract checks and UI smoke verification.
### Phase 8: Admin and Site Access Domain
Move operational endpoints into `api/src/routes/admin.ts` and/or `api/src/routes/site-access.ts`:
1. `POST /admin/rollover`
2. `GET /site-access/status`
3. `POST /site-access/unlock`
4. `POST /site-access/lock`
Primary risk:
- Lockout/maintenance flow regressions and accidental open access.
Test focus:
- Site access flow tests
- Admin rollover auth/permission checks
### Phase 9: Final Cleanup
1. Remove dead helper duplicates from `server.ts` and route modules.
2. Consolidate common helpers into `api/src/services/*`.
3. Normalize error envelopes where safe (no contract change unless explicitly planned).
4. Re-run full API/security suites and perform deployment smoke checks.
## Reference Audit Requirement (Per Move)
For every endpoint moved:
1. Record original location in `server.ts`.
2. Record new route module location.
3. Record frontend references (`web/src/api/*`, hooks, pages).
4. Record test references.
5. Add a phase move log under `docs/api-phaseX-move-log.md`.
## Verification Steps (Per Phase)
1. Build:
- `cd api && npm run build`
2. Run focused tests for moved domain.
3. Run security suites relevant to moved endpoints.
4. Deploy.
5. Run production endpoint smoke checks for the moved routes.
6. Confirm logs show expected status codes (no unexplained 401/403/500 shifts).
## Definition of Done per Phase
1. Endpoints compile and register once.
2. Existing tests pass.
3. No route path/method changes.
4. No response contract changes.
5. `server.ts` net line count decreases.

View File

View File

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlNzkzNTNiNS0zNTY1LTQ5YTQtYjE2NC1jNzU4NWVkNmU2MTgiLCJpYXQiOjE3NjM4NzE5ODV9.uoibcsSuH15ZS2bjtssHtt2aOcJF2RdDAHK_ynIeIco

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "SkyMoney",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MzUyZjRhMy1hNjM5LTQyMWEtODI2Ny0yMGM0NTdhMDk3MmUiLCJpYXQiOjE3NjM4NzE5NzN9.ZslzE0_K4Mxsmgg37PsFKUP-lfSdYZaQfUqTf3o5m5Y

View File

@@ -1,62 +0,0 @@
# A01: Broken Access Control
Last updated: March 1, 2026
## Findings
1. Cross-account delete risk in `/account/confirm-delete`.
2. `AUTH_DISABLED` mode allows header-based impersonation (`x-user-id`) and must be tightly controlled.
## Fixes implemented
1. `/account/confirm-delete` is now session-bound:
- Lookup uses `req.userId`.
- Request `email` must match session user's email.
- Mismatch returns `403`.
2. Insecure auth guard added:
- `AUTH_DISABLED=true` now requires `ALLOW_INSECURE_AUTH_FOR_DEV=true` unless `NODE_ENV=test`.
3. `/admin/rollover` hardened:
- Still requires `AUTH_DISABLED=true`.
- Now also requires request IP to be internal/private.
## Test coverage
1. `api/tests/access-control.account-delete.test.ts`
- Verifies cross-account deletion attempt is denied (`403`).
- Verifies victim account is not deleted.
2. `api/tests/auth.routes.test.ts`
- Verifies protected route rejects unauthenticated access.
- Verifies spoofed `x-user-id` is rejected when auth is enabled.
- Verifies login/session/logout behavior with CSRF handling.
- Verifies login lockout behavior.
3. `api/tests/access-control.admin-rollover.test.ts`
- Verifies unauthenticated access to `/admin/rollover` is denied when `AUTH_DISABLED=false`.
- Verifies authenticated access still receives `403` when `AUTH_DISABLED=false`.
- Verifies `/admin/rollover` rejects non-internal client IP when `AUTH_DISABLED=true`.
- Verifies `/admin/rollover` allows internal client IP when `AUTH_DISABLED=true`.
## Run commands
From `api/`:
```bash
npm test -- tests/auth.routes.test.ts tests/access-control.account-delete.test.ts tests/access-control.admin-rollover.test.ts
```
## Expected results
1. All three test files pass.
2. No access granted to protected routes without valid auth cookie/JWT.
3. Spoofed `x-user-id` does not bypass auth when `AUTH_DISABLED=false`.
4. Cross-account delete attempt fails with `403`.
5. `/admin/rollover` remains inaccessible from public/non-internal clients.
## Production configuration requirements
1. `AUTH_DISABLED=false`
2. `ALLOW_INSECURE_AUTH_FOR_DEV=false`
3. Strong `JWT_SECRET` and `COOKIE_SECRET`

View File

@@ -1,89 +0,0 @@
# A02: Security Misconfiguration
Last updated: March 1, 2026
## Findings addressed
1. SMTP transport debug logging enabled in all environments.
2. Production CORS had a fail-open branch when configured origins were empty.
3. Missing explicit anti-framing headers at edge.
4. Docker compose exposed Postgres/API on all host interfaces.
## Fixes implemented
1. SMTP transport hardening in API:
- `requireTLS` now respects config (`SMTP_REQUIRE_TLS`).
- SMTP debug/logger are disabled in production (`!isProd` only).
2. CORS production behavior tightened:
- Removed fail-open branch for empty configured origins.
- Production now allows only explicitly configured origins.
3. Edge header hardening:
- Added `X-Frame-Options: DENY`.
- Added `Content-Security-Policy: frame-ancestors 'none'`.
4. Compose exposure reduction:
- Bound Postgres and API ports to localhost only.
## Files changed
1. `api/src/server.ts`
2. `Caddyfile.prod`
3. `Caddyfile.dev`
4. `deploy/nginx/skymoneybudget.com.conf`
5. `docker-compose.yml`
## Verification
### Automated security regression tests
Command (A01 regression):
```bash
cd api
npm test -- auth.routes.test.ts access-control.account-delete.test.ts
```
Verified output (provided from host run):
- Test Files: `2 passed (2)`
- Tests: `6 passed (6)`
- Start time: `16:39:35`
Command (A02 dedicated suite):
```bash
cd api
npx vitest --run -c vitest.security.config.ts
```
Verified output:
- Test Files: `1 passed (1)`
- Tests: `5 passed (5)`
- Suite: `tests/security-misconfiguration.test.ts`
Coverage in dedicated suite:
1. Production CORS allowlist enforcement (allowed origin accepted, denied origin does not receive allow headers).
2. SMTP production mailer options disable debug/logger.
3. Runtime CORS preflight headers validated for allowed origins (`allow-origin`, `allow-credentials`, `vary`).
4. Edge config files contain anti-framing headers (`X-Frame-Options`, `frame-ancestors` CSP).
5. `docker-compose.yml` binds Postgres/API ports to localhost only.
### Expected operational checks after deploy
1. Unauthenticated `GET /dashboard` returns `401`.
2. Spoofed `x-user-id` does not bypass auth when `AUTH_DISABLED=false`.
3. `/admin/rollover` remains inaccessible from public network.
4. Response headers include anti-framing policy.
## Residual notes
1. Keep production env pinned to:
- `AUTH_DISABLED=false`
- `ALLOW_INSECURE_AUTH_FOR_DEV=false`
2. Keep CORS origins explicitly configured in production:
- `CORS_ORIGIN` and/or `CORS_ORIGINS`.

View File

@@ -1,77 +0,0 @@
# A03: Software Supply Chain Failures
Last updated: March 1, 2026
## Findings addressed
1. Production dependency vulnerabilities were present in both API and web lockfiles.
2. Deploy pipeline had no explicit dependency vulnerability gate.
## Fixes implemented
1. Dependency remediation:
- Ran `npm audit fix` in `api` and `web`.
- Revalidated production dependencies are clean with `npm audit --omit=dev`.
2. Pipeline hardening:
- Added supply-chain check step in deploy workflow:
- `npm ci` + `npm audit --omit=dev --audit-level=high` for API and web.
- Updated checkout action from broad major tag to explicit release tag `v4.2.2`.
## Files changed
1. `.gitea/workflows/deploy.yml`
2. `api/package-lock.json`
3. `web/package-lock.json`
4. `api/tests/software-supply-chain-failures.test.ts`
5. `api/vitest.security.config.ts`
## Verification
### Production dependency vulnerability scans
Command:
```bash
cd api
npm audit --omit=dev --audit-level=high
cd ../web
npm audit --omit=dev --audit-level=high
```
Verified output:
- `found 0 vulnerabilities` (api)
- `found 0 vulnerabilities` (web)
### Workflow policy verification (automated)
Command:
```bash
cd api
npx vitest run -c vitest.security.config.ts tests/software-supply-chain-failures.test.ts
```
Verified output:
- Test Files: `1 passed (1)`
- Tests: `2 passed (2)`
Coverage in policy suite:
1. Deploy workflow includes dependency gate step for API and web.
2. Workflow requires `npm ci` and `npm audit --omit=dev --audit-level=high` for both projects.
3. `actions/checkout` remains pinned to an explicit release tag.
## Residual risks (not yet fully eliminated)
1. Base image tags are still mutable (`node:20-bookworm-slim`, `postgres:15`) and not digest-pinned.
2. `actions/checkout` is pinned to a release tag, not a full commit SHA.
3. No artifact signing/attestation verification (e.g., cosign/SLSA) in current deploy pipeline.
## Recommended next hardening steps
1. Pin container images by immutable digest in `Dockerfile`/`docker-compose.yml`.
2. Pin workflow actions to full commit SHAs.
3. Add SBOM generation and signature/attestation verification before deploy.

View File

@@ -1,71 +0,0 @@
# A04: Cryptographic Failures
Last updated: March 1, 2026
## Findings addressed
1. Production origin could be configured as non-HTTPS.
2. JWT configuration did not explicitly constrain issuer/audience/algorithm.
3. Missing explicit env contract for JWT issuer/audience values.
## Fixes implemented
1. Production HTTPS origin enforcement:
- Added production guard requiring `APP_ORIGIN` to start with `https://`.
2. JWT hardening in Fastify:
- Explicit signing algorithm set to `HS256`.
- JWT signing includes configured `iss` and `aud`.
- JWT verification enforces:
- allowed algorithm (`HS256`)
- allowed issuer
- allowed audience
3. Env schema expanded:
- Added `JWT_ISSUER` and `JWT_AUDIENCE` with secure defaults:
- `skymoney-api`
- `skymoney-web`
## Files changed
1. `api/src/env.ts`
2. `api/src/server.ts`
3. `.env.example`
4. `api/tests/cryptographic-failures.test.ts`
5. `api/tests/cryptographic-failures.runtime.test.ts`
6. `api/vitest.security.config.ts`
## Verification
Commands:
```bash
cd api
npx vitest run -c vitest.security.config.ts tests/cryptographic-failures.test.ts tests/cryptographic-failures.runtime.test.ts
```
Verified output:
- Test Files: `2 passed (2)`
- Tests: `7 passed (7)`
Dedicated A04 checks in `cryptographic-failures.test.ts`:
1. Production env rejects non-HTTPS `APP_ORIGIN`.
2. JWT issuer/audience defaults resolve as expected.
3. Fastify JWT plugin is registered with explicit algorithm + issuer + audience constraints.
Runtime adversarial checks in `cryptographic-failures.runtime.test.ts`:
1. Token with invalid issuer is rejected (`401`).
2. Token with invalid audience is rejected (`401`).
3. Unsigned token (`alg=none`) is rejected (`401`).
4. Token with valid signature + expected issuer/audience is accepted on protected auth refresh route.
## Required production env values
1. `APP_ORIGIN=https://...`
2. `JWT_SECRET` strong 32+ chars
3. `COOKIE_SECRET` strong 32+ chars
4. `JWT_ISSUER=skymoney-api` (or your approved value)
5. `JWT_AUDIENCE=skymoney-web` (or your approved value)

View File

@@ -1,50 +0,0 @@
# A05: Injection
Last updated: March 1, 2026
## Findings addressed
1. API route used unsafe Prisma raw SQL helper (`$queryRawUnsafe`) for `/health/db`.
2. Restore script accepted unvalidated external inputs that could be abused for command/SQL injection scenarios during operational use.
## Fixes implemented
1. Replaced unsafe raw SQL helper:
- `app.prisma.$queryRawUnsafe("SELECT now() as now")`
- replaced with tagged, parameter-safe:
- `app.prisma.$queryRaw\`SELECT now() as now\``
2. Hardened `scripts/restore.sh` input handling:
- Added required file existence check for `BACKUP_FILE`.
- Added strict identifier validation for `RESTORE_DB` (`^[A-Za-z0-9_]+$`).
## Files changed
1. `api/src/server.ts`
2. `scripts/restore.sh`
3. `api/tests/injection-safety.test.ts`
4. `api/vitest.security.config.ts`
## Verification
Command:
```bash
cd api
npx vitest run -c vitest.security.config.ts tests/injection-safety.test.ts
```
Verified output:
- Test Files: `1 passed (1)`
- Tests: `2 passed (2)`
Dedicated A05 checks in `injection-safety.test.ts`:
1. Verifies no usage of `$queryRawUnsafe` / `$executeRawUnsafe` across API source files.
2. Executes `scripts/restore.sh` with adversarial `RESTORE_DB` input and verifies restore is rejected before DB commands execute.
## Residual notes
1. Main API query paths use Prisma query builder + zod input schemas (reduces SQL injection risk).
2. Operational scripts should remain restricted to trusted operators and environments.

View File

@@ -1,54 +0,0 @@
# A06: Insecure Design
Last updated: March 1, 2026
## Findings addressed
1. Sensitive email-token workflows did not enforce a cooldown between repeated code requests.
2. Verification and account-deletion flows needed tighter, route-specific throttling to reduce brute-force and abuse risk.
## Fixes implemented
1. Added explicit email-token cooldown guard in API:
- New helper `assertEmailTokenCooldown(userId, type, cooldownMs)`.
- Throws structured `429` error with code `EMAIL_TOKEN_COOLDOWN`.
- Sets `Retry-After` header when cooldown is active.
2. Applied cooldown checks to both token issuance paths:
- `/auth/verify/resend` for signup verification codes.
- `/account/delete-request` for account deletion confirmation codes.
3. Split and applied stricter rate-limit profiles for sensitive auth/account routes:
- `authRateLimit` on `/auth/register` and `/auth/login`.
- `codeVerificationRateLimit` on `/auth/verify` and `/account/confirm-delete`.
- `codeIssueRateLimit` on `/auth/verify/resend` and `/account/delete-request`.
## Files changed
1. `api/src/server.ts`
2. `api/tests/insecure-design.test.ts`
3. `api/vitest.security.config.ts`
## Verification
Command:
```bash
cd api
npx vitest --run -c vitest.security.config.ts
```
Verified output:
- Test Files: `4 passed (4)`
- Tests: `10 passed (10)`
Dedicated A06 checks in `insecure-design.test.ts`:
1. Runtime verification resend endpoint enforces cooldown (`/auth/register` issues token, then immediate `/auth/verify/resend` is blocked with `429` + `Retry-After`).
2. Runtime verification delete-request endpoint enforces cooldown (`/account/delete-request` second attempt returns `429` + `Retry-After`).
3. Runtime verification repeated invalid `/auth/verify` requests trigger route throttling (`429`).
## Residual notes
1. A06 runtime tests require PostgreSQL availability for user/token persistence paths.

View File

@@ -1,79 +0,0 @@
# A07: Identification and Authentication Failures
Last updated: March 1, 2026
## Findings addressed
1. No explicit account lockout after repeated failed login attempts (brute-force risk).
2. Password policy for registration and password updates was too weak (length-only).
## Fixes implemented
1. Added login lockout controls:
- Tracks failed login attempts per normalized email in server memory.
- Locks login for a configurable window after threshold failures.
- Returns `429` with code `LOGIN_LOCKED` and `Retry-After` header during lockout.
2. Added strong password policy:
- Minimum length `12`.
- Requires lowercase, uppercase, number, and symbol.
- Applied to:
- `/auth/register` password.
- `/me/password` new password.
3. Added auth hardening configuration:
- `AUTH_MAX_FAILED_ATTEMPTS` (default: `5`)
- `AUTH_LOCKOUT_WINDOW_MS` (default: `900000`, 15 minutes)
4. Added forgot-password hardening:
- Public reset request endpoint always returns a generic success response.
- Reset token issuance is restricted to verified users.
- Reset confirmation enforces strong password policy and one-time expiring token usage.
- Successful reset updates `passwordChangedAt` so existing sessions become invalid.
## Files changed
1. `api/src/server.ts`
2. `api/src/env.ts`
3. `.env.example`
4. `api/tests/auth.routes.test.ts`
5. `api/tests/identification-auth-failures.test.ts`
6. `api/vitest.security.config.ts`
7. `api/tests/forgot-password.security.test.ts`
8. `api/prisma/schema.prisma`
9. `api/prisma/migrations/20260302000000_add_password_changed_at/migration.sql`
## Verification
Dedicated security suite command (executed):
```bash
cd api
npx vitest --run -c vitest.security.config.ts
```
Verified output:
- Test Files: `5 passed (5)`
- Tests: `12 passed (12)`
Dedicated A07 checks in `identification-auth-failures.test.ts`:
1. Runtime checks weak password rejection for registration and `/me/password` update flow.
2. Runtime checks lockout threshold/window behavior with configured `AUTH_MAX_FAILED_ATTEMPTS` and verifies `LOGIN_LOCKED` response + `Retry-After`.
Runtime auth flow checks added in `auth.routes.test.ts`:
1. Rejects weak passwords on registration.
2. Locks login after repeated failed attempts.
Run this in an environment with PostgreSQL running to verify runtime behavior:
```bash
cd api
npm test -- tests/auth.routes.test.ts tests/identification-auth-failures.test.ts
```
## Residual notes
1. Current lockout state is in-memory per API instance; for horizontally scaled production, move lockout tracking to a shared store (Redis/DB) for consistent enforcement across instances.

View File

@@ -1,49 +0,0 @@
# A08: Software and Data Integrity Failures
Last updated: March 1, 2026
## Findings addressed
1. Backup/restore workflow did not verify backup artifact integrity before restoring.
2. Restores could proceed with tampered/corrupted dump files, risking silent data corruption.
## Fixes implemented
1. Added checksum artifact generation during backups:
- `scripts/backup.sh` now generates a SHA-256 checksum file next to each dump (`.sha256`).
2. Added checksum verification before restore:
- `scripts/restore.sh` now requires `${BACKUP_FILE}.sha256`.
- Validates checksum format (64 hex chars).
- Computes runtime SHA-256 of backup file and blocks restore on mismatch.
## Files changed
1. `scripts/backup.sh`
2. `scripts/restore.sh`
3. `api/tests/software-data-integrity-failures.test.ts`
4. `api/vitest.security.config.ts`
## Verification
Command:
```bash
cd api
npx vitest run -c vitest.security.config.ts tests/software-data-integrity-failures.test.ts
```
Verified output:
- Test Files: `1 passed (1)`
- Tests: `2 passed (2)`
Dedicated A08 checks in `software-data-integrity-failures.test.ts`:
1. Executes `scripts/backup.sh` with stubbed `pg_dump` and verifies dump + `.sha256` artifact generation.
2. Executes `scripts/restore.sh` with tampered checksum and verifies restore is blocked before DB commands are invoked.
## Residual notes
1. This secures backup artifact integrity in operational scripts.
2. For CI/CD artifact integrity hardening, next step is attestation/signature verification for deployed build artifacts.

View File

@@ -1,65 +0,0 @@
# A09: Security Logging and Monitoring Failures
Last updated: March 1, 2026
## Findings addressed
1. Security-sensitive auth/account outcomes were not consistently logged as structured audit events.
2. Incident triage required better request correlation for failed auth/CSRF and account-deletion attempts.
## Fixes implemented
1. Added centralized structured security logging helper in API:
- `logSecurityEvent(req, event, outcome, details)`
- Includes request correlation fields (`requestId`, `ip`, `userAgent`).
2. Added audit logging for critical security events:
- `auth.unauthenticated_request` (JWT auth failure)
- `csrf.validation` (CSRF check failure)
- `auth.register` success/blocked
- `auth.login` success/failure/blocked (including lockout cases)
- `auth.logout` success
- `auth.verify` success/failure
- `auth.verify_resend` success/failure/blocked
- `auth.password_reset.request` success/blocked
- `auth.password_reset.email` success/failure
- `auth.password_reset.confirm` success/failure
- `account.delete_request` success/failure/blocked
- `account.confirm_delete` success/failure/blocked
3. Reduced sensitive data exposure in logs:
- Added email fingerprinting (`sha256` prefix) for event context instead of plain-text credentials.
## Files changed
1. `api/src/server.ts`
2. `api/tests/security-logging-monitoring-failures.test.ts`
3. `api/vitest.security.config.ts`
4. `api/tests/forgot-password.security.test.ts`
5. `SECURITY_FORGOT_PASSWORD.md`
## Verification
Command:
```bash
cd api
npx vitest run -c vitest.security.config.ts tests/security-logging-monitoring-failures.test.ts
```
Verified output:
- Test Files: `1 passed (1)`
- Tests: `2 passed (2)`
Dedicated A09 checks in `security-logging-monitoring-failures.test.ts`:
1. Runtime check emits structured `auth.unauthenticated_request` security event for protected-route access failures.
2. Runtime check emits structured `csrf.validation` security event for CSRF failures.
3. Validates correlation fields (`requestId`, `ip`, `outcome`) are present in emitted security events.
4. Runtime check emits `auth.password_reset.request` events and confirms raw token fields are absent.
## Residual notes
1. Event logs are currently emitted through app logs; ensure production log shipping/alerting (e.g., SIEM rules on repeated `auth.login` failure/blocked events).
2. Next step for A09 maturity is alert thresholds and automated incident notifications.

View File

@@ -1,50 +0,0 @@
# A10: Server-Side Request Forgery (SSRF)
Last updated: March 1, 2026
## Findings addressed
1. Production `APP_ORIGIN` previously enforced HTTPS but did not explicitly block localhost/private-network targets.
2. SSRF posture needed explicit verification that API runtime code does not introduce generic outbound HTTP clients for user-influenced targets.
## Fixes implemented
1. Hardened production `APP_ORIGIN` validation in env parsing:
- Requires valid URL format.
- Rejects localhost/private-network hosts:
- `localhost`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `0.0.0.0`, `.local`.
2. Added dedicated A10 verification tests:
- Rejects private/loopback `APP_ORIGIN` in production mode.
- Asserts API server source (`api/src/server.ts`) does not use generic outbound HTTP request clients (`fetch`, `axios`, `http.request`, `https.request`).
## Files changed
1. `api/src/env.ts`
2. `api/tests/server-side-request-forgery.test.ts`
3. `api/vitest.security.config.ts`
## Verification
Command:
```bash
cd api
npx vitest run -c vitest.security.config.ts tests/server-side-request-forgery.test.ts
```
Verified output:
- Test Files: `1 passed (1)`
- Tests: `3 passed (3)`
Dedicated A10 checks in `server-side-request-forgery.test.ts`:
1. Asserts production env parsing rejects multiple private/localhost `APP_ORIGIN` variants.
2. Asserts production env parsing accepts public HTTPS `APP_ORIGIN`.
3. Asserts API source code has no generic outbound HTTP client usage (`fetch`, `axios`, `http.request`, `https.request`) outside test scripts.
## Residual notes
1. Current API architecture has minimal outbound HTTP surface (primarily SMTP transport).
2. If future features add URL fetch/proxy/webhook integrations, enforce strict destination allowlists and network egress controls at implementation time.

View File

@@ -1,41 +0,0 @@
# OWASP Test Results
Last updated: March 2, 2026
This directory is the source of truth for SkyMoney OWASP validation work.
## Purpose
- Track implemented security tests and hardening changes.
- Define exact pre-deploy and post-deploy verification steps.
- Keep release evidence (commands, outputs, timestamps, pass/fail).
## Files
- `A01-Broken-Access-Control.md`: Findings, fixes, and verification for OWASP A01.
- `A02-Security-Misconfiguration.md`: Findings, fixes, and dedicated verification suite for OWASP A02.
- `A03-Software-Supply-Chain-Failures.md`: Dependency and pipeline supply-chain findings/fixes/verification.
- `A04-Cryptographic-Failures.md`: Crypto/session token hardening findings/fixes/verification.
- `A05-Injection.md`: Injection sink remediation and script input hardening verification.
- `A06-Insecure-Design.md`: Abuse-resistance design hardening (cooldowns + tighter route throttling).
- `A07-Identification-and-Authentication-Failures.md`: Login lockout and strong-password policy hardening.
- `A08-Software-and-Data-Integrity-Failures.md`: Backup/restore checksum integrity controls.
- `A09-Security-Logging-and-Monitoring-Failures.md`: Structured security event auditing for auth/account flows.
- `A10-Server-Side-Request-Forgery.md`: SSRF hardening and outbound-request surface validation.
- `post-deployment-verification-checklist.md`: Production smoke checks after each deploy.
- `evidence-log-template.md`: Copy/paste template for recording each verification run.
- `residual-risk-backlog.md`: Open non-blocking hardening items tracked release-to-release.
- `../docs/production-db-recovery-runbook.md`: Incident response + recovery + admin bootstrap runbook.
## Current status
1. A01 complete: implemented and tested.
2. A02 complete: implemented and tested.
3. A03 complete (initial hardening): implemented and tested.
4. A04 complete: implemented and tested.
5. A05 complete: implemented and tested.
6. A06 complete: implemented and tested.
7. A07 complete: implemented and tested.
8. A08 complete: implemented and tested.
9. A09 complete: implemented and tested.
10. A10 complete: implemented and tested.

View File

@@ -1,100 +0,0 @@
# OWASP Verification Evidence Log Template
## Run metadata
- Date:
- Environment: `local` | `staging` | `production`
- App/API version (git SHA):
- Operator:
- Incident/reference ticket (if recovery event):
## Environment flags
- `NODE_ENV`:
- `AUTH_DISABLED`:
- `ALLOW_INSECURE_AUTH_FOR_DEV`:
- `COMPOSE_PROJECT_NAME`:
- `EXPECTED_PROD_DB_HOST`:
- `EXPECTED_PROD_DB_NAME`:
## Commands executed
1.
```bash
# command
```
Output summary:
2.
```bash
# command
```
Output summary:
3.
```bash
# command
```
Output summary:
4.
```bash
# command
```
Output summary:
## Recoverability Evidence
- Current Postgres container:
- Mounted volume(s):
- Candidate old volume(s) inspected:
- Recoverable artifact found: `yes` | `no`
- Artifact location:
- Recovery decision:
## Backup/Restore Drill Evidence
- Latest backup file:
- Latest checksum file:
- Checksum verified: `yes` | `no`
- Restore test DB name:
- Restore succeeded: `yes` | `no`
- Row count checks performed:
## Results
- A01 protected route unauthenticated check: `pass` | `fail`
- A01 spoofed header check: `pass` | `fail`
- A01 admin rollover exposure check: `pass` | `fail`
- A01 automated suite (`auth` + `account-delete` + `admin-rollover`): `pass` | `fail`
- A02 dedicated suite (`security-misconfiguration`): `pass` | `fail`
- A03 dedicated suite (`software-supply-chain-failures`): `pass` | `fail`
- A04 dedicated suites (`cryptographic-failures*`): `pass` | `fail`
- A05 dedicated suite (`injection-safety`): `pass` | `fail`
- A06 dedicated suite (`insecure-design`): `pass` | `fail`
- A07 dedicated suites (`auth.routes` + `identification-auth-failures`): `pass` | `fail`
- A08 dedicated suite (`software-data-integrity-failures`): `pass` | `fail`
- A09 dedicated suite (`security-logging-monitoring-failures`): `pass` | `fail`
- A10 dedicated suite (`server-side-request-forgery`): `pass` | `fail`
- Non-DB security suite (`SECURITY_DB_TESTS=0`): `pass` | `fail`
- DB security suite (`SECURITY_DB_TESTS=1`): `pass` | `fail`
## Findings
- New issues observed:
- Regressions observed:
- Follow-up tickets:
- Data recovery status:
- Admin user bootstrap status:
## Residual Risk Review
- Reviewed `residual-risk-backlog.md`: `yes` | `no`
- Items accepted for this release:
- Items escalated/blocked:
## Sign-off
- Security reviewer:
- Engineering owner:
- Decision: `approved` | `blocked`

View File

@@ -1,161 +0,0 @@
# Post-Deployment Verification Checklist
Use this after every deploy (staging and production).
## Preconditions
1. Deployment completed successfully.
2. Migrations completed successfully.
3. Correct environment flags:
- `AUTH_DISABLED=false`
- `ALLOW_INSECURE_AUTH_FOR_DEV=false`
4. Test DB preflight (for DB-backed suites):
- `TEST_DATABASE_URL` points to a reachable PostgreSQL instance.
- `TEST_DATABASE_URL` database name is not `skymoney` and is clearly test-only (for example `skymoney_test`).
- `bash ./scripts/validate-test-db-target.sh` passes before DB-backed suites run.
- Example quick check:
```bash
echo "$TEST_DATABASE_URL"
```
Expected:
- single valid URL value
- host/port match the intended test database (for local runs usually `127.0.0.1:5432`)
5. Compose/DB safety preflight:
- `COMPOSE_PROJECT_NAME=skymoney` is set for deploy runtime.
- `docker-compose.yml` volume `pgdata` is pinned to `skymoney_pgdata`.
- `scripts/validate-prod-db-target.sh` passes for current `.env`.
- `scripts/guard-prod-volume.sh` passes (or explicit one-time rebuild override is documented).
- deploy runbook acknowledges forbidden destructive commands in prod:
- `prisma migrate reset`
- `prisma migrate dev`
- `prisma db push --accept-data-loss`
- `docker compose down -v` / `docker-compose down -v`
## Database recoverability and safety checks
### 0) Capture current container and volume bindings
```bash
docker ps --format '{{.Names}}'
docker inspect <postgres-container> --format '{{json .Mounts}}'
docker volume ls | grep -E 'pgdata|skymoney|postgres'
PROD_DB_VOLUME_NAME=skymoney_pgdata ALLOW_EMPTY_PROD_VOLUME=0 DOCKER_CMD="sudo docker" bash ./scripts/guard-prod-volume.sh
```
Expected:
- production Postgres uses `skymoney_pgdata`.
- no unexpected new empty volume silently substituted.
### 0.1) Validate latest backup artifact exists and verifies
```bash
ls -lt /opt/skymoney/backups | head
LATEST_DUMP="$(ls -1t /opt/skymoney/backups/*.dump | head -n 1)"
sha256sum -c "${LATEST_DUMP}.sha256"
```
Expected:
- latest dump and checksum exist.
- checksum verification returns `OK`.
### 0.2) Restore drill into isolated test DB (same VPS)
```bash
RESTORE_DB="skymoney_restore_test_$(date +%Y%m%d%H%M)" \
BACKUP_FILE="$LATEST_DUMP" \
RESTORE_DATABASE_URL="postgres://<user>:<pass>@127.0.0.1:5432/${RESTORE_DB}" \
DATABASE_URL="postgres://<admin-user>:<admin-pass>@127.0.0.1:5432/skymoney" \
./scripts/restore.sh
```
Expected:
- restore completes without manual edits.
- key tables readable in restored DB.
## A01 smoke checks
Replace `${API_BASE}` with your deployed API base URL.
### 1) Protected route requires auth
```bash
curl -i "${API_BASE}/dashboard"
```
Expected:
- HTTP `401`
- response body includes `UNAUTHENTICATED`
### 2) Spoofed identity header is ignored
```bash
curl -i -H "x-user-id: spoofed-user-id" "${API_BASE}/dashboard"
```
Expected:
- HTTP `401`
### 3) Admin rollover is not publicly callable
```bash
curl -i -X POST "${API_BASE}/admin/rollover" \
-H "Content-Type: application/json" \
-d '{"dryRun":true}'
```
Expected:
- HTTP `401` or `403` (must not be publicly callable)
## A09 smoke checks
### 4) Security events are emitted for failed auth attempts
Trigger a failed login attempt:
```bash
curl -i -X POST "${API_BASE}/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"nonexistent@example.com","password":"WrongPass123!"}'
```
Expected:
- HTTP `401`
- API logs include a structured `securityEvent` for `auth.login` with `outcome=failure`
- log entry includes `requestId`
## A10 smoke checks
### 5) Production origin configuration is public and non-local
Verify production env/config:
- `APP_ORIGIN` uses public HTTPS host (not localhost/private IP ranges)
Expected:
- API boots successfully with production env validation.
## Automated regression checks
Run in CI against a prod-like environment:
```bash
cd api
npm test -- tests/auth.routes.test.ts tests/access-control.account-delete.test.ts tests/access-control.admin-rollover.test.ts
SECURITY_DB_TESTS=0 npx vitest run -c vitest.security.config.ts
SECURITY_DB_TESTS=1 npx vitest run -c vitest.security.config.ts
```
Expected:
- all tests pass
Note:
- A06/A07 runtime suites require PostgreSQL availability.
- `SECURITY_DB_TESTS=0` runs non-DB security controls only.
- `SECURITY_DB_TESTS=1` includes DB-backed A06/A07/forgot-password suites.
## Sign-off
1. Record outputs in `evidence-log-template.md`.
2. Review open residual risks in `residual-risk-backlog.md`.
3. Record backup + restore drill evidence.
4. Mark release security check as pass/fail.

View File

@@ -1,26 +0,0 @@
# OWASP Residual Risk Backlog
Last updated: March 2, 2026
Use this file to track non-blocking hardening items that remain after automated controls pass.
## Open items
| ID | OWASP | Residual risk | Status |
|---|---|---|---|
| RR-001 | A01 | Add explicit authorization integration tests for all future admin-only routes (deny-by-default coverage expansion). | Open |
| RR-002 | A02 | Add runtime CSP and full security-header verification from deployed edge stack (not only config checks). | Open |
| RR-003 | A03 | Add stronger supply-chain provenance controls (digest pinning, SLSA attestations, artifact signing). | Open |
| RR-004 | A04 | Add key rotation runbook validation and automated stale-key detection checks. | Open |
| RR-005 | A05 | Add static taint analysis or Semgrep policy bundle in CI for command/SQL injection sinks. | Open |
| RR-006 | A06 | Add abuse-case tests for account recovery and verification flows under distributed-IP pressure. | Open |
| RR-007 | A07 | Add MFA/WebAuthn roadmap tests once MFA is implemented; currently password+lockout only. | Open |
| RR-008 | A08 | Add signed backup manifests and restore provenance verification for operational artifacts. | Open |
| RR-009 | A09 | Add alerting pipeline assertions (SIEM/webhook delivery) in pre-prod smoke tests. | Open |
| RR-010 | A10 | Add egress firewall enforcement tests to complement application-layer SSRF guards. | Open |
## Close criteria
1. A concrete control is implemented and validated by an automated test or policy gate.
2. Evidence is attached in `evidence-log-template.md`.
3. Owning team marks item as Closed with date and link to implementation PR.

View File

@@ -1,39 +0,0 @@
# Variable Pool Rebalance / Transfer Feature
## Summary
- Allow users to redistribute current variable pool (BudgetSession.availableCents) by setting per-category dollar targets, without changing future percent-based allocations.
- Enforce guards: sum = available, savings floor confirm, non-negative, no single category >80%, warnings when lowering savings.
- New backend endpoint performs atomic balance updates and audit logging; fixed expenses unaffected.
## API
- `POST /variable-categories/manual-rebalance`
- Body: `{ targets: [{ id, targetCents }], forceLowerSavings?: boolean }`
- Uses latest BudgetSession by periodStart; availableCents is the pool to balance.
- Validations: targets cover every category; non-negative; sum(targets)=available; each ≤80% of available; lowering savings or savings <20% requires `forceLowerSavings`.
- Transaction: update category balanceCents to targets; insert transaction(kind=`rebalance`, note snapshot); fixed plans untouched.
- Response: `{ ok: true, availableCents, categories: [{ id, balanceCents }] }`.
## UI/UX
- New Rebalance page (Settings → Expenses tab entry point) showing availableCents and per-category balances.
- Editable dollar inputs with live total meter and inline errors for rule violations.
- Savings floor warning/confirm; optional helper to adjust one category and auto-scale others respecting floors.
- Confirmation modal summarizing before/after deltas.
## Data / Logic
- Active session = latest BudgetSession for user.
- Rebalance acts on current variable pool only; future income remains percent-based.
- Savings floor default 20% of available; lowering requires confirmation flag.
## Tests
- Sum=available happy path; savings unchanged.
- Lowering savings w/out flag → 400; with flag → OK.
- Savings total <20% w/out flag → 400; with flag → OK.
- >80% single category → 400.
- Sum mismatch → 400; negative target → 400.
- Negative existing balance allowed only if target >=0 (ending non-negative).
- Adjust-one helper unit: scaling respects floors.
- Audit entry created; fixed plans and percents unchanged.
## Assumptions
- availableCents equals dashboard “Available” variable pool.
- No localization requirements for new errors.

View File

@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -1,552 +0,0 @@
// web/src/pages/settings/CategoriesPage.tsx
import { useMemo, useState, useEffect, type FormEvent } from "react";
import {
DndContext,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useDashboard } from "../../hooks/useDashboard";
import { Money } from "../../components/ui";
import SettingsNav from "./_SettingsNav";
import {
useCategories,
useCreateCategory,
useUpdateCategory,
useDeleteCategory,
} from "../../hooks/useCategories";
import { useToast } from "../../components/Toast";
type Row = {
id: string;
name: string;
percent: number;
priority: number;
isSavings: boolean;
balanceCents: number;
};
function SumBadge({ total }: { total: number }) {
const tone =
total === 100
? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"
: total < 100
? "bg-amber-500/10 text-amber-100 border border-amber-500/30"
: "bg-red-500/10 text-red-100 border border-red-500/40";
const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over";
return (
<div className={`badge ${tone}`}>
{label}: {total}%
</div>
);
}
export default function SettingsCategoriesPage() {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const cats = useCategories() as Row[];
const createM = useCreateCategory();
const updateM = useUpdateCategory();
const deleteM = useDeleteCategory();
const { push } = useToast();
const normalizeName = (value: string) => value.trim().toLowerCase();
const MIN_SAVINGS_PERCENT = 20;
const total = useMemo(
() => cats.reduce((s, c) => s + c.percent, 0),
[cats],
);
const savingsTotal = useMemo(
() => cats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0),
[cats]
);
const remainingPercent = Math.max(0, 100 - total);
// Drag ordering state (initially from priority)
const [order, setOrder] = useState<string[]>([]);
useEffect(() => {
const sorted = cats
.slice()
.sort(
(a, b) =>
a.priority - b.priority || a.name.localeCompare(b.name),
);
const next = sorted.map((c) => c.id);
// Reset order when cats change in length or ids
if (
order.length !== next.length ||
next.some((id, i) => order[i] !== id)
) {
setOrder(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cats.map((c) => c.id).join("|")]);
// Add form state
const [name, setName] = useState("");
const [percent, setPercent] = useState("");
const [priority, setPriority] = useState("");
const [isSavings, setIsSavings] = useState(false);
const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0));
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
const addDisabled =
!name.trim() ||
parsedPercent <= 0 ||
parsedPercent > 100 ||
parsedPercent > remainingPercent ||
createM.isPending;
const onAdd = (e: FormEvent) => {
e.preventDefault();
const normalizedName = normalizeName(name);
if (cats.some((c) => normalizeName(c.name) === normalizedName)) {
push("err", `Expense name '${normalizedName}' already exists`);
return;
}
const body = {
name: normalizedName,
percent: parsedPercent,
priority: parsedPriority,
isSavings,
};
if (!body.name) return;
if (body.percent > remainingPercent) {
push("err", `Only ${remainingPercent}% is available right now.`);
return;
}
const nextTotal = total + body.percent;
const nextSavingsTotal = savingsTotal + (body.isSavings ? body.percent : 0);
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
push(
"err",
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
);
return;
}
createM.mutate(body, {
onSuccess: () => {
push("ok", "Expense created");
setName("");
setPercent("");
setPriority("");
setIsSavings(false);
},
onError: (err: any) =>
push("err", err?.message ?? "Create failed"),
});
};
const onEdit = (id: string, patch: Partial<Row>) => {
if (patch.name !== undefined) {
const normalizedName = normalizeName(patch.name);
if (
cats.some((c) => c.id !== id && normalizeName(c.name) === normalizedName)
) {
push("err", `Expense name '${normalizedName}' already exists`);
return;
}
patch.name = normalizedName;
}
if (patch.percent !== undefined) {
const current = cats.find((c) => c.id === id);
if (!current) return;
const sanitized = Math.max(
0,
Math.min(100, Math.floor(patch.percent)),
);
const nextTotal = total - current.percent + sanitized;
if (nextTotal > 100) {
push("err", `Updating this would push totals to ${nextTotal}%.`);
return;
}
patch.percent = sanitized;
}
if (patch.priority !== undefined) {
patch.priority = Math.max(0, Math.floor(patch.priority));
}
if (patch.isSavings !== undefined || patch.percent !== undefined) {
const current = cats.find((c) => c.id === id);
if (!current) return;
const nextPercent = patch.percent ?? current.percent;
const wasSavings = current.isSavings ? current.percent : 0;
const nextIsSavings = patch.isSavings ?? current.isSavings;
const nextSavings = nextIsSavings ? nextPercent : 0;
const nextTotal =
total - current.percent + (patch.percent ?? current.percent);
const nextSavingsTotal =
savingsTotal - wasSavings + nextSavings;
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
push(
"err",
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
);
return;
}
}
updateM.mutate(
{ id, body: patch },
{
onError: (err: any) =>
push("err", err?.message ?? "Update failed"),
},
);
};
const onDelete = (id: string) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Expense deleted"),
onError: (err: any) =>
push("err", err?.message ?? "Delete failed"),
});
};
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setOrder((prev) => {
const oldIndex = prev.indexOf(String(active.id));
const newIndex = prev.indexOf(String(over.id));
const next = arrayMove(prev, oldIndex, newIndex);
// Apply new priorities to server (only changed ones)
const updates = onDragOrderApply(next);
updates.forEach(({ id, priority }) => {
const existing = cats.find((c) => c.id === id);
if (existing && existing.priority !== priority) {
updateM.mutate({ id, body: { priority } });
}
});
return next;
});
};
if (isLoading)
return (
<div className="card max-w-2xl">
<SettingsNav />
<div className="muted">Loading</div>
</div>
);
if (error || !data) {
return (
<div className="card max-w-2xl">
<SettingsNav />
<p className="mb-3">Couldn't load expenses.</p>
<button
className="btn"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
);
}
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav />
<header className="mb-4 space-y-1">
<h1 className="text-lg font-semibold">Expenses</h1>
<p className="text-sm muted">
Decide how every dollar is divided. Percentages must always
add up to 100%.
</p>
</header>
{/* Add form */}
<form
onSubmit={onAdd}
className="row gap-2 mb-4 flex-wrap items-end"
>
<input
className="input w-full sm:w-44"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="%"
type="number"
min={0}
max={100}
value={percent}
onChange={(e) => setPercent(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Priority"
type="number"
min={0}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
<label className="row w-full sm:w-auto">
<input
type="checkbox"
checked={isSavings}
onChange={(e) => setIsSavings(e.target.checked)}
/>
<span className="muted text-sm">Savings</span>
</label>
<button
className="btn w-full sm:w-auto"
disabled={addDisabled || createM.isPending}
>
Add
</button>
<div className="ml-auto text-right">
<SumBadge total={total} />
</div>
</form>
{cats.length === 0 ? (
<div className="muted text-sm mt-4">
No expenses yet.
</div>
) : (
<DndContext
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<table className="table mt-4">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>%</th>
<th>Priority</th>
<th>Savings</th>
<th>Balance</th>
<th></th>
</tr>
</thead>
<SortableContext
items={order}
strategy={verticalListSortingStrategy}
>
<tbody>
{order
.map((id) => cats.find((c) => c.id === id))
.filter(Boolean)
.map((c) => (
<SortableTr key={c!.id} id={c!.id}>
<td className="px-3 py-2">
<span className="drag-handle inline-flex items-center justify-center w-6 h-6 rounded-full bg-[--color-panel] text-lg cursor-grab active:cursor-grabbing">
</span>
</td>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText
value={c!.name}
onChange={(v) =>
onEdit(c!.id, { name: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditNumber
value={c!.percent}
min={0}
max={100}
onChange={(v) =>
onEdit(c!.id, { percent: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditNumber
value={c!.priority}
min={0}
onChange={(v) =>
onEdit(c!.id, { priority: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditCheckbox
checked={c!.isSavings}
onChange={(v) =>
onEdit(c!.id, { isSavings: v })
}
/>
{c!.isSavings && (
<span className="badge ml-2 text-[10px] bg-emerald-500/10 text-emerald-200 border border-emerald-500/40">
Savings
</span>
)}
</td>
<td className="px-3 py-2">
<Money cents={c!.balanceCents ?? 0} />
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button
className="btn"
type="button"
onClick={() => onDelete(c!.id)}
disabled={deleteM.isPending}
>
Delete
</button>
</td>
</SortableTr>
))}
</tbody>
</SortableContext>
</table>
</DndContext>
)}
{/* Guard if total != 100 */}
{total !== 100 && (
<div className="toast-err mt-3">
Percents must sum to <strong>100%</strong> for allocations.
Current total: {total}%.
</div>
)}
</section>
</div>
);
}
/* --- tiny inline editors --- */
function InlineEditText({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [v, setV] = useState(value);
const [editing, setEditing] = useState(false);
useEffect(() => setV(value), [value]);
const commit = () => {
if (v !== value) onChange(v.trim());
setEditing(false);
};
return editing ? (
<input
className="input"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link inline-flex items-center gap-1"
onClick={() => setEditing(true)}
>
<span>{value}</span>
<span className="text-[10px] opacity-60">Edit</span>
</button>
);
}
function InlineEditNumber({
value,
onChange,
min = 0,
max = Number.MAX_SAFE_INTEGER,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
useEffect(() => setV(String(value)), [value]);
const commit = () => {
const n = Math.max(
min,
Math.min(max, Math.floor(Number(v) || 0)),
);
if (n !== value) onChange(n);
setEditing(false);
};
return editing ? (
<input
className="input w-24 text-right"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{value}
</button>
);
}
function InlineEditCheckbox({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="row">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="muted text-sm">
{checked ? "Yes" : "No"}
</span>
</label>
);
}
function SortableTr({
id,
children,
}: {
id: string;
children: React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="sortable-row"
>
{children}
</tr>
);
}
function onDragOrderApply(ids: string[]) {
return ids.map((id, idx) => ({ id, priority: idx + 1 }));
}

View File

@@ -1,747 +0,0 @@
// web/src/pages/settings/PlansPage.tsx
import {
useMemo,
useState,
useEffect,
type FormEvent,
type ReactNode,
} from "react";
import { useDashboard } from "../../hooks/useDashboard";
import SettingsNav from "./_SettingsNav";
import {
useCreatePlan,
useUpdatePlan,
useDeletePlan,
} from "../../hooks/useFixedPlans";
import { Money } from "../../components/ui";
import { useToast } from "../../components/Toast";
import { getTodayInTimezone, dateStringToUTCMidnight, isoToDateString, getBrowserTimezone, formatDateInTimezone } from "../../utils/timezone";
function daysUntil(iso: string, userTimezone: string) {
const today = getTodayInTimezone(userTimezone);
const due = isoToDateString(iso, userTimezone);
const todayDate = new Date(today);
const dueDate = new Date(due);
const diffMs = dueDate.getTime() - todayDate.getTime();
return Math.round(diffMs / (24 * 60 * 60 * 1000));
}
function DueBadge({ dueISO, userTimezone }: { dueISO: string; userTimezone: string }) {
const d = daysUntil(dueISO, userTimezone);
if (d < 0)
return (
<span
className="badge"
style={{ borderColor: "#7f1d1d" }}
>
Overdue
</span>
);
if (d <= 7) return <span className="badge">Due in {d}d</span>;
return (
<span className="badge" aria-hidden="true">
On track
</span>
);
}
export default function SettingsPlansPage() {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const createM = useCreatePlan();
const updateM = useUpdatePlan();
const deleteM = useDeletePlan();
const { push } = useToast();
// Get user timezone from dashboard data
const userTimezone = data?.user?.timezone || getBrowserTimezone();
// Add form state
const [name, setName] = useState("");
const [total, setTotal] = useState("");
const [funded, setFunded] = useState("");
const [priority, setPriority] = useState("");
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
// Auto-payment form state
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "daily" | "custom">("monthly");
const [dayOfMonth, setDayOfMonth] = useState(1);
const [dayOfWeek, setDayOfWeek] = useState(0);
const [everyNDays, setEveryNDays] = useState(30);
const [minFundingPercent, setMinFundingPercent] = useState(100);
const totals = useMemo(() => {
if (!data) return { funded: 0, total: 0, remaining: 0 };
const funded = data.fixedPlans.reduce(
(s, p) => s + p.fundedCents,
0,
);
const total = data.fixedPlans.reduce(
(s, p) => s + p.totalCents,
0,
);
return {
funded,
total,
remaining: Math.max(0, total - funded),
};
}, [data]);
const overallPctFunded = useMemo(() => {
if (!totals.total) return 0;
return Math.round((totals.funded / totals.total) * 100);
}, [totals.funded, totals.total]);
if (isLoading)
return (
<div className="card max-w-3xl">
<SettingsNav />
<div className="muted">Loading</div>
</div>
);
if (error || !data) {
return (
<div className="card max-w-3xl">
<SettingsNav />
<p className="mb-3">Couldn't load fixed expenses.</p>
<button
className="btn"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
);
}
const onAdd = (e: FormEvent) => {
e.preventDefault();
const totalCents = Math.max(
0,
Math.round((parseFloat(total || "0")) * 100),
);
const fundedCents = Math.max(
0,
Math.round((parseFloat(funded || "0")) * 100),
);
const paymentSchedule = autoPayEnabled ? {
frequency,
...(frequency === "monthly" ? { dayOfMonth } : {}),
...(frequency === "weekly" || frequency === "biweekly" ? { dayOfWeek } : {}),
...(frequency === "custom" ? { everyNDays } : {}),
minFundingPercent,
} : undefined;
const body = {
name: name.trim(),
totalCents,
fundedCents: Math.min(fundedCents, totalCents),
priority: Math.max(
0,
Math.floor(Number(priority) || 0),
),
dueOn: dateStringToUTCMidnight(due, userTimezone),
autoPayEnabled,
paymentSchedule,
};
if (!body.name || totalCents <= 0) return;
createM.mutate(body, {
onSuccess: () => {
push("ok", "Plan created");
setName("");
setTotal("");
setFunded("");
setPriority("");
setDue(getTodayInTimezone(userTimezone));
setAutoPayEnabled(false);
setFrequency("monthly");
setDayOfMonth(1);
setDayOfWeek(0);
setEveryNDays(30);
setMinFundingPercent(100);
},
onError: (err: any) =>
push("err", err?.message ?? "Create failed"),
});
};
const onEdit = (
id: string,
patch: Partial<{
name: string;
totalCents: number;
fundedCents: number;
priority: number;
dueOn: string;
}>,
) => {
if (
"totalCents" in patch &&
"fundedCents" in patch &&
(patch.totalCents ?? 0) < (patch.fundedCents ?? 0)
) {
patch.fundedCents = patch.totalCents;
}
updateM.mutate(
{ id, body: patch },
{
onSuccess: () => push("ok", "Plan updated"),
onError: (err: any) =>
push("err", err?.message ?? "Update failed"),
},
);
};
const onDelete = (id: string) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Plan deleted"),
onError: (err: any) =>
push("err", err?.message ?? "Delete failed"),
});
};
const addDisabled =
!name || !total || createM.isPending;
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav />
{/* Header */}
<header className="mb-4 space-y-1">
<h1 className="text-lg font-semibold">
Fixed expenses
</h1>
<p className="text-sm muted">
Long-term goals and obligations youre funding over
time.
</p>
</header>
{/* KPI strip */}
<div className="grid gap-2 sm:grid-cols-4 mb-4">
<KpiCard label="Funded">
<Money cents={totals.funded} />
</KpiCard>
<KpiCard label="Total">
<Money cents={totals.total} />
</KpiCard>
<KpiCard label="Remaining">
<Money cents={totals.remaining} />
</KpiCard>
<KpiCard label="Overall progress">
<span className="text-xl font-semibold">
{overallPctFunded}%
</span>
</KpiCard>
</div>
{/* Overall progress bar */}
<div className="mb-4 space-y-1">
<div className="row text-xs muted">
<span>All fixed expenses funded</span>
<span className="ml-auto">
{overallPctFunded}% of target
</span>
</div>
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
<div
className="h-full bg-[--color-accent]"
style={{
width: `${Math.min(100, overallPctFunded)}%`,
}}
/>
</div>
</div>
{/* Add form */}
<form
onSubmit={onAdd}
className="row gap-2 mb-4 flex-wrap items-end"
>
<input
className="input w-full sm:w-48"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Total $"
type="number"
min={0}
step="0.01"
value={total}
onChange={(e) => setTotal(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Funded $"
type="number"
min={0}
step="0.01"
value={funded}
onChange={(e) => setFunded(e.target.value)}
/>
<input
className="input w-full sm:w-24"
placeholder="Priority"
type="number"
min={0}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
<input
className="input w-full sm:w-40"
type="date"
value={due}
onChange={(e) => setDue(e.target.value)}
/>
<label className="row gap-2 items-center text-sm cursor-pointer px-3 py-2 rounded-lg bg-[--color-panel] w-full sm:w-auto">
<input
type="checkbox"
checked={autoPayEnabled}
onChange={(e) => setAutoPayEnabled(e.target.checked)}
/>
<span>Auto-fund</span>
</label>
<button className="btn w-full sm:w-auto" disabled={addDisabled}>
Add
</button>
</form>
{/* Auto-payment configuration */}
{autoPayEnabled && (
<div className="card bg-[--color-panel] p-4 mb-4">
<h4 className="section-title text-sm mb-3">Auto-Fund Schedule</h4>
<div className="row gap-4 flex-wrap items-end">
<label className="stack text-sm">
<span className="muted text-xs">Frequency</span>
<select
className="input w-32"
value={frequency}
onChange={(e) => setFrequency(e.target.value as any)}
>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly">Monthly</option>
<option value="daily">Daily</option>
<option value="custom">Custom</option>
</select>
</label>
{(frequency === "weekly" || frequency === "biweekly") && (
<label className="stack text-sm">
<span className="muted text-xs">Day of Week</span>
<select
className="input w-32"
value={dayOfWeek}
onChange={(e) => setDayOfWeek(Number(e.target.value))}
>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
</label>
)}
{frequency === "monthly" && (
<label className="stack text-sm">
<span className="muted text-xs">Day of Month</span>
<input
className="input w-24"
type="number"
min="1"
max="31"
value={dayOfMonth}
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
/>
</label>
)}
{frequency === "custom" && (
<label className="stack text-sm">
<span className="muted text-xs">Every N Days</span>
<input
className="input w-24"
type="number"
min="1"
value={everyNDays}
onChange={(e) => setEveryNDays(Number(e.target.value) || 30)}
/>
</label>
)}
<label className="stack text-sm">
<span className="muted text-xs">Min. Funding %</span>
<input
className="input w-24"
type="number"
min="0"
max="100"
value={minFundingPercent}
onChange={(e) => setMinFundingPercent(Math.max(0, Math.min(100, Number(e.target.value) || 0)))}
/>
</label>
</div>
<p className="text-xs muted mt-2">
Automatic payments will only occur if the expense is funded to at least the minimum percentage.
</p>
</div>
)}
{/* Table */}
{data.fixedPlans.length === 0 ? (
<div className="muted text-sm">
No fixed expenses yet.
</div>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Due</th>
<th>Priority</th>
<th>Funded</th>
<th>Total</th>
<th>Remaining</th>
<th>Status</th>
<th>Auto-Pay</th>
<th></th>
</tr>
</thead>
<tbody>
{data.fixedPlans
.slice()
.sort(
(a, b) =>
a.priority - b.priority ||
new Date(a.dueOn).getTime() -
new Date(b.dueOn).getTime(),
)
.map((p) => {
const remaining = Math.max(
0,
p.totalCents - p.fundedCents,
);
const pctFunded = p.totalCents
? Math.round(
(p.fundedCents / p.totalCents) * 100,
)
: 0;
return (
<tr key={p.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText
value={p.name}
onChange={(v) =>
onEdit(p.id, { name: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditDate
value={p.dueOn}
timezone={userTimezone}
onChange={(iso) =>
onEdit(p.id, { dueOn: iso })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditNumber
value={p.priority}
min={0}
onChange={(n) =>
onEdit(p.id, { priority: n })
}
/>
</td>
<td className="px-3 py-2">
<div className="space-y-1">
<InlineEditMoney
valueCents={p.fundedCents}
onChange={(cents) =>
onEdit(p.id, {
fundedCents: Math.max(
0,
Math.min(cents, p.totalCents),
),
})
}
/>
<div className="row text-xs muted">
<span>{pctFunded}% funded</span>
</div>
</div>
</td>
<td className="px-3 py-2">
<div className="space-y-1">
<InlineEditMoney
valueCents={p.totalCents}
onChange={(cents) =>
onEdit(p.id, {
totalCents: Math.max(cents, 0),
fundedCents: Math.min(
p.fundedCents,
cents,
),
})
}
/>
<FundingBar
pct={pctFunded}
/>
</div>
</td>
<td className="px-3 py-2">
<Money cents={remaining} />
</td>
<td className="px-3 py-2">
<DueBadge dueISO={p.dueOn} userTimezone={userTimezone} />
</td>
<td className="px-3 py-2">
<div className="flex items-center space-x-2">
<span className={`text-xs px-2 py-1 rounded ${
p.autoPayEnabled
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{p.autoPayEnabled ? 'Enabled' : 'Disabled'}
</span>
{p.autoPayEnabled && p.paymentSchedule && (
<span className="text-xs text-gray-500">
{p.paymentSchedule.frequency === 'custom'
? `Every ${p.paymentSchedule.customDays} days`
: p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1)
}
</span>
)}
</div>
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button
className="btn"
type="button"
onClick={() => onDelete(p.id)}
disabled={deleteM.isPending}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</div>
);
}
/* --- Small presentational helpers --- */
function KpiCard({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div className="card kpi">
<h3>{label}</h3>
<div className="val">{children}</div>
</div>
);
}
function FundingBar({ pct }: { pct: number }) {
const clamped = Math.min(100, Math.max(0, pct));
return (
<div className="h-1.5 w-full rounded-full bg-[--color-panel] overflow-hidden">
<div
className="h-full bg-[--color-accent]"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
/* --- Inline editors (same behavior, slightly nicer UX) --- */
function InlineEditText({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(value);
useEffect(() => setV(value), [value]);
const commit = () => {
const t = v.trim();
if (t && t !== value) onChange(t);
setEditing(false);
};
return editing ? (
<input
className="input"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link inline-flex items-center gap-1"
onClick={() => setEditing(true)}
>
<span>{value}</span>
<span className="text-[10px] opacity-60">Edit</span>
</button>
);
}
function InlineEditNumber({
value,
onChange,
min = 0,
max = Number.MAX_SAFE_INTEGER,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
useEffect(() => setV(String(value)), [value]);
const commit = () => {
const n = Math.max(
min,
Math.min(max, Math.floor(Number(v) || 0)),
);
if (n !== value) onChange(n);
setEditing(false);
};
return editing ? (
<input
className="input w-24 text-right"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{value}
</button>
);
}
function InlineEditMoney({
valueCents,
onChange,
}: {
valueCents: number;
onChange: (cents: number) => void;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState((valueCents / 100).toFixed(2));
useEffect(
() => setV((valueCents / 100).toFixed(2)),
[valueCents],
);
const commit = () => {
const cents = Math.max(
0,
Math.round((parseFloat(v || "0")) * 100),
);
if (cents !== valueCents) onChange(cents);
setEditing(false);
};
return editing ? (
<input
className="input w-28 text-right font-mono"
type="number"
step="0.01"
min={0}
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link font-mono"
onClick={() => setEditing(true)}
>
{(valueCents / 100).toFixed(2)}
</button>
);
}
function InlineEditDate({
value,
onChange,
timezone,
}: {
value: string;
onChange: (iso: string) => void;
timezone: string;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(
isoToDateString(value, timezone),
);
useEffect(
() =>
setV(
isoToDateString(value, timezone),
),
[value, timezone],
);
const commit = () => {
const iso = dateStringToUTCMidnight(v, timezone);
if (iso !== value) onChange(iso);
setEditing(false);
};
return editing ? (
<input
className="input w-40"
type="date"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{formatDateInTimezone(value, timezone)}
</button>
);
}

View File

@@ -1,14 +0,0 @@
import { NavLink } from "react-router-dom";
export default function SettingsNav() {
const link = (to: string, label: string) =>
<NavLink to={to} className={({isActive}) => `link ${isActive ? "link-active" : ""}`}>{label}</NavLink>;
return (
<div className="flex flex-col gap-2 mb-3 sm:flex-row sm:items-center">
<h2 className="section-title m-0">Settings</h2>
<div className="flex flex-wrap gap-1 sm:ml-auto">
{link("/settings/categories", "Expenses")}
{link("/settings/plans", "Fixed Expenses")}
</div>
</div>
);
}

View File

@@ -1,132 +0,0 @@
:root {
--color-bg: #0b0c10;
--color-panel: #111318;
--color-fg: #e7e9ee;
--color-ink: #2a2e37;
--color-accent: #5dd6b2;
--radius-xl: 12px;
--radius-lg: 10px;
--radius-md: 8px;
--shadow-1: 0 6px 20px rgba(0,0,0,0.25);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body { margin: 0; background: var(--color-bg); color: var(--color-fg); font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
a { color: inherit; text-decoration: none; }
.container { width: min(1100px, 100vww); margin-inline: auto; padding: 0 16px; }
.muted { opacity: 0.7; }
.card {
background: var(--color-panel);
border: 1px solid var(--color-ink);
border-radius: var(--radius-xl);
padding: 16px;
box-shadow: var(--shadow-1);
}
.row { display: flex; align-items: center; }
.stack { display: grid; gap: 12px; }
.section-title { font-weight: 700; font-size: 16px; margin-bottom: 10px; }
.input {
background: #0f1116;
color: var(--color-fg);
border: 1px solid var(--color-ink);
border-radius: var(--radius-lg);
padding: 8px 10px;
outline: none;
}
.input:focus { border-color: var(--color-accent); }
.btn {
background: var(--color-accent);
color: #062016;
border: none;
border-radius: var(--radius-lg);
padding: 8px 12px;
font-weight: 700;
cursor: pointer;
}
.btn[disabled] { opacity: 0.5; cursor: default; }
.badge {
background: var(--color-ink);
border-radius: var(--radius-lg);
padding: 4px 8px;
font-size: 12px;
}
.table { width: 100%; border-collapse: separate; border-spacing: 0 8px; }
.table thead th {
text-align: left; font-size: 12px; opacity: 0.7; padding: 0 8px;
}
.table tbody tr {
background: var(--color-panel);
border: 1px solid var(--color-ink);
border-radius: var(--radius-xl);
}
.table td { padding: 8px; }
.toast-err {
background: #b21d2a;
color: white;
border-radius: var(--radius-lg);
padding: 10px 12px;
}
.border { border: 1px solid var(--color-ink); }
.rounded-xl { border-radius: var(--radius-xl); }
.divide-y > * + * { border-top: 1px solid var(--color-ink); }
/* utility-ish */
.w-44 { width: 11rem; }
.w-56 { width: 14rem; }
.w-40 { width: 10rem; }
.ml-auto { margin-left: auto; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.mb-3 { margin-bottom: 0.75rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.flex-wrap { flex-wrap: wrap; }
.text-sm { font-size: 0.875rem; }
.text-xl { font-size: 1.25rem; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.bg-\[--color-ink\] { background: var(--color-ink); }
.bg-\[--color-ink\]\/60 { background: color-mix(in oklab, var(--color-ink), transparent 40%); }
.bg-\[--color-panel\] { background: var(--color-panel); }
.text-\[--color-fg\] { color: var(--color-fg); }
.border-\[--color-ink\] { border-color: var(--color-ink); }
.rounded-\[--radius-xl\] { border-radius: var(--radius-xl); }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.py-8 { padding-block: 2rem; }
.h-14 { height: 3.5rem; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.opacity-70 { opacity: .7; }
.grid { display: grid; }
.md\:grid-cols-2 { grid-template-columns: 1fr; }
.md\:grid-cols-3 { grid-template-columns: 1fr; }
@media (min-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: 1fr 1fr; }
.md\:grid-cols-3 { grid-template-columns: 1fr 1fr 1fr; }
}
.shadow-sm { box-shadow: 0 2px 12px rgba(0,0,0,0.2); }
.underline { text-decoration: underline; }
.fixed { position: fixed; }
.bottom-4 { bottom: 1rem; }
.left-1\/2 { left: 50%; }
.-translate-x-1\/2 { transform: translateX(-50%); }
.z-50 { z-index: 50; }
.space-y-2 > * + * { margin-top: .5rem; }
.space-y-8 > * + * { margin-top: 2rem; }
.space-y-6 > * + * { margin-top: 1.5rem; }
.overflow-x-auto { overflow-x: auto; }