final touches for beta skymoney (at least i think)

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

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# App
NODE_ENV=development
PORT=8080
CORS_ORIGIN=http://localhost:5173
AUTH_DISABLED=false
SEED_DEFAULT_BUDGET=false
ROLLOVER_SCHEDULE_CRON=0 6 * * *
# Database (app runtime)
POSTGRES_DB=skymoney
POSTGRES_USER=skymoney_app
POSTGRES_PASSWORD=change-me
DATABASE_URL=postgres://skymoney_app:change-me@postgres:5432/skymoney
# Database (backup/restore on host)
BACKUP_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney
RESTORE_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney_restore_test
ADMIN_DATABASE_URL=postgres://postgres:change-me@127.0.0.1:5432/postgres
# Auth secrets (min 32 chars)
JWT_SECRET=replace-with-32+-chars
COOKIE_SECRET=replace-with-32+-chars
COOKIE_DOMAIN=skymoneybudget.com

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Secrets
.env
.env.*
!.env.example
# Local backups
backups/

28
Caddyfile.prod Normal file
View File

@@ -0,0 +1,28 @@
# Caddyfile.prod — production (HTTPS)
{
email admin@skymoneybudget.com
}
skymoneybudget.com {
encode zstd gzip
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
# Serve static SPA
root * /var/www/skymoney/dist
file_server
# SPA fallback
try_files {path} /index.html
# Proxy API
handle_path /api/* {
reverse_proxy 127.0.0.1:8081
}
}
}

View File

@@ -1,17 +1,7 @@
TODO:
Better the logo
add caddy files
add db routes and set up db
test api with docker
-Test DB
-test functionality
-simulate results in scheduling
-ensure security
build UI and UX
add new logo
build theme
interactivity with tailwind
make better Read me
add comments to code
get ben to post it
UI changes and UX:
onboarding:
Pressing enter submits and goes to next step

View File

@@ -0,0 +1,121 @@
# 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

@@ -17,8 +17,10 @@ FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# optional but nice
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
# optional but nice (health check uses wget)
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget \
&& rm -rf /var/lib/apt/lists/*
# 1) deps: prod node_modules
COPY --from=deps /app/node_modules ./node_modules
@@ -35,4 +37,4 @@ COPY entrypoint.sh ./entrypoint.sh
RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh
EXPOSE 8080
CMD ["/app/entrypoint.sh"]
CMD ["/app/entrypoint.sh"]

34
api/check-allocations.cjs Normal file
View File

@@ -0,0 +1,34 @@
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();

49
api/check-overdue.cjs Normal file
View File

@@ -0,0 +1,49 @@
// 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

@@ -88,7 +88,6 @@ export type FetchLike = typeof fetch;
export type SDKOptions = {
baseUrl?: string;
userId?: string;
fetch?: FetchLike;
requestIdFactory?: () => string; // to set x-request-id if desired
};
@@ -107,14 +106,9 @@ export class SkyMoney {
readonly baseUrl: string;
private readonly f: FetchLike;
private readonly reqId?: () => string;
userId?: string;
constructor(opts: SDKOptions = {}) {
this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, "");
this.userId = opts.userId ?? (
// Try localStorage if present (browser)
typeof localStorage !== "undefined" ? localStorage.getItem("x-user-id") || undefined : undefined
);
this.f = opts.fetch || fetch;
this.reqId = opts.requestIdFactory;
}
@@ -128,12 +122,12 @@ export class SkyMoney {
): Promise<T> {
const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`;
const h: Record<string, string> = { ...(headers || {}) };
if (this.userId) h["x-user-id"] = this.userId;
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,

View File

@@ -0,0 +1,135 @@
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

@@ -0,0 +1,133 @@
// 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();

91
api/create-test-user.cjs Normal file
View File

@@ -0,0 +1,91 @@
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

@@ -4,8 +4,9 @@ info:
version: 0.1.0
description: |
Fastify backend for budgeting/allocations.
Most endpoints accept an optional `x-user-id` header; when omitted, the server
defaults to `demo-user-1`.
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
@@ -323,6 +324,9 @@ components:
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:

255
api/package-lock.json generated
View File

@@ -8,10 +8,15 @@
"name": "skymoney-api",
"version": "0.1.0",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.1.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@prisma/client": "^5.22.0",
"argon2": "^0.40.1",
"date-fns-tz": "^3.2.0",
"fastify": "^5.6.2",
"node-cron": "^4.2.1",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -487,6 +492,26 @@
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
"integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^1.0.0",
"fastify-plugin": "^5.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz",
@@ -558,6 +583,29 @@
],
"license": "MIT"
},
"node_modules/@fastify/jwt": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
"integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.2.0",
"@lukeed/ms": "^2.0.2",
"fast-jwt": "^6.0.2",
"fastify-plugin": "^5.0.1",
"steed": "^1.1.3"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@@ -657,6 +705,15 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -1246,6 +1303,21 @@
}
}
},
"node_modules/argon2": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.40.3.tgz",
"integrity": "sha512-FrSmz4VeM91jwFvvjsQv9GYp6o/kARWoYKjbjDB2U5io1H3e5X67PYGclFDeQff6UXIhUd4aHR3mxCdBbMMuQw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@phc/format": "^1.0.0",
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0"
},
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
@@ -1253,6 +1325,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1289,6 +1373,12 @@
"fastq": "^1.17.1"
}
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -1396,6 +1486,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-tz": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
"license": "MIT",
"peerDependencies": {
"date-fns": "^3.0.0 || ^4.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1469,6 +1579,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1623,6 +1742,21 @@
"rfdc": "^1.2.0"
}
},
"node_modules/fast-jwt": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.0.2.tgz",
"integrity": "sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
@@ -1655,6 +1789,18 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastfall": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
"license": "MIT",
"dependencies": {
"reusify": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fastify": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.6.2.tgz",
@@ -1704,6 +1850,16 @@
],
"license": "MIT"
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4",
"xtend": "^4.0.2"
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -1713,6 +1869,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fastseries": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.0",
"xtend": "^4.0.0"
}
},
"node_modules/find-my-way": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.3.0.tgz",
@@ -1892,6 +2058,12 @@
"node": ">= 0.4"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -2036,6 +2208,12 @@
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/mnemonist": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz",
@@ -2071,6 +2249,35 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2352,6 +2559,26 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
@@ -2380,6 +2607,12 @@
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -2539,6 +2772,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
"license": "MIT",
"dependencies": {
"fastfall": "^1.5.0",
"fastparallel": "^2.2.0",
"fastq": "^1.3.0",
"fastseries": "^1.7.0",
"reusify": "^1.0.0"
}
},
"node_modules/superagent": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz",
@@ -3283,6 +3529,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@@ -10,7 +10,9 @@
"migrate": "prisma migrate dev",
"seed": "prisma db seed",
"test": "vitest --run",
"test:watch": "vitest"
"test:watch": "vitest",
"rollover": "tsx src/scripts/run-rollover.ts",
"plan:manage": "tsx src/scripts/manage-plan.ts"
},
"prisma": {
"seed": "tsx --tsconfig prisma/tsconfig.seed.json prisma/seed.ts"
@@ -25,10 +27,15 @@
"vitest": "^2.1.3"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.1.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@prisma/client": "^5.22.0",
"argon2": "^0.40.1",
"date-fns-tz": "^3.2.0",
"fastify": "^5.6.2",
"node-cron": "^4.2.1",
"zod": "^3.23.8"
}
}

1607
api/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "passwordHash" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,9 @@
-- AddForeignKey
ALTER TABLE "Transaction"
ADD CONSTRAINT "Transaction_categoryId_fkey"
FOREIGN KEY ("categoryId") REFERENCES "VariableCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Transaction"
ADD CONSTRAINT "Transaction_planId_fkey"
FOREIGN KEY ("planId") REFERENCES "FixedPlan"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "VariableCategory" ADD COLUMN "savingsTargetCents" BIGINT;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Transaction" ADD COLUMN "isReconciled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "note" TEXT,
ADD COLUMN "receiptUrl" TEXT;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "FixedPlan" ADD COLUMN "autoRollover" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "lastRollover" TIMESTAMP(3),
ADD COLUMN "periodDays" INTEGER NOT NULL DEFAULT 30;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "displayName" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "IncomeEvent" ADD COLUMN "note" TEXT;

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "FixedPlan" ADD COLUMN "autoPayEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "lastAutoPayment" TIMESTAMP(3),
ADD COLUMN "maxRetryAttempts" INTEGER NOT NULL DEFAULT 3,
ADD COLUMN "nextPaymentDate" TIMESTAMP(3),
ADD COLUMN "paymentSchedule" JSONB;

View File

@@ -0,0 +1,14 @@
-- CreateEnum
CREATE TYPE "FundingStrategy" AS ENUM ('tight', 'moderate', 'comfortable');
-- CreateEnum
CREATE TYPE "IncomeFrequency" AS ENUM ('weekly', 'biweekly', 'monthly', 'irregular');
-- AlterTable
ALTER TABLE "FixedPlan" ADD COLUMN "currentFundedCents" BIGINT NOT NULL DEFAULT 0,
ADD COLUMN "lastFundingDate" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "User" ADD COLUMN "fundingStrategy" "FundingStrategy" NOT NULL DEFAULT 'moderate',
ADD COLUMN "incomeFrequency" "IncomeFrequency" NOT NULL DEFAULT 'irregular',
ADD COLUMN "typicalIncomeCents" BIGINT;

View File

@@ -0,0 +1,26 @@
/*
Warnings:
- The values [irregular] on the enum `IncomeFrequency` will be removed. If these variants are still used in the database, this will fail.
- You are about to drop the column `fundingStrategy` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `typicalIncomeCents` on the `User` table. All the data in the column will be lost.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "IncomeFrequency_new" AS ENUM ('weekly', 'biweekly', 'monthly');
ALTER TABLE "User" ALTER COLUMN "incomeFrequency" DROP DEFAULT;
ALTER TABLE "User" ALTER COLUMN "incomeFrequency" TYPE "IncomeFrequency_new" USING ("incomeFrequency"::text::"IncomeFrequency_new");
ALTER TYPE "IncomeFrequency" RENAME TO "IncomeFrequency_old";
ALTER TYPE "IncomeFrequency_new" RENAME TO "IncomeFrequency";
DROP TYPE "IncomeFrequency_old";
ALTER TABLE "User" ALTER COLUMN "incomeFrequency" SET DEFAULT 'biweekly';
COMMIT;
-- AlterTable
ALTER TABLE "User" DROP COLUMN "fundingStrategy",
DROP COLUMN "typicalIncomeCents",
ALTER COLUMN "incomeFrequency" SET DEFAULT 'biweekly';
-- DropEnum
DROP TYPE "FundingStrategy";

View File

@@ -0,0 +1,32 @@
-- CreateEnum
CREATE TYPE "IncomeType" AS ENUM ('regular', 'irregular');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "budgetPeriod" TEXT NOT NULL DEFAULT 'monthly',
ADD COLUMN "incomeType" "IncomeType" NOT NULL DEFAULT 'regular',
ADD COLUMN "totalBudgetCents" BIGINT;
-- CreateTable
CREATE TABLE "BudgetSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"totalBudgetCents" BIGINT NOT NULL,
"allocatedCents" BIGINT NOT NULL DEFAULT 0,
"fundedCents" BIGINT NOT NULL DEFAULT 0,
"availableCents" BIGINT NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BudgetSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BudgetSession_userId_periodStart_idx" ON "BudgetSession"("userId", "periodStart");
-- CreateIndex
CREATE UNIQUE INDEX "BudgetSession_userId_periodStart_key" ON "BudgetSession"("userId", "periodStart");
-- AddForeignKey
ALTER TABLE "BudgetSession" ADD CONSTRAINT "BudgetSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "FixedPlan" ADD COLUMN "lastFundedPayPeriod" TIMESTAMP(3),
ADD COLUMN "needsFundingThisPeriod" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "firstIncomeDate" TIMESTAMP(3);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "VariableCategory" ADD COLUMN "isLocked" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `isLocked` on the `VariableCategory` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "VariableCategory" DROP COLUMN "isLocked";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "FixedPlan" ADD COLUMN "frequency" TEXT;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "IncomeEvent" ADD COLUMN "isScheduledIncome" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "pendingScheduledIncome" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'America/New_York';

View File

@@ -0,0 +1,4 @@
-- Add overdue tracking fields to FixedPlan
ALTER TABLE "FixedPlan" ADD COLUMN "isOverdue" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "FixedPlan" ADD COLUMN "overdueAmount" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "FixedPlan" ADD COLUMN "overdueSince" TIMESTAMP(3);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Transaction" ADD COLUMN "isAutoPayment" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "fixedExpensePercentage" INTEGER NOT NULL DEFAULT 40;

View File

@@ -9,16 +9,40 @@ datasource db {
url = env("DATABASE_URL")
}
enum IncomeFrequency {
weekly
biweekly
monthly
}
enum IncomeType {
regular
irregular
}
model User {
id String @id @default(uuid())
email String @unique
createdAt DateTime @default(now())
id String @id @default(uuid())
email String @unique
passwordHash String?
displayName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
incomeFrequency IncomeFrequency @default(biweekly)
incomeType IncomeType @default(regular)
firstIncomeDate DateTime? // Track when user's first income was received for accurate pay period calculation
pendingScheduledIncome Boolean @default(false) // Flag set when user dismisses payday overlay, cleared when paycheck is entered
timezone String @default("America/New_York") // IANA timezone identifier for accurate date calculations
fixedExpensePercentage Int @default(40) // Irregular income: percent of new income to auto-fund fixed expenses
totalBudgetCents BigInt?
budgetPeriod String @default("monthly")
variableCategories VariableCategory[]
fixedPlans FixedPlan[]
incomes IncomeEvent[]
allocations Allocation[]
transactions Transaction[]
budgetSessions BudgetSession[]
}
model VariableCategory {
@@ -30,6 +54,8 @@ model VariableCategory {
priority Int @default(100)
isSavings Boolean @default(false)
balanceCents BigInt @default(0)
savingsTargetCents BigInt?
transactions Transaction[] @relation("TransactionCategory")
@@unique([userId, name])
@@index([userId, priority])
@@ -44,9 +70,31 @@ model FixedPlan {
dueOn DateTime
totalCents BigInt
fundedCents BigInt @default(0)
currentFundedCents BigInt @default(0)
priority Int @default(100)
fundingMode String @default("auto-on-deposit")
scheduleJson Json?
periodDays Int @default(30)
frequency String? // "one-time", "weekly", "biweekly", "monthly"
autoRollover Boolean @default(true)
lastRollover DateTime?
lastFundingDate DateTime?
lastFundedPayPeriod DateTime? // Track when plan was last funded in a pay period
needsFundingThisPeriod Boolean @default(true) // Simple flag to track funding needs
// Auto-payment fields
autoPayEnabled Boolean @default(false)
paymentSchedule Json? // { frequency: "monthly", dayOfMonth: 1, minFundingPercent: 100 }
nextPaymentDate DateTime?
lastAutoPayment DateTime?
maxRetryAttempts Int @default(3)
// Overdue tracking fields
isOverdue Boolean @default(false)
overdueAmount BigInt @default(0)
overdueSince DateTime?
transactions Transaction[] @relation("TransactionPlan")
@@unique([userId, name])
@@index([userId, dueOn])
@@ -59,6 +107,8 @@ model IncomeEvent {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
postedAt DateTime
amountCents BigInt
note String?
isScheduledIncome Boolean @default(false) // True if this is a regular paycheck (vs bonus/extra income)
allocations Allocation[]
@@index([userId, postedAt])
@@ -83,8 +133,31 @@ model Transaction {
occurredAt DateTime
kind String
categoryId String?
category VariableCategory? @relation("TransactionCategory", fields: [categoryId], references: [id], onDelete: SetNull)
planId String?
plan FixedPlan? @relation("TransactionPlan", fields: [planId], references: [id], onDelete: SetNull)
amountCents BigInt
note String?
receiptUrl String?
isAutoPayment Boolean @default(false)
isReconciled Boolean @default(false)
@@index([userId, occurredAt])
}
model BudgetSession {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
periodStart DateTime
periodEnd DateTime
totalBudgetCents BigInt
allocatedCents BigInt @default(0)
fundedCents BigInt @default(0)
availableCents BigInt @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, periodStart])
@@index([userId, periodStart])
}

View File

@@ -11,22 +11,39 @@ async function main() {
// 1) User
await prisma.user.upsert({
where: { id: userId },
create: { id: userId, email: "demo@example.com" },
update: {},
create: {
id: userId,
email: "demo@example.com",
incomeFrequency: "biweekly"
},
update: { incomeFrequency: "biweekly" },
});
// 2) Variable categories (sum = 100)
const categories = [
{ name: "Savings", percent: 40, isSavings: true, priority: 10 },
{ name: "Needs", percent: 40, isSavings: false, priority: 20 },
{ name: "Wants", percent: 20, isSavings: false, priority: 30 },
{ name: "Savings", percent: 40, isSavings: true, priority: 10, target: cents(5000) },
{ name: "Needs", percent: 40, isSavings: false, priority: 20 },
{ name: "Wants", percent: 20, isSavings: false, priority: 30 },
];
for (const c of categories) {
await prisma.variableCategory.upsert({
where: { userId_name: { userId, name: c.name } },
create: { userId, name: c.name, percent: c.percent, isSavings: c.isSavings, priority: c.priority, balanceCents: 0n },
update: { percent: c.percent, isSavings: c.isSavings, priority: c.priority },
create: {
userId,
name: c.name,
percent: c.percent,
isSavings: c.isSavings,
priority: c.priority,
balanceCents: 0n,
savingsTargetCents: c.target ?? null,
},
update: {
percent: c.percent,
isSavings: c.isSavings,
priority: c.priority,
savingsTargetCents: c.target ?? null,
},
});
}
@@ -82,4 +99,4 @@ main().catch((e) => {
process.exit(1);
}).finally(async () => {
await prisma.$disconnect();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,62 @@
// api/src/env.ts
import { z } from "zod";
const BoolFromEnv = z
.union([z.boolean(), z.string()])
.transform((val) => {
if (typeof val === "boolean") return val;
const normalized = val.trim().toLowerCase();
return normalized === "true" || normalized === "1";
});
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(8080),
HOST: z.string().default("0.0.0.0"),
DATABASE_URL: z.string().min(1),
// Comma-separated list of allowed origins; empty => allow all (dev)
CORS_ORIGIN: z.string().optional(),
// 🔹 New: rate-limit knobs (have defaults so typing is happy)
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
JWT_SECRET: z.string().min(32),
COOKIE_SECRET: z.string().min(32),
COOKIE_DOMAIN: z.string().optional(),
AUTH_DISABLED: BoolFromEnv.optional().default(false),
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
});
export const env = Env.parse({
const rawEnv = {
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT,
HOST: process.env.HOST,
DATABASE_URL: process.env.DATABASE_URL,
CORS_ORIGIN: "http://localhost:5173,http://127.0.0.1:5173",
CORS_ORIGIN: process.env.CORS_ORIGIN,
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
});
JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me",
COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
AUTH_DISABLED: process.env.AUTH_DISABLED,
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
};
const parsed = Env.parse(rawEnv);
if (parsed.NODE_ENV === "production") {
if (!parsed.CORS_ORIGIN) {
throw new Error("CORS_ORIGIN must be set in production.");
}
if (rawEnv.AUTH_DISABLED && parsed.AUTH_DISABLED) {
throw new Error("AUTH_DISABLED cannot be enabled in production.");
}
if (parsed.SEED_DEFAULT_BUDGET) {
throw new Error("SEED_DEFAULT_BUDGET must be disabled in production.");
}
if (parsed.JWT_SECRET.includes("dev-jwt-secret-change-me")) {
throw new Error("JWT_SECRET must be set to a strong value in production.");
}
if (parsed.COOKIE_SECRET.includes("dev-cookie-secret-change-me")) {
throw new Error("COOKIE_SECRET must be set to a strong value in production.");
}
}
export const env = parsed;

View File

@@ -0,0 +1,259 @@
import { PrismaClient, Prisma } from "@prisma/client";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
const DAY_MS = 24 * 60 * 60 * 1000;
export type PaymentSchedule = {
frequency: "monthly" | "weekly" | "biweekly" | "daily" | "custom";
dayOfMonth?: number; // For monthly (1-31)
dayOfWeek?: number; // For weekly/biweekly (0=Sunday, 6=Saturday)
everyNDays?: number; // For custom cadence (fallback to periodDays)
minFundingPercent: number; // 0-100, minimum funding required before auto-pay
};
export type AutoPaymentReport = {
planId: string;
userId: string;
name: string;
paymentAmountCents: number;
success: boolean;
error?: string;
retryCount: number;
nextRetryDate?: string;
};
const isProd = process.env.NODE_ENV === "production";
/**
* Check if auto-payment should run for a user based on their timezone
* Auto-payment runs at 9 AM in the user's timezone
*/
function shouldProcessPaymentForUser(userTimezone: string, asOf: Date): boolean {
const zonedTime = toZonedTime(asOf, userTimezone);
const hour = zonedTime.getHours();
// Process if we're past 9 AM in user's timezone
return hour >= 9;
}
/**
* Process auto-scheduled payments for fixed plans
*/
export async function processAutoPayments(
prisma: PrismaClient,
asOfInput?: Date | string,
{ dryRun = false }: { dryRun?: boolean } = {}
): Promise<AutoPaymentReport[]> {
const asOf = asOfInput ? new Date(asOfInput) : new Date();
// Find plans with auto-payments enabled and due for payment
const candidates = await prisma.fixedPlan.findMany({
where: {
autoPayEnabled: true,
nextPaymentDate: { lte: asOf },
paymentSchedule: { not: Prisma.DbNull },
},
orderBy: { nextPaymentDate: "asc" },
select: {
id: true,
userId: true,
name: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
paymentSchedule: true,
nextPaymentDate: true,
lastAutoPayment: true,
maxRetryAttempts: true,
periodDays: true,
user: {
select: {
timezone: true,
},
},
},
});
const reports: AutoPaymentReport[] = [];
for (const plan of candidates) {
// Check if it's time for auto-payment in this user's timezone
const userTimezone = plan.user.timezone ?? "America/New_York";
if (!shouldProcessPaymentForUser(userTimezone, asOf)) {
if (!isProd) {
console.log(
`[auto-payment] Skipping plan ${plan.id} for user ${plan.userId} - not yet 9 AM in ${userTimezone}`
);
}
continue;
}
const schedule = plan.paymentSchedule as PaymentSchedule | null;
if (!schedule) continue;
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const fundingPercent = total > 0 ? (funded / total) * 100 : 0;
const minFunding = schedule.minFundingPercent ?? 100;
// Check if plan meets minimum funding requirement
if (fundingPercent < minFunding) {
reports.push({
planId: plan.id,
userId: plan.userId,
name: plan.name,
paymentAmountCents: 0,
success: false,
error: `Insufficient funding: ${fundingPercent.toFixed(1)}% < ${minFunding}%`,
retryCount: 0,
});
// Schedule next retry (1 day later)
const nextRetry = new Date(asOf.getTime() + DAY_MS);
if (!dryRun) {
await prisma.fixedPlan.update({
where: { id: plan.id },
data: { nextPaymentDate: nextRetry },
});
}
continue;
}
// Calculate payment amount (use full funded amount)
const paymentAmount = funded;
try {
if (!dryRun) {
// Create the payment transaction
await prisma.$transaction(async (tx) => {
// Create fixed_payment transaction
await tx.transaction.create({
data: {
userId: plan.userId,
kind: "fixed_payment",
amountCents: BigInt(paymentAmount),
occurredAt: asOf,
planId: plan.id,
note: `Auto-payment (${schedule.frequency})`,
isAutoPayment: true,
},
});
// Update plan funding
await tx.fixedPlan.update({
where: { id: plan.id },
data: {
fundedCents: BigInt(funded - paymentAmount),
currentFundedCents: BigInt(funded - paymentAmount),
lastAutoPayment: asOf,
nextPaymentDate: calculateNextPaymentDate(asOf, schedule, plan.periodDays, userTimezone),
},
});
});
}
reports.push({
planId: plan.id,
userId: plan.userId,
name: plan.name,
paymentAmountCents: paymentAmount,
success: true,
retryCount: 0,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
reports.push({
planId: plan.id,
userId: plan.userId,
name: plan.name,
paymentAmountCents: paymentAmount,
success: false,
error: errorMessage,
retryCount: 0,
});
// Schedule retry (1 hour later)
const nextRetry = new Date(asOf.getTime() + 60 * 60 * 1000);
if (!dryRun) {
await prisma.fixedPlan.update({
where: { id: plan.id },
data: { nextPaymentDate: nextRetry },
});
}
}
}
return reports;
}
/**
* Calculate the next payment date based on schedule
*/
export function calculateNextPaymentDate(
currentDate: Date,
schedule: PaymentSchedule,
periodDays: number,
timezone: string
): Date {
const next = toZonedTime(currentDate, timezone);
const hours = next.getUTCHours();
const minutes = next.getUTCMinutes();
const seconds = next.getUTCSeconds();
const ms = next.getUTCMilliseconds();
switch (schedule.frequency) {
case "daily":
next.setUTCDate(next.getUTCDate() + 1);
break;
case "weekly":
// Move to next occurrence of specified day of week
{
const targetDay = schedule.dayOfWeek ?? 0;
const currentDay = next.getUTCDay();
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7));
}
break;
case "biweekly":
{
const targetDay = schedule.dayOfWeek ?? next.getUTCDay();
const currentDay = next.getUTCDay();
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
// ensure at least one full week gap to make it biweekly
daysUntilTarget = daysUntilTarget === 0 ? 14 : daysUntilTarget + 7;
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
}
break;
case "monthly":
{
const targetDay = schedule.dayOfMonth ?? next.getUTCDate();
// Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months.
next.setUTCDate(1);
next.setUTCMonth(next.getUTCMonth() + 1);
const lastDay = getLastDayOfMonth(next);
next.setUTCDate(Math.min(targetDay, lastDay));
}
break;
case "custom":
{
const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays;
next.setUTCDate(next.getUTCDate() + days);
}
break;
default:
// Fallback to periodDays
next.setUTCDate(next.getUTCDate() + periodDays);
}
next.setUTCHours(hours, minutes, seconds, ms);
return fromZonedTime(next, timezone);
}
function getLastDayOfMonth(date: Date): number {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
}

129
api/src/jobs/rollover.ts Normal file
View File

@@ -0,0 +1,129 @@
import { PrismaClient } from "@prisma/client";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js";
const addDaysInTimezone = (date: Date, days: number, timezone: string) => {
const zoned = toZonedTime(date, timezone);
zoned.setUTCDate(zoned.getUTCDate() + days);
zoned.setUTCHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
};
export type RolloverReport = {
planId: string;
userId: string;
name: string;
cyclesAdvanced: number;
deficitCents: number;
carryForwardCents: number;
nextDueOn: string;
};
const isProd = process.env.NODE_ENV === "production";
/**
* Check if rollover should run for a user based on their timezone
* Rollover runs at 6 AM in the user's timezone
*/
function shouldProcessRolloverForUser(userTimezone: string, asOf: Date): boolean {
const zonedTime = toZonedTime(asOf, userTimezone);
const hour = zonedTime.getHours();
// Process if we're past 6 AM in user's timezone
return hour >= 6;
}
export async function rolloverFixedPlans(
prisma: PrismaClient,
asOfInput?: Date | string,
{ dryRun = false }: { dryRun?: boolean } = {}
): Promise<RolloverReport[]> {
const asOf = asOfInput ? new Date(asOfInput) : new Date();
// First, get all candidate plans with user timezone
const candidates = await prisma.fixedPlan.findMany({
where: {
autoRollover: true,
periodDays: { gt: 0 },
dueOn: { lte: asOf },
},
orderBy: { dueOn: "asc" },
select: {
id: true,
userId: true,
name: true,
dueOn: true,
cycleStart: true,
periodDays: true,
totalCents: true,
fundedCents: true,
user: {
select: {
timezone: true,
},
},
},
});
const reports: RolloverReport[] = [];
for (const plan of candidates) {
// Check if it's time for rollover in this user's timezone
const userTimezone = plan.user.timezone ?? "America/New_York";
if (!shouldProcessRolloverForUser(userTimezone, asOf)) {
if (!isProd) {
console.log(
`[rollover] Skipping plan ${plan.id} for user ${plan.userId} - not yet 6 AM in ${userTimezone}`
);
}
continue;
}
const asOfUser = getUserMidnight(userTimezone, asOf);
let dueOn = getUserMidnightFromDateOnly(userTimezone, plan.dueOn);
let cycleStart = getUserMidnightFromDateOnly(userTimezone, plan.cycleStart);
let funded = plan.fundedCents ?? 0n;
const total = plan.totalCents ?? 0n;
const period = Math.max(plan.periodDays ?? 30, 1);
let cycles = 0;
let finalDeficit = 0n;
let finalCarry = 0n;
while (dueOn <= asOfUser) {
const deficit = funded < total ? total - funded : 0n;
const carry = funded > total ? funded - total : 0n;
finalDeficit = deficit;
finalCarry = carry;
funded = carry;
cycleStart = dueOn;
dueOn = addDaysInTimezone(dueOn, period, userTimezone);
cycles += 1;
}
if (cycles === 0) continue;
reports.push({
planId: plan.id,
userId: plan.userId,
name: plan.name,
cyclesAdvanced: cycles,
deficitCents: Number(finalDeficit),
carryForwardCents: Number(finalCarry),
nextDueOn: dueOn.toISOString(),
});
if (!dryRun) {
await prisma.fixedPlan.update({
where: { id: plan.id },
data: {
fundedCents: funded,
cycleStart,
dueOn,
lastRollover: asOf,
needsFundingThisPeriod: true, // Reset flag for new cycle
},
});
}
}
return reports;
}

View File

@@ -1,23 +0,0 @@
import fp from "fastify-plugin";
import { prisma } from "../prisma.js";
declare module "fastify" {
interface FastifyRequest {
userId: string;
}
}
export default fp(async (app) => {
app.addHook("onRequest", async (req) => {
// Dev-only stub: use header if provided, else default
const hdr = req.headers["x-user-id"];
req.userId = typeof hdr === "string" && hdr.trim() ? hdr.trim() : "demo-user-1";
// Ensure the user exists (avoids FK P2003 on first write)
await prisma.user.upsert({
where: { id: req.userId },
update: {},
create: { id: req.userId, email: `${req.userId}@demo.local` },
});
});
});

View File

@@ -1,6 +1,15 @@
import { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { prisma } from "../prisma.js";
import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
const PaymentSchedule = z.object({
frequency: z.enum(["monthly", "weekly", "biweekly", "daily"]),
dayOfMonth: z.number().int().min(1).max(31).optional(),
dayOfWeek: z.number().int().min(0).max(6).optional(),
minFundingPercent: z.number().min(0).max(100).default(100),
});
const NewPlan = z.object({
name: z.string().min(1).max(120),
@@ -8,6 +17,9 @@ const NewPlan = z.object({
fundedCents: z.number().int().min(0).default(0),
priority: z.number().int().min(0).max(10_000),
dueOn: z.string().datetime(), // ISO
frequency: z.enum(["one-time", "weekly", "biweekly", "monthly"]).optional(),
autoPayEnabled: z.boolean().default(false),
paymentSchedule: PaymentSchedule.optional(),
});
const PatchPlan = NewPlan.partial();
const IdParam = z.object({ id: z.string().min(1) });
@@ -22,26 +34,81 @@ function validateFunding(total: bigint, funded: bigint) {
}
}
function calculateNextPaymentDate(dueDate: Date, schedule: any, timezone: string): Date {
const base = getUserMidnightFromDateOnly(timezone, dueDate);
const next = toZonedTime(base, timezone);
switch (schedule.frequency) {
case "daily":
next.setUTCDate(next.getUTCDate() + 1);
break;
case "weekly": {
const targetDay = schedule.dayOfWeek ?? 0;
const currentDay = next.getUTCDay();
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7));
break;
}
case "monthly": {
const targetDay = schedule.dayOfMonth ?? next.getUTCDate();
const nextMonth = next.getUTCMonth() + 1;
const nextYear = next.getUTCFullYear() + Math.floor(nextMonth / 12);
const nextMonthIndex = nextMonth % 12;
const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate();
next.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay));
break;
}
default:
next.setUTCDate(next.getUTCDate() + 30); // fallback
}
next.setUTCHours(0, 0, 0, 0);
return fromZonedTime(next, timezone);
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
app.post("/api/fixed-plans", async (req, reply) => {
app.post("/fixed-plans", async (req, reply) => {
const userId = req.userId;
const parsed = NewPlan.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ error: "INVALID_BODY", details: parsed.error.flatten() });
const userTimezone =
(await prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const totalBI = bi(parsed.data.totalCents);
const fundedBI = bi(parsed.data.fundedCents);
validateFunding(totalBI, fundedBI);
// Calculate next payment date if auto-pay is enabled
const nextPaymentDate = parsed.data.autoPayEnabled && parsed.data.paymentSchedule
? calculateNextPaymentDate(new Date(parsed.data.dueOn), parsed.data.paymentSchedule, userTimezone)
: null;
// Extract frequency from explicit field or paymentSchedule
let frequency = parsed.data.frequency;
if (!frequency && parsed.data.paymentSchedule?.frequency) {
const scheduleFreq = parsed.data.paymentSchedule.frequency;
if (scheduleFreq === "monthly" || scheduleFreq === "weekly" || scheduleFreq === "biweekly") {
frequency = scheduleFreq;
}
}
const rec = await prisma.fixedPlan.create({
data: {
userId,
name: parsed.data.name,
priority: parsed.data.priority,
dueOn: new Date(parsed.data.dueOn),
dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn)),
frequency: frequency || null,
totalCents: totalBI,
fundedCents: fundedBI,
cycleStart: new Date(), // required by your schema
currentFundedCents: fundedBI,
cycleStart: getUserMidnight(userTimezone, new Date()), // required by your schema
autoPayEnabled: parsed.data.autoPayEnabled ?? false,
paymentSchedule: parsed.data.paymentSchedule || undefined,
nextPaymentDate,
lastFundingDate: fundedBI > 0 ? new Date() : null,
},
select: { id: true },
});
@@ -49,12 +116,15 @@ const plugin: FastifyPluginAsync = async (app) => {
});
// UPDATE
app.patch("/api/fixed-plans/:id", async (req, reply) => {
app.patch("/fixed-plans/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
const patch = PatchPlan.safeParse(req.body);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
const userTimezone =
(await prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } });
if (!existing) return reply.status(404).send({ error: "NOT_FOUND" });
@@ -63,22 +133,43 @@ const plugin: FastifyPluginAsync = async (app) => {
const nextFunded = patch.data.fundedCents !== undefined ? bi(patch.data.fundedCents) : (existing.fundedCents as bigint);
validateFunding(nextTotal, nextFunded);
await prisma.fixedPlan.update({
where: { id: pid.data.id },
// Calculate next payment date if auto-pay settings changed
const nextPaymentDate = (patch.data.autoPayEnabled !== undefined || patch.data.paymentSchedule !== undefined)
? ((patch.data.autoPayEnabled ?? existing.autoPayEnabled) && (patch.data.paymentSchedule ?? existing.paymentSchedule))
? calculateNextPaymentDate(
patch.data.dueOn ? new Date(patch.data.dueOn) : existing.dueOn,
patch.data.paymentSchedule ?? existing.paymentSchedule,
userTimezone
)
: null
: undefined;
const updated = await prisma.fixedPlan.updateMany({
where: { id: pid.data.id, userId },
data: {
...(patch.data.name !== undefined ? { name: patch.data.name } : null),
...(patch.data.priority !== undefined ? { priority: patch.data.priority } : null),
...(patch.data.dueOn !== undefined ? { dueOn: new Date(patch.data.dueOn) } : null),
...(patch.data.dueOn !== undefined ? { dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.dueOn)) } : null),
...(patch.data.totalCents !== undefined ? { totalCents: bi(patch.data.totalCents) } : null),
...(patch.data.fundedCents !== undefined ? { fundedCents: bi(patch.data.fundedCents) } : null),
...(patch.data.fundedCents !== undefined
? {
fundedCents: bi(patch.data.fundedCents),
currentFundedCents: bi(patch.data.fundedCents),
lastFundingDate: new Date(),
}
: null),
...(patch.data.autoPayEnabled !== undefined ? { autoPayEnabled: patch.data.autoPayEnabled } : null),
...(patch.data.paymentSchedule !== undefined ? { paymentSchedule: patch.data.paymentSchedule } : null),
...(nextPaymentDate !== undefined ? { nextPaymentDate } : null),
},
});
if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
return reply.send({ ok: true });
});
// DELETE
app.delete("/api/fixed-plans/:id", async (req, reply) => {
app.delete("/fixed-plans/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
@@ -86,9 +177,10 @@ const plugin: FastifyPluginAsync = async (app) => {
const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } });
if (!existing) return reply.status(404).send({ error: "NOT_FOUND" });
await prisma.fixedPlan.delete({ where: { id: pid.data.id } });
const deleted = await prisma.fixedPlan.deleteMany({ where: { id: pid.data.id, userId } });
if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
return reply.send({ ok: true });
});
};
export default plugin;
export default plugin;

View File

@@ -1,82 +1,98 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { previewAllocation, previewIrregularAllocation } from "../allocator.js";
const Body = z.object({ amountCents: z.number().int().nonnegative() });
const Body = z.object({
amountCents: z.number().int().nonnegative(),
occurredAtISO: z.string().datetime().optional(),
});
export default async function incomePreviewRoutes(app: FastifyInstance) {
type PlanPreview = {
id: string;
name: string;
dueOn: Date;
totalCents: number;
fundedCents: number;
remainingCents: number;
daysUntilDue: number;
allocatedThisRun: number;
isCrisis: boolean;
};
type PreviewResult = {
fixedAllocations: Array<{ fixedPlanId: string; amountCents: number; source?: string }>;
variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>;
planStatesAfter: PlanPreview[];
availableBudgetAfterCents: number;
remainingUnallocatedCents: number;
crisis: { active: boolean; plans: Array<{ id: string; name: string; remainingCents: number; daysUntilDue: number; priority: number; allocatedCents: number }> };
};
app.post("/income/preview", async (req, reply) => {
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
const userId = req.userId;
let remaining = Math.max(0, parsed.data.amountCents | 0);
const user = await app.prisma.user.findUnique({
where: { id: userId },
select: { incomeType: true, fixedExpensePercentage: true },
});
const [plans, cats] = await Promise.all([
app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true },
}),
app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
}),
]);
let result: PreviewResult;
// Fixed pass
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
for (const p of plans) {
if (remaining <= 0) break;
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
const need = Number(needBig > 0n ? needBig : 0n);
if (need <= 0) continue;
const give = Math.min(need, remaining);
fixed.push({ id: p.id, name: p.name, amountCents: give });
remaining -= give;
if (user?.incomeType === "irregular") {
const rawResult = await previewIrregularAllocation(
app.prisma,
userId,
parsed.data.amountCents,
user.fixedExpensePercentage ?? 40,
parsed.data.occurredAtISO
);
result = {
fixedAllocations: rawResult.fixedAllocations,
variableAllocations: rawResult.variableAllocations,
planStatesAfter: rawResult.planStatesAfter,
availableBudgetAfterCents: rawResult.availableBudgetCents,
remainingUnallocatedCents: rawResult.remainingBudgetCents,
crisis: rawResult.crisis,
};
} else {
const rawResult = await previewAllocation(
app.prisma,
userId,
parsed.data.amountCents,
parsed.data.occurredAtISO
);
result = {
fixedAllocations: rawResult.fixedAllocations,
variableAllocations: rawResult.variableAllocations,
planStatesAfter: rawResult.planStatesAfter,
availableBudgetAfterCents: rawResult.availableBudgetAfterCents,
remainingUnallocatedCents: rawResult.remainingUnallocatedCents,
crisis: rawResult.crisis,
};
}
// Variable pass — largest remainder with savings-first tiebreak
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
if (remaining > 0 && cats.length > 0) {
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
const normCats =
totalPercent === 100
? cats
: cats.map((c) => ({
...c,
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
}));
const fixedPreview = result.planStatesAfter.map((p) => ({
id: p.id,
name: p.name,
dueOn: p.dueOn.toISOString(),
totalCents: p.totalCents,
fundedCents: p.fundedCents,
remainingCents: p.remainingCents,
daysUntilDue: p.daysUntilDue,
allocatedThisRun: p.allocatedThisRun,
isCrisis: p.isCrisis,
}));
const base: number[] = new Array(normCats.length).fill(0);
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
let sumBase = 0;
normCats.forEach((c, idx) => {
const exact = (remaining * c.percent) / 100;
const floor = Math.floor(exact);
base[idx] = floor;
sumBase += floor;
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
});
let leftovers = remaining - sumBase;
tie.sort((a, b) => {
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
normCats.forEach((c, idx) => {
const give = base[idx] || 0;
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
});
remaining = leftovers;
}
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
return {
fixedAllocations: result.fixedAllocations,
variableAllocations: result.variableAllocations,
fixedPreview,
availableBudgetAfterCents: result.availableBudgetAfterCents,
crisis: result.crisis,
unallocatedCents: result.remainingUnallocatedCents,
};
});
}
}

View File

@@ -1,6 +1,7 @@
// api/src/routes/transactions.ts
import fp from "fastify-plugin";
import { z } from "zod";
import { getUserDateRangeFromDateOnly } from "../allocator.js";
const Query = z.object({
from: z
@@ -19,10 +20,13 @@ const Query = z.object({
export default fp(async function transactionsRoute(app) {
app.get("/transactions", async (req, reply) => {
const userId =
typeof req.userId === "string"
? req.userId
: String(req.userId ?? "demo-user-1");
if (typeof req.userId !== "string") {
return reply.code(401).send({ message: "Unauthorized" });
}
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
@@ -34,9 +38,7 @@ export default fp(async function transactionsRoute(app) {
const where: any = { userId };
if (from || to) {
where.occurredAt = {};
if (from) where.occurredAt.gte = new Date(`${from}T00:00:00.000Z`);
if (to) where.occurredAt.lte = new Date(`${to}T23:59:59.999Z`);
where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, from, to);
}
if (kind) where.kind = kind;

View File

@@ -13,6 +13,39 @@ const NewCat = z.object({
const PatchCat = NewCat.partial();
const IdParam = z.object({ id: z.string().min(1) });
function computeBalanceTargets(
categories: Array<{ id: string; percent: number }>,
totalBalance: number
) {
const percentTotal = categories.reduce((sum, c) => sum + (c.percent || 0), 0);
if (percentTotal <= 0) {
return { ok: false as const, reason: "no_percent" };
}
const targets = categories.map((cat) => {
const raw = (totalBalance * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
target: floored,
frac: raw - floored,
};
});
let remainder = totalBalance - targets.reduce((sum, t) => sum + t.target, 0);
targets
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((t) => {
if (remainder > 0) {
t.target += 1;
remainder -= 1;
}
});
return { ok: true as const, targets };
}
async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: string) {
const g = await tx.variableCategory.groupBy({
by: ["userId"],
@@ -20,66 +53,135 @@ async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: strin
_sum: { percent: true },
});
const sum = g[0]?._sum.percent ?? 0;
if (sum !== 100) {
const err: any = new Error(`Percents must sum to 100 (got ${sum}).`);
// Allow partial states during onboarding (< 100%), but enforce exact 100% when sum would be >= 100%
if (sum > 100) {
const err = new Error(`Percents cannot exceed 100 (got ${sum}).`) as any;
err.statusCode = 400;
err.code = "PERCENT_TOTAL_NOT_100";
err.code = "PERCENT_TOTAL_OVER_100";
throw err;
}
// For now, allow partial completion during onboarding
// The frontend will ensure 100% total before finishing onboarding
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
app.post("/api/variable-categories", async (req, reply) => {
app.post("/variable-categories", async (req, reply) => {
const userId = req.userId;
const body = NewCat.safeParse(req.body);
if (!body.success) return reply.status(400).send({ error: "INVALID_BODY", details: body.error.flatten() });
const created = await prisma.$transaction(async (tx) => {
const rec = await tx.variableCategory.create({
data: { ...body.data, userId },
const normalizedName = body.data.name.trim().toLowerCase();
try {
const result = await prisma.variableCategory.create({
data: { ...body.data, userId, name: normalizedName },
select: { id: true },
});
await assertPercentTotal100(tx, userId);
return rec;
});
return reply.status(201).send(created);
return reply.status(201).send(result);
} catch (error: any) {
if (error.code === 'P2002') {
return reply.status(400).send({ error: 'DUPLICATE_NAME', message: `Category name '${body.data.name}' already exists` });
}
throw error;
}
});
// UPDATE
app.patch("/api/variable-categories/:id", async (req, reply) => {
app.patch("/variable-categories/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
const patch = PatchCat.safeParse(req.body);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() });
await prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
await tx.variableCategory.update({ where: { id: pid.data.id }, data: patch.data });
await assertPercentTotal100(tx, userId);
const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
const updateData = {
...patch.data,
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
};
const updated = await prisma.variableCategory.updateMany({
where: { id: pid.data.id, userId },
data: updateData,
});
if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
return reply.send({ ok: true });
});
// DELETE
app.delete("/api/variable-categories/:id", async (req, reply) => {
app.delete("/variable-categories/:id", async (req, reply) => {
const userId = req.userId;
const pid = IdParam.safeParse(req.params);
if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() });
await prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
await tx.variableCategory.delete({ where: { id: pid.data.id } });
await assertPercentTotal100(tx, userId);
const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } });
if (!exists) return reply.status(404).send({ error: "NOT_FOUND" });
const deleted = await prisma.variableCategory.deleteMany({
where: { id: pid.data.id, userId },
});
if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
return reply.send({ ok: true });
});
// REBALANCE balances based on current percents
app.post("/variable-categories/rebalance", async (req, reply) => {
const userId = req.userId;
const categories = await prisma.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
if (categories.length === 0) {
return reply.send({ ok: true, applied: false });
}
const hasNegative = categories.some(
(c) => Number(c.balanceCents ?? 0n) < 0
);
if (hasNegative) {
return reply.code(400).send({
ok: false,
code: "NEGATIVE_BALANCE",
message: "Cannot rebalance while a category has a negative balance.",
});
}
const totalBalance = categories.reduce(
(sum, c) => sum + Number(c.balanceCents ?? 0n),
0
);
if (totalBalance <= 0) {
return reply.send({ ok: true, applied: false });
}
const targetResult = computeBalanceTargets(categories, totalBalance);
if (!targetResult.ok) {
return reply.code(400).send({
ok: false,
code: "NO_PERCENT",
message: "No percent totals available to rebalance.",
});
}
await prisma.$transaction(
targetResult.targets.map((t) =>
prisma.variableCategory.update({
where: { id: t.id },
data: { balanceCents: BigInt(t.target) },
})
)
);
return reply.send({ ok: true, applied: true, totalBalance });
});
};
export default plugin;
export default plugin;

View File

@@ -0,0 +1,73 @@
import { PrismaClient } from "@prisma/client";
function parseArgs() {
const args = process.argv.slice(2);
const parsed: Record<string, string | boolean> = {};
for (const arg of args) {
if (arg.startsWith("--")) {
const [key, value] = arg.slice(2).split("=");
parsed[key] = value === undefined ? true : value;
}
}
return parsed;
}
const prisma = new PrismaClient();
async function main() {
const args = parseArgs();
const planId = args["planId"] as string | undefined;
if (!planId) {
const plans = await prisma.fixedPlan.findMany({
orderBy: { dueOn: "asc" },
take: 10,
select: {
id: true,
userId: true,
name: true,
dueOn: true,
cycleStart: true,
periodDays: true,
totalCents: true,
fundedCents: true,
},
});
console.log(
JSON.stringify(
plans,
(_k, v) => (typeof v === "bigint" ? v.toString() : v),
2
)
);
return;
}
const data: any = {};
if (args["dueOn"]) data.dueOn = new Date(String(args["dueOn"]));
if (args["cycleStart"]) data.cycleStart = new Date(String(args["cycleStart"]));
if (args["periodDays"]) data.periodDays = Number(args["periodDays"]);
if (args["fundedCents"]) data.fundedCents = BigInt(args["fundedCents"]);
if (args["totalCents"]) data.totalCents = BigInt(args["totalCents"]);
if (Object.keys(data).length === 0) {
console.log("No fields provided to update.");
return;
}
const updated = await prisma.fixedPlan.update({
where: { id: planId },
data,
select: { id: true, name: true, dueOn: true, cycleStart: true, fundedCents: true },
});
console.log("Updated plan:", updated);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,50 @@
import { PrismaClient } from "@prisma/client";
import { rolloverFixedPlans } from "../jobs/rollover.js";
const prisma = new PrismaClient();
function parseArgs() {
const args = process.argv.slice(2);
const parsed: Record<string, string | boolean> = {};
for (const arg of args) {
if (arg.startsWith("--")) {
const [key, value] = arg.slice(2).split("=");
if (value === undefined) {
parsed[key] = true;
} else {
parsed[key] = value;
}
}
}
return parsed;
}
async function main() {
const args = parseArgs();
const asOfRaw = (args["asOf"] as string | undefined) ?? undefined;
const asOf = asOfRaw ? new Date(asOfRaw) : new Date();
const dryRun = Boolean(args["dry-run"] ?? args["dryRun"]);
const results = await rolloverFixedPlans(prisma, asOf, { dryRun });
console.log(
JSON.stringify(
{
asOf: asOf.toISOString(),
dryRun,
processed: results.length,
results,
},
null,
2
)
);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,274 @@
import { PrismaClient } from "@prisma/client";
import * as argon2 from "argon2";
const prisma = new PrismaClient();
async function setupFrontendTestUser() {
console.log("\n🔧 Setting up frontend test user...\n");
const email = "test@skymoney.com";
const password = "password123";
const today = new Date();
today.setHours(0, 0, 0, 0);
// Delete existing test user if exists
await prisma.user.deleteMany({
where: { email },
});
// Hash password
const passwordHash = await argon2.hash(password);
// Create user
const user = await prisma.user.create({
data: {
email,
passwordHash,
displayName: "Test User",
incomeType: "regular",
incomeFrequency: "biweekly",
totalBudgetCents: 200000n, // $2,000
firstIncomeDate: today,
timezone: "America/New_York",
},
});
console.log(`✅ Created user: ${user.email}`);
console.log(` User ID: ${user.id}`);
console.log(` Password: ${password}\n`);
// Create variable categories
const savings = await prisma.variableCategory.create({
data: {
userId: user.id,
name: "Savings",
percent: 30,
balanceCents: 60000n, // $600
isSavings: true,
priority: 1,
},
});
const groceries = await prisma.variableCategory.create({
data: {
userId: user.id,
name: "Groceries",
percent: 40,
balanceCents: 80000n, // $800
isSavings: false,
priority: 2,
},
});
const entertainment = await prisma.variableCategory.create({
data: {
userId: user.id,
name: "Entertainment",
percent: 30,
balanceCents: 60000n, // $600
isSavings: false,
priority: 3,
},
});
console.log(`✅ Created variable categories:`);
console.log(` - Savings: 30% ($600 balance)`);
console.log(` - Groceries: 40% ($800 balance)`);
console.log(` - Entertainment: 30% ($600 balance)\n`);
// Create fixed plans
// Scenario 1: Rent - DUE TODAY, fully funded (test payment reconciliation)
const dueToday = new Date(today);
const rent = await prisma.fixedPlan.create({
data: {
userId: user.id,
name: "Rent",
totalCents: 150000n, // $1,500
fundedCents: 150000n, // Fully funded
currentFundedCents: 150000n,
dueOn: dueToday,
frequency: "monthly",
autoPayEnabled: false,
needsFundingThisPeriod: false,
priority: 10,
cycleStart: new Date(),
},
});
console.log(`✅ Created Rent plan:`);
console.log(` - Total: $1,500`);
console.log(` - Funded: $1,500 (100%)`);
console.log(` - Due: TODAY (${dueToday.toDateString()})`);
console.log(` - Status: Ready for payment reconciliation!\n`);
// Scenario 2: Car Insurance - DUE TODAY, partially funded (test final funding attempt)
const carInsurance = await prisma.fixedPlan.create({
data: {
userId: user.id,
name: "Car Insurance",
totalCents: 40000n, // $400
fundedCents: 25000n, // $250 funded
currentFundedCents: 25000n,
dueOn: dueToday,
frequency: "monthly",
autoPayEnabled: false,
needsFundingThisPeriod: false,
priority: 20,
cycleStart: new Date(),
},
});
console.log(`✅ Created Car Insurance plan:`);
console.log(` - Total: $400`);
console.log(` - Funded: $250 (62.5%)`);
console.log(` - Due: TODAY (${dueToday.toDateString()})`);
console.log(` - Status: Will test final funding attempt!\n`);
// Scenario 3: Phone Bill - Not due yet, partially funded
const nextWeek = new Date(today);
nextWeek.setDate(nextWeek.getDate() + 7);
const phoneBill = await prisma.fixedPlan.create({
data: {
userId: user.id,
name: "Phone Bill",
totalCents: 8000n, // $80
fundedCents: 5000n, // $50 funded
currentFundedCents: 5000n,
dueOn: nextWeek,
frequency: "monthly",
autoPayEnabled: false,
needsFundingThisPeriod: true,
priority: 30,
cycleStart: new Date(),
},
});
console.log(`✅ Created Phone Bill plan:`);
console.log(` - Total: $80`);
console.log(` - Funded: $50 (62.5%)`);
console.log(` - Due: Next week (${nextWeek.toDateString()})\n`);
// Create income event and allocations
const incomeEvent = await prisma.incomeEvent.create({
data: {
userId: user.id,
amountCents: 200000n, // $2,000
postedAt: new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
isScheduledIncome: true,
},
});
console.log(`✅ Created income event:`);
console.log(` - Amount: $2,000`);
console.log(` - Posted: 2 days ago\n`);
// Create allocations (showing where the money went)
await prisma.allocation.create({
data: {
userId: user.id,
kind: "fixed",
toId: rent.id,
amountCents: 150000n,
incomeId: incomeEvent.id,
},
});
await prisma.allocation.create({
data: {
userId: user.id,
kind: "fixed",
toId: carInsurance.id,
amountCents: 25000n,
incomeId: incomeEvent.id,
},
});
await prisma.allocation.create({
data: {
userId: user.id,
kind: "fixed",
toId: phoneBill.id,
amountCents: 5000n,
incomeId: incomeEvent.id,
},
});
await prisma.allocation.create({
data: {
userId: user.id,
kind: "variable",
toId: savings.id,
amountCents: 6000n,
incomeId: incomeEvent.id,
},
});
await prisma.allocation.create({
data: {
userId: user.id,
kind: "variable",
toId: groceries.id,
amountCents: 8000n,
incomeId: incomeEvent.id,
},
});
await prisma.allocation.create({
data: {
userId: user.id,
kind: "variable",
toId: entertainment.id,
amountCents: 6000n,
incomeId: incomeEvent.id,
},
});
console.log(`✅ Created allocations (total $2,000 distributed)\n`);
// Calculate available budget
const totalIncome = 200000;
const totalAllocated = 150000 + 25000 + 5000 + 6000 + 8000 + 6000;
const availableBudget = totalIncome - totalAllocated;
console.log(`📊 Budget Summary:`);
console.log(` - Total Income: $2,000`);
console.log(` - Total Allocated: $${totalAllocated / 100}`);
console.log(` - Available Budget: $${availableBudget / 100}\n`);
console.log(`🎯 TEST SCENARIOS:\n`);
console.log(`1⃣ RENT - Payment Reconciliation Modal:`);
console.log(` - Navigate to Dashboard`);
console.log(` - Should see "Rent" with 100% funded, due TODAY`);
console.log(` - Modal should ask: "Was the full amount ($1,500) paid?"`);
console.log(` - Test: Click "Yes, Full Amount" → Should create transaction & rollover\n`);
console.log(`2⃣ CAR INSURANCE - Attempt Final Funding:`);
console.log(` - Should see "Car Insurance" with 62.5% funded, due TODAY`);
console.log(` - Modal should attempt to fund from available budget first`);
console.log(` - Available: $0, Needed: $150`);
console.log(` - Should mark as OVERDUE with $150 remaining`);
console.log(` - Modal should show: "Could not fully fund. $250/$400 funded."`);
console.log(` - Test: Click "Partial: $100" → Should refund $150 to available\n`);
console.log(`3⃣ PHONE BILL - Regular funding:`);
console.log(` - Due next week (not yet showing payment modal)`);
console.log(` - Add income to test allocation to overdue bills\n`);
console.log(`📧 LOGIN CREDENTIALS:`);
console.log(` Email: ${email}`);
console.log(` Password: ${password}\n`);
console.log(`🌐 Frontend URL: http://localhost:5174\n`);
await prisma.$disconnect();
}
setupFrontendTestUser()
.catch((e) => {
console.error("❌ Error:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,266 @@
#!/usr/bin/env tsx
import { PrismaClient } from "@prisma/client";
import * as argon2 from "argon2";
import { addDays, startOfDay } from "date-fns";
const prisma = new PrismaClient();
const PASSWORD = "password";
type Scenario = {
name: string;
timezone: string;
incomeFrequency: "weekly" | "biweekly" | "monthly";
firstIncomeDate: Date;
incomeCents: number;
variableCats: Array<{ name: string; percent: number; isSavings?: boolean }>;
fixedPlans: Array<{
name: string;
totalCents: number;
fundedCents: number;
dueOn: Date;
frequency: "monthly" | "weekly" | "biweekly";
autoPayEnabled?: boolean;
paymentSchedule?: Record<string, any>;
isOverdue?: boolean;
overdueAmount?: number;
overdueSince?: Date | null;
}>;
};
function zonedStartOfDay(date: Date, timeZone: string) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const getPart = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
const year = Number(getPart("year"));
const month = Number(getPart("month"));
const day = Number(getPart("day"));
const utcMidnight = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
const tzDateStr = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(utcMidnight);
const [mdy, hms] = tzDateStr.split(", ");
const [mm, dd, yyyy] = mdy.split("/");
const [hh, mi, ss] = hms.split(":");
const tzAsUtc = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(mi), Number(ss));
const offsetMs = tzAsUtc - utcMidnight.getTime();
return new Date(utcMidnight.getTime() - offsetMs);
}
async function createScenario(s: Scenario) {
const timestamp = Date.now();
const email = `test-dashboard-${s.name.toLowerCase().replace(/\s+/g, "-")}-${timestamp}@test.com`;
const hashed = await argon2.hash(PASSWORD);
const user = await prisma.user.create({
data: {
email,
passwordHash: hashed,
timezone: s.timezone,
incomeType: "regular",
incomeFrequency: s.incomeFrequency,
firstIncomeDate: s.firstIncomeDate,
totalBudgetCents: BigInt(0),
},
});
const cats = [];
for (const cat of s.variableCats) {
const created = await prisma.variableCategory.create({
data: {
userId: user.id,
name: cat.name,
percent: cat.percent,
isSavings: !!cat.isSavings,
balanceCents: 0n,
},
});
cats.push(created);
}
// Seed income and allocate to variable balances
await prisma.incomeEvent.create({
data: {
userId: user.id,
amountCents: BigInt(s.incomeCents),
postedAt: startOfDay(new Date()),
note: "Seed income",
},
});
const catTotalPercent = s.variableCats.reduce((sum, c) => sum + c.percent, 0);
for (const cat of cats) {
const percent = s.variableCats.find((c) => c.name === cat.name)?.percent ?? 0;
const share = Math.floor((s.incomeCents * percent) / catTotalPercent);
await prisma.allocation.create({
data: {
userId: user.id,
kind: "variable",
toId: cat.id,
amountCents: BigInt(share),
},
});
await prisma.variableCategory.update({
where: { id: cat.id },
data: { balanceCents: BigInt(share) },
});
}
for (const plan of s.fixedPlans) {
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: plan.name,
totalCents: BigInt(plan.totalCents),
fundedCents: BigInt(plan.fundedCents),
currentFundedCents: BigInt(plan.fundedCents),
cycleStart: startOfDay(new Date()),
dueOn: plan.dueOn,
frequency: plan.frequency,
autoPayEnabled: plan.autoPayEnabled ?? true,
paymentSchedule: plan.paymentSchedule ?? { frequency: plan.frequency, minFundingPercent: 100 },
isOverdue: plan.isOverdue ?? false,
overdueAmount: BigInt(plan.overdueAmount ?? 0),
overdueSince: plan.overdueSince ?? null,
needsFundingThisPeriod: true,
},
});
}
return { user, email };
}
async function main() {
const today = startOfDay(new Date());
const tomorrow = addDays(today, 1);
const laToday = zonedStartOfDay(new Date(), "America/Los_Angeles");
const scenarios: Scenario[] = [
{
name: "Multi Due Same Day",
timezone: "America/Chicago",
incomeFrequency: "weekly",
firstIncomeDate: tomorrow,
incomeCents: 150000,
variableCats: [
{ name: "Savings", percent: 30, isSavings: true },
{ name: "Food", percent: 40 },
{ name: "Misc", percent: 30 },
],
fixedPlans: [
{ name: "Rent", totalCents: 120000, fundedCents: 0, dueOn: today, frequency: "monthly" },
{ name: "Phone", totalCents: 8000, fundedCents: 0, dueOn: today, frequency: "monthly" },
],
},
{
name: "Due+Overdue",
timezone: "America/Chicago",
incomeFrequency: "biweekly",
firstIncomeDate: tomorrow,
incomeCents: 80000,
variableCats: [
{ name: "Savings", percent: 20, isSavings: true },
{ name: "Food", percent: 50 },
{ name: "Gas", percent: 30 },
],
fixedPlans: [
{
name: "Insurance",
totalCents: 50000,
fundedCents: 20000,
dueOn: addDays(today, -2),
frequency: "monthly",
isOverdue: true,
overdueAmount: 30000,
overdueSince: addDays(today, -2),
},
{
name: "Utilities",
totalCents: 10000,
fundedCents: 0,
dueOn: today,
frequency: "monthly",
},
],
},
{
name: "Income+Due Same Day",
timezone: "America/Los_Angeles",
incomeFrequency: "weekly",
firstIncomeDate: laToday,
incomeCents: 120000,
variableCats: [
{ name: "Savings", percent: 30, isSavings: true },
{ name: "Food", percent: 40 },
{ name: "Misc", percent: 30 },
],
fixedPlans: [
{ name: "Car", totalCents: 40000, fundedCents: 0, dueOn: laToday, frequency: "monthly" },
],
},
{
name: "Fully Funded Due",
timezone: "America/Chicago",
incomeFrequency: "biweekly",
firstIncomeDate: tomorrow,
incomeCents: 60000,
variableCats: [
{ name: "Savings", percent: 20, isSavings: true },
{ name: "Food", percent: 40 },
{ name: "Misc", percent: 40 },
],
fixedPlans: [
{ name: "Internet", totalCents: 12000, fundedCents: 12000, dueOn: today, frequency: "monthly" },
],
},
{
name: "Partial Available",
timezone: "America/New_York",
incomeFrequency: "weekly",
firstIncomeDate: tomorrow,
incomeCents: 20000,
variableCats: [
{ name: "Savings", percent: 20, isSavings: true },
{ name: "Food", percent: 50 },
{ name: "Gas", percent: 30 },
],
fixedPlans: [
{ name: "Subscription", totalCents: 25000, fundedCents: 0, dueOn: today, frequency: "monthly" },
],
},
];
console.log("\n=== Dashboard Edge Test Setup ===\n");
for (const scenario of scenarios) {
const { user, email } = await createScenario(scenario);
console.log(`Scenario: ${scenario.name}`);
console.log(` Email: ${email}`);
console.log(` Password: ${PASSWORD}`);
console.log(` User ID: ${user.id}`);
console.log(` Timezone: ${scenario.timezone}`);
console.log(` Income: regular ${scenario.incomeFrequency}`);
console.log("");
}
console.log("Use these users to validate dashboard edge flows.");
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,204 @@
#!/usr/bin/env tsx
/**
* Test Script: Early Funding Feature
*
* This script helps test the complete payment → early funding → refunding cycle
*
* Usage:
* npx tsx src/scripts/test-early-funding.ts
*/
import { PrismaClient } from "@prisma/client";
import { randomUUID } from "crypto";
import argon2 from "argon2";
const prisma = new PrismaClient();
async function main() {
console.log("\n=== 🧪 Early Funding Feature Test ===\n");
// Step 1: Create test user with payment plan
const userId = randomUUID();
const email = `test-early-funding-${Date.now()}@test.com`;
const password = "password";
const passwordHash = await argon2.hash(password);
console.log("📝 Step 1: Creating test user with payment plan...");
await prisma.user.create({
data: {
id: userId,
email,
passwordHash,
displayName: "Test User",
incomeType: "regular",
incomeFrequency: "biweekly",
firstIncomeDate: new Date("2025-12-20"), // Next payday Dec 20
timezone: "America/Chicago",
totalBudgetCents: 300000n, // $3,000 monthly budget
},
});
console.log(`✅ Created user: ${email} (${userId})`);
console.log(` Income: Biweekly, Next payday: Dec 20, 2025\n`);
// Step 1.5: Create a variable category (needed for hasBudgetSetup)
await prisma.variableCategory.create({
data: {
id: randomUUID(),
userId,
name: "Savings",
percent: 100, // 100% of variable budget
priority: 1,
isSavings: true,
},
});
console.log("💾 Created Savings category (100% variable budget)\n");
// Step 2: Create recurring rent bill
const rentId = randomUUID();
await prisma.fixedPlan.create({
data: {
id: rentId,
userId,
name: "Rent",
totalCents: 150000, // $1,500
fundedCents: 0n,
currentFundedCents: 0n,
dueOn: new Date("2025-12-28"), // Due Dec 28
cycleStart: new Date("2025-12-01"),
frequency: "monthly",
periodDays: 30,
priority: 1,
autoRollover: true,
needsFundingThisPeriod: true, // Should be funded
paymentSchedule: {
frequency: "monthly",
dayOfMonth: 28,
minFundingPercent: 100,
},
},
});
console.log("🏠 Step 2: Created Rent bill");
console.log(` Amount: $1,500 | Due: Dec 28, 2025`);
console.log(` Status: needsFundingThisPeriod = true\n`);
// Step 3: Fund the rent (simulate payday)
const incomeId = randomUUID();
await prisma.incomeEvent.create({
data: {
id: incomeId,
userId,
amountCents: 150000n, // $1,500
postedAt: new Date("2025-12-17"),
isScheduledIncome: true,
},
});
await prisma.fixedPlan.update({
where: { id: rentId },
data: {
fundedCents: 150000n,
currentFundedCents: 150000n,
needsFundingThisPeriod: false, // Fully funded, no longer needs funding
},
});
await prisma.allocation.create({
data: {
userId,
kind: "fixed",
toId: rentId,
amountCents: 150000n,
incomeId,
},
});
console.log("💰 Step 3: Funded rent with Dec 17 paycheck");
console.log(` Funded: $1,500 / $1,500 (100%)`);
console.log(` Status: needsFundingThisPeriod = false\n`);
console.log("=" .repeat(70));
console.log("\n🎯 TEST SCENARIOS:\n");
console.log("📍 SCENARIO A: User opts for EARLY FUNDING");
console.log("-".repeat(70));
console.log("1. User sees modal: 'Start Funding Early?'");
console.log("2. User clicks: 'Yes, Start Now'");
console.log("3. API call: PATCH /fixed-plans/{rentId}/early-funding");
console.log(" Body: { enableEarlyFunding: true }");
console.log("4. Database: needsFundingThisPeriod = true");
console.log("5. Next paycheck (Dec 20): Rent gets funded ✅");
console.log("");
console.log("📍 SCENARIO B: User opts to WAIT");
console.log("-".repeat(70));
console.log("1. User sees modal: 'Start Funding Early?'");
console.log("2. User clicks: 'Wait Until Rollover'");
console.log("3. No API call made");
console.log("4. Database: needsFundingThisPeriod = false (unchanged)");
console.log("5. Next paycheck (Dec 20): Rent NOT funded ❌");
console.log("6. Jan 28 rollover: needsFundingThisPeriod = true");
console.log("7. Paycheck after Jan 28: Rent gets funded ✅");
console.log("");
console.log("=" .repeat(70));
console.log("\n🧪 MANUAL TESTING STEPS:\n");
console.log("1⃣ Login as: " + email);
console.log("2⃣ Go to Spend page");
console.log("3⃣ Select 'Pay Fixed Expense'");
console.log("4⃣ Select 'Rent' plan");
console.log("5⃣ Enter amount: $1,500");
console.log("6⃣ Click 'Record'");
console.log("7⃣ Modal should appear: 'Start Funding Early?'");
console.log("8⃣ Test both buttons:\n");
console.log(" Option A: Click 'Yes, Start Now'");
console.log(" → Check DB: needsFundingThisPeriod should be TRUE");
console.log(" → Add income: $1,500 on Dec 20");
console.log(" → Verify: Rent receives allocation\n");
console.log(" Option B: Click 'Wait Until Rollover'");
console.log(" → Check DB: needsFundingThisPeriod should be FALSE");
console.log(" → Add income: $1,500 on Dec 20");
console.log(" → Verify: Rent receives NO allocation");
console.log(" → Rollover runs Jan 28");
console.log(" → Add income after Jan 28");
console.log(" → Verify: Rent receives allocation\n");
console.log("=" .repeat(70));
console.log("\n📊 DATABASE VERIFICATION COMMANDS:\n");
console.log("-- Check plan status");
console.log(`SELECT name, "fundedCents", "totalCents", "dueOn", "needsFundingThisPeriod"`);
console.log(`FROM "FixedPlan" WHERE id = '${rentId}';`);
console.log("");
console.log("-- Check last allocation");
console.log(`SELECT kind, "amountCents", "createdAt"`);
console.log(`FROM "Allocation" WHERE "userId" = '${userId}'`);
console.log(`ORDER BY "createdAt" DESC LIMIT 5;`);
console.log("");
console.log("-- Check transactions");
console.log(`SELECT kind, "amountCents", "occurredAt", note`);
console.log(`FROM "Transaction" WHERE "userId" = '${userId}'`);
console.log(`ORDER BY "occurredAt" DESC;`);
console.log("");
console.log("=" .repeat(70));
console.log("\n✅ Test user created successfully!");
console.log(`📧 Email: ${email}`);
console.log(`🔑 Password: ${password}`);
console.log(`🆔 User ID: ${userId}`);
console.log(`🏠 Rent Plan ID: ${rentId}`);
console.log("");
console.log("🚀 Ready to test in the UI!");
console.log("");
}
main()
.catch((err) => {
console.error("❌ Error:", err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,182 @@
/**
* Test script for attempt-final-funding endpoint
*
* This tests the logic that runs when a payment modal opens:
* 1. If bill not fully funded -> attempts to pull from available budget
* 2. If available budget can cover -> fully funds it
* 3. If available budget insufficient -> takes all available, marks overdue
*/
import { PrismaClient } from "@prisma/client";
import { allocateIncome } from "../allocator.js";
const prisma = new PrismaClient();
async function main() {
const testEmail = `test-final-funding-${Date.now()}@test.com`;
console.log(`\n🧪 Testing attempt-final-funding endpoint with user: ${testEmail}\n`);
// 1. Create test user
const user = await prisma.user.create({
data: {
email: testEmail,
displayName: "Test Final Funding",
incomeType: "regular",
incomeFrequency: "biweekly",
timezone: "America/New_York",
},
});
console.log(`✅ Created test user: ${user.id}`);
// 2. Create variable categories (for available budget calculation)
await prisma.variableCategory.createMany({
data: [
{ userId: user.id, name: "Essentials", percent: 50, priority: 10, isSavings: false, balanceCents: 0n },
{ userId: user.id, name: "Savings", percent: 30, priority: 20, isSavings: true, balanceCents: 0n },
{ userId: user.id, name: "Fun", percent: 20, priority: 30, isSavings: false, balanceCents: 0n },
],
});
console.log(`✅ Created variable categories`);
// 3. Create test rent plan ($1,500, due tomorrow)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setUTCHours(0, 0, 0, 0);
const rentPlan = await prisma.fixedPlan.create({
data: {
userId: user.id,
name: "Rent",
totalCents: 150000n, // $1,500
fundedCents: 100000n, // $1,000 already funded
currentFundedCents: 100000n,
priority: 10,
cycleStart: new Date(),
dueOn: tomorrow,
fundingMode: "auto-on-deposit",
needsFundingThisPeriod: true,
},
});
console.log(`✅ Created rent plan: $1,500 total, $1,000 funded, $500 remaining`);
// 4. Add initial income to simulate funded amount
await prisma.incomeEvent.create({
data: {
userId: user.id,
amountCents: 200000n, // $2,000 income
postedAt: new Date(),
},
});
// Allocate to rent to simulate the $1,000 funded
await prisma.allocation.create({
data: {
userId: user.id,
kind: "fixed",
toId: rentPlan.id,
amountCents: 100000n, // $1,000 to rent
},
});
// Allocate rest to variable categories to simulate spending/allocation
const essentials = await prisma.variableCategory.findFirst({ where: { userId: user.id, name: "Essentials" } });
await prisma.allocation.create({
data: {
userId: user.id,
kind: "variable",
toId: essentials!.id,
amountCents: 70000n, // $700
},
});
console.log(`✅ Created income event: $2,000`);
console.log(` Allocated: $1,000 to rent, $700 to essentials`);
console.log(` Available budget: $300\n`);
// 5. TEST CASE 1: Available budget ($300) < Remaining ($500)
console.log("📋 TEST CASE 1: Partial funding from available budget");
console.log(" Remaining needed: $500");
console.log(" Available budget: $300");
console.log(" Expected: Take all $300, mark overdue with $200\n");
const response1 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, {
method: "POST",
headers: {
"x-user-id": user.id,
"Content-Type": "application/json",
},
});
const result1 = await response1.json();
console.log("Response:", JSON.stringify(result1, null, 2));
if (result1.status === "overdue" && result1.fundedCents === 130000 && result1.overdueAmount === 20000) {
console.log("✅ TEST 1 PASSED: Correctly funded $300 and marked $200 overdue\n");
} else {
console.log("❌ TEST 1 FAILED: Unexpected result\n");
}
// 6. TEST CASE 2: Available budget can fully fund
// Add more income to create sufficient available budget
await prisma.incomeEvent.create({
data: {
userId: user.id,
amountCents: 50000n, // $500 more income
postedAt: new Date(),
},
});
console.log(`💰 Added $500 more income`);
console.log(` New available budget: $500`);
console.log(` Remaining on rent: $200 (overdue)\n`);
console.log("📋 TEST CASE 2: Full funding from available budget");
console.log(" Remaining needed: $200");
console.log(" Available budget: $500");
console.log(" Expected: Fund full $200, clear overdue\n");
const response2 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, {
method: "POST",
headers: {
"x-user-id": user.id,
"Content-Type": "application/json",
},
});
const result2 = await response2.json();
console.log("Response:", JSON.stringify(result2, null, 2));
if (result2.status === "fully_funded" && result2.fundedCents === 150000 && result2.isOverdue === false) {
console.log("✅ TEST 2 PASSED: Correctly fully funded and cleared overdue\n");
} else {
console.log("❌ TEST 2 FAILED: Unexpected result\n");
}
// 7. TEST CASE 3: Already fully funded
console.log("📋 TEST CASE 3: Already fully funded");
console.log(" Expected: No changes, return fully_funded status\n");
const response3 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, {
method: "POST",
headers: {
"x-user-id": user.id,
"Content-Type": "application/json",
},
});
const result3 = await response3.json();
console.log("Response:", JSON.stringify(result3, null, 2));
if (result3.status === "fully_funded" && result3.fundedCents === 150000) {
console.log("✅ TEST 3 PASSED: Correctly identified as fully funded\n");
} else {
console.log("❌ TEST 3 FAILED: Unexpected result\n");
}
console.log("\n🎉 All tests completed!");
console.log(`\nTest user: ${testEmail}`);
console.log(`User ID: ${user.id}`);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env tsx
import { PrismaClient } from "@prisma/client";
import * as argon2 from "argon2";
import { addDays, startOfDay } from "date-fns";
const prisma = new PrismaClient();
const PASSWORD = "password";
type Scenario = {
name: string;
timezone: string;
incomeType: "regular" | "irregular";
incomeFrequency: "weekly" | "biweekly" | "monthly" | null;
firstIncomeDate: Date | null;
fixedPlans: Array<{
name: string;
totalCents: number;
dueOn: Date;
frequency?: "weekly" | "biweekly" | "monthly" | "one-time";
autoPayEnabled?: boolean;
paymentSchedule?: Record<string, any> | null;
}>;
variableCategories: Array<{
name: string;
percent: number;
isSavings?: boolean;
}>;
};
async function createScenario(s: Scenario) {
const timestamp = Date.now();
const email = `test-onboarding-${s.name.toLowerCase().replace(/\s+/g, "-")}-${timestamp}@test.com`;
const hashed = await argon2.hash(PASSWORD);
const user = await prisma.user.create({
data: {
email,
passwordHash: hashed,
timezone: s.timezone,
incomeType: s.incomeType,
incomeFrequency: s.incomeFrequency ?? undefined,
firstIncomeDate: s.firstIncomeDate ?? undefined,
totalBudgetCents: 100000, // $1,000 placeholder
},
});
for (const cat of s.variableCategories) {
await prisma.variableCategory.create({
data: {
userId: user.id,
name: cat.name,
percent: cat.percent,
isSavings: !!cat.isSavings,
},
});
}
for (const plan of s.fixedPlans) {
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: plan.name,
totalCents: plan.totalCents,
fundedCents: 0,
currentFundedCents: 0,
cycleStart: new Date().toISOString(),
dueOn: plan.dueOn.toISOString(),
frequency: plan.frequency ?? null,
autoPayEnabled: !!plan.autoPayEnabled,
paymentSchedule: plan.paymentSchedule ?? undefined,
},
});
}
return { user, email };
}
async function main() {
const today = startOfDay(new Date());
const tomorrow = addDays(today, 1);
const scenarios: Scenario[] = [
{
name: "Timezone Tomorrow",
timezone: "America/Los_Angeles",
incomeType: "regular",
incomeFrequency: "weekly",
firstIncomeDate: tomorrow,
fixedPlans: [
{
name: "Rent",
totalCents: 120000,
dueOn: addDays(today, 29),
frequency: "monthly",
autoPayEnabled: true,
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
},
],
variableCategories: [
{ name: "Savings A", percent: 20, isSavings: true },
{ name: "Savings B", percent: 10, isSavings: true },
{ name: "Food", percent: 40 },
{ name: "Gas", percent: 30 },
],
},
{
name: "Month End",
timezone: "America/Chicago",
incomeType: "regular",
incomeFrequency: "monthly",
firstIncomeDate: new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 31)),
fixedPlans: [
{
name: "Insurance",
totalCents: 50000,
dueOn: new Date(Date.UTC(today.getUTCFullYear(), 0, 31)),
frequency: "monthly",
autoPayEnabled: true,
paymentSchedule: { frequency: "monthly", dayOfMonth: 31, minFundingPercent: 100 },
},
],
variableCategories: [
{ name: "Savings", percent: 25, isSavings: true },
{ name: "Food", percent: 50 },
{ name: "Misc", percent: 25 },
],
},
{
name: "Irregular No Auto",
timezone: "America/New_York",
incomeType: "irregular",
incomeFrequency: null,
firstIncomeDate: null,
fixedPlans: [
{
name: "Subscription",
totalCents: 1200,
dueOn: addDays(today, 14),
frequency: "monthly",
autoPayEnabled: false,
paymentSchedule: null,
},
],
variableCategories: [
{ name: "Savings", percent: 30, isSavings: true },
{ name: "Food", percent: 40 },
{ name: "Entertainment", percent: 30 },
],
},
{
name: "Due Today Underfunded",
timezone: "America/Chicago",
incomeType: "regular",
incomeFrequency: "biweekly",
firstIncomeDate: tomorrow,
fixedPlans: [
{
name: "Phone",
totalCents: 25000,
dueOn: today,
frequency: "monthly",
autoPayEnabled: true,
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
},
],
variableCategories: [
{ name: "Savings", percent: 20, isSavings: true },
{ name: "Food", percent: 40 },
{ name: "Gas", percent: 20 },
{ name: "Misc", percent: 20 },
],
},
];
console.log("\n=== Onboarding Edge Test Setup ===\n");
for (const scenario of scenarios) {
const { user, email } = await createScenario(scenario);
console.log(`Scenario: ${scenario.name}`);
console.log(` Email: ${email}`);
console.log(` Password: ${PASSWORD}`);
console.log(` User ID: ${user.id}`);
console.log(` Timezone: ${scenario.timezone}`);
console.log(` Income: ${scenario.incomeType} ${scenario.incomeFrequency ?? ""}`.trim());
console.log("");
}
console.log("Use these users to verify onboarding edge cases and dashboard follow-up behavior.");
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,206 @@
/**
* Test script for Payment Reconciliation System with Overdue tracking
*
* Tests:
* 1. Full payment → Rollover
* 2. Partial payment → Refund + Mark overdue
* 3. No payment → Mark overdue
* 4. Overdue priority in next income allocation
*/
import { PrismaClient } from "@prisma/client";
import * as argon2 from "argon2";
const prisma = new PrismaClient();
async function main() {
const timestamp = Date.now();
const email = `test-overdue-${timestamp}@test.com`;
const password = "testpassword123";
console.log("🧪 Testing Payment Reconciliation System");
console.log("========================================\n");
// 1. Create test user
console.log("1⃣ Creating test user...");
const passwordHash = await argon2.hash(password);
const user = await prisma.user.create({
data: {
email,
passwordHash,
displayName: "Overdue Test User",
incomeFrequency: "biweekly",
incomeType: "regular",
timezone: "America/New_York",
firstIncomeDate: new Date(),
},
});
console.log(`✅ User created: ${email}\n`);
// 2. Create fixed plan (Rent)
console.log("2⃣ Creating Rent plan ($1,500)...");
const rent = await prisma.fixedPlan.create({
data: {
userId: user.id,
name: "Rent",
totalCents: 150000n, // $1,500
fundedCents: 100000n, // $1,000 funded
currentFundedCents: 100000n,
priority: 10,
cycleStart: new Date(),
dueOn: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), // 5 days from now
fundingMode: "auto-on-deposit",
frequency: "monthly",
needsFundingThisPeriod: true,
},
});
console.log(`✅ Rent plan created (ID: ${rent.id})`);
console.log(` Funded: $1,000 / $1,500\n`);
// 3. Create variable categories
console.log("3⃣ Creating variable categories...");
await prisma.variableCategory.createMany({
data: [
{ userId: user.id, name: "Essentials", percent: 50, priority: 10, balanceCents: 50000n },
{ userId: user.id, name: "Savings", percent: 30, priority: 20, isSavings: true, balanceCents: 30000n },
{ userId: user.id, name: "Fun", percent: 20, priority: 30, balanceCents: 20000n },
],
});
console.log("✅ Variable categories created\n");
// 4. Record initial income to establish available budget
console.log("4⃣ Recording initial income ($2,000)...");
const income1 = await prisma.incomeEvent.create({
data: {
userId: user.id,
postedAt: new Date(),
amountCents: 200000n,
note: "Initial income",
},
});
// Create allocations for the funded amount
await prisma.allocation.create({
data: {
userId: user.id,
kind: "fixed",
toId: rent.id,
amountCents: 100000n,
incomeId: income1.id,
},
});
console.log("✅ Income recorded + $1,000 allocated to Rent\n");
// 5. TEST SCENARIO A: Partial Payment
console.log("🧪 TEST SCENARIO A: Partial Payment ($1,000 paid out of $1,500)");
console.log("-----------------------------------------------------------");
const partialPayment = await prisma.transaction.create({
data: {
userId: user.id,
occurredAt: new Date(),
kind: "fixed_payment",
amountCents: 100000n, // Only paid $1,000
planId: rent.id,
note: "Partial payment test",
},
});
const rentAfterPartial = await prisma.fixedPlan.findUnique({
where: { id: rent.id },
});
console.log(`✅ Partial payment recorded: $${Number(partialPayment.amountCents) / 100}`);
console.log(` Plan status:`);
console.log(` - fundedCents: $${Number(rentAfterPartial?.fundedCents) / 100}`);
console.log(` - isOverdue: ${rentAfterPartial?.isOverdue}`);
console.log(` - overdueAmount: $${Number(rentAfterPartial?.overdueAmount ?? 0n) / 100}\n`);
// 6. TEST SCENARIO B: Overdue Priority in Allocation
console.log("🧪 TEST SCENARIO B: Next Income Should Prioritize Overdue");
console.log("--------------------------------------------------------");
console.log("📥 Posting new income ($500) - using direct allocator...");
// Use direct allocator function instead of API
const { allocateIncome } = await import("../allocator.js");
const allocationResult = await allocateIncome(
prisma,
user.id,
50000, // $500
new Date().toISOString(),
"test-income-2",
"Test income after overdue",
true // isScheduledIncome
);
console.log("✅ Income allocated");
console.log(` Fixed allocations:`, JSON.stringify(allocationResult.fixedAllocations, null, 2));
const rentAfterIncome = await prisma.fixedPlan.findUnique({
where: { id: rent.id },
});
console.log(` Rent plan after allocation:`);
console.log(` - overdueAmount: $${Number(rentAfterIncome?.overdueAmount ?? 0n) / 100}`);
console.log(` - fundedCents: $${Number(rentAfterIncome?.fundedCents) / 100}\n`);
// 7. TEST SCENARIO C: Mark as Unpaid
console.log("🧪 TEST SCENARIO C: Mark Bill as Unpaid");
console.log("---------------------------------------");
// Create another plan to test mark-unpaid
const utilities = await prisma.fixedPlan.create({
data: {
userId: user.id,
name: "Utilities",
totalCents: 20000n, // $200
fundedCents: 15000n, // $150 funded
currentFundedCents: 15000n,
priority: 20,
cycleStart: new Date(),
dueOn: new Date(), // Due today
fundingMode: "auto-on-deposit",
frequency: "monthly",
},
});
// Mark as unpaid directly via Prisma
const fundedAmount = Number(utilities.currentFundedCents);
const totalAmount = Number(utilities.totalCents);
const remainingBalance = totalAmount - fundedAmount;
await prisma.fixedPlan.update({
where: { id: utilities.id },
data: {
isOverdue: true,
overdueAmount: BigInt(Math.max(0, remainingBalance)),
overdueSince: new Date(),
needsFundingThisPeriod: true,
},
});
console.log("✅ Marked utilities as unpaid");
console.log(` Remaining balance: $${remainingBalance / 100}`);
const utilitiesAfter = await prisma.fixedPlan.findUnique({
where: { id: utilities.id },
});
console.log(` - isOverdue: ${utilitiesAfter?.isOverdue}`);
console.log(` - overdueAmount: $${Number(utilitiesAfter?.overdueAmount ?? 0n) / 100}`);
console.log(` - overdueSince: ${utilitiesAfter?.overdueSince}\n`);
console.log("✅ All tests completed successfully!");
console.log("\n📊 Final State:");
console.log(` Test user: ${email}`);
console.log(` User ID: ${user.id}`);
}
main()
.catch((e) => {
console.error("❌ Test failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,289 @@
#!/usr/bin/env tsx
import { PrismaClient } from "@prisma/client";
import * as argon2 from "argon2";
import { addDays, startOfDay } from "date-fns";
const prisma = new PrismaClient();
async function main() {
console.log("\n=== 🧪 Payment Flow Test Setup ===\n");
const timestamp = Date.now();
const email = `test-payment-flow-${timestamp}@test.com`;
const password = "password";
const hashedPassword = await argon2.hash(password);
// Calculate dates
const today = startOfDay(new Date());
const nextPayday = addDays(today, 3); // Dec 20
const rentDue = addDays(today, 11); // Dec 28
console.log("📝 Step 1: Creating test user...");
const user = await prisma.user.create({
data: {
email,
passwordHash: hashedPassword,
timezone: "America/Los_Angeles",
incomeType: "regular",
incomeFrequency: "biweekly",
firstIncomeDate: nextPayday.toISOString(),
totalBudgetCents: 300000, // $3,000 total budget
},
});
console.log(`✅ Created user: ${email} (${user.id})`);
console.log(` Total Budget: $3,000 | Next payday: ${nextPayday.toLocaleDateString()}\n`);
console.log("💾 Step 2: Creating budget categories...");
// Create variable categories
const savingsCategory = await prisma.variableCategory.create({
data: {
userId: user.id,
name: "Savings",
percent: 20,
isSavings: true,
},
});
const groceriesCategory = await prisma.variableCategory.create({
data: {
userId: user.id,
name: "Groceries",
percent: 50,
isSavings: false,
},
});
const entertainmentCategory = await prisma.variableCategory.create({
data: {
userId: user.id,
name: "Entertainment",
percent: 30,
isSavings: false,
},
});
console.log(`✅ Created 3 variable categories`);
console.log(` - Savings: 20%`);
console.log(` - Groceries: 50%`);
console.log(` - Entertainment: 30%\n`);
console.log("🏠 Step 3: Creating fixed expense (Rent)...");
const rentPlan = await prisma.fixedPlan.create({
data: {
userId: user.id,
name: "Rent",
totalCents: 150000, // $1,500
fundedCents: 75000, // $750 (50% funded)
cycleStart: today.toISOString(),
dueOn: rentDue.toISOString(),
frequency: "monthly",
needsFundingThisPeriod: true,
},
});
console.log(`✅ Created Rent bill`);
console.log(` Amount: $1,500 | Funded: $750 (50%)`);
console.log(` Due: ${rentDue.toLocaleDateString()}`);
console.log(` Status: needsFundingThisPeriod = true\n`);
console.log("💰 Step 4: Adding income to create available budget...");
// Add income event (not transaction - backend checks IncomeEvent table!)
const incomeAmount = 200000; // $2,000
await prisma.incomeEvent.create({
data: {
userId: user.id,
amountCents: incomeAmount,
postedAt: today,
note: "Test paycheck",
},
});
// Calculate allocations (simplified - in real flow this is done by allocator)
// Rent needs $750 more, gets funded
// Variable gets: $2,000 - $750 = $1,250
await prisma.allocation.create({
data: {
userId: user.id,
kind: "fixed",
toId: rentPlan.id,
amountCents: 75000, // Fund the remaining $750
},
});
await prisma.fixedPlan.update({
where: { id: rentPlan.id },
data: {
fundedCents: 150000, // Now 100% funded
currentFundedCents: 150000, // Dashboard reads from this
},
});
// Variable allocation: $1,250
const variableAmount = 125000;
const savingsAmount = Math.floor(variableAmount * 0.20); // $250
const groceriesAmount = Math.floor(variableAmount * 0.50); // $625
const entertainmentAmount = Math.floor(variableAmount * 0.30); // $375
await prisma.allocation.create({
data: {
userId: user.id,
kind: "variable",
toId: savingsCategory.id,
amountCents: savingsAmount,
},
});
await prisma.allocation.create({
data: {
userId: user.id,
kind: "variable",
toId: groceriesCategory.id,
amountCents: groceriesAmount,
},
});
await prisma.allocation.create({
data: {
userId: user.id,
kind: "variable",
toId: entertainmentCategory.id,
amountCents: entertainmentAmount,
},
});
// Update variable category balances (dashboard reads from these)
await prisma.variableCategory.update({
where: { id: savingsCategory.id },
data: { balanceCents: savingsAmount },
});
await prisma.variableCategory.update({
where: { id: groceriesCategory.id },
data: { balanceCents: groceriesAmount },
});
await prisma.variableCategory.update({
where: { id: entertainmentCategory.id },
data: { balanceCents: entertainmentAmount },
});
console.log(`✅ Recorded income: $${(incomeAmount / 100).toFixed(2)}`);
console.log(` Rent funded: +$750 → 100% complete`);
console.log(` Variable budget: $1,250`);
console.log(` - Savings: $250 (20%)`);
console.log(` - Groceries: $625 (50%)`);
console.log(` - Entertainment: $375 (30%)\n`);
console.log("======================================================================\n");
console.log("🎯 TEST SCENARIOS:\n");
console.log("📍 SCENARIO 1: Pay Rent from fundedCents + available");
console.log("----------------------------------------------------------------------");
console.log("Current state:");
console.log(" - Rent: $1,500 total, $1,500 funded (100%)");
console.log(" - Available budget: $1,250 (all in variable categories)");
console.log("");
console.log("Action: Pay Rent $1,500");
console.log("Expected behavior:");
console.log(" ✅ Takes $1,500 from fundedCents");
console.log(" ✅ Takes $0 from available");
console.log(" ✅ Modal appears: 'Start funding early?'");
console.log(" ✅ No confirmation needed (not depleting variable)\n");
console.log("📍 SCENARIO 2: Pay more than funded (triggers confirmation)");
console.log("----------------------------------------------------------------------");
console.log("Setup: Reset rent to $500 funded");
console.log("");
console.log("Action: Pay Rent $1,500");
console.log("Expected behavior:");
console.log(" ✅ Takes $500 from fundedCents");
console.log(" ✅ Needs $1,000 from available ($1,250 total)");
console.log(" ⚠️ Would deplete 80% of variable balance");
console.log(" ⚠️ CONFIRMATION_REQUIRED modal appears");
console.log(" ✅ User confirms → payment succeeds");
console.log(" ✅ Negative allocation tracks -$1,000 from variable\n");
console.log("📍 SCENARIO 3: Add income that brings bill to 100%");
console.log("----------------------------------------------------------------------");
console.log("Setup: Create new bill '$400 Car Insurance' with $0 funded");
console.log("");
console.log("Action: Record income $500");
console.log("Expected behavior:");
console.log(" ✅ Allocator funds Car Insurance: $400");
console.log(" ✅ Bill reaches 100% funded");
console.log(" ✅ Modal appears: 'Start funding early for Car Insurance?'");
console.log(" ✅ Remaining $100 goes to variable\n");
// Create the car insurance bill for testing scenario 3
console.log("🚗 Creating Car Insurance bill for Scenario 3...");
const carInsurance = await prisma.fixedPlan.create({
data: {
userId: user.id,
name: "Car Insurance",
totalCents: 40000, // $400
fundedCents: 0,
cycleStart: today.toISOString(),
dueOn: addDays(today, 15).toISOString(),
frequency: "monthly",
needsFundingThisPeriod: true,
},
});
console.log(`✅ Created Car Insurance: $400, $0 funded\n`);
console.log("======================================================================\n");
console.log("🧪 MANUAL TESTING INSTRUCTIONS:\n");
console.log("1⃣ Login at http://localhost:5174");
console.log(` Email: ${email}`);
console.log(` Password: ${password}\n`);
console.log("2⃣ SCENARIO 1: Test normal payment");
console.log(" → Go to Spend page");
console.log(" → Pay 'Rent' $1,500");
console.log(" → Should see early funding modal only (no warning)\n");
console.log("3⃣ SCENARIO 2: Test confirmation modal");
console.log(" → First, manually reset rent funded amount to $500:");
console.log(` UPDATE "FixedPlan" SET "fundedCents" = 50000 WHERE id = '${rentPlan.id}';`);
console.log(" → Pay 'Rent' $1,500");
console.log(" → Should see CONFIRMATION modal first");
console.log(" → Confirm payment");
console.log(" → Then see early funding modal\n");
console.log("4⃣ SCENARIO 3: Test income modal");
console.log(" → Go to Income page");
console.log(" → Record income $500");
console.log(" → Should see early funding modal for 'Car Insurance'\n");
console.log("======================================================================\n");
console.log("📊 DATABASE VERIFICATION:\n");
console.log("-- Check rent status");
console.log(`SELECT name, "fundedCents", "totalCents", "needsFundingThisPeriod"`);
console.log(`FROM "FixedPlan" WHERE id = '${rentPlan.id}';\n`);
console.log("-- Check available budget (should decrease after payment from available)");
console.log(`SELECT kind, "categoryId", "amountCents"`);
console.log(`FROM "Allocation" WHERE "userId" = '${user.id}'`);
console.log(`ORDER BY "createdAt" DESC LIMIT 10;\n`);
console.log("-- Check transactions");
console.log(`SELECT kind, "amountCents", "planId", note`);
console.log(`FROM "Transaction" WHERE "userId" = '${user.id}'`);
console.log(`ORDER BY "occurredAt" DESC;\n`);
console.log("======================================================================\n");
console.log("✅ Test user created successfully!");
console.log(`📧 Email: ${email}`);
console.log(`🔑 Password: ${password}`);
console.log(`🆔 User ID: ${user.id}`);
console.log(`🏠 Rent ID: ${rentPlan.id}`);
console.log(`🚗 Car Insurance ID: ${carInsurance.id}`);
console.log("\n🚀 Ready to test all payment scenarios!\n");
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env tsx
/**
* Test script to simulate running scheduled jobs at different times/timezones
*
* Usage:
* npx tsx src/scripts/test-timezone-jobs.ts
*/
import { PrismaClient } from "@prisma/client";
import { rolloverFixedPlans } from "../jobs/rollover.js";
import { processAutoPayments } from "../jobs/auto-payments.js";
import { toZonedTime } from "date-fns-tz";
const prisma = new PrismaClient();
function checkMidnight(date: Date | null, timezone: string, label: string) {
if (!date) return { ok: true, label, reason: "missing" };
const zoned = toZonedTime(date, timezone);
const isMidnight =
zoned.getHours() === 0 &&
zoned.getMinutes() === 0 &&
zoned.getSeconds() === 0 &&
zoned.getMilliseconds() === 0;
return {
ok: isMidnight,
label,
zoned: zoned.toISOString(),
};
}
async function main() {
console.log("\n=== Timezone Job Testing ===\n");
// Get test user
const userId = process.argv[2];
if (!userId) {
console.error("Usage: npx tsx src/scripts/test-timezone-jobs.ts <userId>");
process.exit(1);
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, timezone: true, firstIncomeDate: true },
});
if (!user) {
console.error(`User ${userId} not found`);
process.exit(1);
}
console.log(`Testing for user: ${user.email}`);
const userTimezone = user.timezone ?? "America/New_York";
console.log(`User timezone: ${userTimezone}\n`);
const plans = await prisma.fixedPlan.findMany({
where: { userId },
select: { id: true, name: true, dueOn: true, nextPaymentDate: true, cycleStart: true },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
});
console.log("=== DATE NORMALIZATION CHECKS (local midnight expected) ===\n");
const checks = [
checkMidnight(user.firstIncomeDate ?? null, userTimezone, "user.firstIncomeDate"),
];
plans.forEach((plan) => {
checks.push(checkMidnight(plan.dueOn, userTimezone, `plan:${plan.name}:dueOn`));
checks.push(checkMidnight(plan.cycleStart, userTimezone, `plan:${plan.name}:cycleStart`));
if (plan.nextPaymentDate) {
checks.push(checkMidnight(plan.nextPaymentDate, userTimezone, `plan:${plan.name}:nextPaymentDate`));
}
});
let hasIssues = false;
for (const check of checks) {
if (!check.ok) {
hasIssues = true;
console.log(`${check.label} not at local midnight (${check.zoned})`);
}
}
if (!hasIssues) {
console.log("✅ All date-only fields are stored at local midnight.\n");
} else {
console.log("\n⚠ Some date-only fields are not normalized to local midnight.\n");
process.exitCode = 1;
}
// Test different UTC times to see when jobs would run
const testTimes = [
"2025-12-17T00:00:00Z", // Midnight UTC
"2025-12-17T06:00:00Z", // 6 AM UTC
"2025-12-17T12:00:00Z", // Noon UTC
"2025-12-17T18:00:00Z", // 6 PM UTC
"2025-12-17T23:00:00Z", // 11 PM UTC
];
console.log("=== ROLLOVER JOB (should run at 6 AM user time) ===\n");
for (const utcTime of testTimes) {
const asOf = new Date(utcTime);
const userTime = toZonedTime(asOf, userTimezone);
const userHour = userTime.getHours();
const shouldRun = userHour >= 6;
console.log(`UTC: ${utcTime}`);
console.log(` User time: ${userTime.toLocaleString('en-US', { timeZone: userTimezone })}`);
console.log(` User hour: ${userHour}`);
console.log(` Would run: ${shouldRun ? '✅ YES' : '❌ NO (before 6 AM)'}\n`);
}
console.log("\n=== AUTO-PAYMENT JOB (should run at 9 AM user time) ===\n");
for (const utcTime of testTimes) {
const asOf = new Date(utcTime);
const userTime = toZonedTime(asOf, userTimezone);
const userHour = userTime.getHours();
const shouldRun = userHour >= 9;
console.log(`UTC: ${utcTime}`);
console.log(` User time: ${userTime.toLocaleString('en-US', { timeZone: userTimezone })}`);
console.log(` User hour: ${userHour}`);
console.log(` Would run: ${shouldRun ? '✅ YES' : '❌ NO (before 9 AM)'}\n`);
}
// Actually test rollover with dry-run
console.log("\n=== TESTING ROLLOVER (DRY RUN) ===\n");
const rolloverTestTime = "2025-12-17T22:00:00Z"; // 7 AM Tokyo time (should run)
console.log(`Testing at: ${rolloverTestTime}`);
const rolloverResults = await rolloverFixedPlans(prisma, rolloverTestTime, { dryRun: true });
console.log(`Plans found for rollover: ${rolloverResults.length}`);
if (rolloverResults.length > 0) {
console.log("Plans:", rolloverResults.map(r => ({ name: r.name, cycles: r.cyclesAdvanced })));
}
// Actually test auto-payment with dry-run
console.log("\n=== TESTING AUTO-PAYMENT (DRY RUN) ===\n");
const paymentTestTime = "2025-12-18T01:00:00Z"; // 10 AM Tokyo time (should run)
console.log(`Testing at: ${paymentTestTime}`);
const paymentResults = await processAutoPayments(prisma, paymentTestTime, { dryRun: true });
console.log(`Plans found for auto-payment: ${paymentResults.length}`);
if (paymentResults.length > 0) {
console.log("Plans:", paymentResults.map(r => ({ name: r.name, success: r.success, error: r.error })));
}
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
import cron from "node-cron";
import { PrismaClient } from "@prisma/client";
import { processAutoPayments } from "../jobs/auto-payments.js";
const prisma = new PrismaClient();
// Default: run every 15 minutes to catch users at their local 9 AM across all timezones
// The job itself filters to only process users where local time >= 9 AM
const schedule =
process.env.AUTO_PAYMENT_SCHEDULE_CRON && typeof process.env.AUTO_PAYMENT_SCHEDULE_CRON === "string"
? process.env.AUTO_PAYMENT_SCHEDULE_CRON
: "*/15 * * * *";
async function runOnce() {
const asOf = new Date();
try {
const reports = await processAutoPayments(prisma, asOf, { dryRun: false });
console.log(
`[auto-payments] ${asOf.toISOString()} processed=${reports.length} successful=${reports.filter(r => r.success).length}`
);
// Log any failures
const failures = reports.filter(r => !r.success);
if (failures.length > 0) {
if (process.env.NODE_ENV === "production") {
console.warn(`[auto-payments] ${failures.length} failed payments.`);
} else {
console.warn(
`[auto-payments] ${failures.length} failed payments:`,
failures.map(f => `${f.name}: ${f.error}`).join(", ")
);
}
}
} catch (err) {
console.error("[auto-payments] job failed", err);
}
}
console.log(`[auto-payments-worker] starting cron ${schedule}`);
cron.schedule(schedule, () => {
runOnce().catch((err) => console.error("[auto-payments] schedule error", err));
});
if (process.env.RUN_ONCE === "1") {
runOnce().finally(() => process.exit(0));
}

View File

@@ -0,0 +1,39 @@
import cron from "node-cron";
import { PrismaClient } from "@prisma/client";
import { rolloverFixedPlans } from "../jobs/rollover.js";
const prisma = new PrismaClient();
// Default: run every 15 minutes to catch users at their local 6 AM across all timezones
// The job itself filters to only process users where local time >= 6 AM
const schedule =
process.env.ROLLOVER_SCHEDULE_CRON && typeof process.env.ROLLOVER_SCHEDULE_CRON === "string"
? process.env.ROLLOVER_SCHEDULE_CRON
: "*/15 * * * *";
async function runOnce() {
const asOf = new Date();
try {
const results = await rolloverFixedPlans(prisma, asOf, { dryRun: false });
if (process.env.NODE_ENV === "production") {
console.log(`[rollover] ${asOf.toISOString()} processed=${results.length}`);
} else {
console.log(
`[rollover] ${asOf.toISOString()} processed=${results.length} ids=${results
.map((r) => r.planId)
.join(",")}`
);
}
} catch (err) {
console.error("[rollover] job failed", err);
}
}
console.log(`[rollover-worker] starting cron ${schedule}`);
cron.schedule(schedule, () => {
runOnce().catch((err) => console.error("[rollover] schedule error", err));
});
if (process.env.RUN_ONCE === "1") {
runOnce().finally(() => process.exit(0));
}

View File

@@ -0,0 +1,25 @@
#!/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

228
api/test-monthly-income.cjs Normal file
View File

@@ -0,0 +1,228 @@
/**
* 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('═══════════════════════════════════════════════════════════════');

41
api/test-overdue-api.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/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

@@ -0,0 +1,133 @@
// 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();

19
api/test-simple.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/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

@@ -1,65 +1,232 @@
// tests/allocator.test.ts
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
import { allocateIncome } from "../src/allocator";
import { allocateIncome, buildPlanStates } from "../src/allocator";
describe("allocator — core behaviors", () => {
describe("allocator — new funding system", () => {
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
// Update user with income frequency
await prisma.user.update({
where: { id: U },
data: {
incomeFrequency: "biweekly",
},
});
});
afterAll(async () => {
await closePrisma();
});
it("distributes remainder to variables by largest remainder with savings-first tie", async () => {
const c1 = cid("c1");
const c2 = cid("c2"); // make this savings to test the tiebreaker
await prisma.variableCategory.createMany({
data: [
{ id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n },
{ id: c2, userId: U, name: "Saver", percent: 40, priority: 1, isSavings: true, balanceCents: 0n },
],
describe("buildPlanStates", () => {
it("calculates funding needs based on strategy and time remaining", async () => {
const p1 = pid("rent");
await prisma.fixedPlan.create({
data: {
id: p1,
userId: U,
name: "Rent",
cycleStart: new Date(Date.now() - 86400000).toISOString(), // started yesterday
dueOn: new Date(Date.now() + 14 * 86400000).toISOString(), // due in 2 weeks
totalCents: 100000n, // $1000
fundedCents: 0n,
currentFundedCents: 0n,
lastFundingDate: null,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
const user = await prisma.user.findUniqueOrThrow({ where: { id: U } });
const plans = await prisma.fixedPlan.findMany({ where: { userId: U } });
const states = buildPlanStates(plans,
{ incomeFrequency: user.incomeFrequency! },
new Date());
expect(states).toHaveLength(1);
const rentState = states[0];
expect(rentState.id).toBe(p1);
expect(rentState.desiredThisIncome).toBeGreaterThan(0);
expect(rentState.desiredThisIncome).toBeLessThanOrEqual(100000);
expect(rentState.remainingCents).toBeGreaterThan(0);
expect(rentState.remainingCents).toBeLessThanOrEqual(100000);
});
const p1 = pid("rent");
await prisma.fixedPlan.create({
data: {
id: p1,
userId: U,
name: "Rent",
cycleStart: new Date().toISOString(),
dueOn: new Date(Date.now() + 7 * 864e5).toISOString(),
totalCents: 10000n,
fundedCents: 0n,
priority: 1,
fundingMode: "auto-on-deposit",
},
it("detects crisis mode when payment is due soon", async () => {
const p1 = pid("urgent");
await prisma.fixedPlan.create({
data: {
id: p1,
userId: U,
name: "Urgent Payment",
cycleStart: new Date(Date.now() - 86400000).toISOString(),
dueOn: new Date(Date.now() + 5 * 86400000).toISOString(), // due in 5 days
totalCents: 50000n, // $500
fundedCents: 0n,
currentFundedCents: 10000n, // only $100 funded
lastFundingDate: null,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
const user = await prisma.user.findUniqueOrThrow({ where: { id: U } });
const plans = await prisma.fixedPlan.findMany({ where: { userId: U } });
const states = buildPlanStates(plans,
{ incomeFrequency: user.incomeFrequency! },
new Date());
const urgentState = states[0];
expect(urgentState.isCrisis).toBe(true);
});
// $100 income
const result = await allocateIncome(prisma as any, U, 10000, new Date().toISOString(), "inc1");
expect(result).toBeDefined();
// rent should be funded first up to need
const fixed = result.fixedAllocations ?? [];
const variable = result.variableAllocations ?? [];
// sanity
expect(Array.isArray(fixed)).toBe(true);
expect(Array.isArray(variable)).toBe(true);
});
it("handles zeros and single bucket", async () => {
const cOnly = cid("only");
await prisma.variableCategory.create({
data: { id: cOnly, userId: U, name: "Only", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
describe("allocateIncome", () => {
it("distributes income with new percentage-based funding", async () => {
const c1 = cid("c1");
const c2 = cid("c2");
await prisma.variableCategory.createMany({
data: [
{ id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n },
{ id: c2, userId: U, name: "Saver", percent: 40, priority: 1, isSavings: true, balanceCents: 0n },
],
});
const p1 = pid("rent");
await prisma.fixedPlan.create({
data: {
id: p1,
userId: U,
name: "Rent",
cycleStart: new Date(Date.now() - 86400000).toISOString(),
dueOn: new Date(Date.now() + 14 * 86400000).toISOString(),
totalCents: 30000n, // $300 - much smaller so there's leftover
fundedCents: 0n,
currentFundedCents: 0n,
lastFundingDate: null,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
// Allocate $1000 income (much more than needed for small rent)
const result = await allocateIncome(prisma as any, U, 100000, new Date().toISOString(), "inc1");
expect(result).toBeDefined();
const fixed = result.fixedAllocations ?? [];
const variable = result.variableAllocations ?? [];
console.log('DEBUG: result =', JSON.stringify(result, null, 2));
expect(Array.isArray(fixed)).toBe(true);
expect(Array.isArray(variable)).toBe(true);
// Should have allocations for both fixed and variable
expect(fixed.length).toBeGreaterThan(0);
expect(variable.length).toBeGreaterThan(0);
// Fixed allocation should be based on desired amount, not full income
const rentAllocation = fixed.find(f => f.fixedPlanId === p1);
expect(rentAllocation).toBeDefined();
expect(rentAllocation!.amountCents).toBeGreaterThan(0);
expect(rentAllocation!.amountCents).toBeLessThanOrEqual(30000); // Should not exceed plan's desired amount
});
const result = await allocateIncome(prisma as any, U, 0, new Date().toISOString(), "inc2");
expect(result).toBeDefined();
const variable = result.variableAllocations ?? [];
const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0);
expect(sum).toBe(0);
it("handles crisis mode by prioritizing underfunded expenses", async () => {
const c1 = cid("c1");
await prisma.variableCategory.create({
data: { id: c1, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
});
const p1 = pid("urgent");
const p2 = pid("normal");
await prisma.fixedPlan.createMany({
data: [
{
id: p1,
userId: U,
name: "Urgent Payment",
cycleStart: new Date(Date.now() - 86400000).toISOString(),
dueOn: new Date(Date.now() + 5 * 86400000).toISOString(), // due soon (crisis)
totalCents: 50000n,
fundedCents: 0n,
currentFundedCents: 10000n, // underfunded
lastFundingDate: null,
priority: 1,
fundingMode: "auto-on-deposit",
},
{
id: p2,
userId: U,
name: "Normal Payment",
cycleStart: new Date(Date.now() - 86400000).toISOString(),
dueOn: new Date(Date.now() + 20 * 86400000).toISOString(), // due later
totalCents: 30000n,
fundedCents: 0n,
currentFundedCents: 0n,
lastFundingDate: null,
priority: 2,
fundingMode: "auto-on-deposit",
},
],
});
// Small income that should prioritize crisis
const result = await allocateIncome(prisma as any, U, 20000, new Date().toISOString(), "inc1");
const fixed = result.fixedAllocations ?? [];
const urgentAllocation = fixed.find(f => f.fixedPlanId === p1);
const normalAllocation = fixed.find(f => f.fixedPlanId === p2);
// Urgent should get more funding due to crisis mode
expect(urgentAllocation).toBeDefined();
if (normalAllocation) {
expect(urgentAllocation!.amountCents).toBeGreaterThan(normalAllocation.amountCents);
}
});
it("updates currentFundedCents correctly", async () => {
const p1 = pid("rent");
await prisma.fixedPlan.create({
data: {
id: p1,
userId: U,
name: "Rent",
cycleStart: new Date(Date.now() - 86400000).toISOString(),
dueOn: new Date(Date.now() + 14 * 86400000).toISOString(),
totalCents: 100000n,
fundedCents: 0n,
currentFundedCents: 20000n, // already $200 funded
lastFundingDate: null,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
await allocateIncome(prisma as any, U, 30000, new Date().toISOString(), "inc1");
const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: p1 } });
expect(updatedPlan.currentFundedCents).toBeGreaterThan(20000n); // Should have increased
expect(updatedPlan.lastFundingDate).not.toBeNull(); // Should be updated
});
it("handles zeros and empty allocation", async () => {
const cOnly = cid("only");
await prisma.variableCategory.create({
data: { id: cOnly, userId: U, name: "Only", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
});
const result = await allocateIncome(prisma as any, U, 0, new Date().toISOString(), "inc2");
expect(result).toBeDefined();
const variable = result.variableAllocations ?? [];
const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0);
expect(sum).toBe(0);
});
});
});

View File

@@ -0,0 +1,83 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: true });
await app.ready();
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
describe("Auth routes", () => {
it("rejects protected routes without a session", async () => {
const res = await request(app.server).get("/dashboard");
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("registers a user and grants access via cookie session", async () => {
const agent = request.agent(app.server);
const email = `reg-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
const register = await agent.post("/auth/register").send({ email, password });
expect(register.status).toBe(200);
const dash = await agent.get("/dashboard");
expect(dash.status).toBe(200);
const created = await prisma.user.findUniqueOrThrow({ where: { email } });
const [catCount, planCount] = await Promise.all([
prisma.variableCategory.count({ where: { userId: created.id } }),
prisma.fixedPlan.count({ where: { userId: created.id } }),
]);
expect(catCount).toBeGreaterThan(0);
expect(planCount).toBeGreaterThan(0);
await prisma.user.deleteMany({ where: { email } });
});
it("logs in existing user and accesses dashboard", async () => {
const agent = request.agent(app.server);
const email = `login-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await agent.post("/auth/register").send({ email, password });
await agent.post("/auth/logout");
const login = await agent.post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
const dash = await agent.get("/dashboard");
expect(dash.status).toBe(200);
await prisma.user.deleteMany({ where: { email } });
});
it("reports session info and handles logout", async () => {
const agent = request.agent(app.server);
const email = `session-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await agent.post("/auth/register").send({ email, password });
const session = await agent.get("/auth/session");
expect(session.status).toBe(200);
expect(session.body.userId).toBeDefined();
await agent.post("/auth/logout");
const afterLogout = await agent.get("/dashboard");
expect(afterLogout.status).toBe(401);
await prisma.user.deleteMany({ where: { email } });
});
});

View File

@@ -0,0 +1,338 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
import { processAutoPayments, type PaymentSchedule } from "../src/jobs/auto-payments";
describe("processAutoPayments", () => {
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
});
afterAll(async () => {
await closePrisma();
});
it("processes auto-payment for fully funded monthly plan", async () => {
const paymentSchedule: PaymentSchedule = {
frequency: "monthly",
dayOfMonth: 1,
minFundingPercent: 100,
};
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Rent Auto-Pay",
totalCents: 120000n, // $1,200
fundedCents: 120000n, // Fully funded
currentFundedCents: 120000n, // Set current funding
priority: 10,
cycleStart: new Date("2025-01-01T00:00:00Z"),
dueOn: new Date("2025-01-01T00:00:00Z"),
periodDays: 30,
autoPayEnabled: true,
paymentSchedule,
nextPaymentDate: new Date("2025-01-01T09:00:00Z"), // Payment due
},
select: { id: true },
});
// Process payments as of payment date
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false });
expect(reports).toHaveLength(1);
expect(reports[0].success).toBe(true);
expect(reports[0].paymentAmountCents).toBe(120000);
expect(reports[0].planId).toBe(plan.id);
// Verify payment transaction was created
const transaction = await prisma.transaction.findFirst({
where: { planId: plan.id, kind: "fixed_payment" },
});
expect(transaction).toBeTruthy();
expect(Number(transaction?.amountCents)).toBe(120000);
expect(transaction?.note).toBe("Auto-payment (monthly)");
// Verify plan funding was reduced
const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({
where: { id: plan.id },
});
expect(Number(updatedPlan.fundedCents)).toBe(0);
expect(updatedPlan.lastAutoPayment).toBeTruthy();
expect(updatedPlan.nextPaymentDate).toBeTruthy();
// Next payment should be February 1st
const nextPayment = updatedPlan.nextPaymentDate!;
expect(nextPayment.getMonth()).toBe(1); // February (0-indexed)
expect(nextPayment.getDate()).toBe(1);
});
it("skips payment when funding is below minimum threshold", async () => {
const paymentSchedule: PaymentSchedule = {
frequency: "monthly",
dayOfMonth: 1,
minFundingPercent: 100,
};
await prisma.fixedPlan.create({
data: {
userId: U,
name: "Under-funded Plan",
totalCents: 100000n, // $1,000
fundedCents: 50000n, // Only 50% funded
currentFundedCents: 50000n, // Set current funding
priority: 10,
cycleStart: new Date("2025-01-01T00:00:00Z"),
dueOn: new Date("2025-01-01T00:00:00Z"),
periodDays: 30,
autoPayEnabled: true,
paymentSchedule,
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
},
});
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false });
expect(reports).toHaveLength(1);
expect(reports[0].success).toBe(false);
expect(reports[0].error).toContain("Insufficient funding: 50.0% < 100%");
expect(reports[0].paymentAmountCents).toBe(0);
// Verify no transaction was created
const transaction = await prisma.transaction.findFirst({
where: { kind: "fixed_payment" },
});
expect(transaction).toBeNull();
});
it("processes weekly auto-payment", async () => {
const paymentSchedule: PaymentSchedule = {
frequency: "weekly",
dayOfWeek: 1, // Monday
minFundingPercent: 50,
};
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Weekly Payment",
totalCents: 50000n, // $500
fundedCents: 30000n, // 60% funded (above 50% minimum)
currentFundedCents: 30000n, // Set current funding
priority: 10,
cycleStart: new Date("2025-01-06T00:00:00Z"), // Monday
dueOn: new Date("2025-01-06T00:00:00Z"),
periodDays: 7,
autoPayEnabled: true,
paymentSchedule,
nextPaymentDate: new Date("2025-01-06T09:00:00Z"), // Monday 9 AM
},
select: { id: true },
});
const reports = await processAutoPayments(prisma, "2025-01-06T10:00:00Z", { dryRun: false });
expect(reports).toHaveLength(1);
expect(reports[0].success).toBe(true);
expect(reports[0].paymentAmountCents).toBe(30000); // Full funded amount
// Verify next payment is scheduled for next Monday
const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({
where: { id: plan.id },
});
const nextPayment = updatedPlan.nextPaymentDate!;
expect(nextPayment.getDay()).toBe(1); // Monday
expect(nextPayment.getDate()).toBe(13); // Next Monday (Jan 13)
});
it("processes daily auto-payment", async () => {
const paymentSchedule: PaymentSchedule = {
frequency: "daily",
minFundingPercent: 25,
};
await prisma.fixedPlan.create({
data: {
userId: U,
name: "Daily Payment",
totalCents: 10000n, // $100
fundedCents: 3000n, // 30% funded (above 25% minimum)
currentFundedCents: 3000n, // Set current funding
priority: 10,
cycleStart: new Date("2025-01-01T00:00:00Z"),
dueOn: new Date("2025-01-01T00:00:00Z"),
periodDays: 1,
autoPayEnabled: true,
paymentSchedule,
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
},
});
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false });
expect(reports).toHaveLength(1);
expect(reports[0].success).toBe(true);
expect(reports[0].paymentAmountCents).toBe(3000);
});
it("handles multiple plans with different schedules", async () => {
// Plan 1: Ready for payment
await prisma.fixedPlan.create({
data: {
userId: U,
name: "Ready Plan",
totalCents: 50000n,
fundedCents: 50000n,
currentFundedCents: 50000n,
priority: 10,
cycleStart: new Date("2025-01-01T00:00:00Z"),
dueOn: new Date("2025-01-01T00:00:00Z"),
periodDays: 30,
autoPayEnabled: true,
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
},
});
// Plan 2: Not ready (insufficient funding)
await prisma.fixedPlan.create({
data: {
userId: U,
name: "Not Ready Plan",
totalCents: 100000n,
fundedCents: 30000n, // Only 30% funded
currentFundedCents: 30000n,
priority: 20,
cycleStart: new Date("2025-01-01T00:00:00Z"),
dueOn: new Date("2025-01-01T00:00:00Z"),
periodDays: 30,
autoPayEnabled: true,
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
},
});
// Plan 3: Auto-pay disabled
await prisma.fixedPlan.create({
data: {
userId: U,
name: "Disabled Plan",
totalCents: 75000n,
fundedCents: 75000n,
currentFundedCents: 75000n,
priority: 30,
cycleStart: new Date("2025-01-01T00:00:00Z"),
dueOn: new Date("2025-01-01T00:00:00Z"),
periodDays: 30,
autoPayEnabled: false, // Disabled
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
},
});
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false });
// Should only process the first two plans (third is disabled)
expect(reports).toHaveLength(2);
const successfulPayments = reports.filter(r => r.success);
const failedPayments = reports.filter(r => !r.success);
expect(successfulPayments).toHaveLength(1);
expect(failedPayments).toHaveLength(1);
expect(successfulPayments[0].name).toBe("Ready Plan");
expect(failedPayments[0].name).toBe("Not Ready Plan");
// Verify only one transaction was created
const transactions = await prisma.transaction.findMany({
where: { kind: "fixed_payment" },
});
expect(transactions).toHaveLength(1);
});
it("handles dry run mode", async () => {
await prisma.fixedPlan.create({
data: {
userId: U,
name: "Dry Run Plan",
totalCents: 100000n,
fundedCents: 100000n,
currentFundedCents: 100000n,
priority: 10,
cycleStart: new Date("2025-01-01T00:00:00Z"),
dueOn: new Date("2025-01-01T00:00:00Z"),
periodDays: 30,
autoPayEnabled: true,
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
nextPaymentDate: new Date("2025-01-01T09:00:00Z"),
},
});
// Run in dry-run mode
const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: true });
expect(reports).toHaveLength(1);
expect(reports[0].success).toBe(true);
expect(reports[0].paymentAmountCents).toBe(100000);
// Verify no transaction was created in dry-run mode
const transaction = await prisma.transaction.findFirst({
where: { kind: "fixed_payment" },
});
expect(transaction).toBeNull();
// Verify plan was not modified in dry-run mode
const plan = await prisma.fixedPlan.findFirst({
where: { name: "Dry Run Plan" },
});
expect(Number(plan?.fundedCents)).toBe(100000); // Still fully funded
expect(plan?.lastAutoPayment).toBeNull(); // Not updated
});
it("calculates next payment dates correctly for end-of-month scenarios", async () => {
const paymentSchedule: PaymentSchedule = {
frequency: "monthly",
dayOfMonth: 31, // End of month
minFundingPercent: 100,
};
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "End of Month Plan",
totalCents: 100000n,
fundedCents: 100000n,
currentFundedCents: 100000n,
priority: 10,
cycleStart: new Date("2025-01-31T00:00:00Z"), // January 31st
dueOn: new Date("2025-01-31T00:00:00Z"),
periodDays: 30,
autoPayEnabled: true,
paymentSchedule,
nextPaymentDate: new Date("2025-01-31T09:00:00Z"),
},
select: { id: true },
});
const reports = await processAutoPayments(prisma, "2025-01-31T10:00:00Z", { dryRun: false });
expect(reports).toHaveLength(1);
expect(reports[0].success).toBe(true);
// Verify next payment is February 28th (since Feb doesn't have 31 days)
const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({
where: { id: plan.id },
});
const nextPayment = updatedPlan.nextPaymentDate!;
expect(nextPayment.getMonth()).toBe(1); // February
expect(nextPayment.getDate()).toBe(28); // February 28th (not 31st)
});
});

View File

@@ -0,0 +1,132 @@
// tests/budget-allocation.test.ts
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
import { allocateBudget } from "../src/allocator";
describe("Budget Allocation for Irregular Income", () => {
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
// Update user to irregular income type
await prisma.user.update({
where: { id: U },
data: {
incomeType: "irregular",
totalBudgetCents: 300000n, // $3000 monthly budget
budgetPeriod: "monthly",
},
});
});
afterAll(async () => {
await closePrisma();
});
describe("allocateBudget", () => {
it("allocates budget between fixed plans and variable categories", async () => {
const c1 = cid("groceries");
const c2 = cid("savings");
await prisma.variableCategory.createMany({
data: [
{ id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n },
{ id: c2, userId: U, name: "Savings", percent: 40, priority: 1, isSavings: true, balanceCents: 0n },
],
});
const p1 = pid("rent");
await prisma.fixedPlan.create({
data: {
id: p1,
userId: U,
name: "Rent",
cycleStart: new Date().toISOString(),
dueOn: new Date(Date.now() + 21 * 86400000).toISOString(), // due in 3 weeks
totalCents: 120000n, // $1200 rent
fundedCents: 0n,
currentFundedCents: 0n,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
// Allocate $3000 budget with 30% to fixed expenses
const result = await allocateBudget(prisma as any, U, 300000, 30);
expect(result).toBeDefined();
expect(result.totalBudgetCents).toBe(300000);
// Should have both fixed and variable allocations
expect(result.fixedAllocations.length).toBeGreaterThan(0);
expect(result.variableAllocations.length).toBeGreaterThan(0);
// Total allocation should not exceed budget
const totalAllocated = result.fundedBudgetCents + result.availableBudgetCents;
expect(totalAllocated).toBeLessThanOrEqual(300000);
// Rent should get some funding
const rentAllocation = result.fixedAllocations.find(a => a.fixedPlanId === p1);
expect(rentAllocation).toBeDefined();
expect(rentAllocation!.amountCents).toBeGreaterThan(0);
});
it("handles crisis mode with longer window for irregular income", async () => {
const c1 = cid("emergency");
await prisma.variableCategory.create({
data: { id: c1, userId: U, name: "Emergency", percent: 100, priority: 1, isSavings: true, balanceCents: 0n },
});
const p1 = pid("urgent");
await prisma.fixedPlan.create({
data: {
id: p1,
userId: U,
name: "Urgent Bill",
cycleStart: new Date().toISOString(),
dueOn: new Date(Date.now() + 10 * 86400000).toISOString(), // due in 10 days
totalCents: 80000n, // $800 - more than the 50% allocation ($500)
fundedCents: 0n,
currentFundedCents: 0n,
priority: 1,
fundingMode: "auto-on-deposit",
},
});
const result = await allocateBudget(prisma as any, U, 100000, 50); // 50% to fixed for crisis testing
// Crisis should be detected (10 days < 14 day window for irregular income)
expect(result.crisis.active).toBe(true);
expect(result.crisis.plans.length).toBeGreaterThan(0);
const urgentPlan = result.crisis.plans.find(p => p.id === p1);
expect(urgentPlan).toBeDefined();
});
it("creates and updates budget session", async () => {
const c1 = cid("test");
await prisma.variableCategory.create({
data: { id: c1, userId: U, name: "Test", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
});
const result = await allocateBudget(prisma as any, U, 200000, 40); // 40% to fixed expenses
expect(result.totalBudgetCents).toBe(200000);
expect(result.availableBudgetCents).toBeGreaterThan(0);
});
it("handles zero budget gracefully", async () => {
const c1 = cid("test");
await prisma.variableCategory.create({
data: { id: c1, userId: U, name: "Test", percent: 100, priority: 1, isSavings: false, balanceCents: 0n },
});
const result = await allocateBudget(prisma as any, U, 0, 30); // 30% to fixed expenses
expect(result.totalBudgetCents).toBe(0);
expect(result.fundedBudgetCents).toBe(0);
expect(result.availableBudgetCents).toBe(0);
expect(result.fixedAllocations).toHaveLength(0);
expect(result.variableAllocations).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { allocateBudget, applyIrregularIncome, previewAllocation } from '../src/allocator.ts';
import { prisma, U, resetUser, ensureUser, cid, pid } from './helpers.ts';
describe('Irregular Income Allocation', () => {
const userId = U;
beforeEach(async () => {
await resetUser(userId);
await ensureUser(userId);
});
async function createFixedPlan(totalCents: number, name: string, priority: number, dueInDays: number) {
const id = pid();
const now = new Date();
await prisma.fixedPlan.create({
data: {
id,
userId,
name,
totalCents: BigInt(totalCents),
priority,
dueOn: new Date(Date.now() + dueInDays * 24 * 60 * 60 * 1000),
cycleStart: now, // Required field
},
});
return id;
}
async function createVariableCategory(name: string, percent: number, priority: number, isSavings = false) {
const id = cid();
await prisma.variableCategory.create({
data: {
id,
userId,
name,
percent,
priority,
isSavings,
},
});
return id;
}
async function createIncomeEvent(amountCents: number, daysAgo = 0) {
const id = `income_${Date.now()}_${Math.random()}`;
await prisma.incomeEvent.create({
data: {
id,
userId,
amountCents: BigInt(amountCents),
postedAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000),
},
});
return id;
}
describe('Basic Allocation Logic', () => {
it('should allocate percentage of new income to fixed expenses', async () => {
// Setup: Create fixed plans and variable categories
const rentId = await createFixedPlan(50000, 'Rent', 1, 10); // $500, due in 10 days (reduced so both can get funding)
const carId = await createFixedPlan(40000, 'Car Payment', 2, 5); // $400, due in 5 days
await createVariableCategory('Groceries', 50, 1);
await createVariableCategory('Entertainment', 30, 2);
await createVariableCategory('Savings', 20, 3, true);
// Add some available budget
await createIncomeEvent(50000, 1); // $500 from 1 day ago
// Test: Allocate $2,000 with 30% to fixed expenses
const result = await allocateBudget(prisma, userId, 200000, 30);
expect(result.totalBudgetCents).toBe(200000); // $2,000 new income
// Fixed expenses should get 30% of new income = $600
const totalFixedAllocated = result.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
expect(totalFixedAllocated).toBe(60000); // $600
// Car payment (5 days, crisis for irregular) should get some allocation
const carAllocation = result.fixedAllocations.find(a => a.fixedPlanId === carId);
expect(carAllocation).toBeDefined();
expect(carAllocation!.amountCents).toBeGreaterThan(0);
// Variables should get available budget + remaining new income
const totalVariableAllocated = result.variableAllocations.reduce((sum, a) => sum + a.amountCents, 0);
expect(totalVariableAllocated).toBeGreaterThan(0);
});
it('should treat plans due within 14 days as crisis', async () => {
// Setup: Create fixed plan due in 12 days
const urgentBillId = await createFixedPlan(80000, 'Urgent Bill', 1, 12); // $800, due in 12 days
const result = await allocateBudget(prisma, userId, 100000, 50); // $1,000 with 50% to fixed
expect(result.crisis.active).toBe(true);
expect(result.crisis.plans).toHaveLength(1);
expect(result.crisis.plans[0].id).toBe(urgentBillId);
expect(result.crisis.plans[0].daysUntilDue).toBe(12);
});
it('should work with different fixed expense percentages', async () => {
await createFixedPlan(100000, 'Rent', 1, 30); // $1,000
await createVariableCategory('Everything', 100, 1);
// Test 10% to fixed expenses
const result10 = await allocateBudget(prisma, userId, 200000, 10);
const fixed10 = result10.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
// Test 50% to fixed expenses
const result50 = await allocateBudget(prisma, userId, 200000, 50);
const fixed50 = result50.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
expect(fixed50).toBeGreaterThan(fixed10);
console.log('10% allocation to fixed:', fixed10);
console.log('50% allocation to fixed:', fixed50);
});
});
describe('Comparison with Regular Income', () => {
it('should show meaningful differences from regular income allocation', async () => {
// Setup same scenario for both
await createFixedPlan(120000, 'Rent', 1, 10); // $1,200, due in 10 days
await createFixedPlan(40000, 'Car Payment', 2, 5); // $400, due in 5 days
await createVariableCategory('Groceries', 60, 1);
await createVariableCategory('Savings', 40, 2, true);
// Add available budget
await createIncomeEvent(75000, 1); // $750 available
// Set user to biweekly frequency for regular income test
await prisma.user.update({
where: { id: userId },
data: { incomeFrequency: 'biweekly' }
});
// Test regular income allocation
const regularResult = await previewAllocation(prisma, userId, 200000); // $2,000
// Test irregular income allocation
const irregularResult = await allocateBudget(prisma, userId, 200000, 30); // $2,000 with 30% fixed
// Compare results
const regularFixedTotal = regularResult.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
const irregularFixedTotal = irregularResult.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
// The strategies should be different
expect(regularFixedTotal).not.toBe(irregularFixedTotal);
console.log('Regular Income Fixed Allocation:', regularFixedTotal);
console.log('Irregular Income Fixed Allocation:', irregularFixedTotal);
console.log('Irregular should be capped at 30% = $600:', irregularFixedTotal <= 60000);
});
});
describe('Edge Cases', () => {
it('should handle 0% fixed expense allocation', async () => {
await createFixedPlan(100000, 'Rent', 1, 30);
await createVariableCategory('Everything', 100, 1);
const result = await allocateBudget(prisma, userId, 200000, 0); // 0% to fixed
const fixedTotal = result.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0);
expect(fixedTotal).toBe(0);
});
it('should handle no fixed expenses', async () => {
await createVariableCategory('Everything', 100, 1);
const result = await allocateBudget(prisma, userId, 200000, 30);
expect(result.fixedAllocations).toHaveLength(0);
const variableTotal = result.variableAllocations.reduce((sum, a) => sum + a.amountCents, 0);
expect(variableTotal).toBe(200000); // All income to variables
});
});
});

View File

@@ -0,0 +1,230 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest";
import appFactory from "./appFactory";
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
beforeAll(async () => {
app = await appFactory();
});
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
});
afterAll(async () => {
await app.close();
await closePrisma();
});
describe("Payment-Triggered Rollover", () => {
it("advances due date for weekly frequency on payment", async () => {
// Create a fixed plan with weekly frequency
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Weekly Subscription",
totalCents: 1000n,
fundedCents: 1000n,
currentFundedCents: 1000n,
priority: 10,
cycleStart: new Date("2025-11-01T00:00:00Z"),
dueOn: new Date("2025-12-01T00:00:00Z"),
frequency: "weekly",
},
});
// Make payment
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 1000,
planId: plan.id,
});
if (txRes.status !== 200) {
console.log("Response status:", txRes.status);
console.log("Response body:", txRes.body);
}
expect(txRes.status).toBe(200);
// Check plan was updated with next due date (7 days later)
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n);
expect(updated?.currentFundedCents).toBe(0n);
expect(updated?.dueOn.toISOString()).toBe("2025-12-08T00:00:00.000Z");
});
it("advances due date for biweekly frequency on payment", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Biweekly Bill",
totalCents: 5000n,
fundedCents: 5000n,
currentFundedCents: 5000n,
priority: 10,
cycleStart: new Date("2025-11-01T00:00:00Z"),
dueOn: new Date("2025-12-01T00:00:00Z"),
frequency: "biweekly",
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 5000,
planId: plan.id,
});
expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n);
expect(updated?.dueOn.toISOString()).toBe("2025-12-15T00:00:00.000Z");
});
it("advances due date for monthly frequency on payment", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Monthly Rent",
totalCents: 100000n,
fundedCents: 100000n,
currentFundedCents: 100000n,
priority: 10,
cycleStart: new Date("2025-11-01T00:00:00Z"),
dueOn: new Date("2025-12-01T00:00:00Z"),
frequency: "monthly",
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 100000,
planId: plan.id,
});
expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n);
expect(updated?.dueOn.toISOString()).toBe("2026-01-01T00:00:00.000Z");
});
it("does not advance due date for one-time frequency", async () => {
const originalDueDate = new Date("2025-12-01T00:00:00Z");
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "One-time Expense",
totalCents: 2000n,
fundedCents: 2000n,
currentFundedCents: 2000n,
priority: 10,
cycleStart: new Date("2025-11-01T00:00:00Z"),
dueOn: originalDueDate,
frequency: "one-time",
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 2000,
planId: plan.id,
});
expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n);
// Due date should remain unchanged for one-time expenses
expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString());
});
it("does not advance due date when no frequency is set", async () => {
const originalDueDate = new Date("2025-12-01T00:00:00Z");
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Manual Bill",
totalCents: 3000n,
fundedCents: 3000n,
currentFundedCents: 3000n,
priority: 10,
cycleStart: new Date("2025-11-01T00:00:00Z"),
dueOn: originalDueDate,
frequency: null,
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 3000,
planId: plan.id,
});
expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n);
// Due date should remain unchanged when no frequency
expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString());
});
it("prevents payment when insufficient funded amount", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Underfunded Bill",
totalCents: 10000n,
fundedCents: 5000n,
currentFundedCents: 5000n,
priority: 10,
cycleStart: new Date("2025-11-01T00:00:00Z"),
dueOn: new Date("2025-12-01T00:00:00Z"),
frequency: "monthly",
},
});
// Try to pay more than funded amount
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 10000,
planId: plan.id,
});
expect(txRes.status).toBe(400);
expect(txRes.body.code).toBe("OVERDRAFT_PLAN");
// Plan should remain unchanged
const unchanged = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(unchanged?.fundedCents).toBe(5000n);
expect(unchanged?.dueOn.toISOString()).toBe("2025-12-01T00:00:00.000Z");
});
});

View File

@@ -0,0 +1,63 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
import { rolloverFixedPlans } from "../src/jobs/rollover";
describe("rolloverFixedPlans", () => {
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
});
it("advances overdue plans and resets funding", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Test Plan",
totalCents: 10000n,
fundedCents: 6000n,
priority: 10,
cycleStart: new Date("2025-01-01T00:00:00Z"),
dueOn: new Date("2025-01-05T00:00:00Z"),
periodDays: 30,
},
select: { id: true },
});
const results = await rolloverFixedPlans(prisma, "2025-01-10T00:00:00Z");
const match = results.find((r) => r.planId === plan.id);
expect(match?.cyclesAdvanced).toBe(1);
expect(match?.deficitCents).toBe(4000);
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
expect(updated.fundedCents).toBe(0n);
expect(updated.dueOn.toISOString()).toBe("2025-02-04T00:00:00.000Z");
});
it("handles multiple missed cycles and carries surplus", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Surplus Plan",
totalCents: 5000n,
fundedCents: 12000n,
priority: 5,
cycleStart: new Date("2025-01-01T00:00:00Z"),
dueOn: new Date("2025-01-10T00:00:00Z"),
periodDays: 15,
},
select: { id: true },
});
const results = await rolloverFixedPlans(prisma, "2025-02-05T00:00:00Z");
const match = results.find((r) => r.planId === plan.id);
expect(match?.cyclesAdvanced).toBe(2);
expect(match?.carryForwardCents).toBe(2000);
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
expect(updated.fundedCents).toBe(2000n);
});
});
afterAll(async () => {
await closePrisma();
});

View File

@@ -9,6 +9,8 @@ process.env.DATABASE_URL =
process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port
process.env.HOST ??= "127.0.0.1";
process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || "";
process.env.AUTH_DISABLED = process.env.AUTH_DISABLED || "1";
process.env.SEED_DEFAULT_BUDGET = process.env.SEED_DEFAULT_BUDGET || "1";
export const prisma = new PrismaClient();
@@ -25,8 +27,18 @@ export async function resetUser(userId: string) {
beforeAll(async () => {
// make sure the schema is applied before running tests
execSync("npx prisma migrate deploy", { stdio: "inherit" });
// Ensure a clean slate: wipe all tables to avoid cross-file leakage
await prisma.$transaction([
prisma.allocation.deleteMany({}),
prisma.transaction.deleteMany({}),
prisma.incomeEvent.deleteMany({}),
prisma.fixedPlan.deleteMany({}),
prisma.variableCategory.deleteMany({}),
prisma.user.deleteMany({}),
]);
});
afterAll(async () => {
await prisma.$disconnect();
});
});

View File

@@ -1,7 +1,7 @@
import request from "supertest";
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
@@ -16,16 +16,34 @@ afterAll(async () => {
});
describe("GET /transactions", () => {
let catId: string;
let planId: string;
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
const c = cid("c");
catId = cid("c");
planId = pid("p");
await prisma.variableCategory.create({
data: { id: c, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n },
data: { id: catId, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n },
});
await prisma.fixedPlan.create({
data: {
id: planId,
userId: U,
name: "Rent",
totalCents: 10000n,
fundedCents: 2000n,
priority: 1,
cycleStart: new Date().toISOString(),
dueOn: new Date(Date.now() + 864e5).toISOString(),
fundingMode: "auto-on-deposit",
},
});
// seed some transactions of different kinds/dates
await prisma.transaction.createMany({
data: [
{
@@ -33,7 +51,7 @@ describe("GET /transactions", () => {
userId: U,
occurredAt: new Date("2025-01-03T12:00:00.000Z"),
kind: "variable_spend",
categoryId: c,
categoryId: catId,
amountCents: 1000n,
},
{
@@ -41,17 +59,13 @@ describe("GET /transactions", () => {
userId: U,
occurredAt: new Date("2025-01-10T12:00:00.000Z"),
kind: "fixed_payment",
planId: null,
planId,
amountCents: 2000n,
},
],
});
});
afterAll(async () => {
await closePrisma();
});
it("paginates + filters by kind/date", async () => {
const res = await request(app.server)
.get("/transactions?from=2025-01-02&to=2025-01-06&kind=variable_spend&page=1&limit=10")
@@ -63,4 +77,129 @@ describe("GET /transactions", () => {
expect(body.items.length).toBe(1);
expect(body.items[0].kind).toBe("variable_spend");
});
it("filters by bucket id for either category or plan", async () => {
const byCategory = await request(app.server)
.get(`/transactions?bucketId=${catId}`)
.set("x-user-id", U);
expect(byCategory.status).toBe(200);
expect(byCategory.body.items.every((t: any) => t.categoryId === catId)).toBe(true);
const byPlan = await request(app.server)
.get(`/transactions?bucketId=${planId}`)
.set("x-user-id", U);
expect(byPlan.status).toBe(200);
expect(byPlan.body.items.every((t: any) => t.planId === planId)).toBe(true);
});
});
describe("POST /transactions", () => {
const dateISO = new Date().toISOString();
let catId: string;
let planId: string;
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
catId = cid("cat");
planId = pid("plan");
await prisma.variableCategory.create({
data: {
id: catId,
userId: U,
name: "Dining",
percent: 100,
priority: 1,
isSavings: false,
balanceCents: 5000n,
},
});
await prisma.fixedPlan.create({
data: {
id: planId,
userId: U,
name: "Loan",
totalCents: 10000n,
fundedCents: 3000n,
priority: 1,
cycleStart: new Date().toISOString(),
dueOn: new Date(Date.now() + 864e5).toISOString(),
fundingMode: "auto-on-deposit",
},
});
});
it("spends from a variable category and updates balance", async () => {
const res = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
.send({
kind: "variable_spend",
amountCents: 2000,
occurredAtISO: dateISO,
categoryId: catId,
note: "Groceries run",
receiptUrl: "https://example.com/receipt",
isReconciled: true,
});
expect(res.status).toBe(200);
const category = await prisma.variableCategory.findUniqueOrThrow({ where: { id: catId } });
expect(Number(category.balanceCents)).toBe(3000);
const tx = await prisma.transaction.findFirstOrThrow({ where: { userId: U, categoryId: catId } });
expect(tx.note).toBe("Groceries run");
expect(tx.receiptUrl).toBe("https://example.com/receipt");
expect(tx.isReconciled).toBe(true);
});
it("prevents overdrawing fixed plans", async () => {
const res = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
.send({
kind: "fixed_payment",
amountCents: 400000, // exceeds funded
occurredAtISO: dateISO,
planId,
});
expect(res.status).toBe(400);
expect(res.body.code).toBe("OVERDRAFT_PLAN");
});
it("updates note/receipt and reconciliation via patch", async () => {
const created = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
.send({
kind: "variable_spend",
amountCents: 1000,
occurredAtISO: dateISO,
categoryId: catId,
});
expect(created.status).toBe(200);
const txId = created.body.id;
const res = await request(app.server)
.patch(`/transactions/${txId}`)
.set("x-user-id", U)
.send({
note: "Cleared",
isReconciled: true,
receiptUrl: "https://example.com/r.pdf",
});
expect(res.status).toBe(200);
expect(res.body.isReconciled).toBe(true);
expect(res.body.note).toBe("Cleared");
expect(res.body.receiptUrl).toBe("https://example.com/r.pdf");
const tx = await prisma.transaction.findUniqueOrThrow({ where: { id: txId } });
expect(tx.isReconciled).toBe(true);
});
});

View File

@@ -8,7 +8,12 @@ export default defineConfig({
pool: "threads",
poolOptions: { threads: { singleThread: true } },
testTimeout: 30_000,
env: { NODE_ENV: "test" },
env: {
NODE_ENV: "test",
AUTH_DISABLED: "1",
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
SEED_DEFAULT_BUDGET: "1"
},
setupFiles: ['tests/setup.ts'],
},
});

5
cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# 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

5
cookies2.txt Normal file
View File

@@ -0,0 +1,5 @@
# 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

5
cookies_debug.txt Normal file
View File

@@ -0,0 +1,5 @@
# 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

5
cookies_fixed.txt Normal file
View File

@@ -0,0 +1,5 @@
# 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

5
cookies_immediate.txt Normal file
View File

@@ -0,0 +1,5 @@
# 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

5
cookies_login.txt Normal file
View File

@@ -0,0 +1,5 @@
# 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

5
cookies_test.txt Normal file
View File

@@ -0,0 +1,5 @@
# 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

@@ -0,0 +1,39 @@
server {
listen 80;
server_name skymoneybudget.com;
# Redirect all HTTP to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name skymoneybudget.com;
# TLS certs (adjust paths if using certbot or another ACME client)
ssl_certificate /etc/letsencrypt/live/skymoneybudget.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/skymoneybudget.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Static web app
root /var/www/skymoney/dist;
index index.html;
location / {
try_files $uri /index.html;
}
# API reverse proxy
location /api/ {
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
}
}

View File

@@ -1,19 +1,18 @@
version: "3.9"
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: skymoney
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: ${POSTGRES_DB:-skymoney}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d skymoney"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-skymoney}"]
interval: 5s
timeout: 3s
retries: 10
@@ -23,10 +22,14 @@ services:
context: ./api
dockerfile: Dockerfile
environment:
NODE_ENV: production
PORT: "8080"
DATABASE_URL: postgres://app:app@postgres:5432/skymoney
CORS_ORIGIN: http://localhost:5173
NODE_ENV: ${NODE_ENV:-development}
PORT: ${PORT:-8080}
DATABASE_URL: ${DATABASE_URL:-postgres://app:app@postgres:5432/skymoney}
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173}
SEED_DEFAULT_BUDGET: ${SEED_DEFAULT_BUDGET:-0}
AUTH_DISABLED: ${AUTH_DISABLED:-false}
JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me}
COOKIE_SECRET: ${COOKIE_SECRET:-dev-cookie-secret-change-me}
depends_on:
- postgres
@@ -57,5 +60,23 @@ services:
timeout: 3s
retries: 10
scheduler:
build:
context: ./api
dockerfile: Dockerfile
command: ["node", "dist/worker/rollover.js"]
environment:
NODE_ENV: ${NODE_ENV:-production}
DATABASE_URL: ${DATABASE_URL:-postgres://app:app@postgres:5432/skymoney}
ROLLOVER_SCHEDULE_CRON: "${ROLLOVER_SCHEDULE_CRON:-0 6 * * *}"
restart: unless-stopped
depends_on:
- postgres
healthcheck:
test: ["CMD-SHELL", "node -e \"process.exit(0)\""]
interval: 60s
timeout: 5s
retries: 3
volumes:
pgdata:

0
exporting Normal file
View File

5
irregular_cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# 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

5
regular_cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# 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

25
scripts/backup.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ -z "${DATABASE_URL:-}" && -z "${BACKUP_DATABASE_URL:-}" ]]; then
echo "DATABASE_URL or BACKUP_DATABASE_URL is required."
exit 1
fi
ENV_FILE="${ENV_FILE:-./.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck source=/dev/null
. "$ENV_FILE"
set +a
fi
OUT_DIR="${BACKUP_DIR:-./backups}"
mkdir -p "$OUT_DIR"
STAMP="$(date +%F_%H%M%S)"
OUT_FILE="${OUT_DIR}/skymoney_${STAMP}.dump"
pg_dump "${BACKUP_DATABASE_URL:-$DATABASE_URL}" -Fc -f "$OUT_FILE"
echo "Backup written to: $OUT_FILE"

37
scripts/restore.sh Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${ENV_FILE:-./.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck source=/dev/null
. "$ENV_FILE"
set +a
fi
if [[ -z "${BACKUP_FILE:-}" ]]; then
echo "BACKUP_FILE is required."
exit 1
fi
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "DATABASE_URL is required."
exit 1
fi
RESTORE_DB="${RESTORE_DB:-skymoney_restore_test}"
RESTORE_URL="${RESTORE_DATABASE_URL:-}"
ADMIN_URL="${ADMIN_DATABASE_URL:-$DATABASE_URL}"
if [[ -z "$RESTORE_URL" ]]; then
echo "RESTORE_DATABASE_URL is required (example: postgresql://user:pass@host:5432/${RESTORE_DB})."
exit 1
fi
echo "Creating restore database: ${RESTORE_DB}"
psql "$ADMIN_URL" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${RESTORE_DB}\";" >/dev/null
psql "$ADMIN_URL" -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"${RESTORE_DB}\";" >/dev/null
pg_restore --no-owner --no-privileges --dbname="$RESTORE_URL" "$BACKUP_FILE"
echo "Restore completed into: ${RESTORE_DB}"

View File

@@ -5,6 +5,36 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script>
// Initialize theme immediately to prevent flash and enable Tailwind dark: classes
(function() {
const theme = localStorage.getItem("theme") || "system";
const actualTheme = theme === "system"
? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: theme;
const scheme = localStorage.getItem("colorScheme") || "blue";
document.documentElement.classList.remove("scheme-blue", "scheme-green", "scheme-purple", "scheme-orange");
document.documentElement.classList.add(`scheme-${scheme}`);
if (actualTheme === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
document.documentElement.classList.add("dark");
if (document.body) {
document.body.classList.add("dark");
document.body.setAttribute("data-theme", "dark");
}
} else {
document.documentElement.setAttribute("data-theme", "light");
document.documentElement.classList.remove("dark");
if (document.body) {
document.body.classList.remove("dark");
document.body.setAttribute("data-theme", "light");
}
}
})();
</script>
</head>
<body>
<div id="root"></div>

88
web/package-lock.json generated
View File

@@ -8,11 +8,14 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"react-router-dom": "^7.12.0",
"recharts": "^3.4.1",
"tailwindcss": "^4.1.17",
"zod": "^4.1.12"
@@ -316,6 +319,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -2479,12 +2535,16 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
@@ -4026,9 +4086,9 @@
}
},
"node_modules/react-router": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
"integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -4048,12 +4108,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
"integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.6"
"react-router": "7.12.0"
},
"engines": {
"node": ">=20.0.0"
@@ -4441,6 +4501,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -12,11 +12,14 @@
"test:watch": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"react-router-dom": "^7.12.0",
"recharts": "^3.4.1",
"tailwindcss": "^4.1.17",
"zod": "^4.1.12"

View File

@@ -1,63 +1,18 @@
import { Link, NavLink, Route, Routes } from "react-router-dom";
import DashboardPage from "./pages/DashboardPage";
import IncomePage from "./pages/IncomePage";
import TransactionsPage from "./pages/TransactionsPage";
import { Suspense } from "react";
import { Outlet } from "react-router-dom";
import { SessionTimeoutWarning } from "./components/SessionTimeoutWarning";
import NavBar from "./components/NavBar";
export default function App() {
return (
<div className="min-h-screen bg-[--color-bg] text-[--color-fg]">
<TopNav />
<main className="container">
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/income" element={<IncomePage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
<>
<SessionTimeoutWarning />
<NavBar />
<main className="container py-6 h-full">
<Suspense fallback={<div className="muted text-sm">Loading</div>}>
<Outlet />
</Suspense>
</main>
<footer className="container py-8 text-center text-sm muted">
SkyMoney {new Date().getFullYear()}
</footer>
</div>
);
}
function TopNav() {
return (
<header className="border-b border-[--color-ink] bg-[--color-panel]">
<div className="container h-14 flex items-center gap-4">
<Link to="/" className="font-bold">
SkyMoney
</Link>
<Nav to="/">Dashboard</Nav>
<Nav to="/income">Income</Nav>
<Nav to="/transactions">Transactions</Nav>
<div className="ml-auto text-xs muted">demo user</div>
</div>
</header>
);
}
function Nav({ to, children }: { to: string; children: React.ReactNode }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
"px-3 py-1 rounded-xl hover:bg-[--color-ink]/60 " +
(isActive ? "bg-[--color-ink]" : "")
}
end
>
{children}
</NavLink>
);
}
function NotFound() {
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-2">404</h1>
<p className="muted">This page got lost in the budget cuts.</p>
</div>
</>
);
}

77
web/src/api/budget.ts Normal file
View File

@@ -0,0 +1,77 @@
import { http } from "./http";
export interface BudgetAllocationRequest {
newIncomeCents: number;
fixedExpensePercentage: number;
postedAtISO?: string;
}
export interface BudgetAllocationResponse {
fixedAllocations: Array<{
fixedPlanId: string;
amountCents: number;
source: string;
}>;
variableAllocations: Array<{
variableCategoryId: string;
amountCents: number;
}>;
totalBudgetCents: number;
fundedBudgetCents: number;
availableBudgetCents: number;
remainingBudgetCents: number;
crisis: {
active: boolean;
plans: Array<{
id: string;
name: string;
remainingCents: number;
daysUntilDue: number;
priority: number;
allocatedCents: number;
}>;
};
planStatesAfter: Array<{
id: string;
name: string;
totalCents: number;
fundedCents: number;
remainingCents: number;
daysUntilDue: number;
isCrisis: boolean;
}>;
}
export interface BudgetReconcileRequest {
bankTotalCents: number;
}
export interface BudgetReconcileResponse {
ok: boolean;
deltaCents: number;
currentTotalCents: number;
newTotalCents: number;
}
export const budgetApi = {
async allocate(data: BudgetAllocationRequest): Promise<BudgetAllocationResponse> {
return http<BudgetAllocationResponse>("/budget/allocate", {
method: "POST",
body: data,
});
},
async fund(data: BudgetAllocationRequest): Promise<BudgetAllocationResponse> {
return http<BudgetAllocationResponse>("/budget/fund", {
method: "POST",
body: data,
});
},
async reconcile(data: BudgetReconcileRequest): Promise<BudgetReconcileResponse> {
return http<BudgetReconcileResponse>("/budget/reconcile", {
method: "POST",
body: data,
});
},
};

View File

@@ -1,4 +1,4 @@
import { request } from "./client";
import { apiDelete, apiPatch, apiPost } from "./http";
export type NewCategory = {
name: string;
@@ -9,22 +9,8 @@ export type NewCategory = {
export type UpdateCategory = Partial<NewCategory>;
export const categoriesApi = {
create: (body: NewCategory) =>
request<{ id: number }>("/variable-categories", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
update: (id: number, body: UpdateCategory) =>
request(`/variable-categories/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
delete: (id: number) =>
request(`/variable-categories/${id}`, {
method: "DELETE"
})
};
create: (body: NewCategory) => apiPost<{ id: string }>("/variable-categories", body),
update: (id: string, body: UpdateCategory) => apiPatch(`/variable-categories/${id}`, body),
delete: (id: string) => apiDelete(`/variable-categories/${id}`),
rebalance: () => apiPost<{ ok: boolean; applied?: boolean; totalBalance?: number }>("/variable-categories/rebalance", {}),
};

View File

@@ -1,30 +0,0 @@
export type ApiError = { status: number; message: string };
const base = "/api";
const KEY = "skymoney:userId";
export function getUserId(): string {
let id = localStorage.getItem(KEY);
if (!id) { id = "1"; localStorage.setItem(KEY, id); }
return id;
}
export function setUserId(id: string) {
localStorage.setItem(KEY, String(id || "1"));
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
headers: { "Content-Type": "application/json", "x-user-id": getUserId(), ...(init?.headers || {}) },
...init
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw { status: res.status, message: data?.message || res.statusText } as ApiError;
return data as T;
}
export const api = {
get: <T,>(path: string) => request<T>(path),
post: <T,>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
};

View File

@@ -1,4 +1,11 @@
import { request } from "./client";
import { apiDelete, apiGet, apiPatch, apiPost } from "./http";
export type PaymentScheduleInput = {
dayOfMonth?: number;
dayOfWeek?: number;
everyNDays?: number;
minFundingPercent?: number;
};
export type NewPlan = {
name: string;
@@ -6,22 +13,81 @@ export type NewPlan = {
fundedCents?: number; // optional, default 0
priority: number; // int
dueOn: string; // ISO date
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
autoPayEnabled?: boolean;
paymentSchedule?: PaymentScheduleInput | null;
nextPaymentDate?: string | null;
maxRetryAttempts?: number;
};
export type UpdatePlan = Partial<NewPlan>;
export const fixedPlansApi = {
create: (body: NewPlan) =>
request<{ id: number }>("/fixed-plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
update: (id: number, body: UpdatePlan) =>
request(`/fixed-plans/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
delete: (id: number) =>
request(`/fixed-plans/${id}`, { method: "DELETE" }),
};
create: (body: NewPlan) => apiPost<{ id: string }>("/fixed-plans", body),
update: (id: string, body: UpdatePlan) =>
apiPatch(`/fixed-plans/${id}`, body),
delete: (id: string) => apiDelete(`/fixed-plans/${id}`),
due: (query?: { asOf?: string; daysAhead?: number }) =>
apiGet<{ items: Array<{ id: string; name: string; dueOn: string; remainingCents: number; percentFunded: number; isDue: boolean; isOverdue: boolean }>; asOfISO: string }>(
"/fixed-plans/due",
query as any
),
attemptFinalFunding: (id: string) =>
apiPost<{
ok: boolean;
planId: string;
status: "fully_funded" | "overdue";
fundedCents: number;
totalCents: number;
isOverdue: boolean;
overdueAmount?: number;
message: string;
}>(`/fixed-plans/${id}/attempt-final-funding`, {}),
payNow: (
id: string,
body: {
occurredAtISO?: string;
overrideDueOnISO?: string;
fundingSource?: "funded" | "savings" | "deficit";
savingsCategoryId?: string;
note?: string;
}
) =>
apiPost<{
ok: boolean;
planId: string;
transactionId: string;
nextDueOn: string | null;
savingsUsed: boolean;
deficitCovered: boolean;
shortageCents: number;
}>(`/fixed-plans/${id}/pay-now`, body),
markUnpaid: (id: string) =>
apiPatch<{
ok: boolean;
planId: string;
isOverdue: boolean;
overdueAmount: number;
}>(`/fixed-plans/${id}/mark-unpaid`, {}),
fundFromAvailable: (id: string) =>
apiPost<{
ok: boolean;
planId: string;
funded: boolean;
fundedAmountCents: number;
fundedCents: number;
totalCents: number;
availableBudget?: number;
message: string;
}>(`/fixed-plans/${id}/fund-from-available`, {}),
catchUpFunding: (id: string) =>
apiPost<{
ok: boolean;
planId: string;
funded: boolean;
fundedAmountCents: number;
fundedCents: number;
totalCents: number;
availableBudget?: number;
message: string;
}>(`/fixed-plans/${id}/catch-up-funding`, {}),
};

View File

@@ -2,13 +2,14 @@
const BASE =
(typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_URL) ||
""; // e.g. "http://localhost:8080" or proxy
"/api"; // default to proxy prefix when no explicit API URL
type FetchOpts = {
method?: "GET" | "POST" | "PATCH" | "DELETE";
headers?: Record<string, string>;
body?: any;
query?: Record<string, string | number | boolean | undefined>;
skipAuthRedirect?: boolean;
};
function toQS(q?: FetchOpts["query"]) {
@@ -22,19 +23,48 @@ function toQS(q?: FetchOpts["query"]) {
return s ? `?${s}` : "";
}
async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const { method = "GET", headers = {}, body, query } = opts;
function getCookie(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
const cookies = document.cookie.split(";").map((cookie) => cookie.trim());
for (const entry of cookies) {
if (!entry) continue;
const [key, ...rest] = entry.split("=");
if (key === name) {
return decodeURIComponent(rest.join("="));
}
}
return undefined;
}
export async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const {
method = "GET",
headers = {},
body,
query,
skipAuthRedirect = false,
} = opts;
const url = `${BASE}${path}${toQS(query)}`;
const hasBody = body !== undefined;
const requestHeaders: Record<string, string> = { ...headers };
// Only set Content-Type header if we have a body to send
if (hasBody) {
requestHeaders["Content-Type"] = "application/json";
}
if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
const csrfToken = getCookie("csrf");
if (csrfToken && !requestHeaders["x-csrf-token"]) {
requestHeaders["x-csrf-token"] = csrfToken;
}
}
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
// The API defaults x-user-id if missing; add your own if you want:
// "x-user-id": localStorage.getItem("userId") ?? "demo-user-1",
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
credentials: "include",
headers: requestHeaders,
body: hasBody ? JSON.stringify(body) : undefined,
});
// Try to parse JSON either way
@@ -42,11 +72,44 @@ async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
if (
res.status === 401 &&
!skipAuthRedirect &&
typeof window !== "undefined"
) {
const next = `${window.location.pathname}${window.location.search}`;
const encoded = encodeURIComponent(next);
const dest =
encoded && encoded !== "%2F" ? `/login?next=${encoded}` : "/login";
window.location.assign(dest);
}
const msg =
(json && (json.message || json.error)) ||
`${res.status} ${res.statusText}` ||
"Request failed";
throw new Error(msg);
const err = new Error(msg) as Error & {
status?: number;
code?: string;
data?: any;
overdraftAmount?: number;
categoryName?: string;
currentBalance?: number;
availableBudget?: number;
shortage?: number;
};
err.status = res.status;
if (json && typeof json === "object") {
const payload = json as any;
if (payload.code) err.code = payload.code;
err.data = payload;
if (payload.overdraftAmount !== undefined) err.overdraftAmount = payload.overdraftAmount;
if (payload.categoryName !== undefined) err.categoryName = payload.categoryName;
if (payload.currentBalance !== undefined) err.currentBalance = payload.currentBalance;
if (payload.availableBudget !== undefined) err.availableBudget = payload.availableBudget;
if (payload.shortage !== undefined) err.shortage = payload.shortage;
}
throw err;
}
return json as T;
}

Some files were not shown because too many files have changed in this diff Show More