final touches for beta skymoney (at least i think)
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local backups
|
||||
backups/
|
||||
28
Caddyfile.prod
Normal file
28
Caddyfile.prod
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
README.md
20
README.md
@@ -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
|
||||
|
||||
|
||||
121
TIMEZONE_ROLLOVER_EXPLAINED.md
Normal file
121
TIMEZONE_ROLLOVER_EXPLAINED.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
34
api/check-allocations.cjs
Normal file
34
api/check-allocations.cjs
Normal 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
49
api/check-overdue.cjs
Normal 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();
|
||||
@@ -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,
|
||||
|
||||
135
api/create-multi-overdue-test.cjs
Normal file
135
api/create-multi-overdue-test.cjs
Normal 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();
|
||||
133
api/create-multi-overdue-user.cjs
Normal file
133
api/create-multi-overdue-user.cjs
Normal 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
91
api/create-test-user.cjs
Normal 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();
|
||||
@@ -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
255
api/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
1607
api/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "passwordHash" TEXT,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "VariableCategory" ADD COLUMN "savingsTargetCents" BIGINT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Transaction" ADD COLUMN "isReconciled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "note" TEXT,
|
||||
ADD COLUMN "receiptUrl" TEXT;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "displayName" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "IncomeEvent" ADD COLUMN "note" TEXT;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FixedPlan" ADD COLUMN "lastFundedPayPeriod" TIMESTAMP(3),
|
||||
ADD COLUMN "needsFundingThisPeriod" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "firstIncomeDate" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "VariableCategory" ADD COLUMN "isLocked" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FixedPlan" ADD COLUMN "frequency" TEXT;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'America/New_York';
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Transaction" ADD COLUMN "isAutoPayment" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "fixedExpensePercentage" INTEGER NOT NULL DEFAULT 40;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
1726
api/src/allocator.ts
1726
api/src/allocator.ts
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
259
api/src/jobs/auto-payments.ts
Normal file
259
api/src/jobs/auto-payments.ts
Normal 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
129
api/src/jobs/rollover.ts
Normal 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;
|
||||
}
|
||||
@@ -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` },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,7 +177,8 @@ 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 });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
73
api/src/scripts/manage-plan.ts
Normal file
73
api/src/scripts/manage-plan.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed: Record<string, string | boolean> = {};
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--")) {
|
||||
const [key, value] = arg.slice(2).split("=");
|
||||
parsed[key] = value === undefined ? true : value;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
const planId = args["planId"] as string | undefined;
|
||||
|
||||
if (!planId) {
|
||||
const plans = await prisma.fixedPlan.findMany({
|
||||
orderBy: { dueOn: "asc" },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
dueOn: true,
|
||||
cycleStart: true,
|
||||
periodDays: true,
|
||||
totalCents: true,
|
||||
fundedCents: true,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
plans,
|
||||
(_k, v) => (typeof v === "bigint" ? v.toString() : v),
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
if (args["dueOn"]) data.dueOn = new Date(String(args["dueOn"]));
|
||||
if (args["cycleStart"]) data.cycleStart = new Date(String(args["cycleStart"]));
|
||||
if (args["periodDays"]) data.periodDays = Number(args["periodDays"]);
|
||||
if (args["fundedCents"]) data.fundedCents = BigInt(args["fundedCents"]);
|
||||
if (args["totalCents"]) data.totalCents = BigInt(args["totalCents"]);
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
console.log("No fields provided to update.");
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await prisma.fixedPlan.update({
|
||||
where: { id: planId },
|
||||
data,
|
||||
select: { id: true, name: true, dueOn: true, cycleStart: true, fundedCents: true },
|
||||
});
|
||||
console.log("Updated plan:", updated);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
50
api/src/scripts/run-rollover.ts
Normal file
50
api/src/scripts/run-rollover.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { rolloverFixedPlans } from "../jobs/rollover.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed: Record<string, string | boolean> = {};
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--")) {
|
||||
const [key, value] = arg.slice(2).split("=");
|
||||
if (value === undefined) {
|
||||
parsed[key] = true;
|
||||
} else {
|
||||
parsed[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
const asOfRaw = (args["asOf"] as string | undefined) ?? undefined;
|
||||
const asOf = asOfRaw ? new Date(asOfRaw) : new Date();
|
||||
const dryRun = Boolean(args["dry-run"] ?? args["dryRun"]);
|
||||
|
||||
const results = await rolloverFixedPlans(prisma, asOf, { dryRun });
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
asOf: asOf.toISOString(),
|
||||
dryRun,
|
||||
processed: results.length,
|
||||
results,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
274
api/src/scripts/setup-frontend-test-user.ts
Normal file
274
api/src/scripts/setup-frontend-test-user.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function setupFrontendTestUser() {
|
||||
console.log("\n🔧 Setting up frontend test user...\n");
|
||||
|
||||
const email = "test@skymoney.com";
|
||||
const password = "password123";
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Delete existing test user if exists
|
||||
await prisma.user.deleteMany({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
displayName: "Test User",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
totalBudgetCents: 200000n, // $2,000
|
||||
firstIncomeDate: today,
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created user: ${user.email}`);
|
||||
console.log(` User ID: ${user.id}`);
|
||||
console.log(` Password: ${password}\n`);
|
||||
|
||||
// Create variable categories
|
||||
const savings = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Savings",
|
||||
percent: 30,
|
||||
balanceCents: 60000n, // $600
|
||||
isSavings: true,
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const groceries = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Groceries",
|
||||
percent: 40,
|
||||
balanceCents: 80000n, // $800
|
||||
isSavings: false,
|
||||
priority: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const entertainment = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Entertainment",
|
||||
percent: 30,
|
||||
balanceCents: 60000n, // $600
|
||||
isSavings: false,
|
||||
priority: 3,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created variable categories:`);
|
||||
console.log(` - Savings: 30% ($600 balance)`);
|
||||
console.log(` - Groceries: 40% ($800 balance)`);
|
||||
console.log(` - Entertainment: 30% ($600 balance)\n`);
|
||||
|
||||
// Create fixed plans
|
||||
|
||||
// Scenario 1: Rent - DUE TODAY, fully funded (test payment reconciliation)
|
||||
const dueToday = new Date(today);
|
||||
const rent = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Rent",
|
||||
totalCents: 150000n, // $1,500
|
||||
fundedCents: 150000n, // Fully funded
|
||||
currentFundedCents: 150000n,
|
||||
dueOn: dueToday,
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: false,
|
||||
needsFundingThisPeriod: false,
|
||||
priority: 10,
|
||||
cycleStart: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created Rent plan:`);
|
||||
console.log(` - Total: $1,500`);
|
||||
console.log(` - Funded: $1,500 (100%)`);
|
||||
console.log(` - Due: TODAY (${dueToday.toDateString()})`);
|
||||
console.log(` - Status: Ready for payment reconciliation!\n`);
|
||||
|
||||
// Scenario 2: Car Insurance - DUE TODAY, partially funded (test final funding attempt)
|
||||
const carInsurance = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Car Insurance",
|
||||
totalCents: 40000n, // $400
|
||||
fundedCents: 25000n, // $250 funded
|
||||
currentFundedCents: 25000n,
|
||||
dueOn: dueToday,
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: false,
|
||||
needsFundingThisPeriod: false,
|
||||
priority: 20,
|
||||
cycleStart: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created Car Insurance plan:`);
|
||||
console.log(` - Total: $400`);
|
||||
console.log(` - Funded: $250 (62.5%)`);
|
||||
console.log(` - Due: TODAY (${dueToday.toDateString()})`);
|
||||
console.log(` - Status: Will test final funding attempt!\n`);
|
||||
|
||||
// Scenario 3: Phone Bill - Not due yet, partially funded
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
const phoneBill = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Phone Bill",
|
||||
totalCents: 8000n, // $80
|
||||
fundedCents: 5000n, // $50 funded
|
||||
currentFundedCents: 5000n,
|
||||
dueOn: nextWeek,
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: false,
|
||||
needsFundingThisPeriod: true,
|
||||
priority: 30,
|
||||
cycleStart: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created Phone Bill plan:`);
|
||||
console.log(` - Total: $80`);
|
||||
console.log(` - Funded: $50 (62.5%)`);
|
||||
console.log(` - Due: Next week (${nextWeek.toDateString()})\n`);
|
||||
|
||||
// Create income event and allocations
|
||||
const incomeEvent = await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: 200000n, // $2,000
|
||||
postedAt: new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
|
||||
isScheduledIncome: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created income event:`);
|
||||
console.log(` - Amount: $2,000`);
|
||||
console.log(` - Posted: 2 days ago\n`);
|
||||
|
||||
// Create allocations (showing where the money went)
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: rent.id,
|
||||
amountCents: 150000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: carInsurance.id,
|
||||
amountCents: 25000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: phoneBill.id,
|
||||
amountCents: 5000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: savings.id,
|
||||
amountCents: 6000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: groceries.id,
|
||||
amountCents: 8000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: entertainment.id,
|
||||
amountCents: 6000n,
|
||||
incomeId: incomeEvent.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created allocations (total $2,000 distributed)\n`);
|
||||
|
||||
// Calculate available budget
|
||||
const totalIncome = 200000;
|
||||
const totalAllocated = 150000 + 25000 + 5000 + 6000 + 8000 + 6000;
|
||||
const availableBudget = totalIncome - totalAllocated;
|
||||
|
||||
console.log(`📊 Budget Summary:`);
|
||||
console.log(` - Total Income: $2,000`);
|
||||
console.log(` - Total Allocated: $${totalAllocated / 100}`);
|
||||
console.log(` - Available Budget: $${availableBudget / 100}\n`);
|
||||
|
||||
console.log(`🎯 TEST SCENARIOS:\n`);
|
||||
console.log(`1️⃣ RENT - Payment Reconciliation Modal:`);
|
||||
console.log(` - Navigate to Dashboard`);
|
||||
console.log(` - Should see "Rent" with 100% funded, due TODAY`);
|
||||
console.log(` - Modal should ask: "Was the full amount ($1,500) paid?"`);
|
||||
console.log(` - Test: Click "Yes, Full Amount" → Should create transaction & rollover\n`);
|
||||
|
||||
console.log(`2️⃣ CAR INSURANCE - Attempt Final Funding:`);
|
||||
console.log(` - Should see "Car Insurance" with 62.5% funded, due TODAY`);
|
||||
console.log(` - Modal should attempt to fund from available budget first`);
|
||||
console.log(` - Available: $0, Needed: $150`);
|
||||
console.log(` - Should mark as OVERDUE with $150 remaining`);
|
||||
console.log(` - Modal should show: "Could not fully fund. $250/$400 funded."`);
|
||||
console.log(` - Test: Click "Partial: $100" → Should refund $150 to available\n`);
|
||||
|
||||
console.log(`3️⃣ PHONE BILL - Regular funding:`);
|
||||
console.log(` - Due next week (not yet showing payment modal)`);
|
||||
console.log(` - Add income to test allocation to overdue bills\n`);
|
||||
|
||||
console.log(`📧 LOGIN CREDENTIALS:`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${password}\n`);
|
||||
|
||||
console.log(`🌐 Frontend URL: http://localhost:5174\n`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
setupFrontendTestUser()
|
||||
.catch((e) => {
|
||||
console.error("❌ Error:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
266
api/src/scripts/test-dashboard-edge.ts
Normal file
266
api/src/scripts/test-dashboard-edge.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
import { addDays, startOfDay } from "date-fns";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const PASSWORD = "password";
|
||||
|
||||
type Scenario = {
|
||||
name: string;
|
||||
timezone: string;
|
||||
incomeFrequency: "weekly" | "biweekly" | "monthly";
|
||||
firstIncomeDate: Date;
|
||||
incomeCents: number;
|
||||
variableCats: Array<{ name: string; percent: number; isSavings?: boolean }>;
|
||||
fixedPlans: Array<{
|
||||
name: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
dueOn: Date;
|
||||
frequency: "monthly" | "weekly" | "biweekly";
|
||||
autoPayEnabled?: boolean;
|
||||
paymentSchedule?: Record<string, any>;
|
||||
isOverdue?: boolean;
|
||||
overdueAmount?: number;
|
||||
overdueSince?: Date | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function zonedStartOfDay(date: Date, timeZone: string) {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const getPart = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||||
const year = Number(getPart("year"));
|
||||
const month = Number(getPart("month"));
|
||||
const day = Number(getPart("day"));
|
||||
const utcMidnight = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
|
||||
const tzDateStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(utcMidnight);
|
||||
const [mdy, hms] = tzDateStr.split(", ");
|
||||
const [mm, dd, yyyy] = mdy.split("/");
|
||||
const [hh, mi, ss] = hms.split(":");
|
||||
const tzAsUtc = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(mi), Number(ss));
|
||||
const offsetMs = tzAsUtc - utcMidnight.getTime();
|
||||
return new Date(utcMidnight.getTime() - offsetMs);
|
||||
}
|
||||
|
||||
async function createScenario(s: Scenario) {
|
||||
const timestamp = Date.now();
|
||||
const email = `test-dashboard-${s.name.toLowerCase().replace(/\s+/g, "-")}-${timestamp}@test.com`;
|
||||
const hashed = await argon2.hash(PASSWORD);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: hashed,
|
||||
timezone: s.timezone,
|
||||
incomeType: "regular",
|
||||
incomeFrequency: s.incomeFrequency,
|
||||
firstIncomeDate: s.firstIncomeDate,
|
||||
totalBudgetCents: BigInt(0),
|
||||
},
|
||||
});
|
||||
|
||||
const cats = [];
|
||||
for (const cat of s.variableCats) {
|
||||
const created = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: cat.name,
|
||||
percent: cat.percent,
|
||||
isSavings: !!cat.isSavings,
|
||||
balanceCents: 0n,
|
||||
},
|
||||
});
|
||||
cats.push(created);
|
||||
}
|
||||
|
||||
// Seed income and allocate to variable balances
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: BigInt(s.incomeCents),
|
||||
postedAt: startOfDay(new Date()),
|
||||
note: "Seed income",
|
||||
},
|
||||
});
|
||||
|
||||
const catTotalPercent = s.variableCats.reduce((sum, c) => sum + c.percent, 0);
|
||||
for (const cat of cats) {
|
||||
const percent = s.variableCats.find((c) => c.name === cat.name)?.percent ?? 0;
|
||||
const share = Math.floor((s.incomeCents * percent) / catTotalPercent);
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: cat.id,
|
||||
amountCents: BigInt(share),
|
||||
},
|
||||
});
|
||||
await prisma.variableCategory.update({
|
||||
where: { id: cat.id },
|
||||
data: { balanceCents: BigInt(share) },
|
||||
});
|
||||
}
|
||||
|
||||
for (const plan of s.fixedPlans) {
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: plan.name,
|
||||
totalCents: BigInt(plan.totalCents),
|
||||
fundedCents: BigInt(plan.fundedCents),
|
||||
currentFundedCents: BigInt(plan.fundedCents),
|
||||
cycleStart: startOfDay(new Date()),
|
||||
dueOn: plan.dueOn,
|
||||
frequency: plan.frequency,
|
||||
autoPayEnabled: plan.autoPayEnabled ?? true,
|
||||
paymentSchedule: plan.paymentSchedule ?? { frequency: plan.frequency, minFundingPercent: 100 },
|
||||
isOverdue: plan.isOverdue ?? false,
|
||||
overdueAmount: BigInt(plan.overdueAmount ?? 0),
|
||||
overdueSince: plan.overdueSince ?? null,
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { user, email };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const today = startOfDay(new Date());
|
||||
const tomorrow = addDays(today, 1);
|
||||
const laToday = zonedStartOfDay(new Date(), "America/Los_Angeles");
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: "Multi Due Same Day",
|
||||
timezone: "America/Chicago",
|
||||
incomeFrequency: "weekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
incomeCents: 150000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 30, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Misc", percent: 30 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{ name: "Rent", totalCents: 120000, fundedCents: 0, dueOn: today, frequency: "monthly" },
|
||||
{ name: "Phone", totalCents: 8000, fundedCents: 0, dueOn: today, frequency: "monthly" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Due+Overdue",
|
||||
timezone: "America/Chicago",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
incomeCents: 80000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 20, isSavings: true },
|
||||
{ name: "Food", percent: 50 },
|
||||
{ name: "Gas", percent: 30 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Insurance",
|
||||
totalCents: 50000,
|
||||
fundedCents: 20000,
|
||||
dueOn: addDays(today, -2),
|
||||
frequency: "monthly",
|
||||
isOverdue: true,
|
||||
overdueAmount: 30000,
|
||||
overdueSince: addDays(today, -2),
|
||||
},
|
||||
{
|
||||
name: "Utilities",
|
||||
totalCents: 10000,
|
||||
fundedCents: 0,
|
||||
dueOn: today,
|
||||
frequency: "monthly",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Income+Due Same Day",
|
||||
timezone: "America/Los_Angeles",
|
||||
incomeFrequency: "weekly",
|
||||
firstIncomeDate: laToday,
|
||||
incomeCents: 120000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 30, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Misc", percent: 30 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{ name: "Car", totalCents: 40000, fundedCents: 0, dueOn: laToday, frequency: "monthly" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Fully Funded Due",
|
||||
timezone: "America/Chicago",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
incomeCents: 60000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 20, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Misc", percent: 40 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{ name: "Internet", totalCents: 12000, fundedCents: 12000, dueOn: today, frequency: "monthly" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Partial Available",
|
||||
timezone: "America/New_York",
|
||||
incomeFrequency: "weekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
incomeCents: 20000,
|
||||
variableCats: [
|
||||
{ name: "Savings", percent: 20, isSavings: true },
|
||||
{ name: "Food", percent: 50 },
|
||||
{ name: "Gas", percent: 30 },
|
||||
],
|
||||
fixedPlans: [
|
||||
{ name: "Subscription", totalCents: 25000, fundedCents: 0, dueOn: today, frequency: "monthly" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
console.log("\n=== Dashboard Edge Test Setup ===\n");
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { user, email } = await createScenario(scenario);
|
||||
console.log(`Scenario: ${scenario.name}`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${PASSWORD}`);
|
||||
console.log(` User ID: ${user.id}`);
|
||||
console.log(` Timezone: ${scenario.timezone}`);
|
||||
console.log(` Income: regular ${scenario.incomeFrequency}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("Use these users to validate dashboard edge flows.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
204
api/src/scripts/test-early-funding.ts
Normal file
204
api/src/scripts/test-early-funding.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Test Script: Early Funding Feature
|
||||
*
|
||||
* This script helps test the complete payment → early funding → refunding cycle
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx src/scripts/test-early-funding.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { randomUUID } from "crypto";
|
||||
import argon2 from "argon2";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("\n=== 🧪 Early Funding Feature Test ===\n");
|
||||
|
||||
// Step 1: Create test user with payment plan
|
||||
const userId = randomUUID();
|
||||
const email = `test-early-funding-${Date.now()}@test.com`;
|
||||
const password = "password";
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
console.log("📝 Step 1: Creating test user with payment plan...");
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
email,
|
||||
passwordHash,
|
||||
displayName: "Test User",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: new Date("2025-12-20"), // Next payday Dec 20
|
||||
timezone: "America/Chicago",
|
||||
totalBudgetCents: 300000n, // $3,000 monthly budget
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created user: ${email} (${userId})`);
|
||||
console.log(` Income: Biweekly, Next payday: Dec 20, 2025\n`);
|
||||
|
||||
// Step 1.5: Create a variable category (needed for hasBudgetSetup)
|
||||
await prisma.variableCategory.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
name: "Savings",
|
||||
percent: 100, // 100% of variable budget
|
||||
priority: 1,
|
||||
isSavings: true,
|
||||
},
|
||||
});
|
||||
console.log("💾 Created Savings category (100% variable budget)\n");
|
||||
|
||||
// Step 2: Create recurring rent bill
|
||||
const rentId = randomUUID();
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
id: rentId,
|
||||
userId,
|
||||
name: "Rent",
|
||||
totalCents: 150000, // $1,500
|
||||
fundedCents: 0n,
|
||||
currentFundedCents: 0n,
|
||||
dueOn: new Date("2025-12-28"), // Due Dec 28
|
||||
cycleStart: new Date("2025-12-01"),
|
||||
frequency: "monthly",
|
||||
periodDays: 30,
|
||||
priority: 1,
|
||||
autoRollover: true,
|
||||
needsFundingThisPeriod: true, // Should be funded
|
||||
paymentSchedule: {
|
||||
frequency: "monthly",
|
||||
dayOfMonth: 28,
|
||||
minFundingPercent: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log("🏠 Step 2: Created Rent bill");
|
||||
console.log(` Amount: $1,500 | Due: Dec 28, 2025`);
|
||||
console.log(` Status: needsFundingThisPeriod = true\n`);
|
||||
|
||||
// Step 3: Fund the rent (simulate payday)
|
||||
const incomeId = randomUUID();
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
id: incomeId,
|
||||
userId,
|
||||
amountCents: 150000n, // $1,500
|
||||
postedAt: new Date("2025-12-17"),
|
||||
isScheduledIncome: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: rentId },
|
||||
data: {
|
||||
fundedCents: 150000n,
|
||||
currentFundedCents: 150000n,
|
||||
needsFundingThisPeriod: false, // Fully funded, no longer needs funding
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "fixed",
|
||||
toId: rentId,
|
||||
amountCents: 150000n,
|
||||
incomeId,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("💰 Step 3: Funded rent with Dec 17 paycheck");
|
||||
console.log(` Funded: $1,500 / $1,500 (100%)`);
|
||||
console.log(` Status: needsFundingThisPeriod = false\n`);
|
||||
|
||||
console.log("=" .repeat(70));
|
||||
console.log("\n🎯 TEST SCENARIOS:\n");
|
||||
|
||||
console.log("📍 SCENARIO A: User opts for EARLY FUNDING");
|
||||
console.log("-".repeat(70));
|
||||
console.log("1. User sees modal: 'Start Funding Early?'");
|
||||
console.log("2. User clicks: 'Yes, Start Now'");
|
||||
console.log("3. API call: PATCH /fixed-plans/{rentId}/early-funding");
|
||||
console.log(" Body: { enableEarlyFunding: true }");
|
||||
console.log("4. Database: needsFundingThisPeriod = true");
|
||||
console.log("5. Next paycheck (Dec 20): Rent gets funded ✅");
|
||||
console.log("");
|
||||
|
||||
console.log("📍 SCENARIO B: User opts to WAIT");
|
||||
console.log("-".repeat(70));
|
||||
console.log("1. User sees modal: 'Start Funding Early?'");
|
||||
console.log("2. User clicks: 'Wait Until Rollover'");
|
||||
console.log("3. No API call made");
|
||||
console.log("4. Database: needsFundingThisPeriod = false (unchanged)");
|
||||
console.log("5. Next paycheck (Dec 20): Rent NOT funded ❌");
|
||||
console.log("6. Jan 28 rollover: needsFundingThisPeriod = true");
|
||||
console.log("7. Paycheck after Jan 28: Rent gets funded ✅");
|
||||
console.log("");
|
||||
|
||||
console.log("=" .repeat(70));
|
||||
console.log("\n🧪 MANUAL TESTING STEPS:\n");
|
||||
|
||||
console.log("1️⃣ Login as: " + email);
|
||||
console.log("2️⃣ Go to Spend page");
|
||||
console.log("3️⃣ Select 'Pay Fixed Expense'");
|
||||
console.log("4️⃣ Select 'Rent' plan");
|
||||
console.log("5️⃣ Enter amount: $1,500");
|
||||
console.log("6️⃣ Click 'Record'");
|
||||
console.log("7️⃣ Modal should appear: 'Start Funding Early?'");
|
||||
console.log("8️⃣ Test both buttons:\n");
|
||||
|
||||
console.log(" Option A: Click 'Yes, Start Now'");
|
||||
console.log(" → Check DB: needsFundingThisPeriod should be TRUE");
|
||||
console.log(" → Add income: $1,500 on Dec 20");
|
||||
console.log(" → Verify: Rent receives allocation\n");
|
||||
|
||||
console.log(" Option B: Click 'Wait Until Rollover'");
|
||||
console.log(" → Check DB: needsFundingThisPeriod should be FALSE");
|
||||
console.log(" → Add income: $1,500 on Dec 20");
|
||||
console.log(" → Verify: Rent receives NO allocation");
|
||||
console.log(" → Rollover runs Jan 28");
|
||||
console.log(" → Add income after Jan 28");
|
||||
console.log(" → Verify: Rent receives allocation\n");
|
||||
|
||||
console.log("=" .repeat(70));
|
||||
console.log("\n📊 DATABASE VERIFICATION COMMANDS:\n");
|
||||
|
||||
console.log("-- Check plan status");
|
||||
console.log(`SELECT name, "fundedCents", "totalCents", "dueOn", "needsFundingThisPeriod"`);
|
||||
console.log(`FROM "FixedPlan" WHERE id = '${rentId}';`);
|
||||
console.log("");
|
||||
|
||||
console.log("-- Check last allocation");
|
||||
console.log(`SELECT kind, "amountCents", "createdAt"`);
|
||||
console.log(`FROM "Allocation" WHERE "userId" = '${userId}'`);
|
||||
console.log(`ORDER BY "createdAt" DESC LIMIT 5;`);
|
||||
console.log("");
|
||||
|
||||
console.log("-- Check transactions");
|
||||
console.log(`SELECT kind, "amountCents", "occurredAt", note`);
|
||||
console.log(`FROM "Transaction" WHERE "userId" = '${userId}'`);
|
||||
console.log(`ORDER BY "occurredAt" DESC;`);
|
||||
console.log("");
|
||||
|
||||
console.log("=" .repeat(70));
|
||||
console.log("\n✅ Test user created successfully!");
|
||||
console.log(`📧 Email: ${email}`);
|
||||
console.log(`🔑 Password: ${password}`);
|
||||
console.log(`🆔 User ID: ${userId}`);
|
||||
console.log(`🏠 Rent Plan ID: ${rentId}`);
|
||||
console.log("");
|
||||
console.log("🚀 Ready to test in the UI!");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error("❌ Error:", err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
182
api/src/scripts/test-final-funding.ts
Normal file
182
api/src/scripts/test-final-funding.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Test script for attempt-final-funding endpoint
|
||||
*
|
||||
* This tests the logic that runs when a payment modal opens:
|
||||
* 1. If bill not fully funded -> attempts to pull from available budget
|
||||
* 2. If available budget can cover -> fully funds it
|
||||
* 3. If available budget insufficient -> takes all available, marks overdue
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { allocateIncome } from "../allocator.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const testEmail = `test-final-funding-${Date.now()}@test.com`;
|
||||
console.log(`\n🧪 Testing attempt-final-funding endpoint with user: ${testEmail}\n`);
|
||||
|
||||
// 1. Create test user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: testEmail,
|
||||
displayName: "Test Final Funding",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created test user: ${user.id}`);
|
||||
|
||||
// 2. Create variable categories (for available budget calculation)
|
||||
await prisma.variableCategory.createMany({
|
||||
data: [
|
||||
{ userId: user.id, name: "Essentials", percent: 50, priority: 10, isSavings: false, balanceCents: 0n },
|
||||
{ userId: user.id, name: "Savings", percent: 30, priority: 20, isSavings: true, balanceCents: 0n },
|
||||
{ userId: user.id, name: "Fun", percent: 20, priority: 30, isSavings: false, balanceCents: 0n },
|
||||
],
|
||||
});
|
||||
console.log(`✅ Created variable categories`);
|
||||
|
||||
// 3. Create test rent plan ($1,500, due tomorrow)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const rentPlan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Rent",
|
||||
totalCents: 150000n, // $1,500
|
||||
fundedCents: 100000n, // $1,000 already funded
|
||||
currentFundedCents: 100000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date(),
|
||||
dueOn: tomorrow,
|
||||
fundingMode: "auto-on-deposit",
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created rent plan: $1,500 total, $1,000 funded, $500 remaining`);
|
||||
|
||||
// 4. Add initial income to simulate funded amount
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: 200000n, // $2,000 income
|
||||
postedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Allocate to rent to simulate the $1,000 funded
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: rentPlan.id,
|
||||
amountCents: 100000n, // $1,000 to rent
|
||||
},
|
||||
});
|
||||
|
||||
// Allocate rest to variable categories to simulate spending/allocation
|
||||
const essentials = await prisma.variableCategory.findFirst({ where: { userId: user.id, name: "Essentials" } });
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: essentials!.id,
|
||||
amountCents: 70000n, // $700
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created income event: $2,000`);
|
||||
console.log(` Allocated: $1,000 to rent, $700 to essentials`);
|
||||
console.log(` Available budget: $300\n`);
|
||||
|
||||
// 5. TEST CASE 1: Available budget ($300) < Remaining ($500)
|
||||
console.log("📋 TEST CASE 1: Partial funding from available budget");
|
||||
console.log(" Remaining needed: $500");
|
||||
console.log(" Available budget: $300");
|
||||
console.log(" Expected: Take all $300, mark overdue with $200\n");
|
||||
|
||||
const response1 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-user-id": user.id,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result1 = await response1.json();
|
||||
console.log("Response:", JSON.stringify(result1, null, 2));
|
||||
|
||||
if (result1.status === "overdue" && result1.fundedCents === 130000 && result1.overdueAmount === 20000) {
|
||||
console.log("✅ TEST 1 PASSED: Correctly funded $300 and marked $200 overdue\n");
|
||||
} else {
|
||||
console.log("❌ TEST 1 FAILED: Unexpected result\n");
|
||||
}
|
||||
|
||||
// 6. TEST CASE 2: Available budget can fully fund
|
||||
// Add more income to create sufficient available budget
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: 50000n, // $500 more income
|
||||
postedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(`💰 Added $500 more income`);
|
||||
console.log(` New available budget: $500`);
|
||||
console.log(` Remaining on rent: $200 (overdue)\n`);
|
||||
|
||||
console.log("📋 TEST CASE 2: Full funding from available budget");
|
||||
console.log(" Remaining needed: $200");
|
||||
console.log(" Available budget: $500");
|
||||
console.log(" Expected: Fund full $200, clear overdue\n");
|
||||
|
||||
const response2 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-user-id": user.id,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result2 = await response2.json();
|
||||
console.log("Response:", JSON.stringify(result2, null, 2));
|
||||
|
||||
if (result2.status === "fully_funded" && result2.fundedCents === 150000 && result2.isOverdue === false) {
|
||||
console.log("✅ TEST 2 PASSED: Correctly fully funded and cleared overdue\n");
|
||||
} else {
|
||||
console.log("❌ TEST 2 FAILED: Unexpected result\n");
|
||||
}
|
||||
|
||||
// 7. TEST CASE 3: Already fully funded
|
||||
console.log("📋 TEST CASE 3: Already fully funded");
|
||||
console.log(" Expected: No changes, return fully_funded status\n");
|
||||
|
||||
const response3 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-user-id": user.id,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result3 = await response3.json();
|
||||
console.log("Response:", JSON.stringify(result3, null, 2));
|
||||
|
||||
if (result3.status === "fully_funded" && result3.fundedCents === 150000) {
|
||||
console.log("✅ TEST 3 PASSED: Correctly identified as fully funded\n");
|
||||
} else {
|
||||
console.log("❌ TEST 3 FAILED: Unexpected result\n");
|
||||
}
|
||||
|
||||
console.log("\n🎉 All tests completed!");
|
||||
console.log(`\nTest user: ${testEmail}`);
|
||||
console.log(`User ID: ${user.id}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
199
api/src/scripts/test-onboarding-edge.ts
Normal file
199
api/src/scripts/test-onboarding-edge.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
import { addDays, startOfDay } from "date-fns";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const PASSWORD = "password";
|
||||
|
||||
type Scenario = {
|
||||
name: string;
|
||||
timezone: string;
|
||||
incomeType: "regular" | "irregular";
|
||||
incomeFrequency: "weekly" | "biweekly" | "monthly" | null;
|
||||
firstIncomeDate: Date | null;
|
||||
fixedPlans: Array<{
|
||||
name: string;
|
||||
totalCents: number;
|
||||
dueOn: Date;
|
||||
frequency?: "weekly" | "biweekly" | "monthly" | "one-time";
|
||||
autoPayEnabled?: boolean;
|
||||
paymentSchedule?: Record<string, any> | null;
|
||||
}>;
|
||||
variableCategories: Array<{
|
||||
name: string;
|
||||
percent: number;
|
||||
isSavings?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function createScenario(s: Scenario) {
|
||||
const timestamp = Date.now();
|
||||
const email = `test-onboarding-${s.name.toLowerCase().replace(/\s+/g, "-")}-${timestamp}@test.com`;
|
||||
const hashed = await argon2.hash(PASSWORD);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: hashed,
|
||||
timezone: s.timezone,
|
||||
incomeType: s.incomeType,
|
||||
incomeFrequency: s.incomeFrequency ?? undefined,
|
||||
firstIncomeDate: s.firstIncomeDate ?? undefined,
|
||||
totalBudgetCents: 100000, // $1,000 placeholder
|
||||
},
|
||||
});
|
||||
|
||||
for (const cat of s.variableCategories) {
|
||||
await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: cat.name,
|
||||
percent: cat.percent,
|
||||
isSavings: !!cat.isSavings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const plan of s.fixedPlans) {
|
||||
await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: plan.name,
|
||||
totalCents: plan.totalCents,
|
||||
fundedCents: 0,
|
||||
currentFundedCents: 0,
|
||||
cycleStart: new Date().toISOString(),
|
||||
dueOn: plan.dueOn.toISOString(),
|
||||
frequency: plan.frequency ?? null,
|
||||
autoPayEnabled: !!plan.autoPayEnabled,
|
||||
paymentSchedule: plan.paymentSchedule ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { user, email };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const today = startOfDay(new Date());
|
||||
const tomorrow = addDays(today, 1);
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: "Timezone Tomorrow",
|
||||
timezone: "America/Los_Angeles",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "weekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Rent",
|
||||
totalCents: 120000,
|
||||
dueOn: addDays(today, 29),
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
|
||||
},
|
||||
],
|
||||
variableCategories: [
|
||||
{ name: "Savings A", percent: 20, isSavings: true },
|
||||
{ name: "Savings B", percent: 10, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Gas", percent: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Month End",
|
||||
timezone: "America/Chicago",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "monthly",
|
||||
firstIncomeDate: new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 31)),
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Insurance",
|
||||
totalCents: 50000,
|
||||
dueOn: new Date(Date.UTC(today.getUTCFullYear(), 0, 31)),
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule: { frequency: "monthly", dayOfMonth: 31, minFundingPercent: 100 },
|
||||
},
|
||||
],
|
||||
variableCategories: [
|
||||
{ name: "Savings", percent: 25, isSavings: true },
|
||||
{ name: "Food", percent: 50 },
|
||||
{ name: "Misc", percent: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Irregular No Auto",
|
||||
timezone: "America/New_York",
|
||||
incomeType: "irregular",
|
||||
incomeFrequency: null,
|
||||
firstIncomeDate: null,
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Subscription",
|
||||
totalCents: 1200,
|
||||
dueOn: addDays(today, 14),
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: false,
|
||||
paymentSchedule: null,
|
||||
},
|
||||
],
|
||||
variableCategories: [
|
||||
{ name: "Savings", percent: 30, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Entertainment", percent: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Due Today Underfunded",
|
||||
timezone: "America/Chicago",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: tomorrow,
|
||||
fixedPlans: [
|
||||
{
|
||||
name: "Phone",
|
||||
totalCents: 25000,
|
||||
dueOn: today,
|
||||
frequency: "monthly",
|
||||
autoPayEnabled: true,
|
||||
paymentSchedule: { frequency: "monthly", minFundingPercent: 100 },
|
||||
},
|
||||
],
|
||||
variableCategories: [
|
||||
{ name: "Savings", percent: 20, isSavings: true },
|
||||
{ name: "Food", percent: 40 },
|
||||
{ name: "Gas", percent: 20 },
|
||||
{ name: "Misc", percent: 20 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
console.log("\n=== Onboarding Edge Test Setup ===\n");
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { user, email } = await createScenario(scenario);
|
||||
console.log(`Scenario: ${scenario.name}`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${PASSWORD}`);
|
||||
console.log(` User ID: ${user.id}`);
|
||||
console.log(` Timezone: ${scenario.timezone}`);
|
||||
console.log(` Income: ${scenario.incomeType} ${scenario.incomeFrequency ?? ""}`.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("Use these users to verify onboarding edge cases and dashboard follow-up behavior.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
206
api/src/scripts/test-overdue-reconciliation.ts
Normal file
206
api/src/scripts/test-overdue-reconciliation.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Test script for Payment Reconciliation System with Overdue tracking
|
||||
*
|
||||
* Tests:
|
||||
* 1. Full payment → Rollover
|
||||
* 2. Partial payment → Refund + Mark overdue
|
||||
* 3. No payment → Mark overdue
|
||||
* 4. Overdue priority in next income allocation
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const timestamp = Date.now();
|
||||
const email = `test-overdue-${timestamp}@test.com`;
|
||||
const password = "testpassword123";
|
||||
|
||||
console.log("🧪 Testing Payment Reconciliation System");
|
||||
console.log("========================================\n");
|
||||
|
||||
// 1. Create test user
|
||||
console.log("1️⃣ Creating test user...");
|
||||
const passwordHash = await argon2.hash(password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
displayName: "Overdue Test User",
|
||||
incomeFrequency: "biweekly",
|
||||
incomeType: "regular",
|
||||
timezone: "America/New_York",
|
||||
firstIncomeDate: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(`✅ User created: ${email}\n`);
|
||||
|
||||
// 2. Create fixed plan (Rent)
|
||||
console.log("2️⃣ Creating Rent plan ($1,500)...");
|
||||
const rent = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Rent",
|
||||
totalCents: 150000n, // $1,500
|
||||
fundedCents: 100000n, // $1,000 funded
|
||||
currentFundedCents: 100000n,
|
||||
priority: 10,
|
||||
cycleStart: new Date(),
|
||||
dueOn: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), // 5 days from now
|
||||
fundingMode: "auto-on-deposit",
|
||||
frequency: "monthly",
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Rent plan created (ID: ${rent.id})`);
|
||||
console.log(` Funded: $1,000 / $1,500\n`);
|
||||
|
||||
// 3. Create variable categories
|
||||
console.log("3️⃣ Creating variable categories...");
|
||||
await prisma.variableCategory.createMany({
|
||||
data: [
|
||||
{ userId: user.id, name: "Essentials", percent: 50, priority: 10, balanceCents: 50000n },
|
||||
{ userId: user.id, name: "Savings", percent: 30, priority: 20, isSavings: true, balanceCents: 30000n },
|
||||
{ userId: user.id, name: "Fun", percent: 20, priority: 30, balanceCents: 20000n },
|
||||
],
|
||||
});
|
||||
console.log("✅ Variable categories created\n");
|
||||
|
||||
// 4. Record initial income to establish available budget
|
||||
console.log("4️⃣ Recording initial income ($2,000)...");
|
||||
const income1 = await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
postedAt: new Date(),
|
||||
amountCents: 200000n,
|
||||
note: "Initial income",
|
||||
},
|
||||
});
|
||||
|
||||
// Create allocations for the funded amount
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: rent.id,
|
||||
amountCents: 100000n,
|
||||
incomeId: income1.id,
|
||||
},
|
||||
});
|
||||
console.log("✅ Income recorded + $1,000 allocated to Rent\n");
|
||||
|
||||
// 5. TEST SCENARIO A: Partial Payment
|
||||
console.log("🧪 TEST SCENARIO A: Partial Payment ($1,000 paid out of $1,500)");
|
||||
console.log("-----------------------------------------------------------");
|
||||
|
||||
const partialPayment = await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
occurredAt: new Date(),
|
||||
kind: "fixed_payment",
|
||||
amountCents: 100000n, // Only paid $1,000
|
||||
planId: rent.id,
|
||||
note: "Partial payment test",
|
||||
},
|
||||
});
|
||||
|
||||
const rentAfterPartial = await prisma.fixedPlan.findUnique({
|
||||
where: { id: rent.id },
|
||||
});
|
||||
|
||||
console.log(`✅ Partial payment recorded: $${Number(partialPayment.amountCents) / 100}`);
|
||||
console.log(` Plan status:`);
|
||||
console.log(` - fundedCents: $${Number(rentAfterPartial?.fundedCents) / 100}`);
|
||||
console.log(` - isOverdue: ${rentAfterPartial?.isOverdue}`);
|
||||
console.log(` - overdueAmount: $${Number(rentAfterPartial?.overdueAmount ?? 0n) / 100}\n`);
|
||||
|
||||
// 6. TEST SCENARIO B: Overdue Priority in Allocation
|
||||
console.log("🧪 TEST SCENARIO B: Next Income Should Prioritize Overdue");
|
||||
console.log("--------------------------------------------------------");
|
||||
|
||||
console.log("📥 Posting new income ($500) - using direct allocator...");
|
||||
|
||||
// Use direct allocator function instead of API
|
||||
const { allocateIncome } = await import("../allocator.js");
|
||||
const allocationResult = await allocateIncome(
|
||||
prisma,
|
||||
user.id,
|
||||
50000, // $500
|
||||
new Date().toISOString(),
|
||||
"test-income-2",
|
||||
"Test income after overdue",
|
||||
true // isScheduledIncome
|
||||
);
|
||||
|
||||
console.log("✅ Income allocated");
|
||||
console.log(` Fixed allocations:`, JSON.stringify(allocationResult.fixedAllocations, null, 2));
|
||||
|
||||
const rentAfterIncome = await prisma.fixedPlan.findUnique({
|
||||
where: { id: rent.id },
|
||||
});
|
||||
|
||||
console.log(` Rent plan after allocation:`);
|
||||
console.log(` - overdueAmount: $${Number(rentAfterIncome?.overdueAmount ?? 0n) / 100}`);
|
||||
console.log(` - fundedCents: $${Number(rentAfterIncome?.fundedCents) / 100}\n`);
|
||||
|
||||
// 7. TEST SCENARIO C: Mark as Unpaid
|
||||
console.log("🧪 TEST SCENARIO C: Mark Bill as Unpaid");
|
||||
console.log("---------------------------------------");
|
||||
|
||||
// Create another plan to test mark-unpaid
|
||||
const utilities = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Utilities",
|
||||
totalCents: 20000n, // $200
|
||||
fundedCents: 15000n, // $150 funded
|
||||
currentFundedCents: 15000n,
|
||||
priority: 20,
|
||||
cycleStart: new Date(),
|
||||
dueOn: new Date(), // Due today
|
||||
fundingMode: "auto-on-deposit",
|
||||
frequency: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
// Mark as unpaid directly via Prisma
|
||||
const fundedAmount = Number(utilities.currentFundedCents);
|
||||
const totalAmount = Number(utilities.totalCents);
|
||||
const remainingBalance = totalAmount - fundedAmount;
|
||||
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: utilities.id },
|
||||
data: {
|
||||
isOverdue: true,
|
||||
overdueAmount: BigInt(Math.max(0, remainingBalance)),
|
||||
overdueSince: new Date(),
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ Marked utilities as unpaid");
|
||||
console.log(` Remaining balance: $${remainingBalance / 100}`);
|
||||
|
||||
const utilitiesAfter = await prisma.fixedPlan.findUnique({
|
||||
where: { id: utilities.id },
|
||||
});
|
||||
console.log(` - isOverdue: ${utilitiesAfter?.isOverdue}`);
|
||||
console.log(` - overdueAmount: $${Number(utilitiesAfter?.overdueAmount ?? 0n) / 100}`);
|
||||
console.log(` - overdueSince: ${utilitiesAfter?.overdueSince}\n`);
|
||||
|
||||
console.log("✅ All tests completed successfully!");
|
||||
console.log("\n📊 Final State:");
|
||||
console.log(` Test user: ${email}`);
|
||||
console.log(` User ID: ${user.id}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("❌ Test failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
289
api/src/scripts/test-payment-flow.ts
Normal file
289
api/src/scripts/test-payment-flow.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as argon2 from "argon2";
|
||||
import { addDays, startOfDay } from "date-fns";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("\n=== 🧪 Payment Flow Test Setup ===\n");
|
||||
|
||||
const timestamp = Date.now();
|
||||
const email = `test-payment-flow-${timestamp}@test.com`;
|
||||
const password = "password";
|
||||
const hashedPassword = await argon2.hash(password);
|
||||
|
||||
// Calculate dates
|
||||
const today = startOfDay(new Date());
|
||||
const nextPayday = addDays(today, 3); // Dec 20
|
||||
const rentDue = addDays(today, 11); // Dec 28
|
||||
|
||||
console.log("📝 Step 1: Creating test user...");
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: hashedPassword,
|
||||
timezone: "America/Los_Angeles",
|
||||
incomeType: "regular",
|
||||
incomeFrequency: "biweekly",
|
||||
firstIncomeDate: nextPayday.toISOString(),
|
||||
totalBudgetCents: 300000, // $3,000 total budget
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created user: ${email} (${user.id})`);
|
||||
console.log(` Total Budget: $3,000 | Next payday: ${nextPayday.toLocaleDateString()}\n`);
|
||||
|
||||
console.log("💾 Step 2: Creating budget categories...");
|
||||
|
||||
// Create variable categories
|
||||
const savingsCategory = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Savings",
|
||||
percent: 20,
|
||||
isSavings: true,
|
||||
},
|
||||
});
|
||||
|
||||
const groceriesCategory = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Groceries",
|
||||
percent: 50,
|
||||
isSavings: false,
|
||||
},
|
||||
});
|
||||
|
||||
const entertainmentCategory = await prisma.variableCategory.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Entertainment",
|
||||
percent: 30,
|
||||
isSavings: false,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Created 3 variable categories`);
|
||||
console.log(` - Savings: 20%`);
|
||||
console.log(` - Groceries: 50%`);
|
||||
console.log(` - Entertainment: 30%\n`);
|
||||
|
||||
console.log("🏠 Step 3: Creating fixed expense (Rent)...");
|
||||
const rentPlan = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Rent",
|
||||
totalCents: 150000, // $1,500
|
||||
fundedCents: 75000, // $750 (50% funded)
|
||||
cycleStart: today.toISOString(),
|
||||
dueOn: rentDue.toISOString(),
|
||||
frequency: "monthly",
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created Rent bill`);
|
||||
console.log(` Amount: $1,500 | Funded: $750 (50%)`);
|
||||
console.log(` Due: ${rentDue.toLocaleDateString()}`);
|
||||
console.log(` Status: needsFundingThisPeriod = true\n`);
|
||||
|
||||
console.log("💰 Step 4: Adding income to create available budget...");
|
||||
|
||||
// Add income event (not transaction - backend checks IncomeEvent table!)
|
||||
const incomeAmount = 200000; // $2,000
|
||||
await prisma.incomeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amountCents: incomeAmount,
|
||||
postedAt: today,
|
||||
note: "Test paycheck",
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate allocations (simplified - in real flow this is done by allocator)
|
||||
// Rent needs $750 more, gets funded
|
||||
// Variable gets: $2,000 - $750 = $1,250
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "fixed",
|
||||
toId: rentPlan.id,
|
||||
amountCents: 75000, // Fund the remaining $750
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.fixedPlan.update({
|
||||
where: { id: rentPlan.id },
|
||||
data: {
|
||||
fundedCents: 150000, // Now 100% funded
|
||||
currentFundedCents: 150000, // Dashboard reads from this
|
||||
},
|
||||
});
|
||||
|
||||
// Variable allocation: $1,250
|
||||
const variableAmount = 125000;
|
||||
const savingsAmount = Math.floor(variableAmount * 0.20); // $250
|
||||
const groceriesAmount = Math.floor(variableAmount * 0.50); // $625
|
||||
const entertainmentAmount = Math.floor(variableAmount * 0.30); // $375
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: savingsCategory.id,
|
||||
amountCents: savingsAmount,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: groceriesCategory.id,
|
||||
amountCents: groceriesAmount,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.allocation.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "variable",
|
||||
toId: entertainmentCategory.id,
|
||||
amountCents: entertainmentAmount,
|
||||
},
|
||||
});
|
||||
|
||||
// Update variable category balances (dashboard reads from these)
|
||||
await prisma.variableCategory.update({
|
||||
where: { id: savingsCategory.id },
|
||||
data: { balanceCents: savingsAmount },
|
||||
});
|
||||
|
||||
await prisma.variableCategory.update({
|
||||
where: { id: groceriesCategory.id },
|
||||
data: { balanceCents: groceriesAmount },
|
||||
});
|
||||
|
||||
await prisma.variableCategory.update({
|
||||
where: { id: entertainmentCategory.id },
|
||||
data: { balanceCents: entertainmentAmount },
|
||||
});
|
||||
|
||||
console.log(`✅ Recorded income: $${(incomeAmount / 100).toFixed(2)}`);
|
||||
console.log(` Rent funded: +$750 → 100% complete`);
|
||||
console.log(` Variable budget: $1,250`);
|
||||
console.log(` - Savings: $250 (20%)`);
|
||||
console.log(` - Groceries: $625 (50%)`);
|
||||
console.log(` - Entertainment: $375 (30%)\n`);
|
||||
|
||||
console.log("======================================================================\n");
|
||||
console.log("🎯 TEST SCENARIOS:\n");
|
||||
|
||||
console.log("📍 SCENARIO 1: Pay Rent from fundedCents + available");
|
||||
console.log("----------------------------------------------------------------------");
|
||||
console.log("Current state:");
|
||||
console.log(" - Rent: $1,500 total, $1,500 funded (100%)");
|
||||
console.log(" - Available budget: $1,250 (all in variable categories)");
|
||||
console.log("");
|
||||
console.log("Action: Pay Rent $1,500");
|
||||
console.log("Expected behavior:");
|
||||
console.log(" ✅ Takes $1,500 from fundedCents");
|
||||
console.log(" ✅ Takes $0 from available");
|
||||
console.log(" ✅ Modal appears: 'Start funding early?'");
|
||||
console.log(" ✅ No confirmation needed (not depleting variable)\n");
|
||||
|
||||
console.log("📍 SCENARIO 2: Pay more than funded (triggers confirmation)");
|
||||
console.log("----------------------------------------------------------------------");
|
||||
console.log("Setup: Reset rent to $500 funded");
|
||||
console.log("");
|
||||
console.log("Action: Pay Rent $1,500");
|
||||
console.log("Expected behavior:");
|
||||
console.log(" ✅ Takes $500 from fundedCents");
|
||||
console.log(" ✅ Needs $1,000 from available ($1,250 total)");
|
||||
console.log(" ⚠️ Would deplete 80% of variable balance");
|
||||
console.log(" ⚠️ CONFIRMATION_REQUIRED modal appears");
|
||||
console.log(" ✅ User confirms → payment succeeds");
|
||||
console.log(" ✅ Negative allocation tracks -$1,000 from variable\n");
|
||||
|
||||
console.log("📍 SCENARIO 3: Add income that brings bill to 100%");
|
||||
console.log("----------------------------------------------------------------------");
|
||||
console.log("Setup: Create new bill '$400 Car Insurance' with $0 funded");
|
||||
console.log("");
|
||||
console.log("Action: Record income $500");
|
||||
console.log("Expected behavior:");
|
||||
console.log(" ✅ Allocator funds Car Insurance: $400");
|
||||
console.log(" ✅ Bill reaches 100% funded");
|
||||
console.log(" ✅ Modal appears: 'Start funding early for Car Insurance?'");
|
||||
console.log(" ✅ Remaining $100 goes to variable\n");
|
||||
|
||||
// Create the car insurance bill for testing scenario 3
|
||||
console.log("🚗 Creating Car Insurance bill for Scenario 3...");
|
||||
const carInsurance = await prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Car Insurance",
|
||||
totalCents: 40000, // $400
|
||||
fundedCents: 0,
|
||||
cycleStart: today.toISOString(),
|
||||
dueOn: addDays(today, 15).toISOString(),
|
||||
frequency: "monthly",
|
||||
needsFundingThisPeriod: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created Car Insurance: $400, $0 funded\n`);
|
||||
|
||||
console.log("======================================================================\n");
|
||||
console.log("🧪 MANUAL TESTING INSTRUCTIONS:\n");
|
||||
|
||||
console.log("1️⃣ Login at http://localhost:5174");
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${password}\n`);
|
||||
|
||||
console.log("2️⃣ SCENARIO 1: Test normal payment");
|
||||
console.log(" → Go to Spend page");
|
||||
console.log(" → Pay 'Rent' $1,500");
|
||||
console.log(" → Should see early funding modal only (no warning)\n");
|
||||
|
||||
console.log("3️⃣ SCENARIO 2: Test confirmation modal");
|
||||
console.log(" → First, manually reset rent funded amount to $500:");
|
||||
console.log(` UPDATE "FixedPlan" SET "fundedCents" = 50000 WHERE id = '${rentPlan.id}';`);
|
||||
console.log(" → Pay 'Rent' $1,500");
|
||||
console.log(" → Should see CONFIRMATION modal first");
|
||||
console.log(" → Confirm payment");
|
||||
console.log(" → Then see early funding modal\n");
|
||||
|
||||
console.log("4️⃣ SCENARIO 3: Test income modal");
|
||||
console.log(" → Go to Income page");
|
||||
console.log(" → Record income $500");
|
||||
console.log(" → Should see early funding modal for 'Car Insurance'\n");
|
||||
|
||||
console.log("======================================================================\n");
|
||||
console.log("📊 DATABASE VERIFICATION:\n");
|
||||
|
||||
console.log("-- Check rent status");
|
||||
console.log(`SELECT name, "fundedCents", "totalCents", "needsFundingThisPeriod"`);
|
||||
console.log(`FROM "FixedPlan" WHERE id = '${rentPlan.id}';\n`);
|
||||
|
||||
console.log("-- Check available budget (should decrease after payment from available)");
|
||||
console.log(`SELECT kind, "categoryId", "amountCents"`);
|
||||
console.log(`FROM "Allocation" WHERE "userId" = '${user.id}'`);
|
||||
console.log(`ORDER BY "createdAt" DESC LIMIT 10;\n`);
|
||||
|
||||
console.log("-- Check transactions");
|
||||
console.log(`SELECT kind, "amountCents", "planId", note`);
|
||||
console.log(`FROM "Transaction" WHERE "userId" = '${user.id}'`);
|
||||
console.log(`ORDER BY "occurredAt" DESC;\n`);
|
||||
|
||||
console.log("======================================================================\n");
|
||||
console.log("✅ Test user created successfully!");
|
||||
console.log(`📧 Email: ${email}`);
|
||||
console.log(`🔑 Password: ${password}`);
|
||||
console.log(`🆔 User ID: ${user.id}`);
|
||||
console.log(`🏠 Rent ID: ${rentPlan.id}`);
|
||||
console.log(`🚗 Car Insurance ID: ${carInsurance.id}`);
|
||||
console.log("\n🚀 Ready to test all payment scenarios!\n");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
145
api/src/scripts/test-timezone-jobs.ts
Normal file
145
api/src/scripts/test-timezone-jobs.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Test script to simulate running scheduled jobs at different times/timezones
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx src/scripts/test-timezone-jobs.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { rolloverFixedPlans } from "../jobs/rollover.js";
|
||||
import { processAutoPayments } from "../jobs/auto-payments.js";
|
||||
import { toZonedTime } from "date-fns-tz";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function checkMidnight(date: Date | null, timezone: string, label: string) {
|
||||
if (!date) return { ok: true, label, reason: "missing" };
|
||||
const zoned = toZonedTime(date, timezone);
|
||||
const isMidnight =
|
||||
zoned.getHours() === 0 &&
|
||||
zoned.getMinutes() === 0 &&
|
||||
zoned.getSeconds() === 0 &&
|
||||
zoned.getMilliseconds() === 0;
|
||||
return {
|
||||
ok: isMidnight,
|
||||
label,
|
||||
zoned: zoned.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("\n=== Timezone Job Testing ===\n");
|
||||
|
||||
// Get test user
|
||||
const userId = process.argv[2];
|
||||
if (!userId) {
|
||||
console.error("Usage: npx tsx src/scripts/test-timezone-jobs.ts <userId>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, timezone: true, firstIncomeDate: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error(`User ${userId} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Testing for user: ${user.email}`);
|
||||
const userTimezone = user.timezone ?? "America/New_York";
|
||||
console.log(`User timezone: ${userTimezone}\n`);
|
||||
|
||||
const plans = await prisma.fixedPlan.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, name: true, dueOn: true, nextPaymentDate: true, cycleStart: true },
|
||||
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
|
||||
});
|
||||
|
||||
console.log("=== DATE NORMALIZATION CHECKS (local midnight expected) ===\n");
|
||||
const checks = [
|
||||
checkMidnight(user.firstIncomeDate ?? null, userTimezone, "user.firstIncomeDate"),
|
||||
];
|
||||
plans.forEach((plan) => {
|
||||
checks.push(checkMidnight(plan.dueOn, userTimezone, `plan:${plan.name}:dueOn`));
|
||||
checks.push(checkMidnight(plan.cycleStart, userTimezone, `plan:${plan.name}:cycleStart`));
|
||||
if (plan.nextPaymentDate) {
|
||||
checks.push(checkMidnight(plan.nextPaymentDate, userTimezone, `plan:${plan.name}:nextPaymentDate`));
|
||||
}
|
||||
});
|
||||
|
||||
let hasIssues = false;
|
||||
for (const check of checks) {
|
||||
if (!check.ok) {
|
||||
hasIssues = true;
|
||||
console.log(`❌ ${check.label} not at local midnight (${check.zoned})`);
|
||||
}
|
||||
}
|
||||
if (!hasIssues) {
|
||||
console.log("✅ All date-only fields are stored at local midnight.\n");
|
||||
} else {
|
||||
console.log("\n⚠️ Some date-only fields are not normalized to local midnight.\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
// Test different UTC times to see when jobs would run
|
||||
const testTimes = [
|
||||
"2025-12-17T00:00:00Z", // Midnight UTC
|
||||
"2025-12-17T06:00:00Z", // 6 AM UTC
|
||||
"2025-12-17T12:00:00Z", // Noon UTC
|
||||
"2025-12-17T18:00:00Z", // 6 PM UTC
|
||||
"2025-12-17T23:00:00Z", // 11 PM UTC
|
||||
];
|
||||
|
||||
console.log("=== ROLLOVER JOB (should run at 6 AM user time) ===\n");
|
||||
for (const utcTime of testTimes) {
|
||||
const asOf = new Date(utcTime);
|
||||
const userTime = toZonedTime(asOf, userTimezone);
|
||||
const userHour = userTime.getHours();
|
||||
const shouldRun = userHour >= 6;
|
||||
|
||||
console.log(`UTC: ${utcTime}`);
|
||||
console.log(` User time: ${userTime.toLocaleString('en-US', { timeZone: userTimezone })}`);
|
||||
console.log(` User hour: ${userHour}`);
|
||||
console.log(` Would run: ${shouldRun ? '✅ YES' : '❌ NO (before 6 AM)'}\n`);
|
||||
}
|
||||
|
||||
console.log("\n=== AUTO-PAYMENT JOB (should run at 9 AM user time) ===\n");
|
||||
for (const utcTime of testTimes) {
|
||||
const asOf = new Date(utcTime);
|
||||
const userTime = toZonedTime(asOf, userTimezone);
|
||||
const userHour = userTime.getHours();
|
||||
const shouldRun = userHour >= 9;
|
||||
|
||||
console.log(`UTC: ${utcTime}`);
|
||||
console.log(` User time: ${userTime.toLocaleString('en-US', { timeZone: userTimezone })}`);
|
||||
console.log(` User hour: ${userHour}`);
|
||||
console.log(` Would run: ${shouldRun ? '✅ YES' : '❌ NO (before 9 AM)'}\n`);
|
||||
}
|
||||
|
||||
// Actually test rollover with dry-run
|
||||
console.log("\n=== TESTING ROLLOVER (DRY RUN) ===\n");
|
||||
const rolloverTestTime = "2025-12-17T22:00:00Z"; // 7 AM Tokyo time (should run)
|
||||
console.log(`Testing at: ${rolloverTestTime}`);
|
||||
const rolloverResults = await rolloverFixedPlans(prisma, rolloverTestTime, { dryRun: true });
|
||||
console.log(`Plans found for rollover: ${rolloverResults.length}`);
|
||||
if (rolloverResults.length > 0) {
|
||||
console.log("Plans:", rolloverResults.map(r => ({ name: r.name, cycles: r.cyclesAdvanced })));
|
||||
}
|
||||
|
||||
// Actually test auto-payment with dry-run
|
||||
console.log("\n=== TESTING AUTO-PAYMENT (DRY RUN) ===\n");
|
||||
const paymentTestTime = "2025-12-18T01:00:00Z"; // 10 AM Tokyo time (should run)
|
||||
console.log(`Testing at: ${paymentTestTime}`);
|
||||
const paymentResults = await processAutoPayments(prisma, paymentTestTime, { dryRun: true });
|
||||
console.log(`Plans found for auto-payment: ${paymentResults.length}`);
|
||||
if (paymentResults.length > 0) {
|
||||
console.log("Plans:", paymentResults.map(r => ({ name: r.name, success: r.success, error: r.error })));
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
3535
api/src/server.ts
3535
api/src/server.ts
File diff suppressed because it is too large
Load Diff
46
api/src/worker/auto-payments.ts
Normal file
46
api/src/worker/auto-payments.ts
Normal 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));
|
||||
}
|
||||
39
api/src/worker/rollover.ts
Normal file
39
api/src/worker/rollover.ts
Normal 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));
|
||||
}
|
||||
25
api/test-income-overdue.sh
Normal file
25
api/test-income-overdue.sh
Normal 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
228
api/test-monthly-income.cjs
Normal 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
41
api/test-overdue-api.sh
Normal 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}'
|
||||
133
api/test-overdue-payment.cjs
Normal file
133
api/test-overdue-payment.cjs
Normal 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
19
api/test-simple.sh
Normal 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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
83
api/tests/auth.routes.test.ts
Normal file
83
api/tests/auth.routes.test.ts
Normal 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 } });
|
||||
});
|
||||
});
|
||||
338
api/tests/auto-payments.test.ts
Normal file
338
api/tests/auto-payments.test.ts
Normal 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)
|
||||
});
|
||||
});
|
||||
132
api/tests/budget-allocation.test.ts
Normal file
132
api/tests/budget-allocation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
180
api/tests/irregular-income-simple.test.ts
Normal file
180
api/tests/irregular-income-simple.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
230
api/tests/payment-rollover.test.ts
Normal file
230
api/tests/payment-rollover.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
63
api/tests/rollover.test.ts
Normal file
63
api/tests/rollover.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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,6 +27,16 @@ 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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
5
cookies.txt
Normal 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
5
cookies2.txt
Normal 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
5
cookies_debug.txt
Normal 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
5
cookies_fixed.txt
Normal 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
5
cookies_immediate.txt
Normal 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
5
cookies_login.txt
Normal 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
5
cookies_test.txt
Normal 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
|
||||
39
deploy/nginx/skymoneybudget.com.conf
Normal file
39
deploy/nginx/skymoneybudget.com.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
5
irregular_cookies.txt
Normal file
5
irregular_cookies.txt
Normal 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
5
regular_cookies.txt
Normal 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
25
scripts/backup.sh
Normal 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
37
scripts/restore.sh
Normal 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}"
|
||||
@@ -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
88
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
77
web/src/api/budget.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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", {}),
|
||||
};
|
||||
@@ -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) }),
|
||||
};
|
||||
@@ -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`, {}),
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user