removed unneccesary files
This commit is contained in:
@@ -22,7 +22,7 @@ COOKIE_SECRET= PYjozZs+CxkU+In/FX/EI/5SB5ETAEw2AzCAF+G4Zgc=
|
|||||||
# Leave unset in local dev so host-only cookie is used.
|
# Leave unset in local dev so host-only cookie is used.
|
||||||
# COOKIE_DOMAIN=
|
# COOKIE_DOMAIN=
|
||||||
AUTH_DISABLED=false
|
AUTH_DISABLED=false
|
||||||
ALLOW_INSECURE_AUTH_FOR_DEV=false
|
ALLOW_INSECURE_AUTH_FOR_DEV=true
|
||||||
SEED_DEFAULT_BUDGET=false
|
SEED_DEFAULT_BUDGET=false
|
||||||
|
|
||||||
BREAK_GLASS_VERIFY_ENABLED=true
|
BREAK_GLASS_VERIFY_ENABLED=true
|
||||||
|
|||||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"css.lint.unknownAtRules": "ignore",
|
|
||||||
"scss.lint.unknownAtRules": "ignore",
|
|
||||||
"less.lint.unknownAtRules": "ignore",
|
|
||||||
"cSpell.words": [
|
|
||||||
"skymoney"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Security Forgot Password Controls
|
|
||||||
|
|
||||||
## Implemented Controls
|
|
||||||
|
|
||||||
- Public entry points:
|
|
||||||
- `POST /auth/forgot-password/request`
|
|
||||||
- `POST /auth/forgot-password/confirm`
|
|
||||||
- Enumeration resistance:
|
|
||||||
- Request endpoint always returns `200` with a generic success message.
|
|
||||||
- No account existence signal for unknown/unverified emails.
|
|
||||||
- Verified-account gate:
|
|
||||||
- Reset tokens are issued only when `emailVerified=true`.
|
|
||||||
- Token security:
|
|
||||||
- Reset links contain `uid` and raw `token` in query params.
|
|
||||||
- Server stores only `SHA-256(token)`.
|
|
||||||
- Token type is `password_reset`.
|
|
||||||
- Token must match `uid`, be unused, and be unexpired.
|
|
||||||
- Token is consumed once (`usedAt`) and cannot be reused.
|
|
||||||
- Session invalidation:
|
|
||||||
- Added `User.passwordChangedAt`.
|
|
||||||
- JWT auth middleware rejects tokens with `iat <= passwordChangedAt`.
|
|
||||||
- Reset and authenticated password-change both set `passwordChangedAt`.
|
|
||||||
- Abuse controls:
|
|
||||||
- Request endpoint: per-IP + email-fingerprint route keying.
|
|
||||||
- Confirm endpoint: per-IP + uid-fingerprint route keying.
|
|
||||||
- Endpoint-specific rate limits via env config.
|
|
||||||
- Logging hygiene:
|
|
||||||
- Structured security events for request/email/confirm outcomes.
|
|
||||||
- No plaintext password or raw token in logs.
|
|
||||||
- Misconfiguration resilience:
|
|
||||||
- Email send failures do not leak through API response shape.
|
|
||||||
- Generic response is preserved if SMTP is unavailable.
|
|
||||||
|
|
||||||
## Environment Settings
|
|
||||||
|
|
||||||
- `PASSWORD_RESET_TTL_MINUTES` (default `30`)
|
|
||||||
- `PASSWORD_RESET_RATE_LIMIT_PER_MINUTE` (default `5`)
|
|
||||||
- `PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE` (default `10`)
|
|
||||||
|
|
||||||
## Operational Verification
|
|
||||||
|
|
||||||
1. Verify `/auth/forgot-password/request` always returns the same JSON for unknown, unverified, and verified addresses.
|
|
||||||
2. Verify only verified users get `EmailToken(type=password_reset)` rows.
|
|
||||||
3. Verify `confirm` succeeds once and fails on replay.
|
|
||||||
4. Verify pre-reset session cookies fail on protected routes after successful reset.
|
|
||||||
5. Verify security logs contain `auth.password_reset.*` events and no raw token values.
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# Timezone-Aware Job Scheduling
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Cron Schedule (Every 15 Minutes)
|
|
||||||
```
|
|
||||||
*/15 * * * * → Runs at: 00:00, 00:15, 00:30, 00:45, 01:00, 01:15, ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Each Run:
|
|
||||||
1. **Query** all candidate plans (across ALL users, all timezones)
|
|
||||||
2. **For each plan**, check: "Is it past target hour in THIS user's timezone?"
|
|
||||||
3. **If yes** → process the plan
|
|
||||||
4. **If no** → skip until next run (15 min later)
|
|
||||||
|
|
||||||
## Real World Example: Rollover at 6 AM Local Time
|
|
||||||
|
|
||||||
### Timeline Across Timezones (Dec 17, 2025)
|
|
||||||
|
|
||||||
| UTC Time | Los Angeles (UTC-8) | New York (UTC-5) | London (UTC+0) | Tokyo (UTC+9) | Action |
|
|
||||||
|----------|---------------------|------------------|----------------|---------------|--------|
|
|
||||||
| 21:00 Dec 16 | 1:00 PM Dec 16 | 4:00 PM Dec 16 | 9:00 PM Dec 16 | **6:00 AM Dec 17** | ✅ Process Tokyo users |
|
|
||||||
| 00:00 Dec 17 | 4:00 PM Dec 16 | 7:00 PM Dec 16 | 12:00 AM Dec 17 | 9:00 AM Dec 17 | (Tokyo already done) |
|
|
||||||
| 06:00 Dec 17 | 10:00 PM Dec 16 | 1:00 AM Dec 17 | **6:00 AM Dec 17** | 3:00 PM Dec 17 | ✅ Process London users |
|
|
||||||
| 11:00 Dec 17 | 3:00 AM Dec 17 | **6:00 AM Dec 17** | 11:00 AM Dec 17 | 8:00 PM Dec 17 | ✅ Process NYC users |
|
|
||||||
| 14:00 Dec 17 | **6:00 AM Dec 17** | 9:00 AM Dec 17 | 2:00 PM Dec 17 | 11:00 PM Dec 17 | ✅ Process LA users |
|
|
||||||
|
|
||||||
### Processing Window
|
|
||||||
- **With 15-min cron**: Users processed within **0-15 minutes** after their local 6 AM
|
|
||||||
- **With hourly cron**: Users processed within **0-60 minutes** after their local 6 AM
|
|
||||||
- **With 5-min cron**: Users processed within **0-5 minutes** after their local 6 AM
|
|
||||||
|
|
||||||
## Why This Approach?
|
|
||||||
|
|
||||||
### ✅ Advantages
|
|
||||||
1. **No per-user scheduling** needed - single cron handles all users
|
|
||||||
2. **Automatic timezone handling** - works for any timezone without config
|
|
||||||
3. **Scalable** - adding users doesn't increase job complexity
|
|
||||||
4. **Self-correcting** - if a job misses a run, next run catches it
|
|
||||||
|
|
||||||
### ⚠️ Considerations
|
|
||||||
1. **Small delay** - Users processed within 15 min (not exactly at 6:00 AM)
|
|
||||||
2. **Query overhead** - Queries all candidate plans every 15 min
|
|
||||||
3. **Database filtering** - Good indexes on `dueOn` and `nextPaymentDate` are important
|
|
||||||
|
|
||||||
### 🔄 Alternative Approach (Not Implemented)
|
|
||||||
Store each user's next run time as UTC timestamp:
|
|
||||||
```sql
|
|
||||||
nextRolloverAt = '2025-12-17T21:00:00Z' -- for Tokyo user's 6 AM
|
|
||||||
```
|
|
||||||
Then query: `WHERE nextRolloverAt <= NOW()`
|
|
||||||
|
|
||||||
**Trade-offs:**
|
|
||||||
- ✅ Exact timing - no delay
|
|
||||||
- ✅ More efficient query - index on single timestamp column
|
|
||||||
- ❌ More complex - need to update nextRolloverAt after each run
|
|
||||||
- ❌ DST complications - need to recalculate when timezone rules change
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
```bash
|
|
||||||
# Rollover: default = every 15 minutes
|
|
||||||
ROLLOVER_SCHEDULE_CRON="*/15 * * * *"
|
|
||||||
|
|
||||||
# Auto-payment: default = every 15 minutes
|
|
||||||
AUTO_PAYMENT_SCHEDULE_CRON="*/15 * * * *"
|
|
||||||
|
|
||||||
# For high-precision (every 5 minutes):
|
|
||||||
ROLLOVER_SCHEDULE_CRON="*/5 * * * *"
|
|
||||||
|
|
||||||
# For lower load (hourly):
|
|
||||||
ROLLOVER_SCHEDULE_CRON="0 * * * *"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cron Format
|
|
||||||
```
|
|
||||||
* * * * *
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ │ └─ Day of week (0-7, both 0 and 7 = Sunday)
|
|
||||||
│ │ │ └─── Month (1-12)
|
|
||||||
│ │ └───── Day of month (1-31)
|
|
||||||
│ └─────── Hour (0-23)
|
|
||||||
└───────── Minute (0-59)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
*/15 * * * * → Every 15 minutes
|
|
||||||
0 * * * * → Every hour at :00
|
|
||||||
0 6 * * * → Once daily at 6 AM UTC
|
|
||||||
*/5 * * * * → Every 5 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Test Specific Timezone
|
|
||||||
```bash
|
|
||||||
# 1. Change user timezone
|
|
||||||
docker compose exec -T postgres psql -U app -d skymoney -c \
|
|
||||||
"UPDATE \"User\" SET timezone = 'Asia/Tokyo' WHERE id = 'user-id';"
|
|
||||||
|
|
||||||
# 2. Run test script
|
|
||||||
npx tsx src/scripts/test-timezone-jobs.ts user-id
|
|
||||||
```
|
|
||||||
|
|
||||||
### Simulate Specific UTC Time
|
|
||||||
```typescript
|
|
||||||
import { rolloverFixedPlans } from "./src/jobs/rollover.js";
|
|
||||||
|
|
||||||
// Simulate running at 21:00 UTC (= 6 AM Tokyo)
|
|
||||||
await rolloverFixedPlans(prisma, "2025-12-17T21:00:00Z", { dryRun: true });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Different Timezones
|
|
||||||
```bash
|
|
||||||
# Tokyo (UTC+9) - 6 AM = 21:00 UTC previous day
|
|
||||||
npx tsx -e "import { rolloverFixedPlans } from './src/jobs/rollover.js'; ..."
|
|
||||||
|
|
||||||
# Los Angeles (UTC-8) - 6 AM = 14:00 UTC same day
|
|
||||||
# London (UTC+0) - 6 AM = 06:00 UTC same day
|
|
||||||
# New York (UTC-5) - 6 AM = 11:00 UTC same day
|
|
||||||
```
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
const {PrismaClient} = require('@prisma/client');
|
|
||||||
|
|
||||||
async function checkAllocations() {
|
|
||||||
const p = new PrismaClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await p.user.findUnique({
|
|
||||||
where: { email: 'test@skymoney.com' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const income = await p.incomeEvent.findFirst({
|
|
||||||
where: { userId: user.id },
|
|
||||||
orderBy: { postedAt: 'desc' },
|
|
||||||
include: { allocations: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n💵 LATEST INCOME:', Number(income.amountCents)/100);
|
|
||||||
console.log('\n📊 ALLOCATIONS:');
|
|
||||||
|
|
||||||
for (const a of income.allocations) {
|
|
||||||
if (a.kind === 'fixed') {
|
|
||||||
const plan = await p.fixedPlan.findUnique({ where: { id: a.toId } });
|
|
||||||
console.log(' Fixed -', plan.name + ':', Number(a.amountCents)/100);
|
|
||||||
} else if (a.kind === 'variable') {
|
|
||||||
const cat = await p.variableCategory.findUnique({ where: { id: a.toId } });
|
|
||||||
console.log(' Variable -', cat.name + ':', Number(a.amountCents)/100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await p.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAllocations();
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// Script to check overdue status of test user
|
|
||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: 'test@skymoney.com' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.log('❌ Test user not found. Run create-test-user.cjs first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Found test user:', user.email);
|
|
||||||
|
|
||||||
const plans = await prisma.fixedPlan.findMany({
|
|
||||||
where: { userId: user.id },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
totalCents: true,
|
|
||||||
fundedCents: true,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: true,
|
|
||||||
overdueSince: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n📋 Fixed Plans:');
|
|
||||||
for (const plan of plans) {
|
|
||||||
console.log(`\n ${plan.name}:`);
|
|
||||||
console.log(` Total: $${Number(plan.totalCents) / 100}`);
|
|
||||||
console.log(` Funded: $${Number(plan.fundedCents) / 100}`);
|
|
||||||
console.log(` Overdue: ${plan.isOverdue ? 'YES' : 'NO'}`);
|
|
||||||
if (plan.isOverdue) {
|
|
||||||
console.log(` Overdue Amount: $${plan.overdueAmount / 100}`);
|
|
||||||
console.log(` Overdue Since: ${plan.overdueSince}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
/* SkyMoney SDK: zero-dep, Fetch-based, TypeScript-first.
|
|
||||||
Usage:
|
|
||||||
import { SkyMoney } from "./sdk";
|
|
||||||
const api = new SkyMoney({ baseUrl: import.meta.env.VITE_API_URL });
|
|
||||||
const dash = await api.dashboard.get();
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type TransactionKind = "variable_spend" | "fixed_payment";
|
|
||||||
|
|
||||||
export interface OkResponse { ok: true }
|
|
||||||
export interface ErrorResponse {
|
|
||||||
ok: false; code: string; message: string; requestId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VariableCategory {
|
|
||||||
id: string;
|
|
||||||
userId?: string;
|
|
||||||
name: string;
|
|
||||||
percent: number; // 0..100
|
|
||||||
isSavings: boolean;
|
|
||||||
priority: number;
|
|
||||||
balanceCents?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FixedPlan {
|
|
||||||
id: string;
|
|
||||||
userId?: string;
|
|
||||||
name: string;
|
|
||||||
totalCents?: number;
|
|
||||||
fundedCents?: number;
|
|
||||||
priority: number;
|
|
||||||
dueOn: string; // ISO
|
|
||||||
cycleStart?: string;// ISO
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Transaction {
|
|
||||||
id: string;
|
|
||||||
userId?: string;
|
|
||||||
kind: TransactionKind;
|
|
||||||
amountCents: number;
|
|
||||||
occurredAt: string; // ISO
|
|
||||||
categoryId?: string | null;
|
|
||||||
planId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TransactionList {
|
|
||||||
items: Transaction[];
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardResponse {
|
|
||||||
totals: {
|
|
||||||
incomeCents: number;
|
|
||||||
variableBalanceCents: number;
|
|
||||||
fixedRemainingCents: number;
|
|
||||||
};
|
|
||||||
percentTotal: number;
|
|
||||||
variableCategories: VariableCategory[];
|
|
||||||
fixedPlans: FixedPlan[];
|
|
||||||
recentTransactions: Transaction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IncomeRequest { amountCents: number; }
|
|
||||||
|
|
||||||
export interface AllocationItem {
|
|
||||||
id: string; name: string; amountCents: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IncomePreviewResponse {
|
|
||||||
fixed: AllocationItem[];
|
|
||||||
variable: AllocationItem[];
|
|
||||||
unallocatedCents: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// allocateIncome returns a richer object; tests expect these fields:
|
|
||||||
export interface IncomeAllocationResponse {
|
|
||||||
fixedAllocations?: AllocationItem[];
|
|
||||||
variableAllocations?: AllocationItem[];
|
|
||||||
remainingUnallocatedCents?: number;
|
|
||||||
// allow any extra fields without type errors:
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
[k: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FetchLike = typeof fetch;
|
|
||||||
|
|
||||||
export type SDKOptions = {
|
|
||||||
baseUrl?: string;
|
|
||||||
fetch?: FetchLike;
|
|
||||||
requestIdFactory?: () => string; // to set x-request-id if desired
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeQuery(params: Record<string, unknown | undefined>): string {
|
|
||||||
const sp = new URLSearchParams();
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
if (v === undefined || v === null || v === "") continue;
|
|
||||||
sp.set(k, String(v));
|
|
||||||
}
|
|
||||||
const s = sp.toString();
|
|
||||||
return s ? `?${s}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SkyMoney {
|
|
||||||
readonly baseUrl: string;
|
|
||||||
private readonly f: FetchLike;
|
|
||||||
private readonly reqId?: () => string;
|
|
||||||
|
|
||||||
constructor(opts: SDKOptions = {}) {
|
|
||||||
this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, "");
|
|
||||||
this.f = opts.fetch || fetch;
|
|
||||||
this.reqId = opts.requestIdFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request<T>(
|
|
||||||
method: "GET" | "POST" | "PATCH" | "DELETE",
|
|
||||||
path: string,
|
|
||||||
body?: unknown,
|
|
||||||
query?: Record<string, unknown>,
|
|
||||||
headers?: Record<string, string>
|
|
||||||
): Promise<T> {
|
|
||||||
const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`;
|
|
||||||
const h: Record<string, string> = { ...(headers || {}) };
|
|
||||||
if (this.reqId) h["x-request-id"] = this.reqId();
|
|
||||||
const hasBody = body !== undefined && body !== null;
|
|
||||||
|
|
||||||
const res = await this.f(url, {
|
|
||||||
method,
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
...(hasBody ? { "content-type": "application/json" } : {}),
|
|
||||||
...h,
|
|
||||||
},
|
|
||||||
body: hasBody ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attempt to parse JSON; fall back to text
|
|
||||||
const text = await res.text();
|
|
||||||
const data = text ? safeJson(text) : undefined;
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = new Error((data as any)?.message || `HTTP ${res.status}`);
|
|
||||||
(err as any).status = res.status;
|
|
||||||
(err as any).body = data ?? text;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Health
|
|
||||||
health = {
|
|
||||||
get: () => this.request<{ ok: true }>("GET", "/health"),
|
|
||||||
db: () => this.request<{ ok: true; nowISO: string; latencyMs: number }>("GET", "/health/db"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Dashboard
|
|
||||||
dashboard = {
|
|
||||||
get: () => this.request<DashboardResponse>("GET", "/dashboard"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Income
|
|
||||||
income = {
|
|
||||||
preview: (amountCents: number) =>
|
|
||||||
this.request<IncomePreviewResponse>("POST", "/income/preview", { amountCents }),
|
|
||||||
create: (amountCents: number) =>
|
|
||||||
this.request<IncomeAllocationResponse>("POST", "/income", { amountCents }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Transactions
|
|
||||||
transactions = {
|
|
||||||
list: (args: {
|
|
||||||
from?: string; // YYYY-MM-DD
|
|
||||||
to?: string; // YYYY-MM-DD
|
|
||||||
kind?: TransactionKind;
|
|
||||||
q?: string;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
}) =>
|
|
||||||
this.request<TransactionList>("GET", "/transactions", undefined, args),
|
|
||||||
create: (payload: {
|
|
||||||
kind: TransactionKind;
|
|
||||||
amountCents: number;
|
|
||||||
occurredAtISO: string;
|
|
||||||
categoryId?: string;
|
|
||||||
planId?: string;
|
|
||||||
}) => this.request<Transaction>("POST", "/transactions", payload),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Variable Categories
|
|
||||||
variableCategories = {
|
|
||||||
create: (payload: {
|
|
||||||
name: string;
|
|
||||||
percent: number;
|
|
||||||
isSavings: boolean;
|
|
||||||
priority: number;
|
|
||||||
}) => this.request<OkResponse>("POST", "/variable-categories", payload),
|
|
||||||
|
|
||||||
update: (id: string, patch: Partial<{
|
|
||||||
name: string;
|
|
||||||
percent: number;
|
|
||||||
isSavings: boolean;
|
|
||||||
priority: number;
|
|
||||||
}>) => this.request<OkResponse>("PATCH", `/variable-categories/${encodeURIComponent(id)}`, patch),
|
|
||||||
|
|
||||||
delete: (id: string) =>
|
|
||||||
this.request<OkResponse>("DELETE", `/variable-categories/${encodeURIComponent(id)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Fixed Plans
|
|
||||||
fixedPlans = {
|
|
||||||
create: (payload: {
|
|
||||||
name: string;
|
|
||||||
totalCents: number;
|
|
||||||
fundedCents?: number;
|
|
||||||
priority: number;
|
|
||||||
dueOn: string; // ISO
|
|
||||||
cycleStart?: string; // ISO
|
|
||||||
}) => this.request<OkResponse>("POST", "/fixed-plans", payload),
|
|
||||||
|
|
||||||
update: (id: string, patch: Partial<{
|
|
||||||
name: string;
|
|
||||||
totalCents: number;
|
|
||||||
fundedCents: number;
|
|
||||||
priority: number;
|
|
||||||
dueOn: string;
|
|
||||||
cycleStart: string;
|
|
||||||
}>) => this.request<OkResponse>("PATCH", `/fixed-plans/${encodeURIComponent(id)}`, patch),
|
|
||||||
|
|
||||||
delete: (id: string) =>
|
|
||||||
this.request<OkResponse>("DELETE", `/fixed-plans/${encodeURIComponent(id)}`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- helpers ----------
|
|
||||||
function safeJson(s: string) {
|
|
||||||
try { return JSON.parse(s) } catch { return s }
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
const argon2 = require('argon2');
|
|
||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
|
|
||||||
async function createTestUser() {
|
|
||||||
const prisma = new PrismaClient({
|
|
||||||
datasourceUrl: 'postgres://app:app@localhost:5432/skymoney'
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Delete existing test user if exists
|
|
||||||
await prisma.user.deleteMany({
|
|
||||||
where: { email: 'test@skymoney.com' }
|
|
||||||
});
|
|
||||||
console.log('✓ Cleaned up old test user');
|
|
||||||
|
|
||||||
// Create user
|
|
||||||
const hash = await argon2.hash('password123');
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email: 'test@skymoney.com',
|
|
||||||
passwordHash: hash,
|
|
||||||
displayName: 'Test User',
|
|
||||||
timezone: 'America/New_York'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('✓ Created user:', user.id);
|
|
||||||
|
|
||||||
// Create categories (must total 100%)
|
|
||||||
await prisma.variableCategory.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Groceries',
|
|
||||||
percent: 50,
|
|
||||||
balanceCents: 150000n // $1500
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await prisma.variableCategory.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Other',
|
|
||||||
percent: 50,
|
|
||||||
balanceCents: 150000n // $1500
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('✓ Created categories (100% total)');
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
// Create 3 overdue bills with different overdue dates (oldest first priority)
|
|
||||||
|
|
||||||
// 1. RENT - Overdue 5 days ago (OLDEST = HIGHEST PRIORITY)
|
|
||||||
const rentOverdue = new Date(today);
|
|
||||||
rentOverdue.setDate(rentOverdue.getDate() - 5);
|
|
||||||
await prisma.fixedPlan.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Rent',
|
|
||||||
totalCents: 150000n, // $1500 total
|
|
||||||
fundedCents: 100000n, // $1000 funded
|
|
||||||
currentFundedCents: 100000n,
|
|
||||||
dueOn: rentOverdue,
|
|
||||||
cycleStart: rentOverdue,
|
|
||||||
frequency: 'monthly',
|
|
||||||
needsFundingThisPeriod: true,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: 50000n, // $500 overdue
|
|
||||||
overdueSince: rentOverdue
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('✓ Rent: $1500 total, $500 overdue (5 days ago - OLDEST)');
|
|
||||||
|
|
||||||
// 2. UTILITIES - Overdue 3 days ago (SECOND PRIORITY)
|
|
||||||
const utilOverdue = new Date(today);
|
|
||||||
utilOverdue.setDate(utilOverdue.getDate() - 3);
|
|
||||||
await prisma.fixedPlan.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Utilities',
|
|
||||||
totalCents: 20000n, // $200 total
|
|
||||||
fundedCents: 10000n, // $100 funded
|
|
||||||
currentFundedCents: 10000n,
|
|
||||||
dueOn: utilOverdue,
|
|
||||||
cycleStart: utilOverdue,
|
|
||||||
frequency: 'monthly',
|
|
||||||
needsFundingThisPeriod: true,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: 10000n, // $100 overdue
|
|
||||||
overdueSince: utilOverdue
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('✓ Utilities: $200 total, $100 overdue (3 days ago)');
|
|
||||||
|
|
||||||
// 3. PHONE - Overdue 1 day ago (NEWEST = LOWEST PRIORITY)
|
|
||||||
const phoneOverdue = new Date(today);
|
|
||||||
phoneOverdue.setDate(phoneOverdue.getDate() - 1);
|
|
||||||
await prisma.fixedPlan.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Phone',
|
|
||||||
totalCents: 10000n, // $100 total
|
|
||||||
fundedCents: 5000n, // $50 funded
|
|
||||||
currentFundedCents: 5000n,
|
|
||||||
dueOn: phoneOverdue,
|
|
||||||
cycleStart: phoneOverdue,
|
|
||||||
frequency: 'monthly',
|
|
||||||
needsFundingThisPeriod: true,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: 5000n, // $50 overdue
|
|
||||||
overdueSince: phoneOverdue
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('✓ Phone: $100 total, $50 overdue (1 day ago - NEWEST)');
|
|
||||||
|
|
||||||
console.log('\n✅ Multi-overdue test user ready!');
|
|
||||||
console.log(' Email: test@skymoney.com');
|
|
||||||
console.log(' Password: password123');
|
|
||||||
console.log('\n OVERDUE BILLS (priority order):');
|
|
||||||
console.log(' 1. Rent: $500 (5 days overdue)');
|
|
||||||
console.log(' 2. Utilities: $100 (3 days overdue)');
|
|
||||||
console.log(' 3. Phone: $50 (1 day overdue)');
|
|
||||||
console.log(' TOTAL OVERDUE: $650');
|
|
||||||
console.log('\n Test scenarios:');
|
|
||||||
console.log(' - Post $500 income → Should pay Rent only');
|
|
||||||
console.log(' - Post $600 income → Should pay Rent ($500) + Utilities ($100)');
|
|
||||||
console.log(' - Post $700 income → Should pay all 3 overdue bills');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error:', error.message);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createTestUser();
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
// Create test user with MULTIPLE overdue bills
|
|
||||||
const argon2 = require('argon2');
|
|
||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const prisma = new PrismaClient({
|
|
||||||
datasourceUrl: 'postgres://app:app@localhost:5432/skymoney'
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const email = 'test@skymoney.com';
|
|
||||||
const password = 'password123';
|
|
||||||
|
|
||||||
// Clean up existing user
|
|
||||||
await prisma.user.deleteMany({ where: { email } });
|
|
||||||
console.log('✓ Cleaned up old test user');
|
|
||||||
|
|
||||||
// Create user
|
|
||||||
const passwordHash = await argon2.hash(password);
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email,
|
|
||||||
passwordHash,
|
|
||||||
displayName: 'Test User',
|
|
||||||
incomeFrequency: 'biweekly',
|
|
||||||
totalBudgetCents: BigInt(300000), // $3000
|
|
||||||
timezone: 'America/New_York',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✓ Created user:', user.id);
|
|
||||||
|
|
||||||
// Create income source
|
|
||||||
await prisma.incomeEvent.create({
|
|
||||||
data: {
|
|
||||||
id: '00000000-0000-0000-0000-000000000001',
|
|
||||||
userId: user.id,
|
|
||||||
postedAt: new Date(),
|
|
||||||
amountCents: BigInt(300000),
|
|
||||||
note: 'Initial budget',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('✓ Created income: $3000');
|
|
||||||
|
|
||||||
// Create categories
|
|
||||||
await prisma.variableCategory.createMany({
|
|
||||||
data: [
|
|
||||||
{ userId: user.id, name: 'Groceries', percent: 50, priority: 1, balanceCents: BigInt(150000) },
|
|
||||||
{ userId: user.id, name: 'Other', percent: 50, priority: 2, balanceCents: BigInt(150000) },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
console.log('✓ Created categories (100% total)');
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(6, 0, 0, 0); // 6am today
|
|
||||||
|
|
||||||
const threeDaysAgo = new Date(today);
|
|
||||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
|
|
||||||
|
|
||||||
const oneWeekAgo = new Date(today);
|
|
||||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
|
||||||
|
|
||||||
// Create THREE overdue bills with different dates
|
|
||||||
// 1. Rent - $1500, $1000 funded, $500 overdue (oldest - 7 days ago)
|
|
||||||
await prisma.fixedPlan.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Rent',
|
|
||||||
cycleStart: oneWeekAgo,
|
|
||||||
dueOn: today,
|
|
||||||
totalCents: BigInt(150000), // $1500
|
|
||||||
fundedCents: BigInt(100000), // $1000 funded
|
|
||||||
currentFundedCents: BigInt(100000),
|
|
||||||
priority: 1,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: BigInt(50000), // $500 overdue
|
|
||||||
overdueSince: oneWeekAgo,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Utilities - $200, $100 funded, $100 overdue (3 days ago)
|
|
||||||
await prisma.fixedPlan.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Utilities',
|
|
||||||
cycleStart: threeDaysAgo,
|
|
||||||
dueOn: today,
|
|
||||||
totalCents: BigInt(20000), // $200
|
|
||||||
fundedCents: BigInt(10000), // $100 funded
|
|
||||||
currentFundedCents: BigInt(10000),
|
|
||||||
priority: 2,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: BigInt(10000), // $100 overdue
|
|
||||||
overdueSince: threeDaysAgo,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Phone - $100, $50 funded, $50 overdue (today)
|
|
||||||
await prisma.fixedPlan.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Phone',
|
|
||||||
cycleStart: today,
|
|
||||||
dueOn: today,
|
|
||||||
totalCents: BigInt(10000), // $100
|
|
||||||
fundedCents: BigInt(5000), // $50 funded
|
|
||||||
currentFundedCents: BigInt(5000),
|
|
||||||
priority: 3,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: BigInt(5000), // $50 overdue
|
|
||||||
overdueSince: today,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✓ Created 3 overdue plans:');
|
|
||||||
console.log(' - Rent: $1500 total, $1000 funded, $500 overdue (7 days ago)');
|
|
||||||
console.log(' - Utilities: $200 total, $100 funded, $100 overdue (3 days ago)');
|
|
||||||
console.log(' - Phone: $100 total, $50 funded, $50 overdue (today)');
|
|
||||||
console.log('\n✅ Test user ready!');
|
|
||||||
console.log(' Email: test@skymoney.com');
|
|
||||||
console.log(' Password: password123');
|
|
||||||
console.log(' Total overdue: $650');
|
|
||||||
console.log('\n💡 Post $1000 income to see priority order:');
|
|
||||||
console.log(' 1st: Rent $500 (oldest)');
|
|
||||||
console.log(' 2nd: Utilities $100');
|
|
||||||
console.log(' 3rd: Phone $50');
|
|
||||||
console.log(' Remaining $350 → normal allocation');
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
const argon2 = require('argon2');
|
|
||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
|
|
||||||
async function createTestUser() {
|
|
||||||
const prisma = new PrismaClient({
|
|
||||||
datasourceUrl: 'postgres://app:app@localhost:5432/skymoney'
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Delete existing test user if exists
|
|
||||||
await prisma.user.deleteMany({
|
|
||||||
where: { email: 'test@skymoney.com' }
|
|
||||||
});
|
|
||||||
console.log('✓ Cleaned up old test user');
|
|
||||||
|
|
||||||
// Create user
|
|
||||||
const hash = await argon2.hash('password123');
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email: 'test@skymoney.com',
|
|
||||||
passwordHash: hash,
|
|
||||||
displayName: 'Test User',
|
|
||||||
timezone: 'America/New_York'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('✓ Created user:', user.id);
|
|
||||||
|
|
||||||
// Create income
|
|
||||||
await prisma.incomeEvent.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
amountCents: 300000n, // $3000
|
|
||||||
postedAt: new Date(),
|
|
||||||
isScheduledIncome: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('✓ Created income: $3000');
|
|
||||||
|
|
||||||
// Create categories (must total 100%)
|
|
||||||
await prisma.variableCategory.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Groceries',
|
|
||||||
percent: 50,
|
|
||||||
balanceCents: 150000n // $1500
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await prisma.variableCategory.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Other',
|
|
||||||
percent: 50,
|
|
||||||
balanceCents: 0n
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('✓ Created categories (100% total)');
|
|
||||||
|
|
||||||
// Create rent bill due today - PARTIALLY FUNDED & OVERDUE
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
await prisma.fixedPlan.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
name: 'Rent',
|
|
||||||
totalCents: 150000n, // $1500 total
|
|
||||||
fundedCents: 100000n, // $1000 funded (partial)
|
|
||||||
currentFundedCents: 100000n, // $1000 available
|
|
||||||
dueOn: today,
|
|
||||||
cycleStart: today,
|
|
||||||
frequency: 'monthly',
|
|
||||||
needsFundingThisPeriod: true,
|
|
||||||
isOverdue: true, // Marked overdue
|
|
||||||
overdueAmount: 50000n, // $500 outstanding
|
|
||||||
overdueSince: new Date()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('✓ Created Rent plan: $1500 total, $1000 funded, $500 overdue');
|
|
||||||
console.log('\n✅ Test user ready!');
|
|
||||||
console.log(' Email: test@skymoney.com');
|
|
||||||
console.log(' Password: password123');
|
|
||||||
console.log(' Rent: $1500 due today (partially funded, overdue)');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error:', error.message);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createTestUser();
|
|
||||||
552
api/openapi.yaml
552
api/openapi.yaml
@@ -1,552 +0,0 @@
|
|||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: SkyMoney API
|
|
||||||
version: 0.1.0
|
|
||||||
description: |
|
|
||||||
Fastify backend for budgeting/allocations.
|
|
||||||
Authentication uses secure httpOnly session cookies (Fastify JWT). During tests
|
|
||||||
or local development you can set `AUTH_DISABLED=1` to use the legacy `x-user-id`
|
|
||||||
header for impersonation, but production relies on the session cookie.
|
|
||||||
|
|
||||||
servers:
|
|
||||||
- url: http://localhost:8080
|
|
||||||
|
|
||||||
tags:
|
|
||||||
- name: Health
|
|
||||||
- name: Dashboard
|
|
||||||
- name: Income
|
|
||||||
- name: Transactions
|
|
||||||
- name: VariableCategories
|
|
||||||
- name: FixedPlans
|
|
||||||
|
|
||||||
paths:
|
|
||||||
/health:
|
|
||||||
get:
|
|
||||||
tags: [Health]
|
|
||||||
summary: Liveness check
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/HealthOk' }
|
|
||||||
|
|
||||||
/health/db:
|
|
||||||
get:
|
|
||||||
tags: [Health]
|
|
||||||
summary: DB health + latency
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: DB OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/DbHealth' }
|
|
||||||
|
|
||||||
/dashboard:
|
|
||||||
get:
|
|
||||||
tags: [Dashboard]
|
|
||||||
summary: Aggregated dashboard data
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Dashboard payload
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/DashboardResponse' }
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/BadRequest'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
|
|
||||||
/income:
|
|
||||||
post:
|
|
||||||
tags: [Income]
|
|
||||||
summary: Create income event and allocate funds
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/IncomeRequest' }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Allocation result
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/IncomeAllocationResponse' }
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/BadRequest'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
|
|
||||||
/income/preview:
|
|
||||||
post:
|
|
||||||
tags: [Income]
|
|
||||||
summary: Preview allocation of a hypothetical income amount
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/IncomeRequest' }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Preview
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/IncomePreviewResponse' }
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/BadRequest'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
|
|
||||||
/transactions:
|
|
||||||
get:
|
|
||||||
tags: [Transactions]
|
|
||||||
summary: List transactions with filters and pagination
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
- in: query
|
|
||||||
name: from
|
|
||||||
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
|
|
||||||
description: Inclusive start date (YYYY-MM-DD)
|
|
||||||
- in: query
|
|
||||||
name: to
|
|
||||||
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
|
|
||||||
description: Inclusive end date (YYYY-MM-DD)
|
|
||||||
- in: query
|
|
||||||
name: kind
|
|
||||||
schema: { $ref: '#/components/schemas/TransactionKind' }
|
|
||||||
- in: query
|
|
||||||
name: q
|
|
||||||
schema: { type: string }
|
|
||||||
description: Simple search (currently numeric amount match)
|
|
||||||
- in: query
|
|
||||||
name: page
|
|
||||||
schema: { type: integer, minimum: 1, default: 1 }
|
|
||||||
- in: query
|
|
||||||
name: limit
|
|
||||||
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/TransactionList' }
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/BadRequest'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
post:
|
|
||||||
tags: [Transactions]
|
|
||||||
summary: Create a transaction
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/TransactionCreate' }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Created
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/Transaction' }
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/BadRequest'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
|
|
||||||
/variable-categories:
|
|
||||||
post:
|
|
||||||
tags: [VariableCategories]
|
|
||||||
summary: Create a variable category (sum of percents must be 100)
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/VariableCategoryCreate' }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/BadRequest'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
|
|
||||||
/variable-categories/{id}:
|
|
||||||
patch:
|
|
||||||
tags: [VariableCategories]
|
|
||||||
summary: Update a variable category (sum of percents must be 100)
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
- in: path
|
|
||||||
name: id
|
|
||||||
required: true
|
|
||||||
schema: { type: string }
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/VariableCategoryPatch' }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/BadRequest'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
delete:
|
|
||||||
tags: [VariableCategories]
|
|
||||||
summary: Delete a variable category (sum of percents must remain 100)
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
- in: path
|
|
||||||
name: id
|
|
||||||
required: true
|
|
||||||
schema: { type: string }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
|
|
||||||
/fixed-plans:
|
|
||||||
post:
|
|
||||||
tags: [FixedPlans]
|
|
||||||
summary: Create a fixed plan
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/FixedPlanCreate' }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/BadRequest'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
|
|
||||||
/fixed-plans/{id}:
|
|
||||||
patch:
|
|
||||||
tags: [FixedPlans]
|
|
||||||
summary: Update a fixed plan (fundedCents cannot exceed totalCents)
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
- in: path
|
|
||||||
name: id
|
|
||||||
required: true
|
|
||||||
schema: { type: string }
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/FixedPlanPatch' }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/BadRequest'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
delete:
|
|
||||||
tags: [FixedPlans]
|
|
||||||
summary: Delete a fixed plan
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
- $ref: '#/components/parameters/RequestId'
|
|
||||||
- in: path
|
|
||||||
name: id
|
|
||||||
required: true
|
|
||||||
schema: { type: string }
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
|
|
||||||
components:
|
|
||||||
parameters:
|
|
||||||
UserId:
|
|
||||||
in: header
|
|
||||||
name: x-user-id
|
|
||||||
required: false
|
|
||||||
description: |
|
|
||||||
Dev/test-only tenant selector when AUTH_DISABLED=1. Production requests rely
|
|
||||||
on the session cookie instead and should omit this header.
|
|
||||||
schema: { type: string }
|
|
||||||
description: Override the stubbed user id for the request.
|
|
||||||
RequestId:
|
|
||||||
in: header
|
|
||||||
name: x-request-id
|
|
||||||
required: false
|
|
||||||
schema: { type: string, maxLength: 64 }
|
|
||||||
description: Custom request id (echoed back by server).
|
|
||||||
|
|
||||||
responses:
|
|
||||||
BadRequest:
|
|
||||||
description: Validation or guard failed
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
|
||||||
NotFound:
|
|
||||||
description: Resource not found
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
|
||||||
InternalError:
|
|
||||||
description: Unexpected server error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
|
||||||
|
|
||||||
schemas:
|
|
||||||
HealthOk:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ok: { type: boolean, const: true }
|
|
||||||
required: [ok]
|
|
||||||
|
|
||||||
DbHealth:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ok: { type: boolean, const: true }
|
|
||||||
nowISO: { type: string, format: date-time }
|
|
||||||
latencyMs: { type: integer, minimum: 0 }
|
|
||||||
required: [ok, nowISO, latencyMs]
|
|
||||||
|
|
||||||
OkResponse:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ok: { type: boolean, const: true }
|
|
||||||
required: [ok]
|
|
||||||
|
|
||||||
ErrorResponse:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ok: { type: boolean, const: false }
|
|
||||||
code: { type: string }
|
|
||||||
message: { type: string }
|
|
||||||
requestId: { type: string }
|
|
||||||
required: [ok, code, message, requestId]
|
|
||||||
|
|
||||||
VariableCategory:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id: { type: string }
|
|
||||||
userId: { type: string }
|
|
||||||
name: { type: string }
|
|
||||||
percent: { type: integer, minimum: 0, maximum: 100 }
|
|
||||||
isSavings: { type: boolean }
|
|
||||||
priority: { type: integer, minimum: 0 }
|
|
||||||
balanceCents:
|
|
||||||
type: integer
|
|
||||||
description: Current balance; may be omitted or 0 when not loaded.
|
|
||||||
required: [id, userId, name, percent, isSavings, priority]
|
|
||||||
|
|
||||||
FixedPlan:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id: { type: string }
|
|
||||||
userId: { type: string }
|
|
||||||
name: { type: string }
|
|
||||||
totalCents: { type: integer, minimum: 0 }
|
|
||||||
fundedCents: { type: integer, minimum: 0 }
|
|
||||||
priority: { type: integer, minimum: 0 }
|
|
||||||
dueOn: { type: string, format: date-time }
|
|
||||||
cycleStart: { type: string, format: date-time }
|
|
||||||
required: [id, userId, name, priority, dueOn]
|
|
||||||
|
|
||||||
TransactionKind:
|
|
||||||
type: string
|
|
||||||
enum: [variable_spend, fixed_payment]
|
|
||||||
|
|
||||||
Transaction:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id: { type: string }
|
|
||||||
userId: { type: string }
|
|
||||||
kind: { $ref: '#/components/schemas/TransactionKind' }
|
|
||||||
amountCents: { type: integer, minimum: 0 }
|
|
||||||
occurredAt: { type: string, format: date-time }
|
|
||||||
categoryId: { type: string, nullable: true }
|
|
||||||
planId: { type: string, nullable: true }
|
|
||||||
required: [id, userId, kind, amountCents, occurredAt]
|
|
||||||
|
|
||||||
TransactionList:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
items:
|
|
||||||
type: array
|
|
||||||
items: { $ref: '#/components/schemas/Transaction' }
|
|
||||||
page: { type: integer, minimum: 1 }
|
|
||||||
limit: { type: integer, minimum: 1, maximum: 100 }
|
|
||||||
total: { type: integer, minimum: 0 }
|
|
||||||
required: [items, page, limit, total]
|
|
||||||
|
|
||||||
TransactionCreate:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
kind: { $ref: '#/components/schemas/TransactionKind' }
|
|
||||||
amountCents: { type: integer, minimum: 1 }
|
|
||||||
occurredAtISO: { type: string, format: date-time }
|
|
||||||
categoryId: { type: string, nullable: true }
|
|
||||||
planId: { type: string, nullable: true }
|
|
||||||
required: [kind, amountCents, occurredAtISO]
|
|
||||||
|
|
||||||
DashboardResponse:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
totals:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
incomeCents: { type: integer, minimum: 0 }
|
|
||||||
variableBalanceCents: { type: integer, minimum: 0 }
|
|
||||||
fixedRemainingCents: { type: integer, minimum: 0 }
|
|
||||||
required: [incomeCents, variableBalanceCents, fixedRemainingCents]
|
|
||||||
percentTotal: { type: integer, minimum: 0, maximum: 100 }
|
|
||||||
variableCategories:
|
|
||||||
type: array
|
|
||||||
items: { $ref: '#/components/schemas/VariableCategory' }
|
|
||||||
fixedPlans:
|
|
||||||
type: array
|
|
||||||
items: { $ref: '#/components/schemas/FixedPlan' }
|
|
||||||
recentTransactions:
|
|
||||||
type: array
|
|
||||||
items: { $ref: '#/components/schemas/Transaction' }
|
|
||||||
required: [totals, percentTotal, variableCategories, fixedPlans, recentTransactions]
|
|
||||||
|
|
||||||
IncomeRequest:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
amountCents: { type: integer, minimum: 0 }
|
|
||||||
required: [amountCents]
|
|
||||||
|
|
||||||
AllocationItem:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id: { type: string }
|
|
||||||
name: { type: string }
|
|
||||||
amountCents: { type: integer, minimum: 0 }
|
|
||||||
required: [id, name, amountCents]
|
|
||||||
|
|
||||||
IncomePreviewResponse:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
fixed:
|
|
||||||
type: array
|
|
||||||
items: { $ref: '#/components/schemas/AllocationItem' }
|
|
||||||
variable:
|
|
||||||
type: array
|
|
||||||
items: { $ref: '#/components/schemas/AllocationItem' }
|
|
||||||
unallocatedCents: { type: integer, minimum: 0 }
|
|
||||||
required: [fixed, variable, unallocatedCents]
|
|
||||||
|
|
||||||
IncomeAllocationResponse:
|
|
||||||
type: object
|
|
||||||
description: >
|
|
||||||
Shape returned by allocateIncome. Tests expect:
|
|
||||||
fixedAllocations, variableAllocations, remainingUnallocatedCents.
|
|
||||||
Additional fields may be present.
|
|
||||||
properties:
|
|
||||||
fixedAllocations:
|
|
||||||
type: array
|
|
||||||
items: { $ref: '#/components/schemas/AllocationItem' }
|
|
||||||
variableAllocations:
|
|
||||||
type: array
|
|
||||||
items: { $ref: '#/components/schemas/AllocationItem' }
|
|
||||||
remainingUnallocatedCents: { type: integer, minimum: 0 }
|
|
||||||
additionalProperties: true
|
|
||||||
|
|
||||||
VariableCategoryCreate:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name: { type: string }
|
|
||||||
percent: { type: integer, minimum: 0, maximum: 100 }
|
|
||||||
isSavings: { type: boolean }
|
|
||||||
priority: { type: integer, minimum: 0 }
|
|
||||||
required: [name, percent, isSavings, priority]
|
|
||||||
|
|
||||||
VariableCategoryPatch:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name: { type: string }
|
|
||||||
percent: { type: integer, minimum: 0, maximum: 100 }
|
|
||||||
isSavings: { type: boolean }
|
|
||||||
priority: { type: integer, minimum: 0 }
|
|
||||||
additionalProperties: false
|
|
||||||
|
|
||||||
FixedPlanCreate:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name: { type: string }
|
|
||||||
totalCents: { type: integer, minimum: 0 }
|
|
||||||
fundedCents: { type: integer, minimum: 0 }
|
|
||||||
priority: { type: integer, minimum: 0 }
|
|
||||||
dueOn: { type: string, format: date-time }
|
|
||||||
cycleStart: { type: string, format: date-time }
|
|
||||||
required: [name, totalCents, priority, dueOn]
|
|
||||||
|
|
||||||
FixedPlanPatch:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name: { type: string }
|
|
||||||
totalCents: { type: integer, minimum: 0 }
|
|
||||||
fundedCents: { type: integer, minimum: 0 }
|
|
||||||
priority: { type: integer, minimum: 0 }
|
|
||||||
dueOn: { type: string, format: date-time }
|
|
||||||
cycleStart: { type: string, format: date-time }
|
|
||||||
additionalProperties: false
|
|
||||||
2173
api/pnpm-lock.yaml
generated
2173
api/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -297,6 +297,7 @@ async function getInputs(
|
|||||||
currentFundedCents: true,
|
currentFundedCents: true,
|
||||||
dueOn: true,
|
dueOn: true,
|
||||||
priority: true,
|
priority: true,
|
||||||
|
fundingMode: true,
|
||||||
needsFundingThisPeriod: true,
|
needsFundingThisPeriod: true,
|
||||||
paymentSchedule: true,
|
paymentSchedule: true,
|
||||||
autoPayEnabled: true,
|
autoPayEnabled: true,
|
||||||
@@ -328,7 +329,8 @@ export function buildPlanStates(
|
|||||||
userIncomeType?: string,
|
userIncomeType?: string,
|
||||||
isScheduledIncome?: boolean
|
isScheduledIncome?: boolean
|
||||||
): PlanState[] {
|
): PlanState[] {
|
||||||
const timezone = config.timezone;
|
const timezone = config.timezone ?? "UTC";
|
||||||
|
const firstIncomeDate = config.firstIncomeDate ?? null;
|
||||||
const freqDays = frequencyDays[config.incomeFrequency];
|
const freqDays = frequencyDays[config.incomeFrequency];
|
||||||
|
|
||||||
// Only handle regular income frequencies
|
// Only handle regular income frequencies
|
||||||
@@ -342,7 +344,8 @@ export function buildPlanStates(
|
|||||||
const remainingCents = Math.max(0, total - funded);
|
const remainingCents = Math.max(0, total - funded);
|
||||||
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
|
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
|
||||||
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
|
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
|
||||||
const autoFundEnabled = !!p.autoPayEnabled;
|
const autoFundEnabled =
|
||||||
|
!p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual";
|
||||||
|
|
||||||
// Calculate preliminary crisis status to determine if we should override funding restrictions
|
// Calculate preliminary crisis status to determine if we should override funding restrictions
|
||||||
// Use timezone-aware date comparison
|
// Use timezone-aware date comparison
|
||||||
@@ -357,14 +360,14 @@ export function buildPlanStates(
|
|||||||
let isPrelimCrisis = false;
|
let isPrelimCrisis = false;
|
||||||
let dueBeforeNextPayday = false;
|
let dueBeforeNextPayday = false;
|
||||||
let daysUntilPayday = 0;
|
let daysUntilPayday = 0;
|
||||||
if (isPaymentPlanUser && config.firstIncomeDate) {
|
if (isPaymentPlanUser && firstIncomeDate) {
|
||||||
const nextPayday = calculateNextPayday(config.firstIncomeDate, config.incomeFrequency, now, timezone);
|
const nextPayday = calculateNextPayday(firstIncomeDate, config.incomeFrequency, now, timezone);
|
||||||
const normalizedNextPayday = getUserMidnight(timezone, nextPayday);
|
const normalizedNextPayday = getUserMidnight(timezone, nextPayday);
|
||||||
daysUntilPayday = Math.max(0, Math.ceil((normalizedNextPayday.getTime() - userNow.getTime()) / DAY_MS));
|
daysUntilPayday = Math.max(0, Math.ceil((normalizedNextPayday.getTime() - userNow.getTime()) / DAY_MS));
|
||||||
dueBeforeNextPayday = userDueDate.getTime() < normalizedNextPayday.getTime();
|
dueBeforeNextPayday = userDueDate.getTime() < normalizedNextPayday.getTime();
|
||||||
}
|
}
|
||||||
if (remainingCents >= CRISIS_MINIMUM_CENTS) {
|
if (remainingCents >= CRISIS_MINIMUM_CENTS) {
|
||||||
if (isPaymentPlanUser && config.firstIncomeDate) {
|
if (isPaymentPlanUser && firstIncomeDate) {
|
||||||
isPrelimCrisis = daysUntilDuePrelim < daysUntilPayday && fundedPercent < 90;
|
isPrelimCrisis = daysUntilDuePrelim < daysUntilPayday && fundedPercent < 90;
|
||||||
} else {
|
} else {
|
||||||
isPrelimCrisis = fundedPercent < 70 && daysUntilDuePrelim <= 14;
|
isPrelimCrisis = fundedPercent < 70 && daysUntilDuePrelim <= 14;
|
||||||
@@ -430,10 +433,10 @@ export function buildPlanStates(
|
|||||||
|
|
||||||
// Calculate payment periods more accurately using firstIncomeDate
|
// Calculate payment periods more accurately using firstIncomeDate
|
||||||
let cyclesLeft: number;
|
let cyclesLeft: number;
|
||||||
if (config.firstIncomeDate) {
|
if (firstIncomeDate) {
|
||||||
// Count actual pay dates between now and due date based on the recurring pattern
|
// Count actual pay dates between now and due date based on the recurring pattern
|
||||||
// established by firstIncomeDate (pass timezone for correct date handling)
|
// established by firstIncomeDate (pass timezone for correct date handling)
|
||||||
cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, config.firstIncomeDate, config.incomeFrequency, timezone);
|
cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, firstIncomeDate, config.incomeFrequency, timezone);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to old calculation if firstIncomeDate not set
|
// Fallback to old calculation if firstIncomeDate not set
|
||||||
cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays));
|
cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays));
|
||||||
@@ -1377,7 +1380,9 @@ function computeBudgetAllocation(
|
|||||||
const availableBudget = inputs.availableBefore;
|
const availableBudget = inputs.availableBefore;
|
||||||
const totalPool = availableBudget + newIncome;
|
const totalPool = availableBudget + newIncome;
|
||||||
|
|
||||||
const eligiblePlans = inputs.plans.filter((plan) => plan.autoPayEnabled);
|
const eligiblePlans = inputs.plans.filter(
|
||||||
|
(plan) => !plan.fundingMode || String(plan.fundingMode).toLowerCase() !== "manual"
|
||||||
|
);
|
||||||
const planStates = buildBudgetPlanStates(eligiblePlans, now, inputs.config.timezone);
|
const planStates = buildBudgetPlanStates(eligiblePlans, now, inputs.config.timezone);
|
||||||
|
|
||||||
// Calculate total remaining needed across all fixed plans
|
// Calculate total remaining needed across all fixed plans
|
||||||
@@ -1505,7 +1510,8 @@ function buildBudgetPlanStates(
|
|||||||
const userDueDate = getUserMidnightFromDateOnly(timezone, p.dueOn);
|
const userDueDate = getUserMidnightFromDateOnly(timezone, p.dueOn);
|
||||||
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS));
|
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS));
|
||||||
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
|
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
|
||||||
const autoFundEnabled = !!p.autoPayEnabled;
|
const autoFundEnabled =
|
||||||
|
!p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual";
|
||||||
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
|
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
|
||||||
|
|
||||||
// For irregular income, crisis mode triggers earlier (14 days)
|
// For irregular income, crisis mode triggers earlier (14 days)
|
||||||
|
|||||||
@@ -196,64 +196,64 @@ export function calculateNextPaymentDate(
|
|||||||
timezone: string
|
timezone: string
|
||||||
): Date {
|
): Date {
|
||||||
const next = toZonedTime(currentDate, timezone);
|
const next = toZonedTime(currentDate, timezone);
|
||||||
const hours = next.getUTCHours();
|
const hours = next.getHours();
|
||||||
const minutes = next.getUTCMinutes();
|
const minutes = next.getMinutes();
|
||||||
const seconds = next.getUTCSeconds();
|
const seconds = next.getSeconds();
|
||||||
const ms = next.getUTCMilliseconds();
|
const ms = next.getMilliseconds();
|
||||||
|
|
||||||
switch (schedule.frequency) {
|
switch (schedule.frequency) {
|
||||||
case "daily":
|
case "daily":
|
||||||
next.setUTCDate(next.getUTCDate() + 1);
|
next.setDate(next.getDate() + 1);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "weekly":
|
case "weekly":
|
||||||
// Move to next occurrence of specified day of week
|
// Move to next occurrence of specified day of week
|
||||||
{
|
{
|
||||||
const targetDay = schedule.dayOfWeek ?? 0;
|
const targetDay = schedule.dayOfWeek ?? 0;
|
||||||
const currentDay = next.getUTCDay();
|
const currentDay = next.getDay();
|
||||||
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
||||||
next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7));
|
next.setDate(next.getDate() + (daysUntilTarget || 7));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "biweekly":
|
case "biweekly":
|
||||||
{
|
{
|
||||||
const targetDay = schedule.dayOfWeek ?? next.getUTCDay();
|
const targetDay = schedule.dayOfWeek ?? next.getDay();
|
||||||
const currentDay = next.getUTCDay();
|
const currentDay = next.getDay();
|
||||||
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
||||||
// ensure at least one full week gap to make it biweekly
|
// ensure at least one full week gap to make it biweekly
|
||||||
daysUntilTarget = daysUntilTarget === 0 ? 14 : daysUntilTarget + 7;
|
daysUntilTarget = daysUntilTarget === 0 ? 14 : daysUntilTarget + 7;
|
||||||
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
|
next.setDate(next.getDate() + daysUntilTarget);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "monthly":
|
case "monthly":
|
||||||
{
|
{
|
||||||
const targetDay = schedule.dayOfMonth ?? next.getUTCDate();
|
const targetDay = schedule.dayOfMonth ?? next.getDate();
|
||||||
// Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months.
|
// Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months.
|
||||||
next.setUTCDate(1);
|
next.setDate(1);
|
||||||
next.setUTCMonth(next.getUTCMonth() + 1);
|
next.setMonth(next.getMonth() + 1);
|
||||||
const lastDay = getLastDayOfMonth(next);
|
const lastDay = getLastDayOfMonth(next);
|
||||||
next.setUTCDate(Math.min(targetDay, lastDay));
|
next.setDate(Math.min(targetDay, lastDay));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "custom":
|
case "custom":
|
||||||
{
|
{
|
||||||
const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays;
|
const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays;
|
||||||
next.setUTCDate(next.getUTCDate() + days);
|
next.setDate(next.getDate() + days);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Fallback to periodDays
|
// Fallback to periodDays
|
||||||
next.setUTCDate(next.getUTCDate() + periodDays);
|
next.setDate(next.getDate() + periodDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
next.setUTCHours(hours, minutes, seconds, ms);
|
next.setHours(hours, minutes, seconds, ms);
|
||||||
return fromZonedTime(next, timezone);
|
return fromZonedTime(next, timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastDayOfMonth(date: Date): number {
|
function getLastDayOfMonth(date: Date): number {
|
||||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
|
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js";
|
|||||||
|
|
||||||
const addDaysInTimezone = (date: Date, days: number, timezone: string) => {
|
const addDaysInTimezone = (date: Date, days: number, timezone: string) => {
|
||||||
const zoned = toZonedTime(date, timezone);
|
const zoned = toZonedTime(date, timezone);
|
||||||
zoned.setUTCDate(zoned.getUTCDate() + days);
|
// Advance by calendar days in the user's local timezone, then normalize
|
||||||
zoned.setUTCHours(0, 0, 0, 0);
|
// to local midnight before converting back to UTC for storage.
|
||||||
|
zoned.setDate(zoned.getDate() + days);
|
||||||
|
zoned.setHours(0, 0, 0, 0);
|
||||||
return fromZonedTime(zoned, timezone);
|
return fromZonedTime(zoned, timezone);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyPluginAsync } from "fastify";
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
import { getUserMidnightFromDateOnly } from "../allocator.js";
|
import { getUserMidnightFromDateOnly } from "../allocator.js";
|
||||||
|
import { getUserTimezone } from "../services/user-context.js";
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -273,9 +274,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.get("/crisis-status", async (req) => {
|
app.get("/crisis-status", async (req) => {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const userTimezone =
|
const userTimezone = await getUserTimezone(app.prisma, userId);
|
||||||
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
|
|
||||||
"America/New_York";
|
|
||||||
const { getUserMidnight } = await import("../allocator.js");
|
const { getUserMidnight } = await import("../allocator.js");
|
||||||
const userNow = getUserMidnight(userTimezone, now);
|
const userNow = getUserMidnight(userTimezone, now);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getUserMidnight,
|
getUserMidnight,
|
||||||
getUserMidnightFromDateOnly,
|
getUserMidnightFromDateOnly,
|
||||||
} from "../allocator.js";
|
} from "../allocator.js";
|
||||||
|
import { getUserTimezone } from "../services/user-context.js";
|
||||||
|
|
||||||
type RateLimitRouteOptions = {
|
type RateLimitRouteOptions = {
|
||||||
config: {
|
config: {
|
||||||
@@ -90,9 +91,7 @@ const fixedPlansRoutes: FastifyPluginAsync<FixedPlansRoutesOptions> = async (
|
|||||||
if (!plan) {
|
if (!plan) {
|
||||||
return reply.code(404).send({ message: "Plan not found" });
|
return reply.code(404).send({ message: "Plan not found" });
|
||||||
}
|
}
|
||||||
const userTimezone =
|
const userTimezone = await getUserTimezone(app.prisma, userId);
|
||||||
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
|
|
||||||
"America/New_York";
|
|
||||||
|
|
||||||
await app.prisma.fixedPlan.update({
|
await app.prisma.fixedPlan.update({
|
||||||
where: { id: planId },
|
where: { id: planId },
|
||||||
@@ -662,9 +661,7 @@ const fixedPlansRoutes: FastifyPluginAsync<FixedPlansRoutesOptions> = async (
|
|||||||
return reply.code(400).send({ message: "Invalid payload" });
|
return reply.code(400).send({ message: "Invalid payload" });
|
||||||
}
|
}
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const userTimezone =
|
const userTimezone = await getUserTimezone(app.prisma, userId);
|
||||||
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
|
|
||||||
"America/New_York";
|
|
||||||
|
|
||||||
const amountMode = parsed.data.amountMode ?? "fixed";
|
const amountMode = parsed.data.amountMode ?? "fixed";
|
||||||
if (amountMode === "estimated" && parsed.data.estimatedCents === undefined) {
|
if (amountMode === "estimated" && parsed.data.estimatedCents === undefined) {
|
||||||
@@ -751,9 +748,7 @@ const fixedPlansRoutes: FastifyPluginAsync<FixedPlansRoutesOptions> = async (
|
|||||||
}
|
}
|
||||||
const id = String((req.params as any).id);
|
const id = String((req.params as any).id);
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const userTimezone =
|
const userTimezone = await getUserTimezone(app.prisma, userId);
|
||||||
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
|
|
||||||
"America/New_York";
|
|
||||||
|
|
||||||
const plan = await app.prisma.fixedPlan.findFirst({
|
const plan = await app.prisma.fixedPlan.findFirst({
|
||||||
where: { id, userId },
|
where: { id, userId },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyPluginAsync, FastifyInstance } from "fastify";
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { ensureBudgetSessionAvailableSynced } from "../services/budget-session.js";
|
||||||
|
|
||||||
type RateLimitRouteOptions = {
|
type RateLimitRouteOptions = {
|
||||||
config: {
|
config: {
|
||||||
@@ -76,52 +77,6 @@ async function assertPercentTotal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestBudgetSession(app: FastifyInstance, userId: string) {
|
|
||||||
return app.prisma.budgetSession.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { periodStart: "desc" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureBudgetSession(
|
|
||||||
app: FastifyInstance,
|
|
||||||
userId: string,
|
|
||||||
fallbackAvailableCents = 0
|
|
||||||
) {
|
|
||||||
const existing = await getLatestBudgetSession(app, userId);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
|
|
||||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0));
|
|
||||||
|
|
||||||
return app.prisma.budgetSession.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
periodStart: start,
|
|
||||||
periodEnd: end,
|
|
||||||
totalBudgetCents: BigInt(Math.max(0, fallbackAvailableCents)),
|
|
||||||
allocatedCents: 0n,
|
|
||||||
fundedCents: 0n,
|
|
||||||
availableCents: BigInt(Math.max(0, fallbackAvailableCents)),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureBudgetSessionAvailableSynced(
|
|
||||||
app: FastifyInstance,
|
|
||||||
userId: string,
|
|
||||||
availableCents: number
|
|
||||||
) {
|
|
||||||
const normalizedAvailableCents = BigInt(Math.max(0, Math.trunc(availableCents)));
|
|
||||||
const session = await ensureBudgetSession(app, userId, Number(normalizedAvailableCents));
|
|
||||||
if ((session.availableCents ?? 0n) === normalizedAvailableCents) return session;
|
|
||||||
return app.prisma.budgetSession.update({
|
|
||||||
where: { id: session.id },
|
|
||||||
data: { availableCents: normalizedAvailableCents },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptions> = async (
|
const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptions> = async (
|
||||||
app,
|
app,
|
||||||
opts
|
opts
|
||||||
@@ -132,15 +87,7 @@ const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptio
|
|||||||
return reply.code(400).send({ message: "Invalid payload" });
|
return reply.code(400).send({ message: "Invalid payload" });
|
||||||
}
|
}
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const userTimezone =
|
|
||||||
(
|
|
||||||
await app.prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { timezone: true },
|
|
||||||
})
|
|
||||||
)?.timezone ?? "America/New_York";
|
|
||||||
const normalizedName = parsed.data.name.trim().toLowerCase();
|
const normalizedName = parsed.data.name.trim().toLowerCase();
|
||||||
void userTimezone;
|
|
||||||
|
|
||||||
return await app.prisma.$transaction(async (tx) => {
|
return await app.prisma.$transaction(async (tx) => {
|
||||||
try {
|
try {
|
||||||
@@ -169,18 +116,10 @@ const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptio
|
|||||||
}
|
}
|
||||||
const id = String((req.params as any).id);
|
const id = String((req.params as any).id);
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const userTimezone =
|
|
||||||
(
|
|
||||||
await app.prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { timezone: true },
|
|
||||||
})
|
|
||||||
)?.timezone ?? "America/New_York";
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
...patch.data,
|
...patch.data,
|
||||||
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
|
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
|
||||||
};
|
};
|
||||||
void userTimezone;
|
|
||||||
|
|
||||||
return await app.prisma.$transaction(async (tx) => {
|
return await app.prisma.$transaction(async (tx) => {
|
||||||
const exists = await tx.variableCategory.findFirst({
|
const exists = await tx.variableCategory.findFirst({
|
||||||
@@ -271,7 +210,7 @@ const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptio
|
|||||||
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
|
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
|
||||||
});
|
});
|
||||||
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
|
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
|
||||||
await ensureBudgetSessionAvailableSynced(app, userId, totalBalance);
|
await ensureBudgetSessionAvailableSynced(app.prisma, userId, totalBalance);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -296,7 +235,7 @@ const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptio
|
|||||||
|
|
||||||
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
|
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
|
||||||
const availableCents = totalBalance;
|
const availableCents = totalBalance;
|
||||||
await ensureBudgetSessionAvailableSynced(app, userId, availableCents);
|
await ensureBudgetSessionAvailableSynced(app.prisma, userId, availableCents);
|
||||||
|
|
||||||
const targetMap = new Map<string, number>();
|
const targetMap = new Map<string, number>();
|
||||||
for (const t of parsed.data.targets) {
|
for (const t of parsed.data.targets) {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { PrismaClient } from "@prisma/client";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getUserMidnightFromDateOnly } from "./allocator.js";
|
import { getUserMidnightFromDateOnly } from "./allocator.js";
|
||||||
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||||
|
import {
|
||||||
|
computeDepositShares,
|
||||||
|
computeOverdraftShares,
|
||||||
|
computeWithdrawShares,
|
||||||
|
} from "./services/category-shares.js";
|
||||||
import healthRoutes from "./routes/health.js";
|
import healthRoutes from "./routes/health.js";
|
||||||
import sessionRoutes from "./routes/session.js";
|
import sessionRoutes from "./routes/session.js";
|
||||||
import userRoutes from "./routes/user.js";
|
import userRoutes from "./routes/user.js";
|
||||||
@@ -429,199 +434,26 @@ function calculateNextDueDate(currentDueDate: Date, frequency: string, timezone:
|
|||||||
|
|
||||||
switch (frequency) {
|
switch (frequency) {
|
||||||
case "weekly":
|
case "weekly":
|
||||||
zoned.setUTCDate(zoned.getUTCDate() + 7);
|
zoned.setDate(zoned.getDate() + 7);
|
||||||
break;
|
break;
|
||||||
case "biweekly":
|
case "biweekly":
|
||||||
zoned.setUTCDate(zoned.getUTCDate() + 14);
|
zoned.setDate(zoned.getDate() + 14);
|
||||||
break;
|
break;
|
||||||
case "monthly": {
|
case "monthly": {
|
||||||
const targetDay = zoned.getUTCDate();
|
const targetDay = zoned.getDate();
|
||||||
const nextMonth = zoned.getUTCMonth() + 1;
|
zoned.setDate(1);
|
||||||
const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12);
|
zoned.setMonth(zoned.getMonth() + 1);
|
||||||
const nextMonthIndex = nextMonth % 12;
|
const lastDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate();
|
||||||
const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate();
|
zoned.setDate(Math.min(targetDay, lastDay));
|
||||||
zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
zoned.setUTCHours(0, 0, 0, 0);
|
zoned.setHours(0, 0, 0, 0);
|
||||||
return fromZonedTime(zoned, timezone);
|
return fromZonedTime(zoned, timezone);
|
||||||
}
|
}
|
||||||
function jsonBigIntSafe(obj: unknown) {
|
|
||||||
return JSON.parse(
|
|
||||||
JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type PercentCategory = {
|
|
||||||
id: string;
|
|
||||||
percent: number;
|
|
||||||
balanceCents: bigint | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function computePercentShares(categories: PercentCategory[], amountCents: number) {
|
|
||||||
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
|
|
||||||
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
|
|
||||||
|
|
||||||
const shares = categories.map((cat) => {
|
|
||||||
const raw = (amountCents * cat.percent) / percentTotal;
|
|
||||||
const floored = Math.floor(raw);
|
|
||||||
return {
|
|
||||||
id: cat.id,
|
|
||||||
balanceCents: Number(cat.balanceCents ?? 0n),
|
|
||||||
share: floored,
|
|
||||||
frac: raw - floored,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0);
|
|
||||||
shares
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.frac - a.frac)
|
|
||||||
.forEach((s) => {
|
|
||||||
if (remainder > 0) {
|
|
||||||
s.share += 1;
|
|
||||||
remainder -= 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shares.some((s) => s.share > s.balanceCents)) {
|
|
||||||
return { ok: false as const, reason: "insufficient_balances" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true as const, shares };
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeWithdrawShares(categories: PercentCategory[], amountCents: number) {
|
|
||||||
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
|
|
||||||
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
|
|
||||||
|
|
||||||
const working = categories.map((cat) => ({
|
|
||||||
id: cat.id,
|
|
||||||
percent: cat.percent,
|
|
||||||
balanceCents: Number(cat.balanceCents ?? 0n),
|
|
||||||
share: 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let remaining = Math.max(0, Math.floor(amountCents));
|
|
||||||
let safety = 0;
|
|
||||||
|
|
||||||
while (remaining > 0 && safety < 1000) {
|
|
||||||
safety += 1;
|
|
||||||
const eligible = working.filter((c) => c.balanceCents > 0 && c.percent > 0);
|
|
||||||
if (eligible.length === 0) break;
|
|
||||||
|
|
||||||
const totalPercent = eligible.reduce((sum, cat) => sum + cat.percent, 0);
|
|
||||||
if (totalPercent <= 0) break;
|
|
||||||
|
|
||||||
const provisional = eligible.map((cat) => {
|
|
||||||
const raw = (remaining * cat.percent) / totalPercent;
|
|
||||||
const floored = Math.floor(raw);
|
|
||||||
return {
|
|
||||||
id: cat.id,
|
|
||||||
raw,
|
|
||||||
floored,
|
|
||||||
remainder: raw - floored,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let sumBase = provisional.reduce((sum, p) => sum + p.floored, 0);
|
|
||||||
let leftovers = remaining - sumBase;
|
|
||||||
provisional
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.remainder - a.remainder)
|
|
||||||
.forEach((p) => {
|
|
||||||
if (leftovers > 0) {
|
|
||||||
p.floored += 1;
|
|
||||||
leftovers -= 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let allocatedThisRound = 0;
|
|
||||||
for (const p of provisional) {
|
|
||||||
const entry = working.find((w) => w.id === p.id);
|
|
||||||
if (!entry) continue;
|
|
||||||
const take = Math.min(p.floored, entry.balanceCents);
|
|
||||||
if (take > 0) {
|
|
||||||
entry.balanceCents -= take;
|
|
||||||
entry.share += take;
|
|
||||||
allocatedThisRound += take;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining -= allocatedThisRound;
|
|
||||||
if (allocatedThisRound === 0) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remaining > 0) {
|
|
||||||
return { ok: false as const, reason: "insufficient_balances" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true as const,
|
|
||||||
shares: working.map((c) => ({ id: c.id, share: c.share })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeOverdraftShares(categories: PercentCategory[], amountCents: number) {
|
|
||||||
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
|
|
||||||
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
|
|
||||||
|
|
||||||
const shares = categories.map((cat) => {
|
|
||||||
const raw = (amountCents * cat.percent) / percentTotal;
|
|
||||||
const floored = Math.floor(raw);
|
|
||||||
return {
|
|
||||||
id: cat.id,
|
|
||||||
share: floored,
|
|
||||||
frac: raw - floored,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0);
|
|
||||||
shares
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.frac - a.frac)
|
|
||||||
.forEach((s) => {
|
|
||||||
if (remainder > 0) {
|
|
||||||
s.share += 1;
|
|
||||||
remainder -= 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: true as const, shares };
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeDepositShares(categories: PercentCategory[], amountCents: number) {
|
|
||||||
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
|
|
||||||
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
|
|
||||||
|
|
||||||
const shares = categories.map((cat) => {
|
|
||||||
const raw = (amountCents * cat.percent) / percentTotal;
|
|
||||||
const floored = Math.floor(raw);
|
|
||||||
return {
|
|
||||||
id: cat.id,
|
|
||||||
share: floored,
|
|
||||||
frac: raw - floored,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0);
|
|
||||||
shares
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.frac - a.frac)
|
|
||||||
.forEach((s) => {
|
|
||||||
if (remainder > 0) {
|
|
||||||
s.share += 1;
|
|
||||||
remainder -= 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: true as const, shares };
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_VARIABLE_CATEGORIES = [
|
const DEFAULT_VARIABLE_CATEGORIES = [
|
||||||
{ name: "Essentials", percent: 50, priority: 10, isSavings: false },
|
{ name: "Essentials", percent: 50, priority: 10, isSavings: false },
|
||||||
{ name: "Savings", percent: 30, priority: 20, isSavings: true },
|
{ name: "Savings", percent: 30, priority: 20, isSavings: true },
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Login and save cookie
|
|
||||||
echo "<22><><EFBFBD> Logging in..."
|
|
||||||
curl -c cookies.txt -X POST http://localhost:8080/api/auth/login \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"test@skymoney.com","password":"password123"}' > /dev/null 2>&1
|
|
||||||
|
|
||||||
# Check current plans
|
|
||||||
echo "<22><><EFBFBD> Plans BEFORE income:"
|
|
||||||
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans | jq '.plans[] | {name: .name, funded: (.fundedCents/100), total: (.totalCents/100), isOverdue: .isOverdue, overdueAmt: (.overdueAmount/100)}'
|
|
||||||
|
|
||||||
# Post $1000 income
|
|
||||||
echo -e "\n<><6E><EFBFBD> Posting $1000 income..."
|
|
||||||
RESULT=$(curl -s -b cookies.txt -X POST http://localhost:8080/api/income \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"amountCents\": 100000, \"postedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"note\": \"Test income\"}")
|
|
||||||
|
|
||||||
echo "$RESULT" | jq '{overduePaid, fixedAllocations, variableAllocations}'
|
|
||||||
|
|
||||||
# Check plans after
|
|
||||||
echo -e "\n<><6E><EFBFBD> Plans AFTER income:"
|
|
||||||
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans | jq '.plans[] | {name: .name, funded: (.fundedCents/100), total: (.totalCents/100), isOverdue: .isOverdue, overdueAmt: (.overdueAmount/100)}'
|
|
||||||
|
|
||||||
rm -f cookies.txt
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test script for monthly income payday calculations with TIMEZONE awareness
|
|
||||||
* Run with: node test-monthly-income.cjs
|
|
||||||
*
|
|
||||||
* This replicates the actual allocator.ts logic including timezone handling
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Simulating date-fns-tz behavior (simplified for testing)
|
|
||||||
function toZonedTime(date, timezone) {
|
|
||||||
// For testing, we'll use a simple offset approach
|
|
||||||
// In real code, this uses proper timezone rules
|
|
||||||
const utc = date.getTime();
|
|
||||||
const tzOffset = getTimezoneOffset(timezone, date);
|
|
||||||
return new Date(utc + tzOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromZonedTime(date, timezone) {
|
|
||||||
const tzOffset = getTimezoneOffset(timezone, date);
|
|
||||||
return new Date(date.getTime() - tzOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simplified timezone offset (real implementation uses IANA database)
|
|
||||||
function getTimezoneOffset(timezone, date) {
|
|
||||||
const offsets = {
|
|
||||||
'UTC': 0,
|
|
||||||
'America/New_York': -5 * 60 * 60 * 1000, // EST (ignoring DST for simplicity)
|
|
||||||
'America/Los_Angeles': -8 * 60 * 60 * 1000, // PST
|
|
||||||
'Asia/Tokyo': 9 * 60 * 60 * 1000, // JST
|
|
||||||
};
|
|
||||||
return offsets[timezone] || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserMidnight(timezone, date = new Date()) {
|
|
||||||
const zonedDate = toZonedTime(date, timezone);
|
|
||||||
zonedDate.setHours(0, 0, 0, 0);
|
|
||||||
return fromZonedTime(zonedDate, timezone);
|
|
||||||
}
|
|
||||||
|
|
||||||
const frequencyDays = {
|
|
||||||
weekly: 7,
|
|
||||||
biweekly: 14,
|
|
||||||
monthly: 30, // Not used for monthly anymore
|
|
||||||
};
|
|
||||||
|
|
||||||
function calculateNextPayday(firstIncomeDate, frequency, fromDate = new Date(), timezone = 'UTC') {
|
|
||||||
const normalizedFrom = getUserMidnight(timezone, fromDate);
|
|
||||||
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
|
|
||||||
|
|
||||||
// Get the target day in the USER'S timezone
|
|
||||||
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
|
|
||||||
const targetDay = zonedFirstIncome.getDate();
|
|
||||||
|
|
||||||
let iterations = 0;
|
|
||||||
while (nextPayDate < normalizedFrom) {
|
|
||||||
if (frequency === 'monthly') {
|
|
||||||
// Work in user's timezone for month advancement
|
|
||||||
const zonedPayDate = toZonedTime(nextPayDate, timezone);
|
|
||||||
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
|
|
||||||
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
|
|
||||||
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
|
|
||||||
zonedPayDate.setHours(0, 0, 0, 0);
|
|
||||||
const newPayDate = fromZonedTime(zonedPayDate, timezone);
|
|
||||||
nextPayDate.setTime(newPayDate.getTime());
|
|
||||||
} else {
|
|
||||||
nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]);
|
|
||||||
}
|
|
||||||
iterations++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { nextPayDate, iterations, targetDay };
|
|
||||||
}
|
|
||||||
|
|
||||||
function countPayPeriodsBetween(startDate, endDate, firstIncomeDate, frequency, timezone = 'UTC') {
|
|
||||||
let count = 0;
|
|
||||||
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
|
|
||||||
const normalizedStart = getUserMidnight(timezone, startDate);
|
|
||||||
const normalizedEnd = getUserMidnight(timezone, endDate);
|
|
||||||
|
|
||||||
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
|
|
||||||
const targetDay = zonedFirstIncome.getDate();
|
|
||||||
|
|
||||||
const advanceByPeriod = () => {
|
|
||||||
if (frequency === 'monthly') {
|
|
||||||
const zonedPayDate = toZonedTime(nextPayDate, timezone);
|
|
||||||
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
|
|
||||||
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
|
|
||||||
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
|
|
||||||
zonedPayDate.setHours(0, 0, 0, 0);
|
|
||||||
const newPayDate = fromZonedTime(zonedPayDate, timezone);
|
|
||||||
nextPayDate.setTime(newPayDate.getTime());
|
|
||||||
} else {
|
|
||||||
nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
while (nextPayDate < normalizedStart) {
|
|
||||||
advanceByPeriod();
|
|
||||||
}
|
|
||||||
|
|
||||||
while (nextPayDate < normalizedEnd) {
|
|
||||||
count++;
|
|
||||||
advanceByPeriod();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(1, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to format dates
|
|
||||||
const fmt = (d) => d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
const fmtISO = (d) => d.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
console.log('═══════════════════════════════════════════════════════════════');
|
|
||||||
console.log(' MONTHLY INCOME PAYDAY CALCULATION TESTS (Timezone-Aware)');
|
|
||||||
console.log('═══════════════════════════════════════════════════════════════\n');
|
|
||||||
|
|
||||||
// Test 1: Monthly payday on the 15th - America/New_York
|
|
||||||
console.log('TEST 1: Monthly payday on the 15th (America/New_York)');
|
|
||||||
console.log('─────────────────────────────────────');
|
|
||||||
const firstPayday15 = new Date('2025-01-15T05:00:00.000Z'); // Midnight EST = 5am UTC
|
|
||||||
const today = new Date('2025-12-20T05:00:00.000Z');
|
|
||||||
|
|
||||||
const result1 = calculateNextPayday(firstPayday15, 'monthly', today, 'America/New_York');
|
|
||||||
console.log(`First income (UTC): ${firstPayday15.toISOString()}`);
|
|
||||||
console.log(`Today (UTC): ${today.toISOString()}`);
|
|
||||||
console.log(`Target day: ${result1.targetDay}th of month`);
|
|
||||||
console.log(`Next payday (UTC): ${result1.nextPayDate.toISOString()}`);
|
|
||||||
console.log(`Iterations: ${result1.iterations}`);
|
|
||||||
console.log(`✓ Should be Jan 15, 2026 in EST\n`);
|
|
||||||
|
|
||||||
// Test 2: Edge case - payday stored as UTC midnight crossing timezone boundary
|
|
||||||
console.log('TEST 2: Timezone boundary edge case');
|
|
||||||
console.log('─────────────────────────────────────');
|
|
||||||
// If user in LA set payday to "15th", it might be stored as 2025-01-15T08:00:00Z (midnight PST)
|
|
||||||
const firstPaydayLA = new Date('2025-01-15T08:00:00.000Z');
|
|
||||||
const todayLA = new Date('2025-12-20T08:00:00.000Z');
|
|
||||||
|
|
||||||
const resultLA = calculateNextPayday(firstPaydayLA, 'monthly', todayLA, 'America/Los_Angeles');
|
|
||||||
console.log(`Timezone: America/Los_Angeles`);
|
|
||||||
console.log(`First income (UTC): ${firstPaydayLA.toISOString()}`);
|
|
||||||
console.log(`Target day: ${resultLA.targetDay}th of month`);
|
|
||||||
console.log(`Next payday (UTC): ${resultLA.nextPayDate.toISOString()}`);
|
|
||||||
console.log(`✓ Target day should be 15, not 14 or 16\n`);
|
|
||||||
|
|
||||||
// Test 3: Compare UTC vs timezone-aware for same "15th" payday
|
|
||||||
console.log('TEST 3: UTC vs Timezone-aware comparison');
|
|
||||||
console.log('─────────────────────────────────────');
|
|
||||||
const sameDate = new Date('2025-01-15T00:00:00.000Z'); // Midnight UTC
|
|
||||||
const fromDate = new Date('2025-06-01T00:00:00.000Z');
|
|
||||||
|
|
||||||
const resultUTC = calculateNextPayday(sameDate, 'monthly', fromDate, 'UTC');
|
|
||||||
const resultEST = calculateNextPayday(sameDate, 'monthly', fromDate, 'America/New_York');
|
|
||||||
const resultTokyo = calculateNextPayday(sameDate, 'monthly', fromDate, 'Asia/Tokyo');
|
|
||||||
|
|
||||||
console.log(`Date stored: ${sameDate.toISOString()}`);
|
|
||||||
console.log(`From date: ${fromDate.toISOString()}`);
|
|
||||||
console.log(`UTC target day: ${resultUTC.targetDay} → Next: ${fmtISO(resultUTC.nextPayDate)}`);
|
|
||||||
console.log(`EST target day: ${resultEST.targetDay} → Next: ${fmtISO(resultEST.nextPayDate)}`);
|
|
||||||
console.log(`JST target day: ${resultTokyo.targetDay} → Next: ${fmtISO(resultTokyo.nextPayDate)}`);
|
|
||||||
console.log(`⚠️ Same UTC date shows different "day of month" in different timezones!\n`);
|
|
||||||
|
|
||||||
// Test 4: Monthly payday on 31st with day clamping
|
|
||||||
console.log('TEST 4: Monthly payday on 31st (day clamping)');
|
|
||||||
console.log('─────────────────────────────────────');
|
|
||||||
const firstPayday31 = new Date('2025-01-31T05:00:00.000Z');
|
|
||||||
console.log(`First payday: Jan 31, 2025`);
|
|
||||||
|
|
||||||
let tempDate = getUserMidnight('America/New_York', firstPayday31);
|
|
||||||
console.log(`\nPayday progression:`);
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
const zoned = toZonedTime(tempDate, 'America/New_York');
|
|
||||||
console.log(` ${zoned.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`);
|
|
||||||
|
|
||||||
// Advance by month
|
|
||||||
zoned.setMonth(zoned.getMonth() + 1);
|
|
||||||
const maxDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate();
|
|
||||||
zoned.setDate(Math.min(31, maxDay));
|
|
||||||
zoned.setHours(0, 0, 0, 0);
|
|
||||||
tempDate = fromZonedTime(zoned, 'America/New_York');
|
|
||||||
}
|
|
||||||
console.log(`✓ Feb shows 28th (clamped), other months show 31st or 30th\n`);
|
|
||||||
|
|
||||||
// Test 5: Count pay periods with timezone
|
|
||||||
console.log('TEST 5: Count pay periods (timezone-aware)');
|
|
||||||
console.log('─────────────────────────────────────');
|
|
||||||
const firstIncome = new Date('2025-01-15T05:00:00.000Z');
|
|
||||||
const nowDate = new Date('2025-12-20T05:00:00.000Z');
|
|
||||||
const billDue = new Date('2026-03-01T05:00:00.000Z');
|
|
||||||
|
|
||||||
const periodsEST = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'America/New_York');
|
|
||||||
const periodsUTC = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'UTC');
|
|
||||||
|
|
||||||
console.log(`Now: Dec 20, 2025`);
|
|
||||||
console.log(`Bill due: Mar 1, 2026`);
|
|
||||||
console.log(`First income: Jan 15, 2025`);
|
|
||||||
console.log(`Periods (EST): ${periodsEST}`);
|
|
||||||
console.log(`Periods (UTC): ${periodsUTC}`);
|
|
||||||
console.log(`✓ Should be 2-3 periods (Jan 15, Feb 15)\n`);
|
|
||||||
|
|
||||||
// Test 6: OLD vs NEW comparison (with timezone)
|
|
||||||
console.log('TEST 6: OLD (30 days) vs NEW (actual month) - with timezone');
|
|
||||||
console.log('─────────────────────────────────────');
|
|
||||||
const startDate = new Date('2025-01-15T05:00:00.000Z');
|
|
||||||
let oldDate = new Date(startDate);
|
|
||||||
let newResult = calculateNextPayday(startDate, 'monthly', startDate, 'America/New_York');
|
|
||||||
let newDate = new Date(newResult.nextPayDate);
|
|
||||||
|
|
||||||
console.log('Month | OLD (30 days) | NEW (timezone) | Drift');
|
|
||||||
console.log('──────┼────────────────┼─────────────────┼───────');
|
|
||||||
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
oldDate.setDate(oldDate.getDate() + 30);
|
|
||||||
|
|
||||||
// For new method, advance one month from previous
|
|
||||||
const nextFrom = new Date(newDate.getTime() + 24 * 60 * 60 * 1000); // +1 day to get next
|
|
||||||
newResult = calculateNextPayday(startDate, 'monthly', nextFrom, 'America/New_York');
|
|
||||||
newDate = newResult.nextPayDate;
|
|
||||||
|
|
||||||
const drift = Math.round((oldDate - newDate) / (24 * 60 * 60 * 1000));
|
|
||||||
console.log(` ${String(i + 1).padStart(2)} | ${fmtISO(oldDate)} | ${fmtISO(newDate)} | ${drift > 0 ? '+' : ''}${drift} days`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✓ NEW method stays on the 15th (in user\'s timezone)!');
|
|
||||||
console.log('✓ OLD method drifts 5-6 days early after 12 months\n');
|
|
||||||
|
|
||||||
console.log('═══════════════════════════════════════════════════════════════');
|
|
||||||
console.log(' ALL TESTS COMPLETE - Timezone handling verified');
|
|
||||||
console.log('═══════════════════════════════════════════════════════════════');
|
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Test overdue payment via API endpoint
|
|
||||||
|
|
||||||
# Login to get token
|
|
||||||
echo "🔐 Logging in..."
|
|
||||||
LOGIN_RESPONSE=$(curl -s -X POST http://localhost:8080/api/auth/login \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"test@skymoney.com","password":"password123"}')
|
|
||||||
|
|
||||||
TOKEN=$(echo $LOGIN_RESPONSE | grep -o '"token":"[^"]*' | cut -d'"' -f4)
|
|
||||||
|
|
||||||
if [ -z "$TOKEN" ]; then
|
|
||||||
echo "❌ Login failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Logged in successfully"
|
|
||||||
|
|
||||||
# Check current state
|
|
||||||
echo -e "\n📋 Checking current plans..."
|
|
||||||
curl -s -X GET http://localhost:8080/api/fixed-plans \
|
|
||||||
-H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}'
|
|
||||||
|
|
||||||
# Post $500 income - should pay $500 to overdue (was $1000, now $500 remaining)
|
|
||||||
echo -e "\n💰 Posting $500 income..."
|
|
||||||
INCOME_RESPONSE=$(curl -s -X POST http://localhost:8080/api/income \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"amountCents": 50000,
|
|
||||||
"postedAt": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'",
|
|
||||||
"note": "Test income for overdue"
|
|
||||||
}')
|
|
||||||
|
|
||||||
echo $INCOME_RESPONSE | jq '.'
|
|
||||||
|
|
||||||
# Check state after income
|
|
||||||
echo -e "\n📋 Checking plans after income..."
|
|
||||||
curl -s -X GET http://localhost:8080/api/fixed-plans \
|
|
||||||
-H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}'
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
// Script to post test income and verify overdue payment
|
|
||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
const { randomUUID } = require('crypto');
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: 'test@skymoney.com' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.log('❌ Test user not found. Run create-test-user.cjs first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Found test user:', user.email);
|
|
||||||
|
|
||||||
// Check overdue status BEFORE posting income
|
|
||||||
const plansBefore = await prisma.fixedPlan.findMany({
|
|
||||||
where: { userId: user.id },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
totalCents: true,
|
|
||||||
fundedCents: true,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n📋 Plans BEFORE income:');
|
|
||||||
for (const plan of plansBefore) {
|
|
||||||
console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post $1000 income - should pay $500 to overdue first, then allocate $500 normally
|
|
||||||
const incomeAmount = 100000; // $1000 in cents
|
|
||||||
console.log(`\n💰 Posting income: $${incomeAmount / 100}`);
|
|
||||||
|
|
||||||
const incomeId = randomUUID();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Simulate what the allocateIncome function does
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.incomeEvent.create({
|
|
||||||
data: {
|
|
||||||
id: incomeId,
|
|
||||||
userId: user.id,
|
|
||||||
postedAt: now,
|
|
||||||
amountCents: BigInt(incomeAmount),
|
|
||||||
note: 'Test income for overdue payment',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find overdue plans
|
|
||||||
const overduePlans = await tx.fixedPlan.findMany({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: { gt: 0 },
|
|
||||||
},
|
|
||||||
orderBy: { overdueSince: 'asc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\n🔍 Found ${overduePlans.length} overdue plan(s)`);
|
|
||||||
|
|
||||||
let remaining = incomeAmount;
|
|
||||||
for (const plan of overduePlans) {
|
|
||||||
if (remaining <= 0) break;
|
|
||||||
|
|
||||||
const overdueAmount = Number(plan.overdueAmount);
|
|
||||||
const amountToPay = Math.min(overdueAmount, remaining);
|
|
||||||
|
|
||||||
console.log(` Paying $${amountToPay / 100} to ${plan.name} (was $${overdueAmount / 100} overdue)`);
|
|
||||||
|
|
||||||
// Create allocation
|
|
||||||
await tx.allocation.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
kind: 'fixed',
|
|
||||||
toId: plan.id,
|
|
||||||
amountCents: BigInt(amountToPay),
|
|
||||||
incomeId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update plan
|
|
||||||
const newOverdueAmount = overdueAmount - amountToPay;
|
|
||||||
await tx.fixedPlan.update({
|
|
||||||
where: { id: plan.id },
|
|
||||||
data: {
|
|
||||||
fundedCents: (plan.fundedCents ?? 0n) + BigInt(amountToPay),
|
|
||||||
currentFundedCents: (plan.currentFundedCents ?? plan.fundedCents ?? 0n) + BigInt(amountToPay),
|
|
||||||
overdueAmount: newOverdueAmount,
|
|
||||||
isOverdue: newOverdueAmount > 0,
|
|
||||||
lastFundingDate: new Date(now),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
remaining -= amountToPay;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { remaining };
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\n💵 Remaining after overdue payments: $${result.remaining / 100}`);
|
|
||||||
|
|
||||||
// Check overdue status AFTER posting income
|
|
||||||
const plansAfter = await prisma.fixedPlan.findMany({
|
|
||||||
where: { userId: user.id },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
totalCents: true,
|
|
||||||
fundedCents: true,
|
|
||||||
isOverdue: true,
|
|
||||||
overdueAmount: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n📋 Plans AFTER income:');
|
|
||||||
for (const plan of plansAfter) {
|
|
||||||
console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "<22><><EFBFBD> Logging in..."
|
|
||||||
curl -s -c cookies.txt -X POST http://localhost:8080/api/auth/login \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"test@skymoney.com","password":"password123"}' > /dev/null
|
|
||||||
|
|
||||||
echo "<22><><EFBFBD> Plans BEFORE:"
|
|
||||||
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans
|
|
||||||
|
|
||||||
echo -e "\n\n<><6E><EFBFBD> Posting $1000 income..."
|
|
||||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/income \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"amountCents\": 100000, \"postedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"note\": \"Test\"}"
|
|
||||||
|
|
||||||
echo -e "\n\n<><6E><EFBFBD> Plans AFTER:"
|
|
||||||
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans
|
|
||||||
|
|
||||||
rm -f cookies.txt
|
|
||||||
@@ -32,8 +32,8 @@ export async function resetUser(userId: string) {
|
|||||||
export async function ensureUser(userId: string) {
|
export async function ensureUser(userId: string) {
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
update: {},
|
update: { timezone: "UTC" },
|
||||||
create: { id: userId, email: `${userId}@demo.local` },
|
create: { id: userId, email: `${userId}@demo.local`, timezone: "UTC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,27 +40,28 @@ function calculateNextDueDateLikeServer(
|
|||||||
|
|
||||||
switch (frequency) {
|
switch (frequency) {
|
||||||
case "weekly":
|
case "weekly":
|
||||||
zoned.setUTCDate(zoned.getUTCDate() + 7);
|
zoned.setDate(zoned.getDate() + 7);
|
||||||
break;
|
break;
|
||||||
case "biweekly":
|
case "biweekly":
|
||||||
zoned.setUTCDate(zoned.getUTCDate() + 14);
|
zoned.setDate(zoned.getDate() + 14);
|
||||||
break;
|
break;
|
||||||
case "monthly": {
|
case "monthly": {
|
||||||
const targetDay = zoned.getUTCDate();
|
const targetDay = zoned.getDate();
|
||||||
const nextMonth = zoned.getUTCMonth() + 1;
|
zoned.setDate(1);
|
||||||
const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12);
|
zoned.setMonth(zoned.getMonth() + 1);
|
||||||
const nextMonthIndex = nextMonth % 12;
|
|
||||||
const lastDay = new Date(
|
const lastDay = new Date(
|
||||||
Date.UTC(nextYear, nextMonthIndex + 1, 0)
|
zoned.getFullYear(),
|
||||||
).getUTCDate();
|
zoned.getMonth() + 1,
|
||||||
zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay));
|
0
|
||||||
|
).getDate();
|
||||||
|
zoned.setDate(Math.min(targetDay, lastDay));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
zoned.setUTCHours(0, 0, 0, 0);
|
zoned.setHours(0, 0, 0, 0);
|
||||||
return fromZonedTime(zoned, timezone);
|
return fromZonedTime(zoned, timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ describe("rolloverFixedPlans", () => {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await rolloverFixedPlans(prisma, "2025-01-10T00:00:00Z");
|
// Rollover job only processes after 6 AM in the user's timezone.
|
||||||
|
const results = await rolloverFixedPlans(prisma, "2025-01-10T10:00:00Z");
|
||||||
const match = results.find((r) => r.planId === plan.id);
|
const match = results.find((r) => r.planId === plan.id);
|
||||||
expect(match?.cyclesAdvanced).toBe(1);
|
expect(match?.cyclesAdvanced).toBe(1);
|
||||||
expect(match?.deficitCents).toBe(4000);
|
expect(match?.deficitCents).toBe(4000);
|
||||||
@@ -48,7 +49,8 @@ describe("rolloverFixedPlans", () => {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await rolloverFixedPlans(prisma, "2025-02-05T00:00:00Z");
|
// Rollover job only processes after 6 AM in the user's timezone.
|
||||||
|
const results = await rolloverFixedPlans(prisma, "2025-02-05T10:00:00Z");
|
||||||
const match = results.find((r) => r.planId === plan.id);
|
const match = results.find((r) => r.planId === plan.id);
|
||||||
expect(match?.cyclesAdvanced).toBe(2);
|
expect(match?.cyclesAdvanced).toBe(2);
|
||||||
expect(match?.carryForwardCents).toBe(2000);
|
expect(match?.carryForwardCents).toBe(2000);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
|
|||||||
import appFactory from "./appFactory";
|
import appFactory from "./appFactory";
|
||||||
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
|
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
|
|
||||||
@@ -34,9 +35,12 @@ describe("Variable Categories guard (sum=100)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects create that would push sum away from 100", async () => {
|
it("rejects create that would push sum away from 100", async () => {
|
||||||
|
const csrf = randomUUID().replace(/-/g, "");
|
||||||
const res = await request(app.server)
|
const res = await request(app.server)
|
||||||
.post("/variable-categories")
|
.post("/variable-categories")
|
||||||
.set("x-user-id", U)
|
.set("x-user-id", U)
|
||||||
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
.send({ name: "Oops", percent: 10, isSavings: false, priority: 99 });
|
.send({ name: "Oops", percent: 10, isSavings: false, priority: 99 });
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
@@ -45,9 +49,12 @@ describe("Variable Categories guard (sum=100)", () => {
|
|||||||
|
|
||||||
it("rejects update that breaks the sum", async () => {
|
it("rejects update that breaks the sum", async () => {
|
||||||
const existing = await prisma.variableCategory.findFirst({ where: { userId: U } });
|
const existing = await prisma.variableCategory.findFirst({ where: { userId: U } });
|
||||||
|
const csrf = randomUUID().replace(/-/g, "");
|
||||||
const res = await request(app.server)
|
const res = await request(app.server)
|
||||||
.patch(`/variable-categories/${existing!.id}`)
|
.patch(`/variable-categories/${existing!.id}`)
|
||||||
.set("x-user-id", U)
|
.set("x-user-id", U)
|
||||||
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
.send({ percent: 90 });
|
.send({ percent: 90 });
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|||||||
import appFactory from "./appFactory";
|
import appFactory from "./appFactory";
|
||||||
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
|
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
|
|
||||||
@@ -59,10 +60,13 @@ describe("manual rebalance", () => {
|
|||||||
|
|
||||||
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
|
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
|
||||||
const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000
|
const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000
|
||||||
|
const csrf = randomUUID().replace(/-/g, "");
|
||||||
const postRes = await request(app.server)
|
const postRes = await request(app.server)
|
||||||
.post("/variable-categories/manual-rebalance")
|
.post("/variable-categories/manual-rebalance")
|
||||||
.set("x-user-id", U)
|
.set("x-user-id", U)
|
||||||
.send({ targets });
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
|
.send({ targets, forceLowerSavings: true });
|
||||||
|
|
||||||
expect(postRes.statusCode).toBe(200);
|
expect(postRes.statusCode).toBe(200);
|
||||||
expect(postRes.body?.availableCents).toBe(10_000);
|
expect(postRes.body?.availableCents).toBe(10_000);
|
||||||
@@ -78,11 +82,14 @@ describe("manual rebalance", () => {
|
|||||||
it("rebalances when sums match available", async () => {
|
it("rebalances when sums match available", async () => {
|
||||||
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
|
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
|
||||||
const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000
|
const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000
|
||||||
|
const csrf = randomUUID().replace(/-/g, "");
|
||||||
|
|
||||||
const res = await request(app.server)
|
const res = await request(app.server)
|
||||||
.post("/variable-categories/manual-rebalance")
|
.post("/variable-categories/manual-rebalance")
|
||||||
.set("x-user-id", U)
|
.set("x-user-id", U)
|
||||||
.send({ targets });
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
|
.send({ targets, forceLowerSavings: true });
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body?.ok).toBe(true);
|
expect(res.body?.ok).toBe(true);
|
||||||
@@ -93,10 +100,13 @@ describe("manual rebalance", () => {
|
|||||||
it("rejects sum mismatch", async () => {
|
it("rejects sum mismatch", async () => {
|
||||||
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
|
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
|
||||||
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 5000 : 1000 })); // 5000 + 3*1000 = 8000
|
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 5000 : 1000 })); // 5000 + 3*1000 = 8000
|
||||||
|
const csrf = randomUUID().replace(/-/g, "");
|
||||||
|
|
||||||
const res = await request(app.server)
|
const res = await request(app.server)
|
||||||
.post("/variable-categories/manual-rebalance")
|
.post("/variable-categories/manual-rebalance")
|
||||||
.set("x-user-id", U)
|
.set("x-user-id", U)
|
||||||
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
.send({ targets });
|
.send({ targets });
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
@@ -108,10 +118,13 @@ describe("manual rebalance", () => {
|
|||||||
// savings to 500 (below 20% of 10000 = 2000)
|
// savings to 500 (below 20% of 10000 = 2000)
|
||||||
const targets = cats.map((c) => ({ id: c.id, targetCents: c.isSavings ? 500 : 3166 })); // 500 + 3*3166 = 9998 -> adjust
|
const targets = cats.map((c) => ({ id: c.id, targetCents: c.isSavings ? 500 : 3166 })); // 500 + 3*3166 = 9998 -> adjust
|
||||||
targets[1].targetCents += 2; // total 10000
|
targets[1].targetCents += 2; // total 10000
|
||||||
|
const csrf = randomUUID().replace(/-/g, "");
|
||||||
|
|
||||||
const res = await request(app.server)
|
const res = await request(app.server)
|
||||||
.post("/variable-categories/manual-rebalance")
|
.post("/variable-categories/manual-rebalance")
|
||||||
.set("x-user-id", U)
|
.set("x-user-id", U)
|
||||||
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
.send({ targets });
|
.send({ targets });
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
@@ -120,6 +133,8 @@ describe("manual rebalance", () => {
|
|||||||
const resOk = await request(app.server)
|
const resOk = await request(app.server)
|
||||||
.post("/variable-categories/manual-rebalance")
|
.post("/variable-categories/manual-rebalance")
|
||||||
.set("x-user-id", U)
|
.set("x-user-id", U)
|
||||||
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
.send({ targets, forceLowerSavings: true });
|
.send({ targets, forceLowerSavings: true });
|
||||||
|
|
||||||
expect(resOk.statusCode).toBe(200);
|
expect(resOk.statusCode).toBe(200);
|
||||||
@@ -129,13 +144,16 @@ describe("manual rebalance", () => {
|
|||||||
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
|
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
|
||||||
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 9000 : 333 }));
|
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 9000 : 333 }));
|
||||||
targets[1].targetCents += 1; // sum 10000
|
targets[1].targetCents += 1; // sum 10000
|
||||||
|
const csrf = randomUUID().replace(/-/g, "");
|
||||||
|
|
||||||
const res = await request(app.server)
|
const res = await request(app.server)
|
||||||
.post("/variable-categories/manual-rebalance")
|
.post("/variable-categories/manual-rebalance")
|
||||||
.set("x-user-id", U)
|
.set("x-user-id", U)
|
||||||
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
.send({ targets });
|
.send({ targets });
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(res.body?.code).toBe("OVER_80_PERCENT");
|
expect(res.body?.code).toBe("OVER_80_CONFIRM_REQUIRED");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,55 +0,0 @@
|
|||||||
Stack trace:
|
|
||||||
Frame Function Args
|
|
||||||
0007FFFF4F30 000210060304 (0007FFFF5138, 0007FFFFCE00, 000000000002, 0007FFFFDC10) msys-2.0.dll+0x20304
|
|
||||||
000000084002 00021006237D (000000000064, 000700000000, 000000000298, 000000000000) msys-2.0.dll+0x2237D
|
|
||||||
0007FFFF5640 0002100C1394 (000000000000, 000000000068, 000000000000, 0006FFFFFFB7) msys-2.0.dll+0x81394
|
|
||||||
0007FFFF5AE0 0002100C1C19 (0007FFFF5810, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x81C19
|
|
||||||
0007FFFF5AE0 00021006A1DE (000000004000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A1DE
|
|
||||||
0007FFFF5AE0 00021006A4A9 (000000000000, 0007FFFF5B88, 0007FFFF5D64, 0000FFFFFFFF) msys-2.0.dll+0x2A4A9
|
|
||||||
0007FFFF5AE0 000210193F2B (000000000000, 0007FFFF5B88, 0007FFFF5D64, 0000FFFFFFFF) msys-2.0.dll+0x153F2B
|
|
||||||
0007FFFF5AE0 00010042DB65 (000A00000004, 0007FFFF5D74, 00010040DD62, 000000000000) bash.exe+0x2DB65
|
|
||||||
0000FFFFFFFF 00010043C4F8 (0000000000C2, 000000000000, 000A001FF4B0, 000A000888B0) bash.exe+0x3C4F8
|
|
||||||
000000000070 00010043E6BE (000000000000, 000000000001, 000000000000, 0007FFFF5D70) bash.exe+0x3E6BE
|
|
||||||
000000000070 000100441B06 (000700000001, 000A00000000, 0007FFFF5E60, 000000000000) bash.exe+0x41B06
|
|
||||||
000000000070 000100441D36 (000100000000, 000A00000000, 0007FFFF5F3C, 0007FFFF5F38) bash.exe+0x41D36
|
|
||||||
000000000001 000100443783 (00010000001F, 000000000001, 000A001FF310, 000A001FD9D0) bash.exe+0x43783
|
|
||||||
0000FFFFFFFF 0001004195CA (00010046F680, 000000000000, 0000FFFFFFFF, 000A001FD9D0) bash.exe+0x195CA
|
|
||||||
000A001FF8E0 00010041AC6A (00000000001F, 000000000000, 000A001FD500, 000000000000) bash.exe+0x1AC6A
|
|
||||||
000A001FF8E0 00010041CD1B (000100000000, 000000000000, 000200000000, 000A001FF8E0) bash.exe+0x1CD1B
|
|
||||||
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FF8E0) bash.exe+0x179C7
|
|
||||||
00000000015B 00010041AC6A (000000000000, 000000000000, 000A001FD500, 000000000000) bash.exe+0x1AC6A
|
|
||||||
00000000015B 0001004180FB (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x180FB
|
|
||||||
000A001FD530 00010041C54A (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x1C54A
|
|
||||||
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x179C7
|
|
||||||
000A001FD4E0 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A
|
|
||||||
000A001FD4E0 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD4E0) bash.exe+0x1C50F
|
|
||||||
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD4E0) bash.exe+0x179C7
|
|
||||||
000A001FD490 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A
|
|
||||||
000A001FD490 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD490) bash.exe+0x1C50F
|
|
||||||
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD490) bash.exe+0x179C7
|
|
||||||
000A001FD440 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A
|
|
||||||
000A001FD440 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD440) bash.exe+0x1C50F
|
|
||||||
0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD440) bash.exe+0x179C7
|
|
||||||
000A001FD3F0 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A
|
|
||||||
000A001FD3F0 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD3F0) bash.exe+0x1C50F
|
|
||||||
End of stack trace (more stack frames may be present)
|
|
||||||
Loaded modules:
|
|
||||||
000100400000 bash.exe
|
|
||||||
7FFF25310000 ntdll.dll
|
|
||||||
7FFF247D0000 KERNEL32.DLL
|
|
||||||
7FFF22E40000 KERNELBASE.dll
|
|
||||||
7FFF24010000 USER32.dll
|
|
||||||
7FFF22BD0000 win32u.dll
|
|
||||||
7FFF246C0000 GDI32.dll
|
|
||||||
000210040000 msys-2.0.dll
|
|
||||||
7FFF22CA0000 gdi32full.dll
|
|
||||||
7FFF22C00000 msvcp_win.dll
|
|
||||||
7FFF22A20000 ucrtbase.dll
|
|
||||||
7FFF23330000 advapi32.dll
|
|
||||||
7FFF249F0000 msvcrt.dll
|
|
||||||
7FFF24A90000 sechost.dll
|
|
||||||
7FFF24DA0000 RPCRT4.dll
|
|
||||||
7FFF229A0000 bcrypt.dll
|
|
||||||
7FFF221F0000 CRYPTBASE.DLL
|
|
||||||
7FFF23140000 bcryptPrimitives.dll
|
|
||||||
7FFF25180000 IMM32.DLL
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJiOGEzN2U1Ny01M2QyLTQyMGYtOWYzZi1lYTdiNmEyM2JmNjgiLCJpYXQiOjE3NjM4NzEwMjl9.2H5loEHfxq33U1D5kS_Jt43CIoq_kKwKeHUTjjXQFzI
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmODE5NzlmYS1mZTQwLTQxOTUtOGMwNC0xZWE2OWQ1NjRmZDQiLCJpYXQiOjE3NjM4NzEwOTl9.Jj4PzUGZuW2Eg-8vt1uJkLIcdDe5ghWFDCcvGYRHjSY
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2ZWQzMTNhMS05MTNiLTRmYzEtYTFiZC0wODhmOWFlY2FhNGUiLCJpYXQiOjE3NjM4NzE0MjN9.hMpW1tyRd-f5P6WuIm-56-PbdVo_7D9reYYYtcSgVn0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhNjEzMDZhZi05Mzg3LTQ3MzYtODYxYS0zODA5M2RhYzYwZmYiLCJpYXQiOjE3NjM4NzE2Mzl9.g_RurcGYjyJitJRLaPiVM5vAk5Nc94u05k5Rc83lV5k
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3MWQxZjc1My1hYTI3LTQxOTEtYWIxNS05NjNjYWZlNjE2MGEiLCJpYXQiOjE3NjM4NzE1MDJ9.djJEIX3xz_typgayo_FcT8lvpWGXOjv8FLalmaPojQg
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhNjEzMDZhZi05Mzg3LTQ3MzYtODYxYS0zODA5M2RhYzYwZmYiLCJpYXQiOjE3NjM4NzE2NTV9.K5pjWe9iUDawB7yrLNoQywvAmjgv33D2LTa6T50FKCY
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyYmFhNzUwMS1iMzVkLTQyN2EtYTRlMy0wNTg0ZDczN2Y4MmYiLCJpYXQiOjE3NjM4NzE0NzN9.LpnakXIqy6brb_I59fFBTPhzyeZ6NoFpCiSvRQla-BE
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# API Phase 1 Move Log
|
|
||||||
|
|
||||||
Date: 2026-03-15
|
|
||||||
Scope: Move low-risk endpoints out of `api/src/server.ts` into dedicated route modules.
|
|
||||||
|
|
||||||
## Route Registration Changes
|
|
||||||
- Registered session routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1518)
|
|
||||||
- Registered user routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1523)
|
|
||||||
- Registered health routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1524)
|
|
||||||
|
|
||||||
## Endpoint Movements
|
|
||||||
|
|
||||||
1. `GET /health`
|
|
||||||
- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:11)
|
|
||||||
- References:
|
|
||||||
- [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:10)
|
|
||||||
- [security-misconfiguration.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/security-misconfiguration.test.ts:29)
|
|
||||||
|
|
||||||
2. `GET /health/db` (non-production only)
|
|
||||||
- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:14)
|
|
||||||
- References:
|
|
||||||
- [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:15)
|
|
||||||
|
|
||||||
3. `GET /auth/session`
|
|
||||||
- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:40)
|
|
||||||
- References:
|
|
||||||
- [useAuthSession.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useAuthSession.ts:23)
|
|
||||||
- [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:120)
|
|
||||||
|
|
||||||
4. `POST /app/update-notice/ack`
|
|
||||||
- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:77)
|
|
||||||
- References:
|
|
||||||
- [App.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/App.tsx:33)
|
|
||||||
|
|
||||||
5. `PATCH /me`
|
|
||||||
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:50)
|
|
||||||
- References:
|
|
||||||
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:394)
|
|
||||||
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:115)
|
|
||||||
|
|
||||||
6. `PATCH /me/password`
|
|
||||||
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:71)
|
|
||||||
- References:
|
|
||||||
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:148)
|
|
||||||
- [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:57)
|
|
||||||
|
|
||||||
7. `PATCH /me/income-frequency`
|
|
||||||
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:112)
|
|
||||||
- References:
|
|
||||||
- Currently no frontend direct calls found by static search.
|
|
||||||
|
|
||||||
8. `PATCH /user/config`
|
|
||||||
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:135)
|
|
||||||
- References:
|
|
||||||
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:408)
|
|
||||||
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:171)
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- `server.ts` endpoint blocks for the above routes were removed to prevent duplicate registration.
|
|
||||||
- Existing path contracts were preserved (same method + path).
|
|
||||||
- `openPaths` and auth/CSRF hook behavior remain unchanged.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# API Phase 2 Move Log
|
|
||||||
|
|
||||||
Date: 2026-03-16
|
|
||||||
Scope: Move `auth` + `account` endpoints out of `api/src/server.ts` into a dedicated route module.
|
|
||||||
|
|
||||||
## Route Registration Changes
|
|
||||||
- Registered auth/account routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:902)
|
|
||||||
- New route module: [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:111)
|
|
||||||
|
|
||||||
## Endpoint Movements
|
|
||||||
|
|
||||||
1. `POST /auth/register`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:115)
|
|
||||||
- References:
|
|
||||||
- [RegisterPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/RegisterPage.tsx:74)
|
|
||||||
- [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:54)
|
|
||||||
|
|
||||||
2. `POST /auth/login`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:169)
|
|
||||||
- References:
|
|
||||||
- [LoginPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/LoginPage.tsx:55)
|
|
||||||
- [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:49)
|
|
||||||
|
|
||||||
3. `POST /auth/logout`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:266)
|
|
||||||
- References:
|
|
||||||
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:215)
|
|
||||||
- [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:53)
|
|
||||||
|
|
||||||
4. `POST /auth/verify`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:278)
|
|
||||||
- References:
|
|
||||||
- [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:43)
|
|
||||||
- [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:93)
|
|
||||||
|
|
||||||
5. `POST /auth/verify/resend`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:336)
|
|
||||||
- References:
|
|
||||||
- [VerifyPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/VerifyPage.tsx:65)
|
|
||||||
- [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:40)
|
|
||||||
|
|
||||||
6. `POST /auth/forgot-password/request`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:391)
|
|
||||||
- References:
|
|
||||||
- [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:23)
|
|
||||||
- [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:45)
|
|
||||||
|
|
||||||
7. `POST /auth/forgot-password/confirm`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:471)
|
|
||||||
- References:
|
|
||||||
- [auth.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/auth.ts:31)
|
|
||||||
- [forgot-password.security.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/forgot-password.security.test.ts:110)
|
|
||||||
|
|
||||||
8. `POST /account/delete-request`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:537)
|
|
||||||
- References:
|
|
||||||
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:251)
|
|
||||||
- [insecure-design.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/insecure-design.test.ts:67)
|
|
||||||
|
|
||||||
9. `POST /account/confirm-delete`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:596)
|
|
||||||
- References:
|
|
||||||
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:270)
|
|
||||||
- [access-control.account-delete.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/access-control.account-delete.test.ts:60)
|
|
||||||
|
|
||||||
10. `POST /auth/refresh`
|
|
||||||
- Moved to [auth-account.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/auth-account.ts:677)
|
|
||||||
- References:
|
|
||||||
- [useSessionTimeout.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useSessionTimeout.ts:26)
|
|
||||||
- [cryptographic-failures.runtime.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/cryptographic-failures.runtime.test.ts:71)
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- `server.ts` auth/account endpoint blocks were removed to prevent duplicate registration.
|
|
||||||
- Existing path contracts were preserved (same method + path + response shapes).
|
|
||||||
- Existing auth helpers (`issueEmailToken`, cooldown checks, security logging, lockout tracking) are still sourced from `server.ts` and injected into the route module.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# API Phase 3 Move Log
|
|
||||||
|
|
||||||
Date: 2026-03-16
|
|
||||||
Scope: Move `variable-categories` endpoints out of `api/src/server.ts` into a dedicated route module.
|
|
||||||
|
|
||||||
## Route Registration Changes
|
|
||||||
- Added variable-categories route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:18)
|
|
||||||
- Registered variable-categories routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:934)
|
|
||||||
- New canonical route module: [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:125)
|
|
||||||
- Removed inline variable-categories route block from `server.ts` to avoid duplicate endpoint registration.
|
|
||||||
|
|
||||||
## Endpoint Movements
|
|
||||||
|
|
||||||
1. `POST /variable-categories`
|
|
||||||
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:129)
|
|
||||||
- References:
|
|
||||||
- [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:12)
|
|
||||||
- [variable-categories.guard.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.guard.test.ts:38)
|
|
||||||
|
|
||||||
2. `PATCH /variable-categories/:id`
|
|
||||||
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:165)
|
|
||||||
- References:
|
|
||||||
- [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:13)
|
|
||||||
- [variable-categories.guard.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.guard.test.ts:49)
|
|
||||||
|
|
||||||
3. `DELETE /variable-categories/:id`
|
|
||||||
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:202)
|
|
||||||
- References:
|
|
||||||
- [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:14)
|
|
||||||
- [CategoriesSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/CategoriesSettings.tsx:360)
|
|
||||||
|
|
||||||
4. `POST /variable-categories/rebalance`
|
|
||||||
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:219)
|
|
||||||
- References:
|
|
||||||
- [categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/categories.ts:15)
|
|
||||||
- [CategoriesSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/CategoriesSettings.tsx:367)
|
|
||||||
|
|
||||||
5. `GET /variable-categories/manual-rebalance`
|
|
||||||
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:266)
|
|
||||||
- References:
|
|
||||||
- [rebalance.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/rebalance.ts:24)
|
|
||||||
- [variable-categories.manual-rebalance.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.manual-rebalance.test.ts:54)
|
|
||||||
|
|
||||||
6. `POST /variable-categories/manual-rebalance`
|
|
||||||
- Moved to [variable-categories.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/variable-categories.ts:283)
|
|
||||||
- References:
|
|
||||||
- [rebalance.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/rebalance.ts:25)
|
|
||||||
- [RebalancePage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/RebalancePage.tsx:148)
|
|
||||||
- [variable-categories.manual-rebalance.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/variable-categories.manual-rebalance.test.ts:63)
|
|
||||||
|
|
||||||
## Helper Ownership in Phase 3
|
|
||||||
- Kept variable-category-specific helpers local to the route module:
|
|
||||||
- `assertPercentTotal`
|
|
||||||
- `getLatestBudgetSession`
|
|
||||||
- `ensureBudgetSession`
|
|
||||||
- `ensureBudgetSessionAvailableSynced`
|
|
||||||
- Reused shared server helper by injection:
|
|
||||||
- `computeDepositShares` injected from `server.ts` registration.
|
|
||||||
- Reused shared mutation throttling by injection:
|
|
||||||
- `mutationRateLimit` injected from `server.ts` registration.
|
|
||||||
|
|
||||||
## Follow-ups To Revisit
|
|
||||||
1. Consolidate budget-session sync helpers into a shared service (`api/src/services/budget-session.ts`) once Phase 4 starts.
|
|
||||||
2. Standardize response error envelopes for variable-category routes (`message` vs `ok/code/message`) to reduce client branching.
|
|
||||||
3. Recheck `variable-categories.manual-rebalance.test.ts` over-80 error-code expectation versus current confirm-style behavior to keep tests aligned with product policy.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# API Phase 4 Move Log
|
|
||||||
|
|
||||||
Date: 2026-03-17
|
|
||||||
Scope: Move `transactions` endpoints out of `api/src/server.ts` into a dedicated route module.
|
|
||||||
|
|
||||||
## Route Registration Changes
|
|
||||||
- Added transactions route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:19)
|
|
||||||
- Registered transactions routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:938)
|
|
||||||
- New canonical route module: [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:38)
|
|
||||||
- Removed inline transactions route blocks from `server.ts` to avoid duplicate registration:
|
|
||||||
- `POST /transactions` block
|
|
||||||
- `GET/PATCH/DELETE /transactions` block
|
|
||||||
|
|
||||||
## Endpoint Movements
|
|
||||||
|
|
||||||
1. `POST /transactions`
|
|
||||||
- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:42)
|
|
||||||
- References:
|
|
||||||
- [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:34)
|
|
||||||
- [payment-rollover.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/payment-rollover.test.ts:42)
|
|
||||||
- [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:139)
|
|
||||||
|
|
||||||
2. `GET /transactions`
|
|
||||||
- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:465)
|
|
||||||
- References:
|
|
||||||
- [useTransactionsQuery.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactionsQuery.tsx:41)
|
|
||||||
- [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/transactions.ts:27)
|
|
||||||
- [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:71)
|
|
||||||
|
|
||||||
3. `PATCH /transactions/:id`
|
|
||||||
- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:601)
|
|
||||||
- References:
|
|
||||||
- [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:63)
|
|
||||||
- [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:189)
|
|
||||||
|
|
||||||
4. `DELETE /transactions/:id`
|
|
||||||
- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:659)
|
|
||||||
- References:
|
|
||||||
- [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:75)
|
|
||||||
- [SpendPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/SpendPage.tsx:296)
|
|
||||||
|
|
||||||
## Helper Ownership in Phase 4
|
|
||||||
- Shared helper injection from `server.ts`:
|
|
||||||
- `mutationRateLimit`
|
|
||||||
- `computeDepositShares`
|
|
||||||
- `computeWithdrawShares`
|
|
||||||
- `computeOverdraftShares`
|
|
||||||
- `calculateNextDueDate`
|
|
||||||
- `toBig`
|
|
||||||
- `parseCurrencyToCents`
|
|
||||||
- Route-local helper:
|
|
||||||
- `isDate` query validator
|
|
||||||
|
|
||||||
## Follow-ups To Revisit
|
|
||||||
1. Split `POST /transactions` into smaller internal handlers (`variable_spend` vs `fixed_payment`) after endpoint migration phases complete.
|
|
||||||
2. Replace broad `any` shapes in transaction where-clause/order composition with a typed query builder to reduce regression risk.
|
|
||||||
3. Reconcile test expectations for plan overdraft behavior (`OVERDRAFT_PLAN` vs current fixed-payment reconciliation semantics) before behavior changes in later phases.
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# API Phase 5 Move Log
|
|
||||||
|
|
||||||
Date: 2026-03-17
|
|
||||||
Scope: Move `fixed-plans` endpoints out of `api/src/server.ts` into a dedicated route module.
|
|
||||||
|
|
||||||
## Route Registration Changes
|
|
||||||
- Added fixed-plans route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:20)
|
|
||||||
- Registered fixed-plans routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:948)
|
|
||||||
- New canonical route module: [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71)
|
|
||||||
- Removed inline fixed-plans route blocks from `server.ts` to avoid duplicate registration:
|
|
||||||
- `PATCH /fixed-plans/:id/early-funding`
|
|
||||||
- `POST /fixed-plans/:id/attempt-final-funding`
|
|
||||||
- `PATCH /fixed-plans/:id/mark-unpaid`
|
|
||||||
- `POST /fixed-plans/:id/fund-from-available`
|
|
||||||
- `POST /fixed-plans/:id/catch-up-funding`
|
|
||||||
- `POST /fixed-plans`
|
|
||||||
- `PATCH /fixed-plans/:id`
|
|
||||||
- `DELETE /fixed-plans/:id`
|
|
||||||
- `POST /fixed-plans/:id/true-up-actual`
|
|
||||||
- `GET /fixed-plans/due`
|
|
||||||
- `POST /fixed-plans/:id/pay-now`
|
|
||||||
|
|
||||||
## Endpoint Movements
|
|
||||||
|
|
||||||
1. `PATCH /fixed-plans/:id/early-funding`
|
|
||||||
- Original: `server.ts` line 1414
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71)
|
|
||||||
- References:
|
|
||||||
- [EarlyPaymentPromptModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyPaymentPromptModal.tsx:34)
|
|
||||||
- [EarlyFundingModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyFundingModal.tsx:19)
|
|
||||||
|
|
||||||
2. `POST /fixed-plans/:id/attempt-final-funding`
|
|
||||||
- Original: `server.ts` line 1475
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:131)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:58)
|
|
||||||
- [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:219)
|
|
||||||
|
|
||||||
3. `PATCH /fixed-plans/:id/mark-unpaid`
|
|
||||||
- Original: `server.ts` line 1635
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:287)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:84)
|
|
||||||
|
|
||||||
4. `POST /fixed-plans/:id/fund-from-available`
|
|
||||||
- Original: `server.ts` line 1674
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:325)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:95)
|
|
||||||
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:461)
|
|
||||||
|
|
||||||
5. `POST /fixed-plans/:id/catch-up-funding`
|
|
||||||
- Original: `server.ts` line 1828
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:478)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:106)
|
|
||||||
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:512)
|
|
||||||
|
|
||||||
6. `POST /fixed-plans`
|
|
||||||
- Original: `server.ts` line 2036
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:659)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:39)
|
|
||||||
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:449)
|
|
||||||
- [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:43)
|
|
||||||
|
|
||||||
7. `PATCH /fixed-plans/:id`
|
|
||||||
- Original: `server.ts` line 2122
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:747)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:41)
|
|
||||||
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:502)
|
|
||||||
|
|
||||||
8. `DELETE /fixed-plans/:id`
|
|
||||||
- Original: `server.ts` line 2239
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:866)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:42)
|
|
||||||
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:443)
|
|
||||||
|
|
||||||
9. `POST /fixed-plans/:id/true-up-actual`
|
|
||||||
- Original: `server.ts` line 2285
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:911)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:108)
|
|
||||||
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:549)
|
|
||||||
- [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:79)
|
|
||||||
|
|
||||||
10. `GET /fixed-plans/due`
|
|
||||||
- Original: `server.ts` line 2429
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1054)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:45)
|
|
||||||
- [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:363)
|
|
||||||
|
|
||||||
11. `POST /fixed-plans/:id/pay-now`
|
|
||||||
- Original: `server.ts` line 2495
|
|
||||||
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1118)
|
|
||||||
- References:
|
|
||||||
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:77)
|
|
||||||
- [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:649)
|
|
||||||
- [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:170)
|
|
||||||
|
|
||||||
## Helper Ownership in Phase 5
|
|
||||||
- Shared helper injection from `server.ts`:
|
|
||||||
- `mutationRateLimit`
|
|
||||||
- `computeDepositShares`
|
|
||||||
- `computeWithdrawShares`
|
|
||||||
- `calculateNextDueDate`
|
|
||||||
- `toBig`
|
|
||||||
- Route-local helper:
|
|
||||||
- `DAY_MS` constant for date-window computations
|
|
||||||
- `PlanBody` / `PlanAmountMode` zod schemas
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
1. Build
|
|
||||||
- `cd api && npm run build` ✅
|
|
||||||
|
|
||||||
2. Focused tests
|
|
||||||
- `cd api && npm run test -- tests/fixed-plans.estimated-true-up.test.ts tests/payment-rollover.test.ts tests/transactions.test.ts`
|
|
||||||
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suites skipped/failed before endpoint assertions.
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# API Phase 6 Move Log
|
|
||||||
|
|
||||||
Date: 2026-03-17
|
|
||||||
Scope: Move `income`, `budget`, and `payday` endpoints out of `api/src/server.ts` into dedicated route modules.
|
|
||||||
|
|
||||||
## Route Registration Changes
|
|
||||||
- Added income route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:21)
|
|
||||||
- Added payday route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:22)
|
|
||||||
- Added budget route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:23)
|
|
||||||
- Registered income routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:946)
|
|
||||||
- Registered payday routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:949)
|
|
||||||
- Registered budget routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:953)
|
|
||||||
- Removed inline route blocks from `server.ts` to avoid duplicate registration:
|
|
||||||
- `POST /income`
|
|
||||||
- `GET /income/history`
|
|
||||||
- `POST /income/preview`
|
|
||||||
- `GET /payday/status`
|
|
||||||
- `POST /payday/dismiss`
|
|
||||||
- `POST /budget/allocate`
|
|
||||||
- `POST /budget/fund`
|
|
||||||
- `POST /budget/reconcile`
|
|
||||||
|
|
||||||
## Endpoint Movements
|
|
||||||
|
|
||||||
1. `POST /income`
|
|
||||||
- Original: `server.ts` line 1382
|
|
||||||
- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:31)
|
|
||||||
- References:
|
|
||||||
- [useIncome.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncome.ts:27)
|
|
||||||
- [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:71)
|
|
||||||
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:479)
|
|
||||||
- [income.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/income.test.ts:19)
|
|
||||||
- [income.integration.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/income.integration.test.ts:59)
|
|
||||||
|
|
||||||
2. `GET /income/history`
|
|
||||||
- Original: `server.ts` line 1421
|
|
||||||
- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:78)
|
|
||||||
- References:
|
|
||||||
- [useIncomeHistory.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncomeHistory.ts:15)
|
|
||||||
|
|
||||||
3. `POST /income/preview`
|
|
||||||
- Original: `server.ts` line 1459
|
|
||||||
- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:115)
|
|
||||||
- References:
|
|
||||||
- [useIncomePreview.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncomePreview.ts:16)
|
|
||||||
|
|
||||||
4. `GET /payday/status`
|
|
||||||
- Original: `server.ts` line 1483
|
|
||||||
- Moved to [payday.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/payday.ts:29)
|
|
||||||
- References:
|
|
||||||
- [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:35)
|
|
||||||
|
|
||||||
5. `POST /payday/dismiss`
|
|
||||||
- Original: `server.ts` line 1586
|
|
||||||
- Moved to [payday.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/payday.ts:135)
|
|
||||||
- References:
|
|
||||||
- [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:54)
|
|
||||||
|
|
||||||
6. `POST /budget/allocate`
|
|
||||||
- Original: `server.ts` line 1597
|
|
||||||
- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:43)
|
|
||||||
- References:
|
|
||||||
- [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:58)
|
|
||||||
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:8)
|
|
||||||
|
|
||||||
7. `POST /budget/fund`
|
|
||||||
- Original: `server.ts` line 1624
|
|
||||||
- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:69)
|
|
||||||
- References:
|
|
||||||
- [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:65)
|
|
||||||
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:473)
|
|
||||||
|
|
||||||
8. `POST /budget/reconcile`
|
|
||||||
- Original: `server.ts` line 1657
|
|
||||||
- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:98)
|
|
||||||
- References:
|
|
||||||
- [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:72)
|
|
||||||
- [ReconcileSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/ReconcileSettings.tsx:8)
|
|
||||||
|
|
||||||
## Helper Ownership in Phase 6
|
|
||||||
- Shared helper injection from `server.ts`:
|
|
||||||
- `mutationRateLimit`
|
|
||||||
- `computeDepositShares`
|
|
||||||
- `computeWithdrawShares`
|
|
||||||
- `isProd` flag for environment-sensitive logging
|
|
||||||
- Route-local schemas/helpers:
|
|
||||||
- `income.ts`: `AllocationOverrideSchema`
|
|
||||||
- `budget.ts`: `BudgetBody`, `ReconcileBody`
|
|
||||||
- `payday.ts`: local debug logger and query schema
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- Removed legacy unregistered `api/src/routes/income-preview.ts` to avoid duplicate endpoint logic drift.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
1. Build
|
|
||||||
- `cd api && npm run build` ✅
|
|
||||||
|
|
||||||
2. Focused tests
|
|
||||||
- `cd api && npm run test -- tests/income.test.ts tests/income.integration.test.ts`
|
|
||||||
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suites skipped/failed before endpoint assertions.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# API Phase 7 Move Log
|
|
||||||
|
|
||||||
Date: 2026-03-17
|
|
||||||
Scope: Move dashboard read endpoints out of `api/src/server.ts` into a dedicated route module.
|
|
||||||
|
|
||||||
## Route Registration Changes
|
|
||||||
- Added dashboard route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:24)
|
|
||||||
- Registered dashboard routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:944)
|
|
||||||
- New canonical route module: [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:24)
|
|
||||||
- Removed inline dashboard route blocks from `server.ts` to avoid duplicate registration:
|
|
||||||
- `GET /dashboard`
|
|
||||||
- `GET /crisis-status`
|
|
||||||
|
|
||||||
## Endpoint Movements
|
|
||||||
|
|
||||||
1. `GET /dashboard`
|
|
||||||
- Original: `server.ts` line 1081
|
|
||||||
- Moved to [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:24)
|
|
||||||
- References:
|
|
||||||
- [useDashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useDashboard.ts:85)
|
|
||||||
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:172)
|
|
||||||
- [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:37)
|
|
||||||
- [security-logging-monitoring-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/security-logging-monitoring-failures.test.ts:48)
|
|
||||||
|
|
||||||
2. `GET /crisis-status`
|
|
||||||
- Original: `server.ts` line 1330
|
|
||||||
- Moved to [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:273)
|
|
||||||
- References:
|
|
||||||
- No direct web/api wrapper references currently found via repo search.
|
|
||||||
- Endpoint remains available for API consumers and future UI wiring.
|
|
||||||
|
|
||||||
## Helper Ownership in Phase 7
|
|
||||||
- Route-local helpers in `dashboard.ts`:
|
|
||||||
- `monthKey`
|
|
||||||
- `monthLabel`
|
|
||||||
- `buildMonthBuckets`
|
|
||||||
- `DAY_MS`
|
|
||||||
- Reused allocator date helpers:
|
|
||||||
- static `getUserMidnightFromDateOnly`
|
|
||||||
- dynamic import of `getUserMidnight` and `calculateNextPayday` for parity with pre-move logic
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
1. Build
|
|
||||||
- `cd api && npm run build` ✅
|
|
||||||
|
|
||||||
2. Focused tests
|
|
||||||
- `cd api && npm run test -- tests/auth.routes.test.ts`
|
|
||||||
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suite skipped/failed before endpoint assertions.
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# API Phase 8 Move Log
|
|
||||||
|
|
||||||
Date: 2026-03-17
|
|
||||||
Scope: Move `admin` and `site-access` endpoints out of `api/src/server.ts` into dedicated route modules.
|
|
||||||
|
|
||||||
## Route Registration Changes
|
|
||||||
- Added site-access route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:24)
|
|
||||||
- Added admin route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:25)
|
|
||||||
- Registered site-access routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:946)
|
|
||||||
- Registered admin routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:960)
|
|
||||||
- New canonical route modules:
|
|
||||||
- [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:29)
|
|
||||||
- [admin.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/admin.ts:10)
|
|
||||||
- Removed inline route blocks from `server.ts` to avoid duplicate registration:
|
|
||||||
- `GET /site-access/status`
|
|
||||||
- `POST /site-access/unlock`
|
|
||||||
- `POST /site-access/lock`
|
|
||||||
- `POST /admin/rollover`
|
|
||||||
|
|
||||||
## Endpoint Movements
|
|
||||||
|
|
||||||
1. `GET /site-access/status`
|
|
||||||
- Original: `server.ts` line 946
|
|
||||||
- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:30)
|
|
||||||
- References:
|
|
||||||
- [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:10)
|
|
||||||
- [BetaGate.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/BetaGate.tsx:20)
|
|
||||||
- [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:22)
|
|
||||||
|
|
||||||
2. `POST /site-access/unlock`
|
|
||||||
- Original: `server.ts` line 957
|
|
||||||
- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:41)
|
|
||||||
- References:
|
|
||||||
- [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:14)
|
|
||||||
- [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:40)
|
|
||||||
|
|
||||||
3. `POST /site-access/lock`
|
|
||||||
- Original: `server.ts` line 994
|
|
||||||
- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:78)
|
|
||||||
- References:
|
|
||||||
- [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:18)
|
|
||||||
- [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:59)
|
|
||||||
|
|
||||||
4. `POST /admin/rollover`
|
|
||||||
- Original: `server.ts` line 1045
|
|
||||||
- Moved to [admin.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/admin.ts:11)
|
|
||||||
- References:
|
|
||||||
- [access-control.admin-rollover.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/access-control.admin-rollover.test.ts:44)
|
|
||||||
|
|
||||||
## Helper Ownership in Phase 8
|
|
||||||
- Shared helper injection from `server.ts`:
|
|
||||||
- `authRateLimit`
|
|
||||||
- `mutationRateLimit`
|
|
||||||
- `hasSiteAccessBypass`
|
|
||||||
- `safeEqual`
|
|
||||||
- `isInternalClientIp`
|
|
||||||
- runtime config flags and cookie settings (`UNDER_CONSTRUCTION`, break-glass, cookie domain/secure, etc.)
|
|
||||||
- Route-local helpers/schemas:
|
|
||||||
- `site-access.ts`: unlock payload schema
|
|
||||||
- `admin.ts`: rollover payload schema
|
|
||||||
- Retained in `server.ts` by design for global hook behavior:
|
|
||||||
- site-access bypass token derivation and onRequest maintenance-mode enforcement
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
1. Build
|
|
||||||
- `cd api && npm run build` ✅
|
|
||||||
|
|
||||||
2. Focused tests
|
|
||||||
- `cd api && npm run test -- tests/access-control.admin-rollover.test.ts tests/security-misconfiguration.test.ts`
|
|
||||||
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suite skipped/failed before endpoint assertions.
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# API Refactor Lightweight Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves.
|
|
||||||
|
|
||||||
Current state (2026-03-17):
|
|
||||||
- `server.ts` still holds most business routes, but Phases 1-8 are complete.
|
|
||||||
- Completed move logs:
|
|
||||||
- `docs/api-phase1-move-log.md`
|
|
||||||
- `docs/api-phase2-move-log.md`
|
|
||||||
- `docs/api-phase3-move-log.md`
|
|
||||||
- `docs/api-phase4-move-log.md`
|
|
||||||
- `docs/api-phase5-move-log.md`
|
|
||||||
- `docs/api-phase6-move-log.md`
|
|
||||||
- `docs/api-phase7-move-log.md`
|
|
||||||
- `docs/api-phase8-move-log.md`
|
|
||||||
|
|
||||||
## Refactor Guardrails
|
|
||||||
1. Keep route behavior identical while moving code.
|
|
||||||
2. Move one domain at a time; do not mix domains in one PR.
|
|
||||||
3. Do not redesign architecture (no repositories/DI/container work).
|
|
||||||
4. Extract only duplicated logic into shared helpers/services.
|
|
||||||
5. Preserve global hooks and plugin registration in `server.ts` until final cleanup.
|
|
||||||
6. Keep response shapes stable (`ok`, `code`, `message`, etc.) during moves.
|
|
||||||
7. Require tests to pass after each move phase.
|
|
||||||
|
|
||||||
## Canonical Source Rule
|
|
||||||
`server.ts` is the canonical route logic right now.
|
|
||||||
|
|
||||||
When moving a domain:
|
|
||||||
1. Copy current logic from `server.ts` into the domain route module.
|
|
||||||
2. Register the module.
|
|
||||||
3. Remove the original block from `server.ts`.
|
|
||||||
4. Confirm no duplicate route registrations remain.
|
|
||||||
|
|
||||||
## Shared Helpers (Phase 0)
|
|
||||||
Create and/or finish these helpers to keep endpoint moves lightweight:
|
|
||||||
|
|
||||||
1. `api/src/services/user-context.ts`
|
|
||||||
- `getUserTimezone(...)`
|
|
||||||
- Removes repeated user timezone lookup logic.
|
|
||||||
|
|
||||||
2. `api/src/services/category-shares.ts`
|
|
||||||
- `computePercentShares(...)`
|
|
||||||
- `computeWithdrawShares(...)`
|
|
||||||
- `computeOverdraftShares(...)`
|
|
||||||
- `computeDepositShares(...)`
|
|
||||||
- Centralizes repeated category-share math.
|
|
||||||
|
|
||||||
3. `api/src/services/budget-session.ts`
|
|
||||||
- `getLatestBudgetSession(...)`
|
|
||||||
- `ensureBudgetSession(...)`
|
|
||||||
- `ensureBudgetSessionAvailableSynced(...)`
|
|
||||||
- Prevents session/value drift bugs.
|
|
||||||
|
|
||||||
4. `api/src/services/api-errors.ts`
|
|
||||||
- Standard typed error object and response body builder.
|
|
||||||
- Keeps endpoint error handling consistent.
|
|
||||||
|
|
||||||
## Progress Snapshot
|
|
||||||
Completed:
|
|
||||||
1. Phase 1: low-risk endpoints (`health`, `session`, `me`, `user/config`).
|
|
||||||
2. Phase 2: `auth` + `account` endpoints.
|
|
||||||
3. Phase 3: `variable-categories` endpoints.
|
|
||||||
4. Phase 4: `transactions` endpoints.
|
|
||||||
5. Phase 5: `fixed-plans` endpoints.
|
|
||||||
6. Phase 6: `income`, `budget`, `payday` endpoints.
|
|
||||||
7. Phase 7: `dashboard` + `crisis-status`.
|
|
||||||
8. Phase 8: `admin` + site access endpoints.
|
|
||||||
|
|
||||||
Remaining:
|
|
||||||
1. Phase 9: final cleanup and helper consolidation.
|
|
||||||
|
|
||||||
## Remaining Plan (Detailed)
|
|
||||||
|
|
||||||
### Phase 5: Fixed Plans Domain
|
|
||||||
Move these routes out of `server.ts` into `api/src/routes/fixed-plans.ts` (or split module if needed):
|
|
||||||
1. `PATCH /fixed-plans/:id/early-funding`
|
|
||||||
2. `POST /fixed-plans/:id/attempt-final-funding`
|
|
||||||
3. `PATCH /fixed-plans/:id/mark-unpaid`
|
|
||||||
4. `POST /fixed-plans/:id/fund-from-available`
|
|
||||||
5. `POST /fixed-plans/:id/catch-up-funding`
|
|
||||||
6. `POST /fixed-plans`
|
|
||||||
7. `PATCH /fixed-plans/:id`
|
|
||||||
8. `DELETE /fixed-plans/:id`
|
|
||||||
9. `POST /fixed-plans/:id/true-up-actual`
|
|
||||||
10. `GET /fixed-plans/due`
|
|
||||||
11. `POST /fixed-plans/:id/pay-now`
|
|
||||||
|
|
||||||
Primary risk:
|
|
||||||
- Payment/funding workflows are tightly coupled with available budget math and rollover rules.
|
|
||||||
|
|
||||||
Test focus:
|
|
||||||
- `api/tests/fixed-plans*.test.ts`
|
|
||||||
- `api/tests/payment-rollover.test.ts`
|
|
||||||
|
|
||||||
### Phase 6: Income, Budget, Payday Domain
|
|
||||||
Move these routes into a dedicated module (e.g., `api/src/routes/budget-income.ts`):
|
|
||||||
1. `POST /income`
|
|
||||||
2. `GET /income/history`
|
|
||||||
3. `POST /income/preview`
|
|
||||||
4. `POST /budget/allocate`
|
|
||||||
5. `POST /budget/fund`
|
|
||||||
6. `POST /budget/reconcile`
|
|
||||||
7. `GET /payday/status`
|
|
||||||
8. `POST /payday/dismiss`
|
|
||||||
|
|
||||||
Primary risk:
|
|
||||||
- Budget-session synchronization and allocator side effects.
|
|
||||||
|
|
||||||
Test focus:
|
|
||||||
- Income/budget allocation tests
|
|
||||||
- Any tests asserting payday status/dismiss behavior
|
|
||||||
|
|
||||||
### Phase 7: Dashboard Read Domain
|
|
||||||
Move read endpoints into `api/src/routes/dashboard.ts`:
|
|
||||||
1. `GET /dashboard`
|
|
||||||
2. `GET /crisis-status`
|
|
||||||
|
|
||||||
Primary risk:
|
|
||||||
- Derived numbers diverging between dashboard and rebalance/session APIs.
|
|
||||||
|
|
||||||
Test focus:
|
|
||||||
- Dashboard API contract checks and UI smoke verification.
|
|
||||||
|
|
||||||
### Phase 8: Admin and Site Access Domain
|
|
||||||
Move operational endpoints into `api/src/routes/admin.ts` and/or `api/src/routes/site-access.ts`:
|
|
||||||
1. `POST /admin/rollover`
|
|
||||||
2. `GET /site-access/status`
|
|
||||||
3. `POST /site-access/unlock`
|
|
||||||
4. `POST /site-access/lock`
|
|
||||||
|
|
||||||
Primary risk:
|
|
||||||
- Lockout/maintenance flow regressions and accidental open access.
|
|
||||||
|
|
||||||
Test focus:
|
|
||||||
- Site access flow tests
|
|
||||||
- Admin rollover auth/permission checks
|
|
||||||
|
|
||||||
### Phase 9: Final Cleanup
|
|
||||||
1. Remove dead helper duplicates from `server.ts` and route modules.
|
|
||||||
2. Consolidate common helpers into `api/src/services/*`.
|
|
||||||
3. Normalize error envelopes where safe (no contract change unless explicitly planned).
|
|
||||||
4. Re-run full API/security suites and perform deployment smoke checks.
|
|
||||||
|
|
||||||
## Reference Audit Requirement (Per Move)
|
|
||||||
For every endpoint moved:
|
|
||||||
1. Record original location in `server.ts`.
|
|
||||||
2. Record new route module location.
|
|
||||||
3. Record frontend references (`web/src/api/*`, hooks, pages).
|
|
||||||
4. Record test references.
|
|
||||||
5. Add a phase move log under `docs/api-phaseX-move-log.md`.
|
|
||||||
|
|
||||||
## Verification Steps (Per Phase)
|
|
||||||
1. Build:
|
|
||||||
- `cd api && npm run build`
|
|
||||||
2. Run focused tests for moved domain.
|
|
||||||
3. Run security suites relevant to moved endpoints.
|
|
||||||
4. Deploy.
|
|
||||||
5. Run production endpoint smoke checks for the moved routes.
|
|
||||||
6. Confirm logs show expected status codes (no unexplained 401/403/500 shifts).
|
|
||||||
|
|
||||||
## Definition of Done per Phase
|
|
||||||
1. Endpoints compile and register once.
|
|
||||||
2. Existing tests pass.
|
|
||||||
3. No route path/method changes.
|
|
||||||
4. No response contract changes.
|
|
||||||
5. `server.ts` net line count decreases.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlNzkzNTNiNS0zNTY1LTQ5YTQtYjE2NC1jNzU4NWVkNmU2MTgiLCJpYXQiOjE3NjM4NzE5ODV9.uoibcsSuH15ZS2bjtssHtt2aOcJF2RdDAHK_ynIeIco
|
|
||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "SkyMoney",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MzUyZjRhMy1hNjM5LTQyMWEtODI2Ny0yMGM0NTdhMDk3MmUiLCJpYXQiOjE3NjM4NzE5NzN9.ZslzE0_K4Mxsmgg37PsFKUP-lfSdYZaQfUqTf3o5m5Y
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# A01: Broken Access Control
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
1. Cross-account delete risk in `/account/confirm-delete`.
|
|
||||||
2. `AUTH_DISABLED` mode allows header-based impersonation (`x-user-id`) and must be tightly controlled.
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. `/account/confirm-delete` is now session-bound:
|
|
||||||
- Lookup uses `req.userId`.
|
|
||||||
- Request `email` must match session user's email.
|
|
||||||
- Mismatch returns `403`.
|
|
||||||
|
|
||||||
2. Insecure auth guard added:
|
|
||||||
- `AUTH_DISABLED=true` now requires `ALLOW_INSECURE_AUTH_FOR_DEV=true` unless `NODE_ENV=test`.
|
|
||||||
|
|
||||||
3. `/admin/rollover` hardened:
|
|
||||||
- Still requires `AUTH_DISABLED=true`.
|
|
||||||
- Now also requires request IP to be internal/private.
|
|
||||||
|
|
||||||
## Test coverage
|
|
||||||
|
|
||||||
1. `api/tests/access-control.account-delete.test.ts`
|
|
||||||
- Verifies cross-account deletion attempt is denied (`403`).
|
|
||||||
- Verifies victim account is not deleted.
|
|
||||||
|
|
||||||
2. `api/tests/auth.routes.test.ts`
|
|
||||||
- Verifies protected route rejects unauthenticated access.
|
|
||||||
- Verifies spoofed `x-user-id` is rejected when auth is enabled.
|
|
||||||
- Verifies login/session/logout behavior with CSRF handling.
|
|
||||||
- Verifies login lockout behavior.
|
|
||||||
|
|
||||||
3. `api/tests/access-control.admin-rollover.test.ts`
|
|
||||||
- Verifies unauthenticated access to `/admin/rollover` is denied when `AUTH_DISABLED=false`.
|
|
||||||
- Verifies authenticated access still receives `403` when `AUTH_DISABLED=false`.
|
|
||||||
- Verifies `/admin/rollover` rejects non-internal client IP when `AUTH_DISABLED=true`.
|
|
||||||
- Verifies `/admin/rollover` allows internal client IP when `AUTH_DISABLED=true`.
|
|
||||||
|
|
||||||
## Run commands
|
|
||||||
|
|
||||||
From `api/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test -- tests/auth.routes.test.ts tests/access-control.account-delete.test.ts tests/access-control.admin-rollover.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Expected results
|
|
||||||
|
|
||||||
1. All three test files pass.
|
|
||||||
2. No access granted to protected routes without valid auth cookie/JWT.
|
|
||||||
3. Spoofed `x-user-id` does not bypass auth when `AUTH_DISABLED=false`.
|
|
||||||
4. Cross-account delete attempt fails with `403`.
|
|
||||||
5. `/admin/rollover` remains inaccessible from public/non-internal clients.
|
|
||||||
|
|
||||||
## Production configuration requirements
|
|
||||||
|
|
||||||
1. `AUTH_DISABLED=false`
|
|
||||||
2. `ALLOW_INSECURE_AUTH_FOR_DEV=false`
|
|
||||||
3. Strong `JWT_SECRET` and `COOKIE_SECRET`
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# A02: Security Misconfiguration
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
1. SMTP transport debug logging enabled in all environments.
|
|
||||||
2. Production CORS had a fail-open branch when configured origins were empty.
|
|
||||||
3. Missing explicit anti-framing headers at edge.
|
|
||||||
4. Docker compose exposed Postgres/API on all host interfaces.
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. SMTP transport hardening in API:
|
|
||||||
- `requireTLS` now respects config (`SMTP_REQUIRE_TLS`).
|
|
||||||
- SMTP debug/logger are disabled in production (`!isProd` only).
|
|
||||||
|
|
||||||
2. CORS production behavior tightened:
|
|
||||||
- Removed fail-open branch for empty configured origins.
|
|
||||||
- Production now allows only explicitly configured origins.
|
|
||||||
|
|
||||||
3. Edge header hardening:
|
|
||||||
- Added `X-Frame-Options: DENY`.
|
|
||||||
- Added `Content-Security-Policy: frame-ancestors 'none'`.
|
|
||||||
|
|
||||||
4. Compose exposure reduction:
|
|
||||||
- Bound Postgres and API ports to localhost only.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
1. `api/src/server.ts`
|
|
||||||
2. `Caddyfile.prod`
|
|
||||||
3. `Caddyfile.dev`
|
|
||||||
4. `deploy/nginx/skymoneybudget.com.conf`
|
|
||||||
5. `docker-compose.yml`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### Automated security regression tests
|
|
||||||
|
|
||||||
Command (A01 regression):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npm test -- auth.routes.test.ts access-control.account-delete.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output (provided from host run):
|
|
||||||
|
|
||||||
- Test Files: `2 passed (2)`
|
|
||||||
- Tests: `6 passed (6)`
|
|
||||||
- Start time: `16:39:35`
|
|
||||||
|
|
||||||
Command (A02 dedicated suite):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npx vitest --run -c vitest.security.config.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- Test Files: `1 passed (1)`
|
|
||||||
- Tests: `5 passed (5)`
|
|
||||||
- Suite: `tests/security-misconfiguration.test.ts`
|
|
||||||
|
|
||||||
Coverage in dedicated suite:
|
|
||||||
|
|
||||||
1. Production CORS allowlist enforcement (allowed origin accepted, denied origin does not receive allow headers).
|
|
||||||
2. SMTP production mailer options disable debug/logger.
|
|
||||||
3. Runtime CORS preflight headers validated for allowed origins (`allow-origin`, `allow-credentials`, `vary`).
|
|
||||||
4. Edge config files contain anti-framing headers (`X-Frame-Options`, `frame-ancestors` CSP).
|
|
||||||
5. `docker-compose.yml` binds Postgres/API ports to localhost only.
|
|
||||||
|
|
||||||
### Expected operational checks after deploy
|
|
||||||
|
|
||||||
1. Unauthenticated `GET /dashboard` returns `401`.
|
|
||||||
2. Spoofed `x-user-id` does not bypass auth when `AUTH_DISABLED=false`.
|
|
||||||
3. `/admin/rollover` remains inaccessible from public network.
|
|
||||||
4. Response headers include anti-framing policy.
|
|
||||||
|
|
||||||
## Residual notes
|
|
||||||
|
|
||||||
1. Keep production env pinned to:
|
|
||||||
- `AUTH_DISABLED=false`
|
|
||||||
- `ALLOW_INSECURE_AUTH_FOR_DEV=false`
|
|
||||||
|
|
||||||
2. Keep CORS origins explicitly configured in production:
|
|
||||||
- `CORS_ORIGIN` and/or `CORS_ORIGINS`.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# A03: Software Supply Chain Failures
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
1. Production dependency vulnerabilities were present in both API and web lockfiles.
|
|
||||||
2. Deploy pipeline had no explicit dependency vulnerability gate.
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. Dependency remediation:
|
|
||||||
- Ran `npm audit fix` in `api` and `web`.
|
|
||||||
- Revalidated production dependencies are clean with `npm audit --omit=dev`.
|
|
||||||
|
|
||||||
2. Pipeline hardening:
|
|
||||||
- Added supply-chain check step in deploy workflow:
|
|
||||||
- `npm ci` + `npm audit --omit=dev --audit-level=high` for API and web.
|
|
||||||
- Updated checkout action from broad major tag to explicit release tag `v4.2.2`.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
1. `.gitea/workflows/deploy.yml`
|
|
||||||
2. `api/package-lock.json`
|
|
||||||
3. `web/package-lock.json`
|
|
||||||
4. `api/tests/software-supply-chain-failures.test.ts`
|
|
||||||
5. `api/vitest.security.config.ts`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### Production dependency vulnerability scans
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npm audit --omit=dev --audit-level=high
|
|
||||||
cd ../web
|
|
||||||
npm audit --omit=dev --audit-level=high
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- `found 0 vulnerabilities` (api)
|
|
||||||
- `found 0 vulnerabilities` (web)
|
|
||||||
|
|
||||||
### Workflow policy verification (automated)
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npx vitest run -c vitest.security.config.ts tests/software-supply-chain-failures.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- Test Files: `1 passed (1)`
|
|
||||||
- Tests: `2 passed (2)`
|
|
||||||
|
|
||||||
Coverage in policy suite:
|
|
||||||
|
|
||||||
1. Deploy workflow includes dependency gate step for API and web.
|
|
||||||
2. Workflow requires `npm ci` and `npm audit --omit=dev --audit-level=high` for both projects.
|
|
||||||
3. `actions/checkout` remains pinned to an explicit release tag.
|
|
||||||
|
|
||||||
## Residual risks (not yet fully eliminated)
|
|
||||||
|
|
||||||
1. Base image tags are still mutable (`node:20-bookworm-slim`, `postgres:15`) and not digest-pinned.
|
|
||||||
2. `actions/checkout` is pinned to a release tag, not a full commit SHA.
|
|
||||||
3. No artifact signing/attestation verification (e.g., cosign/SLSA) in current deploy pipeline.
|
|
||||||
|
|
||||||
## Recommended next hardening steps
|
|
||||||
|
|
||||||
1. Pin container images by immutable digest in `Dockerfile`/`docker-compose.yml`.
|
|
||||||
2. Pin workflow actions to full commit SHAs.
|
|
||||||
3. Add SBOM generation and signature/attestation verification before deploy.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# A04: Cryptographic Failures
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
1. Production origin could be configured as non-HTTPS.
|
|
||||||
2. JWT configuration did not explicitly constrain issuer/audience/algorithm.
|
|
||||||
3. Missing explicit env contract for JWT issuer/audience values.
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. Production HTTPS origin enforcement:
|
|
||||||
- Added production guard requiring `APP_ORIGIN` to start with `https://`.
|
|
||||||
|
|
||||||
2. JWT hardening in Fastify:
|
|
||||||
- Explicit signing algorithm set to `HS256`.
|
|
||||||
- JWT signing includes configured `iss` and `aud`.
|
|
||||||
- JWT verification enforces:
|
|
||||||
- allowed algorithm (`HS256`)
|
|
||||||
- allowed issuer
|
|
||||||
- allowed audience
|
|
||||||
|
|
||||||
3. Env schema expanded:
|
|
||||||
- Added `JWT_ISSUER` and `JWT_AUDIENCE` with secure defaults:
|
|
||||||
- `skymoney-api`
|
|
||||||
- `skymoney-web`
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
1. `api/src/env.ts`
|
|
||||||
2. `api/src/server.ts`
|
|
||||||
3. `.env.example`
|
|
||||||
4. `api/tests/cryptographic-failures.test.ts`
|
|
||||||
5. `api/tests/cryptographic-failures.runtime.test.ts`
|
|
||||||
6. `api/vitest.security.config.ts`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npx vitest run -c vitest.security.config.ts tests/cryptographic-failures.test.ts tests/cryptographic-failures.runtime.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- Test Files: `2 passed (2)`
|
|
||||||
- Tests: `7 passed (7)`
|
|
||||||
|
|
||||||
Dedicated A04 checks in `cryptographic-failures.test.ts`:
|
|
||||||
|
|
||||||
1. Production env rejects non-HTTPS `APP_ORIGIN`.
|
|
||||||
2. JWT issuer/audience defaults resolve as expected.
|
|
||||||
3. Fastify JWT plugin is registered with explicit algorithm + issuer + audience constraints.
|
|
||||||
|
|
||||||
Runtime adversarial checks in `cryptographic-failures.runtime.test.ts`:
|
|
||||||
|
|
||||||
1. Token with invalid issuer is rejected (`401`).
|
|
||||||
2. Token with invalid audience is rejected (`401`).
|
|
||||||
3. Unsigned token (`alg=none`) is rejected (`401`).
|
|
||||||
4. Token with valid signature + expected issuer/audience is accepted on protected auth refresh route.
|
|
||||||
|
|
||||||
## Required production env values
|
|
||||||
|
|
||||||
1. `APP_ORIGIN=https://...`
|
|
||||||
2. `JWT_SECRET` strong 32+ chars
|
|
||||||
3. `COOKIE_SECRET` strong 32+ chars
|
|
||||||
4. `JWT_ISSUER=skymoney-api` (or your approved value)
|
|
||||||
5. `JWT_AUDIENCE=skymoney-web` (or your approved value)
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# A05: Injection
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
1. API route used unsafe Prisma raw SQL helper (`$queryRawUnsafe`) for `/health/db`.
|
|
||||||
2. Restore script accepted unvalidated external inputs that could be abused for command/SQL injection scenarios during operational use.
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. Replaced unsafe raw SQL helper:
|
|
||||||
- `app.prisma.$queryRawUnsafe("SELECT now() as now")`
|
|
||||||
- replaced with tagged, parameter-safe:
|
|
||||||
- `app.prisma.$queryRaw\`SELECT now() as now\``
|
|
||||||
|
|
||||||
2. Hardened `scripts/restore.sh` input handling:
|
|
||||||
- Added required file existence check for `BACKUP_FILE`.
|
|
||||||
- Added strict identifier validation for `RESTORE_DB` (`^[A-Za-z0-9_]+$`).
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
1. `api/src/server.ts`
|
|
||||||
2. `scripts/restore.sh`
|
|
||||||
3. `api/tests/injection-safety.test.ts`
|
|
||||||
4. `api/vitest.security.config.ts`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npx vitest run -c vitest.security.config.ts tests/injection-safety.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- Test Files: `1 passed (1)`
|
|
||||||
- Tests: `2 passed (2)`
|
|
||||||
|
|
||||||
Dedicated A05 checks in `injection-safety.test.ts`:
|
|
||||||
|
|
||||||
1. Verifies no usage of `$queryRawUnsafe` / `$executeRawUnsafe` across API source files.
|
|
||||||
2. Executes `scripts/restore.sh` with adversarial `RESTORE_DB` input and verifies restore is rejected before DB commands execute.
|
|
||||||
|
|
||||||
## Residual notes
|
|
||||||
|
|
||||||
1. Main API query paths use Prisma query builder + zod input schemas (reduces SQL injection risk).
|
|
||||||
2. Operational scripts should remain restricted to trusted operators and environments.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# A06: Insecure Design
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
1. Sensitive email-token workflows did not enforce a cooldown between repeated code requests.
|
|
||||||
2. Verification and account-deletion flows needed tighter, route-specific throttling to reduce brute-force and abuse risk.
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. Added explicit email-token cooldown guard in API:
|
|
||||||
- New helper `assertEmailTokenCooldown(userId, type, cooldownMs)`.
|
|
||||||
- Throws structured `429` error with code `EMAIL_TOKEN_COOLDOWN`.
|
|
||||||
- Sets `Retry-After` header when cooldown is active.
|
|
||||||
|
|
||||||
2. Applied cooldown checks to both token issuance paths:
|
|
||||||
- `/auth/verify/resend` for signup verification codes.
|
|
||||||
- `/account/delete-request` for account deletion confirmation codes.
|
|
||||||
|
|
||||||
3. Split and applied stricter rate-limit profiles for sensitive auth/account routes:
|
|
||||||
- `authRateLimit` on `/auth/register` and `/auth/login`.
|
|
||||||
- `codeVerificationRateLimit` on `/auth/verify` and `/account/confirm-delete`.
|
|
||||||
- `codeIssueRateLimit` on `/auth/verify/resend` and `/account/delete-request`.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
1. `api/src/server.ts`
|
|
||||||
2. `api/tests/insecure-design.test.ts`
|
|
||||||
3. `api/vitest.security.config.ts`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npx vitest --run -c vitest.security.config.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- Test Files: `4 passed (4)`
|
|
||||||
- Tests: `10 passed (10)`
|
|
||||||
|
|
||||||
Dedicated A06 checks in `insecure-design.test.ts`:
|
|
||||||
|
|
||||||
1. Runtime verification resend endpoint enforces cooldown (`/auth/register` issues token, then immediate `/auth/verify/resend` is blocked with `429` + `Retry-After`).
|
|
||||||
2. Runtime verification delete-request endpoint enforces cooldown (`/account/delete-request` second attempt returns `429` + `Retry-After`).
|
|
||||||
3. Runtime verification repeated invalid `/auth/verify` requests trigger route throttling (`429`).
|
|
||||||
|
|
||||||
## Residual notes
|
|
||||||
|
|
||||||
1. A06 runtime tests require PostgreSQL availability for user/token persistence paths.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# A07: Identification and Authentication Failures
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
1. No explicit account lockout after repeated failed login attempts (brute-force risk).
|
|
||||||
2. Password policy for registration and password updates was too weak (length-only).
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. Added login lockout controls:
|
|
||||||
- Tracks failed login attempts per normalized email in server memory.
|
|
||||||
- Locks login for a configurable window after threshold failures.
|
|
||||||
- Returns `429` with code `LOGIN_LOCKED` and `Retry-After` header during lockout.
|
|
||||||
|
|
||||||
2. Added strong password policy:
|
|
||||||
- Minimum length `12`.
|
|
||||||
- Requires lowercase, uppercase, number, and symbol.
|
|
||||||
- Applied to:
|
|
||||||
- `/auth/register` password.
|
|
||||||
- `/me/password` new password.
|
|
||||||
|
|
||||||
3. Added auth hardening configuration:
|
|
||||||
- `AUTH_MAX_FAILED_ATTEMPTS` (default: `5`)
|
|
||||||
- `AUTH_LOCKOUT_WINDOW_MS` (default: `900000`, 15 minutes)
|
|
||||||
|
|
||||||
4. Added forgot-password hardening:
|
|
||||||
- Public reset request endpoint always returns a generic success response.
|
|
||||||
- Reset token issuance is restricted to verified users.
|
|
||||||
- Reset confirmation enforces strong password policy and one-time expiring token usage.
|
|
||||||
- Successful reset updates `passwordChangedAt` so existing sessions become invalid.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
1. `api/src/server.ts`
|
|
||||||
2. `api/src/env.ts`
|
|
||||||
3. `.env.example`
|
|
||||||
4. `api/tests/auth.routes.test.ts`
|
|
||||||
5. `api/tests/identification-auth-failures.test.ts`
|
|
||||||
6. `api/vitest.security.config.ts`
|
|
||||||
7. `api/tests/forgot-password.security.test.ts`
|
|
||||||
8. `api/prisma/schema.prisma`
|
|
||||||
9. `api/prisma/migrations/20260302000000_add_password_changed_at/migration.sql`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Dedicated security suite command (executed):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npx vitest --run -c vitest.security.config.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- Test Files: `5 passed (5)`
|
|
||||||
- Tests: `12 passed (12)`
|
|
||||||
|
|
||||||
Dedicated A07 checks in `identification-auth-failures.test.ts`:
|
|
||||||
|
|
||||||
1. Runtime checks weak password rejection for registration and `/me/password` update flow.
|
|
||||||
2. Runtime checks lockout threshold/window behavior with configured `AUTH_MAX_FAILED_ATTEMPTS` and verifies `LOGIN_LOCKED` response + `Retry-After`.
|
|
||||||
|
|
||||||
Runtime auth flow checks added in `auth.routes.test.ts`:
|
|
||||||
|
|
||||||
1. Rejects weak passwords on registration.
|
|
||||||
2. Locks login after repeated failed attempts.
|
|
||||||
|
|
||||||
Run this in an environment with PostgreSQL running to verify runtime behavior:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npm test -- tests/auth.routes.test.ts tests/identification-auth-failures.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Residual notes
|
|
||||||
|
|
||||||
1. Current lockout state is in-memory per API instance; for horizontally scaled production, move lockout tracking to a shared store (Redis/DB) for consistent enforcement across instances.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# A08: Software and Data Integrity Failures
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
1. Backup/restore workflow did not verify backup artifact integrity before restoring.
|
|
||||||
2. Restores could proceed with tampered/corrupted dump files, risking silent data corruption.
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. Added checksum artifact generation during backups:
|
|
||||||
- `scripts/backup.sh` now generates a SHA-256 checksum file next to each dump (`.sha256`).
|
|
||||||
|
|
||||||
2. Added checksum verification before restore:
|
|
||||||
- `scripts/restore.sh` now requires `${BACKUP_FILE}.sha256`.
|
|
||||||
- Validates checksum format (64 hex chars).
|
|
||||||
- Computes runtime SHA-256 of backup file and blocks restore on mismatch.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
1. `scripts/backup.sh`
|
|
||||||
2. `scripts/restore.sh`
|
|
||||||
3. `api/tests/software-data-integrity-failures.test.ts`
|
|
||||||
4. `api/vitest.security.config.ts`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npx vitest run -c vitest.security.config.ts tests/software-data-integrity-failures.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- Test Files: `1 passed (1)`
|
|
||||||
- Tests: `2 passed (2)`
|
|
||||||
|
|
||||||
Dedicated A08 checks in `software-data-integrity-failures.test.ts`:
|
|
||||||
|
|
||||||
1. Executes `scripts/backup.sh` with stubbed `pg_dump` and verifies dump + `.sha256` artifact generation.
|
|
||||||
2. Executes `scripts/restore.sh` with tampered checksum and verifies restore is blocked before DB commands are invoked.
|
|
||||||
|
|
||||||
## Residual notes
|
|
||||||
|
|
||||||
1. This secures backup artifact integrity in operational scripts.
|
|
||||||
2. For CI/CD artifact integrity hardening, next step is attestation/signature verification for deployed build artifacts.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# A09: Security Logging and Monitoring Failures
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
1. Security-sensitive auth/account outcomes were not consistently logged as structured audit events.
|
|
||||||
2. Incident triage required better request correlation for failed auth/CSRF and account-deletion attempts.
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. Added centralized structured security logging helper in API:
|
|
||||||
- `logSecurityEvent(req, event, outcome, details)`
|
|
||||||
- Includes request correlation fields (`requestId`, `ip`, `userAgent`).
|
|
||||||
|
|
||||||
2. Added audit logging for critical security events:
|
|
||||||
- `auth.unauthenticated_request` (JWT auth failure)
|
|
||||||
- `csrf.validation` (CSRF check failure)
|
|
||||||
- `auth.register` success/blocked
|
|
||||||
- `auth.login` success/failure/blocked (including lockout cases)
|
|
||||||
- `auth.logout` success
|
|
||||||
- `auth.verify` success/failure
|
|
||||||
- `auth.verify_resend` success/failure/blocked
|
|
||||||
- `auth.password_reset.request` success/blocked
|
|
||||||
- `auth.password_reset.email` success/failure
|
|
||||||
- `auth.password_reset.confirm` success/failure
|
|
||||||
- `account.delete_request` success/failure/blocked
|
|
||||||
- `account.confirm_delete` success/failure/blocked
|
|
||||||
|
|
||||||
3. Reduced sensitive data exposure in logs:
|
|
||||||
- Added email fingerprinting (`sha256` prefix) for event context instead of plain-text credentials.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
1. `api/src/server.ts`
|
|
||||||
2. `api/tests/security-logging-monitoring-failures.test.ts`
|
|
||||||
3. `api/vitest.security.config.ts`
|
|
||||||
4. `api/tests/forgot-password.security.test.ts`
|
|
||||||
5. `SECURITY_FORGOT_PASSWORD.md`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npx vitest run -c vitest.security.config.ts tests/security-logging-monitoring-failures.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- Test Files: `1 passed (1)`
|
|
||||||
- Tests: `2 passed (2)`
|
|
||||||
|
|
||||||
Dedicated A09 checks in `security-logging-monitoring-failures.test.ts`:
|
|
||||||
|
|
||||||
1. Runtime check emits structured `auth.unauthenticated_request` security event for protected-route access failures.
|
|
||||||
2. Runtime check emits structured `csrf.validation` security event for CSRF failures.
|
|
||||||
3. Validates correlation fields (`requestId`, `ip`, `outcome`) are present in emitted security events.
|
|
||||||
4. Runtime check emits `auth.password_reset.request` events and confirms raw token fields are absent.
|
|
||||||
|
|
||||||
## Residual notes
|
|
||||||
|
|
||||||
1. Event logs are currently emitted through app logs; ensure production log shipping/alerting (e.g., SIEM rules on repeated `auth.login` failure/blocked events).
|
|
||||||
2. Next step for A09 maturity is alert thresholds and automated incident notifications.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# A10: Server-Side Request Forgery (SSRF)
|
|
||||||
|
|
||||||
Last updated: March 1, 2026
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
1. Production `APP_ORIGIN` previously enforced HTTPS but did not explicitly block localhost/private-network targets.
|
|
||||||
2. SSRF posture needed explicit verification that API runtime code does not introduce generic outbound HTTP clients for user-influenced targets.
|
|
||||||
|
|
||||||
## Fixes implemented
|
|
||||||
|
|
||||||
1. Hardened production `APP_ORIGIN` validation in env parsing:
|
|
||||||
- Requires valid URL format.
|
|
||||||
- Rejects localhost/private-network hosts:
|
|
||||||
- `localhost`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `0.0.0.0`, `.local`.
|
|
||||||
|
|
||||||
2. Added dedicated A10 verification tests:
|
|
||||||
- Rejects private/loopback `APP_ORIGIN` in production mode.
|
|
||||||
- Asserts API server source (`api/src/server.ts`) does not use generic outbound HTTP request clients (`fetch`, `axios`, `http.request`, `https.request`).
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
1. `api/src/env.ts`
|
|
||||||
2. `api/tests/server-side-request-forgery.test.ts`
|
|
||||||
3. `api/vitest.security.config.ts`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npx vitest run -c vitest.security.config.ts tests/server-side-request-forgery.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified output:
|
|
||||||
|
|
||||||
- Test Files: `1 passed (1)`
|
|
||||||
- Tests: `3 passed (3)`
|
|
||||||
|
|
||||||
Dedicated A10 checks in `server-side-request-forgery.test.ts`:
|
|
||||||
|
|
||||||
1. Asserts production env parsing rejects multiple private/localhost `APP_ORIGIN` variants.
|
|
||||||
2. Asserts production env parsing accepts public HTTPS `APP_ORIGIN`.
|
|
||||||
3. Asserts API source code has no generic outbound HTTP client usage (`fetch`, `axios`, `http.request`, `https.request`) outside test scripts.
|
|
||||||
|
|
||||||
## Residual notes
|
|
||||||
|
|
||||||
1. Current API architecture has minimal outbound HTTP surface (primarily SMTP transport).
|
|
||||||
2. If future features add URL fetch/proxy/webhook integrations, enforce strict destination allowlists and network egress controls at implementation time.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# OWASP Test Results
|
|
||||||
|
|
||||||
Last updated: March 2, 2026
|
|
||||||
|
|
||||||
This directory is the source of truth for SkyMoney OWASP validation work.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
- Track implemented security tests and hardening changes.
|
|
||||||
- Define exact pre-deploy and post-deploy verification steps.
|
|
||||||
- Keep release evidence (commands, outputs, timestamps, pass/fail).
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `A01-Broken-Access-Control.md`: Findings, fixes, and verification for OWASP A01.
|
|
||||||
- `A02-Security-Misconfiguration.md`: Findings, fixes, and dedicated verification suite for OWASP A02.
|
|
||||||
- `A03-Software-Supply-Chain-Failures.md`: Dependency and pipeline supply-chain findings/fixes/verification.
|
|
||||||
- `A04-Cryptographic-Failures.md`: Crypto/session token hardening findings/fixes/verification.
|
|
||||||
- `A05-Injection.md`: Injection sink remediation and script input hardening verification.
|
|
||||||
- `A06-Insecure-Design.md`: Abuse-resistance design hardening (cooldowns + tighter route throttling).
|
|
||||||
- `A07-Identification-and-Authentication-Failures.md`: Login lockout and strong-password policy hardening.
|
|
||||||
- `A08-Software-and-Data-Integrity-Failures.md`: Backup/restore checksum integrity controls.
|
|
||||||
- `A09-Security-Logging-and-Monitoring-Failures.md`: Structured security event auditing for auth/account flows.
|
|
||||||
- `A10-Server-Side-Request-Forgery.md`: SSRF hardening and outbound-request surface validation.
|
|
||||||
- `post-deployment-verification-checklist.md`: Production smoke checks after each deploy.
|
|
||||||
- `evidence-log-template.md`: Copy/paste template for recording each verification run.
|
|
||||||
- `residual-risk-backlog.md`: Open non-blocking hardening items tracked release-to-release.
|
|
||||||
- `../docs/production-db-recovery-runbook.md`: Incident response + recovery + admin bootstrap runbook.
|
|
||||||
|
|
||||||
## Current status
|
|
||||||
|
|
||||||
1. A01 complete: implemented and tested.
|
|
||||||
2. A02 complete: implemented and tested.
|
|
||||||
3. A03 complete (initial hardening): implemented and tested.
|
|
||||||
4. A04 complete: implemented and tested.
|
|
||||||
5. A05 complete: implemented and tested.
|
|
||||||
6. A06 complete: implemented and tested.
|
|
||||||
7. A07 complete: implemented and tested.
|
|
||||||
8. A08 complete: implemented and tested.
|
|
||||||
9. A09 complete: implemented and tested.
|
|
||||||
10. A10 complete: implemented and tested.
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# OWASP Verification Evidence Log Template
|
|
||||||
|
|
||||||
## Run metadata
|
|
||||||
|
|
||||||
- Date:
|
|
||||||
- Environment: `local` | `staging` | `production`
|
|
||||||
- App/API version (git SHA):
|
|
||||||
- Operator:
|
|
||||||
- Incident/reference ticket (if recovery event):
|
|
||||||
|
|
||||||
## Environment flags
|
|
||||||
|
|
||||||
- `NODE_ENV`:
|
|
||||||
- `AUTH_DISABLED`:
|
|
||||||
- `ALLOW_INSECURE_AUTH_FOR_DEV`:
|
|
||||||
- `COMPOSE_PROJECT_NAME`:
|
|
||||||
- `EXPECTED_PROD_DB_HOST`:
|
|
||||||
- `EXPECTED_PROD_DB_NAME`:
|
|
||||||
|
|
||||||
## Commands executed
|
|
||||||
|
|
||||||
1.
|
|
||||||
```bash
|
|
||||||
# command
|
|
||||||
```
|
|
||||||
Output summary:
|
|
||||||
|
|
||||||
2.
|
|
||||||
```bash
|
|
||||||
# command
|
|
||||||
```
|
|
||||||
Output summary:
|
|
||||||
|
|
||||||
3.
|
|
||||||
```bash
|
|
||||||
# command
|
|
||||||
```
|
|
||||||
Output summary:
|
|
||||||
|
|
||||||
4.
|
|
||||||
```bash
|
|
||||||
# command
|
|
||||||
```
|
|
||||||
Output summary:
|
|
||||||
|
|
||||||
## Recoverability Evidence
|
|
||||||
|
|
||||||
- Current Postgres container:
|
|
||||||
- Mounted volume(s):
|
|
||||||
- Candidate old volume(s) inspected:
|
|
||||||
- Recoverable artifact found: `yes` | `no`
|
|
||||||
- Artifact location:
|
|
||||||
- Recovery decision:
|
|
||||||
|
|
||||||
## Backup/Restore Drill Evidence
|
|
||||||
|
|
||||||
- Latest backup file:
|
|
||||||
- Latest checksum file:
|
|
||||||
- Checksum verified: `yes` | `no`
|
|
||||||
- Restore test DB name:
|
|
||||||
- Restore succeeded: `yes` | `no`
|
|
||||||
- Row count checks performed:
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
- A01 protected route unauthenticated check: `pass` | `fail`
|
|
||||||
- A01 spoofed header check: `pass` | `fail`
|
|
||||||
- A01 admin rollover exposure check: `pass` | `fail`
|
|
||||||
- A01 automated suite (`auth` + `account-delete` + `admin-rollover`): `pass` | `fail`
|
|
||||||
- A02 dedicated suite (`security-misconfiguration`): `pass` | `fail`
|
|
||||||
- A03 dedicated suite (`software-supply-chain-failures`): `pass` | `fail`
|
|
||||||
- A04 dedicated suites (`cryptographic-failures*`): `pass` | `fail`
|
|
||||||
- A05 dedicated suite (`injection-safety`): `pass` | `fail`
|
|
||||||
- A06 dedicated suite (`insecure-design`): `pass` | `fail`
|
|
||||||
- A07 dedicated suites (`auth.routes` + `identification-auth-failures`): `pass` | `fail`
|
|
||||||
- A08 dedicated suite (`software-data-integrity-failures`): `pass` | `fail`
|
|
||||||
- A09 dedicated suite (`security-logging-monitoring-failures`): `pass` | `fail`
|
|
||||||
- A10 dedicated suite (`server-side-request-forgery`): `pass` | `fail`
|
|
||||||
- Non-DB security suite (`SECURITY_DB_TESTS=0`): `pass` | `fail`
|
|
||||||
- DB security suite (`SECURITY_DB_TESTS=1`): `pass` | `fail`
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
- New issues observed:
|
|
||||||
- Regressions observed:
|
|
||||||
- Follow-up tickets:
|
|
||||||
- Data recovery status:
|
|
||||||
- Admin user bootstrap status:
|
|
||||||
|
|
||||||
## Residual Risk Review
|
|
||||||
|
|
||||||
- Reviewed `residual-risk-backlog.md`: `yes` | `no`
|
|
||||||
- Items accepted for this release:
|
|
||||||
- Items escalated/blocked:
|
|
||||||
|
|
||||||
## Sign-off
|
|
||||||
|
|
||||||
- Security reviewer:
|
|
||||||
- Engineering owner:
|
|
||||||
- Decision: `approved` | `blocked`
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
# Post-Deployment Verification Checklist
|
|
||||||
|
|
||||||
Use this after every deploy (staging and production).
|
|
||||||
|
|
||||||
## Preconditions
|
|
||||||
|
|
||||||
1. Deployment completed successfully.
|
|
||||||
2. Migrations completed successfully.
|
|
||||||
3. Correct environment flags:
|
|
||||||
- `AUTH_DISABLED=false`
|
|
||||||
- `ALLOW_INSECURE_AUTH_FOR_DEV=false`
|
|
||||||
4. Test DB preflight (for DB-backed suites):
|
|
||||||
- `TEST_DATABASE_URL` points to a reachable PostgreSQL instance.
|
|
||||||
- `TEST_DATABASE_URL` database name is not `skymoney` and is clearly test-only (for example `skymoney_test`).
|
|
||||||
- `bash ./scripts/validate-test-db-target.sh` passes before DB-backed suites run.
|
|
||||||
- Example quick check:
|
|
||||||
```bash
|
|
||||||
echo "$TEST_DATABASE_URL"
|
|
||||||
```
|
|
||||||
Expected:
|
|
||||||
- single valid URL value
|
|
||||||
- host/port match the intended test database (for local runs usually `127.0.0.1:5432`)
|
|
||||||
5. Compose/DB safety preflight:
|
|
||||||
- `COMPOSE_PROJECT_NAME=skymoney` is set for deploy runtime.
|
|
||||||
- `docker-compose.yml` volume `pgdata` is pinned to `skymoney_pgdata`.
|
|
||||||
- `scripts/validate-prod-db-target.sh` passes for current `.env`.
|
|
||||||
- `scripts/guard-prod-volume.sh` passes (or explicit one-time rebuild override is documented).
|
|
||||||
- deploy runbook acknowledges forbidden destructive commands in prod:
|
|
||||||
- `prisma migrate reset`
|
|
||||||
- `prisma migrate dev`
|
|
||||||
- `prisma db push --accept-data-loss`
|
|
||||||
- `docker compose down -v` / `docker-compose down -v`
|
|
||||||
|
|
||||||
## Database recoverability and safety checks
|
|
||||||
|
|
||||||
### 0) Capture current container and volume bindings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker ps --format '{{.Names}}'
|
|
||||||
docker inspect <postgres-container> --format '{{json .Mounts}}'
|
|
||||||
docker volume ls | grep -E 'pgdata|skymoney|postgres'
|
|
||||||
PROD_DB_VOLUME_NAME=skymoney_pgdata ALLOW_EMPTY_PROD_VOLUME=0 DOCKER_CMD="sudo docker" bash ./scripts/guard-prod-volume.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- production Postgres uses `skymoney_pgdata`.
|
|
||||||
- no unexpected new empty volume silently substituted.
|
|
||||||
|
|
||||||
### 0.1) Validate latest backup artifact exists and verifies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -lt /opt/skymoney/backups | head
|
|
||||||
LATEST_DUMP="$(ls -1t /opt/skymoney/backups/*.dump | head -n 1)"
|
|
||||||
sha256sum -c "${LATEST_DUMP}.sha256"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- latest dump and checksum exist.
|
|
||||||
- checksum verification returns `OK`.
|
|
||||||
|
|
||||||
### 0.2) Restore drill into isolated test DB (same VPS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RESTORE_DB="skymoney_restore_test_$(date +%Y%m%d%H%M)" \
|
|
||||||
BACKUP_FILE="$LATEST_DUMP" \
|
|
||||||
RESTORE_DATABASE_URL="postgres://<user>:<pass>@127.0.0.1:5432/${RESTORE_DB}" \
|
|
||||||
DATABASE_URL="postgres://<admin-user>:<admin-pass>@127.0.0.1:5432/skymoney" \
|
|
||||||
./scripts/restore.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- restore completes without manual edits.
|
|
||||||
- key tables readable in restored DB.
|
|
||||||
|
|
||||||
## A01 smoke checks
|
|
||||||
|
|
||||||
Replace `${API_BASE}` with your deployed API base URL.
|
|
||||||
|
|
||||||
### 1) Protected route requires auth
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -i "${API_BASE}/dashboard"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- HTTP `401`
|
|
||||||
- response body includes `UNAUTHENTICATED`
|
|
||||||
|
|
||||||
### 2) Spoofed identity header is ignored
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -i -H "x-user-id: spoofed-user-id" "${API_BASE}/dashboard"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- HTTP `401`
|
|
||||||
|
|
||||||
### 3) Admin rollover is not publicly callable
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -i -X POST "${API_BASE}/admin/rollover" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"dryRun":true}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- HTTP `401` or `403` (must not be publicly callable)
|
|
||||||
|
|
||||||
## A09 smoke checks
|
|
||||||
|
|
||||||
### 4) Security events are emitted for failed auth attempts
|
|
||||||
|
|
||||||
Trigger a failed login attempt:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -i -X POST "${API_BASE}/auth/login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"nonexistent@example.com","password":"WrongPass123!"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- HTTP `401`
|
|
||||||
- API logs include a structured `securityEvent` for `auth.login` with `outcome=failure`
|
|
||||||
- log entry includes `requestId`
|
|
||||||
|
|
||||||
## A10 smoke checks
|
|
||||||
|
|
||||||
### 5) Production origin configuration is public and non-local
|
|
||||||
|
|
||||||
Verify production env/config:
|
|
||||||
|
|
||||||
- `APP_ORIGIN` uses public HTTPS host (not localhost/private IP ranges)
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- API boots successfully with production env validation.
|
|
||||||
|
|
||||||
## Automated regression checks
|
|
||||||
|
|
||||||
Run in CI against a prod-like environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npm test -- tests/auth.routes.test.ts tests/access-control.account-delete.test.ts tests/access-control.admin-rollover.test.ts
|
|
||||||
SECURITY_DB_TESTS=0 npx vitest run -c vitest.security.config.ts
|
|
||||||
SECURITY_DB_TESTS=1 npx vitest run -c vitest.security.config.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- all tests pass
|
|
||||||
|
|
||||||
Note:
|
|
||||||
- A06/A07 runtime suites require PostgreSQL availability.
|
|
||||||
- `SECURITY_DB_TESTS=0` runs non-DB security controls only.
|
|
||||||
- `SECURITY_DB_TESTS=1` includes DB-backed A06/A07/forgot-password suites.
|
|
||||||
|
|
||||||
## Sign-off
|
|
||||||
|
|
||||||
1. Record outputs in `evidence-log-template.md`.
|
|
||||||
2. Review open residual risks in `residual-risk-backlog.md`.
|
|
||||||
3. Record backup + restore drill evidence.
|
|
||||||
4. Mark release security check as pass/fail.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# OWASP Residual Risk Backlog
|
|
||||||
|
|
||||||
Last updated: March 2, 2026
|
|
||||||
|
|
||||||
Use this file to track non-blocking hardening items that remain after automated controls pass.
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
|
|
||||||
| ID | OWASP | Residual risk | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| RR-001 | A01 | Add explicit authorization integration tests for all future admin-only routes (deny-by-default coverage expansion). | Open |
|
|
||||||
| RR-002 | A02 | Add runtime CSP and full security-header verification from deployed edge stack (not only config checks). | Open |
|
|
||||||
| RR-003 | A03 | Add stronger supply-chain provenance controls (digest pinning, SLSA attestations, artifact signing). | Open |
|
|
||||||
| RR-004 | A04 | Add key rotation runbook validation and automated stale-key detection checks. | Open |
|
|
||||||
| RR-005 | A05 | Add static taint analysis or Semgrep policy bundle in CI for command/SQL injection sinks. | Open |
|
|
||||||
| RR-006 | A06 | Add abuse-case tests for account recovery and verification flows under distributed-IP pressure. | Open |
|
|
||||||
| RR-007 | A07 | Add MFA/WebAuthn roadmap tests once MFA is implemented; currently password+lockout only. | Open |
|
|
||||||
| RR-008 | A08 | Add signed backup manifests and restore provenance verification for operational artifacts. | Open |
|
|
||||||
| RR-009 | A09 | Add alerting pipeline assertions (SIEM/webhook delivery) in pre-prod smoke tests. | Open |
|
|
||||||
| RR-010 | A10 | Add egress firewall enforcement tests to complement application-layer SSRF guards. | Open |
|
|
||||||
|
|
||||||
## Close criteria
|
|
||||||
|
|
||||||
1. A concrete control is implemented and validated by an automated test or policy gate.
|
|
||||||
2. Evidence is attached in `evidence-log-template.md`.
|
|
||||||
3. Owning team marks item as Closed with date and link to implementation PR.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Variable Pool Rebalance / Transfer Feature
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
- Allow users to redistribute current variable pool (BudgetSession.availableCents) by setting per-category dollar targets, without changing future percent-based allocations.
|
|
||||||
- Enforce guards: sum = available, savings floor confirm, non-negative, no single category >80%, warnings when lowering savings.
|
|
||||||
- New backend endpoint performs atomic balance updates and audit logging; fixed expenses unaffected.
|
|
||||||
|
|
||||||
## API
|
|
||||||
- `POST /variable-categories/manual-rebalance`
|
|
||||||
- Body: `{ targets: [{ id, targetCents }], forceLowerSavings?: boolean }`
|
|
||||||
- Uses latest BudgetSession by periodStart; availableCents is the pool to balance.
|
|
||||||
- Validations: targets cover every category; non-negative; sum(targets)=available; each ≤80% of available; lowering savings or savings <20% requires `forceLowerSavings`.
|
|
||||||
- Transaction: update category balanceCents to targets; insert transaction(kind=`rebalance`, note snapshot); fixed plans untouched.
|
|
||||||
- Response: `{ ok: true, availableCents, categories: [{ id, balanceCents }] }`.
|
|
||||||
|
|
||||||
## UI/UX
|
|
||||||
- New Rebalance page (Settings → Expenses tab entry point) showing availableCents and per-category balances.
|
|
||||||
- Editable dollar inputs with live total meter and inline errors for rule violations.
|
|
||||||
- Savings floor warning/confirm; optional helper to adjust one category and auto-scale others respecting floors.
|
|
||||||
- Confirmation modal summarizing before/after deltas.
|
|
||||||
|
|
||||||
## Data / Logic
|
|
||||||
- Active session = latest BudgetSession for user.
|
|
||||||
- Rebalance acts on current variable pool only; future income remains percent-based.
|
|
||||||
- Savings floor default 20% of available; lowering requires confirmation flag.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- Sum=available happy path; savings unchanged.
|
|
||||||
- Lowering savings w/out flag → 400; with flag → OK.
|
|
||||||
- Savings total <20% w/out flag → 400; with flag → OK.
|
|
||||||
- >80% single category → 400.
|
|
||||||
- Sum mismatch → 400; negative target → 400.
|
|
||||||
- Negative existing balance allowed only if target >=0 (ending non-negative).
|
|
||||||
- Adjust-one helper unit: scaling respects floors.
|
|
||||||
- Audit entry created; fixed plans and percents unchanged.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
- availableCents equals dashboard “Available” variable pool.
|
|
||||||
- No localization requirements for new errors.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
// web/src/pages/settings/CategoriesPage.tsx
|
|
||||||
import { useMemo, useState, useEffect, type FormEvent } from "react";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
arrayMove,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import { useDashboard } from "../../hooks/useDashboard";
|
|
||||||
import { Money } from "../../components/ui";
|
|
||||||
import SettingsNav from "./_SettingsNav";
|
|
||||||
import {
|
|
||||||
useCategories,
|
|
||||||
useCreateCategory,
|
|
||||||
useUpdateCategory,
|
|
||||||
useDeleteCategory,
|
|
||||||
} from "../../hooks/useCategories";
|
|
||||||
import { useToast } from "../../components/Toast";
|
|
||||||
|
|
||||||
type Row = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
percent: number;
|
|
||||||
priority: number;
|
|
||||||
isSavings: boolean;
|
|
||||||
balanceCents: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function SumBadge({ total }: { total: number }) {
|
|
||||||
const tone =
|
|
||||||
total === 100
|
|
||||||
? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"
|
|
||||||
: total < 100
|
|
||||||
? "bg-amber-500/10 text-amber-100 border border-amber-500/30"
|
|
||||||
: "bg-red-500/10 text-red-100 border border-red-500/40";
|
|
||||||
const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over";
|
|
||||||
return (
|
|
||||||
<div className={`badge ${tone}`}>
|
|
||||||
{label}: {total}%
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsCategoriesPage() {
|
|
||||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
|
||||||
const cats = useCategories() as Row[];
|
|
||||||
const createM = useCreateCategory();
|
|
||||||
const updateM = useUpdateCategory();
|
|
||||||
const deleteM = useDeleteCategory();
|
|
||||||
const { push } = useToast();
|
|
||||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
|
||||||
const MIN_SAVINGS_PERCENT = 20;
|
|
||||||
|
|
||||||
const total = useMemo(
|
|
||||||
() => cats.reduce((s, c) => s + c.percent, 0),
|
|
||||||
[cats],
|
|
||||||
);
|
|
||||||
const savingsTotal = useMemo(
|
|
||||||
() => cats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0),
|
|
||||||
[cats]
|
|
||||||
);
|
|
||||||
const remainingPercent = Math.max(0, 100 - total);
|
|
||||||
|
|
||||||
// Drag ordering state (initially from priority)
|
|
||||||
const [order, setOrder] = useState<string[]>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
const sorted = cats
|
|
||||||
.slice()
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
a.priority - b.priority || a.name.localeCompare(b.name),
|
|
||||||
);
|
|
||||||
const next = sorted.map((c) => c.id);
|
|
||||||
// Reset order when cats change in length or ids
|
|
||||||
if (
|
|
||||||
order.length !== next.length ||
|
|
||||||
next.some((id, i) => order[i] !== id)
|
|
||||||
) {
|
|
||||||
setOrder(next);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [cats.map((c) => c.id).join("|")]);
|
|
||||||
|
|
||||||
// Add form state
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [percent, setPercent] = useState("");
|
|
||||||
const [priority, setPriority] = useState("");
|
|
||||||
const [isSavings, setIsSavings] = useState(false);
|
|
||||||
|
|
||||||
const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0));
|
|
||||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
|
||||||
const addDisabled =
|
|
||||||
!name.trim() ||
|
|
||||||
parsedPercent <= 0 ||
|
|
||||||
parsedPercent > 100 ||
|
|
||||||
parsedPercent > remainingPercent ||
|
|
||||||
createM.isPending;
|
|
||||||
|
|
||||||
const onAdd = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const normalizedName = normalizeName(name);
|
|
||||||
if (cats.some((c) => normalizeName(c.name) === normalizedName)) {
|
|
||||||
push("err", `Expense name '${normalizedName}' already exists`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = {
|
|
||||||
name: normalizedName,
|
|
||||||
percent: parsedPercent,
|
|
||||||
priority: parsedPriority,
|
|
||||||
isSavings,
|
|
||||||
};
|
|
||||||
if (!body.name) return;
|
|
||||||
if (body.percent > remainingPercent) {
|
|
||||||
push("err", `Only ${remainingPercent}% is available right now.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextTotal = total + body.percent;
|
|
||||||
const nextSavingsTotal = savingsTotal + (body.isSavings ? body.percent : 0);
|
|
||||||
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
|
|
||||||
push(
|
|
||||||
"err",
|
|
||||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createM.mutate(body, {
|
|
||||||
onSuccess: () => {
|
|
||||||
push("ok", "Expense created");
|
|
||||||
setName("");
|
|
||||||
setPercent("");
|
|
||||||
setPriority("");
|
|
||||||
setIsSavings(false);
|
|
||||||
},
|
|
||||||
onError: (err: any) =>
|
|
||||||
push("err", err?.message ?? "Create failed"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEdit = (id: string, patch: Partial<Row>) => {
|
|
||||||
if (patch.name !== undefined) {
|
|
||||||
const normalizedName = normalizeName(patch.name);
|
|
||||||
if (
|
|
||||||
cats.some((c) => c.id !== id && normalizeName(c.name) === normalizedName)
|
|
||||||
) {
|
|
||||||
push("err", `Expense name '${normalizedName}' already exists`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
patch.name = normalizedName;
|
|
||||||
}
|
|
||||||
if (patch.percent !== undefined) {
|
|
||||||
const current = cats.find((c) => c.id === id);
|
|
||||||
if (!current) return;
|
|
||||||
const sanitized = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(100, Math.floor(patch.percent)),
|
|
||||||
);
|
|
||||||
const nextTotal = total - current.percent + sanitized;
|
|
||||||
if (nextTotal > 100) {
|
|
||||||
push("err", `Updating this would push totals to ${nextTotal}%.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
patch.percent = sanitized;
|
|
||||||
}
|
|
||||||
if (patch.priority !== undefined) {
|
|
||||||
patch.priority = Math.max(0, Math.floor(patch.priority));
|
|
||||||
}
|
|
||||||
if (patch.isSavings !== undefined || patch.percent !== undefined) {
|
|
||||||
const current = cats.find((c) => c.id === id);
|
|
||||||
if (!current) return;
|
|
||||||
const nextPercent = patch.percent ?? current.percent;
|
|
||||||
const wasSavings = current.isSavings ? current.percent : 0;
|
|
||||||
const nextIsSavings = patch.isSavings ?? current.isSavings;
|
|
||||||
const nextSavings = nextIsSavings ? nextPercent : 0;
|
|
||||||
const nextTotal =
|
|
||||||
total - current.percent + (patch.percent ?? current.percent);
|
|
||||||
const nextSavingsTotal =
|
|
||||||
savingsTotal - wasSavings + nextSavings;
|
|
||||||
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
|
|
||||||
push(
|
|
||||||
"err",
|
|
||||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateM.mutate(
|
|
||||||
{ id, body: patch },
|
|
||||||
{
|
|
||||||
onError: (err: any) =>
|
|
||||||
push("err", err?.message ?? "Update failed"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDelete = (id: string) => {
|
|
||||||
deleteM.mutate(id, {
|
|
||||||
onSuccess: () => push("ok", "Expense deleted"),
|
|
||||||
onError: (err: any) =>
|
|
||||||
push("err", err?.message ?? "Delete failed"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
setOrder((prev) => {
|
|
||||||
const oldIndex = prev.indexOf(String(active.id));
|
|
||||||
const newIndex = prev.indexOf(String(over.id));
|
|
||||||
const next = arrayMove(prev, oldIndex, newIndex);
|
|
||||||
// Apply new priorities to server (only changed ones)
|
|
||||||
const updates = onDragOrderApply(next);
|
|
||||||
updates.forEach(({ id, priority }) => {
|
|
||||||
const existing = cats.find((c) => c.id === id);
|
|
||||||
if (existing && existing.priority !== priority) {
|
|
||||||
updateM.mutate({ id, body: { priority } });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<div className="card max-w-2xl">
|
|
||||||
<SettingsNav />
|
|
||||||
<div className="muted">Loading…</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (error || !data) {
|
|
||||||
return (
|
|
||||||
<div className="card max-w-2xl">
|
|
||||||
<SettingsNav />
|
|
||||||
<p className="mb-3">Couldn't load expenses.</p>
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isFetching}
|
|
||||||
>
|
|
||||||
{isFetching ? "Retrying…" : "Retry"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 max-w-5xl">
|
|
||||||
<section className="card">
|
|
||||||
<SettingsNav />
|
|
||||||
|
|
||||||
<header className="mb-4 space-y-1">
|
|
||||||
<h1 className="text-lg font-semibold">Expenses</h1>
|
|
||||||
<p className="text-sm muted">
|
|
||||||
Decide how every dollar is divided. Percentages must always
|
|
||||||
add up to 100%.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Add form */}
|
|
||||||
<form
|
|
||||||
onSubmit={onAdd}
|
|
||||||
className="row gap-2 mb-4 flex-wrap items-end"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="input w-full sm:w-44"
|
|
||||||
placeholder="Name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input w-full sm:w-28"
|
|
||||||
placeholder="%"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={percent}
|
|
||||||
onChange={(e) => setPercent(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input w-full sm:w-28"
|
|
||||||
placeholder="Priority"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={priority}
|
|
||||||
onChange={(e) => setPriority(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label className="row w-full sm:w-auto">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSavings}
|
|
||||||
onChange={(e) => setIsSavings(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="muted text-sm">Savings</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="btn w-full sm:w-auto"
|
|
||||||
disabled={addDisabled || createM.isPending}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<div className="ml-auto text-right">
|
|
||||||
<SumBadge total={total} />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{cats.length === 0 ? (
|
|
||||||
<div className="muted text-sm mt-4">
|
|
||||||
No expenses yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DndContext
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
>
|
|
||||||
<table className="table mt-4">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>%</th>
|
|
||||||
<th>Priority</th>
|
|
||||||
<th>Savings</th>
|
|
||||||
<th>Balance</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<SortableContext
|
|
||||||
items={order}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<tbody>
|
|
||||||
{order
|
|
||||||
.map((id) => cats.find((c) => c.id === id))
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((c) => (
|
|
||||||
<SortableTr key={c!.id} id={c!.id}>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<span className="drag-handle inline-flex items-center justify-center w-6 h-6 rounded-full bg-[--color-panel] text-lg cursor-grab active:cursor-grabbing">
|
|
||||||
⋮⋮
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="rounded-l-[--radius-xl] px-3 py-2">
|
|
||||||
<InlineEditText
|
|
||||||
value={c!.name}
|
|
||||||
onChange={(v) =>
|
|
||||||
onEdit(c!.id, { name: v })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<InlineEditNumber
|
|
||||||
value={c!.percent}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
onChange={(v) =>
|
|
||||||
onEdit(c!.id, { percent: v })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<InlineEditNumber
|
|
||||||
value={c!.priority}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) =>
|
|
||||||
onEdit(c!.id, { priority: v })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<InlineEditCheckbox
|
|
||||||
checked={c!.isSavings}
|
|
||||||
onChange={(v) =>
|
|
||||||
onEdit(c!.id, { isSavings: v })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{c!.isSavings && (
|
|
||||||
<span className="badge ml-2 text-[10px] bg-emerald-500/10 text-emerald-200 border border-emerald-500/40">
|
|
||||||
Savings
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Money cents={c!.balanceCents ?? 0} />
|
|
||||||
</td>
|
|
||||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDelete(c!.id)}
|
|
||||||
disabled={deleteM.isPending}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</SortableTr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</SortableContext>
|
|
||||||
</table>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Guard if total != 100 */}
|
|
||||||
{total !== 100 && (
|
|
||||||
<div className="toast-err mt-3">
|
|
||||||
Percents must sum to <strong>100%</strong> for allocations.
|
|
||||||
Current total: {total}%.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- tiny inline editors --- */
|
|
||||||
function InlineEditText({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
const [v, setV] = useState(value);
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
useEffect(() => setV(value), [value]);
|
|
||||||
const commit = () => {
|
|
||||||
if (v !== value) onChange(v.trim());
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
return editing ? (
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={v}
|
|
||||||
onChange={(e) => setV(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="link inline-flex items-center gap-1"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
<span>{value}</span>
|
|
||||||
<span className="text-[10px] opacity-60">Edit</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlineEditNumber({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
min = 0,
|
|
||||||
max = Number.MAX_SAFE_INTEGER,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
onChange: (v: number) => void;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [v, setV] = useState(String(value));
|
|
||||||
useEffect(() => setV(String(value)), [value]);
|
|
||||||
const commit = () => {
|
|
||||||
const n = Math.max(
|
|
||||||
min,
|
|
||||||
Math.min(max, Math.floor(Number(v) || 0)),
|
|
||||||
);
|
|
||||||
if (n !== value) onChange(n);
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
return editing ? (
|
|
||||||
<input
|
|
||||||
className="input w-24 text-right"
|
|
||||||
value={v}
|
|
||||||
onChange={(e) => setV(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="link"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlineEditCheckbox({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="row">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="muted text-sm">
|
|
||||||
{checked ? "Yes" : "No"}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableTr({
|
|
||||||
id,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
} = useSortable({ id });
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
} as React.CSSProperties;
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className="sortable-row"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragOrderApply(ids: string[]) {
|
|
||||||
return ids.map((id, idx) => ({ id, priority: idx + 1 }));
|
|
||||||
}
|
|
||||||
@@ -1,747 +0,0 @@
|
|||||||
// web/src/pages/settings/PlansPage.tsx
|
|
||||||
import {
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
type FormEvent,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { useDashboard } from "../../hooks/useDashboard";
|
|
||||||
import SettingsNav from "./_SettingsNav";
|
|
||||||
import {
|
|
||||||
useCreatePlan,
|
|
||||||
useUpdatePlan,
|
|
||||||
useDeletePlan,
|
|
||||||
} from "../../hooks/useFixedPlans";
|
|
||||||
import { Money } from "../../components/ui";
|
|
||||||
import { useToast } from "../../components/Toast";
|
|
||||||
import { getTodayInTimezone, dateStringToUTCMidnight, isoToDateString, getBrowserTimezone, formatDateInTimezone } from "../../utils/timezone";
|
|
||||||
|
|
||||||
function daysUntil(iso: string, userTimezone: string) {
|
|
||||||
const today = getTodayInTimezone(userTimezone);
|
|
||||||
const due = isoToDateString(iso, userTimezone);
|
|
||||||
const todayDate = new Date(today);
|
|
||||||
const dueDate = new Date(due);
|
|
||||||
const diffMs = dueDate.getTime() - todayDate.getTime();
|
|
||||||
return Math.round(diffMs / (24 * 60 * 60 * 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
function DueBadge({ dueISO, userTimezone }: { dueISO: string; userTimezone: string }) {
|
|
||||||
const d = daysUntil(dueISO, userTimezone);
|
|
||||||
if (d < 0)
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="badge"
|
|
||||||
style={{ borderColor: "#7f1d1d" }}
|
|
||||||
>
|
|
||||||
Overdue
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
if (d <= 7) return <span className="badge">Due in {d}d</span>;
|
|
||||||
return (
|
|
||||||
<span className="badge" aria-hidden="true">
|
|
||||||
On track
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsPlansPage() {
|
|
||||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
|
||||||
const createM = useCreatePlan();
|
|
||||||
const updateM = useUpdatePlan();
|
|
||||||
const deleteM = useDeletePlan();
|
|
||||||
const { push } = useToast();
|
|
||||||
|
|
||||||
// Get user timezone from dashboard data
|
|
||||||
const userTimezone = data?.user?.timezone || getBrowserTimezone();
|
|
||||||
|
|
||||||
// Add form state
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [total, setTotal] = useState("");
|
|
||||||
const [funded, setFunded] = useState("");
|
|
||||||
const [priority, setPriority] = useState("");
|
|
||||||
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
|
||||||
|
|
||||||
// Auto-payment form state
|
|
||||||
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
|
||||||
const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "daily" | "custom">("monthly");
|
|
||||||
const [dayOfMonth, setDayOfMonth] = useState(1);
|
|
||||||
const [dayOfWeek, setDayOfWeek] = useState(0);
|
|
||||||
const [everyNDays, setEveryNDays] = useState(30);
|
|
||||||
const [minFundingPercent, setMinFundingPercent] = useState(100);
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
if (!data) return { funded: 0, total: 0, remaining: 0 };
|
|
||||||
const funded = data.fixedPlans.reduce(
|
|
||||||
(s, p) => s + p.fundedCents,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const total = data.fixedPlans.reduce(
|
|
||||||
(s, p) => s + p.totalCents,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
funded,
|
|
||||||
total,
|
|
||||||
remaining: Math.max(0, total - funded),
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const overallPctFunded = useMemo(() => {
|
|
||||||
if (!totals.total) return 0;
|
|
||||||
return Math.round((totals.funded / totals.total) * 100);
|
|
||||||
}, [totals.funded, totals.total]);
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<div className="card max-w-3xl">
|
|
||||||
<SettingsNav />
|
|
||||||
<div className="muted">Loading…</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return (
|
|
||||||
<div className="card max-w-3xl">
|
|
||||||
<SettingsNav />
|
|
||||||
<p className="mb-3">Couldn't load fixed expenses.</p>
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isFetching}
|
|
||||||
>
|
|
||||||
{isFetching ? "Retrying…" : "Retry"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onAdd = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const totalCents = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((parseFloat(total || "0")) * 100),
|
|
||||||
);
|
|
||||||
const fundedCents = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((parseFloat(funded || "0")) * 100),
|
|
||||||
);
|
|
||||||
const paymentSchedule = autoPayEnabled ? {
|
|
||||||
frequency,
|
|
||||||
...(frequency === "monthly" ? { dayOfMonth } : {}),
|
|
||||||
...(frequency === "weekly" || frequency === "biweekly" ? { dayOfWeek } : {}),
|
|
||||||
...(frequency === "custom" ? { everyNDays } : {}),
|
|
||||||
minFundingPercent,
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
name: name.trim(),
|
|
||||||
totalCents,
|
|
||||||
fundedCents: Math.min(fundedCents, totalCents),
|
|
||||||
priority: Math.max(
|
|
||||||
0,
|
|
||||||
Math.floor(Number(priority) || 0),
|
|
||||||
),
|
|
||||||
dueOn: dateStringToUTCMidnight(due, userTimezone),
|
|
||||||
autoPayEnabled,
|
|
||||||
paymentSchedule,
|
|
||||||
};
|
|
||||||
if (!body.name || totalCents <= 0) return;
|
|
||||||
createM.mutate(body, {
|
|
||||||
onSuccess: () => {
|
|
||||||
push("ok", "Plan created");
|
|
||||||
setName("");
|
|
||||||
setTotal("");
|
|
||||||
setFunded("");
|
|
||||||
setPriority("");
|
|
||||||
setDue(getTodayInTimezone(userTimezone));
|
|
||||||
setAutoPayEnabled(false);
|
|
||||||
setFrequency("monthly");
|
|
||||||
setDayOfMonth(1);
|
|
||||||
setDayOfWeek(0);
|
|
||||||
setEveryNDays(30);
|
|
||||||
setMinFundingPercent(100);
|
|
||||||
},
|
|
||||||
onError: (err: any) =>
|
|
||||||
push("err", err?.message ?? "Create failed"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEdit = (
|
|
||||||
id: string,
|
|
||||||
patch: Partial<{
|
|
||||||
name: string;
|
|
||||||
totalCents: number;
|
|
||||||
fundedCents: number;
|
|
||||||
priority: number;
|
|
||||||
dueOn: string;
|
|
||||||
}>,
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
"totalCents" in patch &&
|
|
||||||
"fundedCents" in patch &&
|
|
||||||
(patch.totalCents ?? 0) < (patch.fundedCents ?? 0)
|
|
||||||
) {
|
|
||||||
patch.fundedCents = patch.totalCents;
|
|
||||||
}
|
|
||||||
updateM.mutate(
|
|
||||||
{ id, body: patch },
|
|
||||||
{
|
|
||||||
onSuccess: () => push("ok", "Plan updated"),
|
|
||||||
onError: (err: any) =>
|
|
||||||
push("err", err?.message ?? "Update failed"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDelete = (id: string) => {
|
|
||||||
deleteM.mutate(id, {
|
|
||||||
onSuccess: () => push("ok", "Plan deleted"),
|
|
||||||
onError: (err: any) =>
|
|
||||||
push("err", err?.message ?? "Delete failed"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addDisabled =
|
|
||||||
!name || !total || createM.isPending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 max-w-5xl">
|
|
||||||
<section className="card">
|
|
||||||
<SettingsNav />
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<header className="mb-4 space-y-1">
|
|
||||||
<h1 className="text-lg font-semibold">
|
|
||||||
Fixed expenses
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm muted">
|
|
||||||
Long-term goals and obligations you’re funding over
|
|
||||||
time.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* KPI strip */}
|
|
||||||
<div className="grid gap-2 sm:grid-cols-4 mb-4">
|
|
||||||
<KpiCard label="Funded">
|
|
||||||
<Money cents={totals.funded} />
|
|
||||||
</KpiCard>
|
|
||||||
<KpiCard label="Total">
|
|
||||||
<Money cents={totals.total} />
|
|
||||||
</KpiCard>
|
|
||||||
<KpiCard label="Remaining">
|
|
||||||
<Money cents={totals.remaining} />
|
|
||||||
</KpiCard>
|
|
||||||
<KpiCard label="Overall progress">
|
|
||||||
<span className="text-xl font-semibold">
|
|
||||||
{overallPctFunded}%
|
|
||||||
</span>
|
|
||||||
</KpiCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overall progress bar */}
|
|
||||||
<div className="mb-4 space-y-1">
|
|
||||||
<div className="row text-xs muted">
|
|
||||||
<span>All fixed expenses funded</span>
|
|
||||||
<span className="ml-auto">
|
|
||||||
{overallPctFunded}% of target
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-[--color-accent]"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, overallPctFunded)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add form */}
|
|
||||||
<form
|
|
||||||
onSubmit={onAdd}
|
|
||||||
className="row gap-2 mb-4 flex-wrap items-end"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="input w-full sm:w-48"
|
|
||||||
placeholder="Name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input w-full sm:w-28"
|
|
||||||
placeholder="Total $"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
value={total}
|
|
||||||
onChange={(e) => setTotal(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input w-full sm:w-28"
|
|
||||||
placeholder="Funded $"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
value={funded}
|
|
||||||
onChange={(e) => setFunded(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input w-full sm:w-24"
|
|
||||||
placeholder="Priority"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={priority}
|
|
||||||
onChange={(e) => setPriority(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input w-full sm:w-40"
|
|
||||||
type="date"
|
|
||||||
value={due}
|
|
||||||
onChange={(e) => setDue(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label className="row gap-2 items-center text-sm cursor-pointer px-3 py-2 rounded-lg bg-[--color-panel] w-full sm:w-auto">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoPayEnabled}
|
|
||||||
onChange={(e) => setAutoPayEnabled(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Auto-fund</span>
|
|
||||||
</label>
|
|
||||||
<button className="btn w-full sm:w-auto" disabled={addDisabled}>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Auto-payment configuration */}
|
|
||||||
{autoPayEnabled && (
|
|
||||||
<div className="card bg-[--color-panel] p-4 mb-4">
|
|
||||||
<h4 className="section-title text-sm mb-3">Auto-Fund Schedule</h4>
|
|
||||||
<div className="row gap-4 flex-wrap items-end">
|
|
||||||
<label className="stack text-sm">
|
|
||||||
<span className="muted text-xs">Frequency</span>
|
|
||||||
<select
|
|
||||||
className="input w-32"
|
|
||||||
value={frequency}
|
|
||||||
onChange={(e) => setFrequency(e.target.value as any)}
|
|
||||||
>
|
|
||||||
<option value="weekly">Weekly</option>
|
|
||||||
<option value="biweekly">Biweekly</option>
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
<option value="custom">Custom</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{(frequency === "weekly" || frequency === "biweekly") && (
|
|
||||||
<label className="stack text-sm">
|
|
||||||
<span className="muted text-xs">Day of Week</span>
|
|
||||||
<select
|
|
||||||
className="input w-32"
|
|
||||||
value={dayOfWeek}
|
|
||||||
onChange={(e) => setDayOfWeek(Number(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value={0}>Sunday</option>
|
|
||||||
<option value={1}>Monday</option>
|
|
||||||
<option value={2}>Tuesday</option>
|
|
||||||
<option value={3}>Wednesday</option>
|
|
||||||
<option value={4}>Thursday</option>
|
|
||||||
<option value={5}>Friday</option>
|
|
||||||
<option value={6}>Saturday</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{frequency === "monthly" && (
|
|
||||||
<label className="stack text-sm">
|
|
||||||
<span className="muted text-xs">Day of Month</span>
|
|
||||||
<input
|
|
||||||
className="input w-24"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="31"
|
|
||||||
value={dayOfMonth}
|
|
||||||
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{frequency === "custom" && (
|
|
||||||
<label className="stack text-sm">
|
|
||||||
<span className="muted text-xs">Every N Days</span>
|
|
||||||
<input
|
|
||||||
className="input w-24"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={everyNDays}
|
|
||||||
onChange={(e) => setEveryNDays(Number(e.target.value) || 30)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="stack text-sm">
|
|
||||||
<span className="muted text-xs">Min. Funding %</span>
|
|
||||||
<input
|
|
||||||
className="input w-24"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={minFundingPercent}
|
|
||||||
onChange={(e) => setMinFundingPercent(Math.max(0, Math.min(100, Number(e.target.value) || 0)))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs muted mt-2">
|
|
||||||
Automatic payments will only occur if the expense is funded to at least the minimum percentage.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
{data.fixedPlans.length === 0 ? (
|
|
||||||
<div className="muted text-sm">
|
|
||||||
No fixed expenses yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Due</th>
|
|
||||||
<th>Priority</th>
|
|
||||||
<th>Funded</th>
|
|
||||||
<th>Total</th>
|
|
||||||
<th>Remaining</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Auto-Pay</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.fixedPlans
|
|
||||||
.slice()
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
a.priority - b.priority ||
|
|
||||||
new Date(a.dueOn).getTime() -
|
|
||||||
new Date(b.dueOn).getTime(),
|
|
||||||
)
|
|
||||||
.map((p) => {
|
|
||||||
const remaining = Math.max(
|
|
||||||
0,
|
|
||||||
p.totalCents - p.fundedCents,
|
|
||||||
);
|
|
||||||
const pctFunded = p.totalCents
|
|
||||||
? Math.round(
|
|
||||||
(p.fundedCents / p.totalCents) * 100,
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
return (
|
|
||||||
<tr key={p.id}>
|
|
||||||
<td className="rounded-l-[--radius-xl] px-3 py-2">
|
|
||||||
<InlineEditText
|
|
||||||
value={p.name}
|
|
||||||
onChange={(v) =>
|
|
||||||
onEdit(p.id, { name: v })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<InlineEditDate
|
|
||||||
value={p.dueOn}
|
|
||||||
timezone={userTimezone}
|
|
||||||
onChange={(iso) =>
|
|
||||||
onEdit(p.id, { dueOn: iso })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<InlineEditNumber
|
|
||||||
value={p.priority}
|
|
||||||
min={0}
|
|
||||||
onChange={(n) =>
|
|
||||||
onEdit(p.id, { priority: n })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<InlineEditMoney
|
|
||||||
valueCents={p.fundedCents}
|
|
||||||
onChange={(cents) =>
|
|
||||||
onEdit(p.id, {
|
|
||||||
fundedCents: Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(cents, p.totalCents),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="row text-xs muted">
|
|
||||||
<span>{pctFunded}% funded</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<InlineEditMoney
|
|
||||||
valueCents={p.totalCents}
|
|
||||||
onChange={(cents) =>
|
|
||||||
onEdit(p.id, {
|
|
||||||
totalCents: Math.max(cents, 0),
|
|
||||||
fundedCents: Math.min(
|
|
||||||
p.fundedCents,
|
|
||||||
cents,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FundingBar
|
|
||||||
pct={pctFunded}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Money cents={remaining} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<DueBadge dueISO={p.dueOn} userTimezone={userTimezone} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className={`text-xs px-2 py-1 rounded ${
|
|
||||||
p.autoPayEnabled
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{p.autoPayEnabled ? 'Enabled' : 'Disabled'}
|
|
||||||
</span>
|
|
||||||
{p.autoPayEnabled && p.paymentSchedule && (
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{p.paymentSchedule.frequency === 'custom'
|
|
||||||
? `Every ${p.paymentSchedule.customDays} days`
|
|
||||||
: p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1)
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDelete(p.id)}
|
|
||||||
disabled={deleteM.isPending}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Small presentational helpers --- */
|
|
||||||
|
|
||||||
function KpiCard({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="card kpi">
|
|
||||||
<h3>{label}</h3>
|
|
||||||
<div className="val">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FundingBar({ pct }: { pct: number }) {
|
|
||||||
const clamped = Math.min(100, Math.max(0, pct));
|
|
||||||
return (
|
|
||||||
<div className="h-1.5 w-full rounded-full bg-[--color-panel] overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-[--color-accent]"
|
|
||||||
style={{ width: `${clamped}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Inline editors (same behavior, slightly nicer UX) --- */
|
|
||||||
|
|
||||||
function InlineEditText({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [v, setV] = useState(value);
|
|
||||||
useEffect(() => setV(value), [value]);
|
|
||||||
const commit = () => {
|
|
||||||
const t = v.trim();
|
|
||||||
if (t && t !== value) onChange(t);
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
return editing ? (
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={v}
|
|
||||||
onChange={(e) => setV(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="link inline-flex items-center gap-1"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
<span>{value}</span>
|
|
||||||
<span className="text-[10px] opacity-60">Edit</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlineEditNumber({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
min = 0,
|
|
||||||
max = Number.MAX_SAFE_INTEGER,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
onChange: (v: number) => void;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [v, setV] = useState(String(value));
|
|
||||||
useEffect(() => setV(String(value)), [value]);
|
|
||||||
const commit = () => {
|
|
||||||
const n = Math.max(
|
|
||||||
min,
|
|
||||||
Math.min(max, Math.floor(Number(v) || 0)),
|
|
||||||
);
|
|
||||||
if (n !== value) onChange(n);
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
return editing ? (
|
|
||||||
<input
|
|
||||||
className="input w-24 text-right"
|
|
||||||
value={v}
|
|
||||||
onChange={(e) => setV(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="link"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlineEditMoney({
|
|
||||||
valueCents,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
valueCents: number;
|
|
||||||
onChange: (cents: number) => void;
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [v, setV] = useState((valueCents / 100).toFixed(2));
|
|
||||||
useEffect(
|
|
||||||
() => setV((valueCents / 100).toFixed(2)),
|
|
||||||
[valueCents],
|
|
||||||
);
|
|
||||||
const commit = () => {
|
|
||||||
const cents = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((parseFloat(v || "0")) * 100),
|
|
||||||
);
|
|
||||||
if (cents !== valueCents) onChange(cents);
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
return editing ? (
|
|
||||||
<input
|
|
||||||
className="input w-28 text-right font-mono"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min={0}
|
|
||||||
value={v}
|
|
||||||
onChange={(e) => setV(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="link font-mono"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
{(valueCents / 100).toFixed(2)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlineEditDate({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
timezone,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (iso: string) => void;
|
|
||||||
timezone: string;
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [v, setV] = useState(
|
|
||||||
isoToDateString(value, timezone),
|
|
||||||
);
|
|
||||||
useEffect(
|
|
||||||
() =>
|
|
||||||
setV(
|
|
||||||
isoToDateString(value, timezone),
|
|
||||||
),
|
|
||||||
[value, timezone],
|
|
||||||
);
|
|
||||||
const commit = () => {
|
|
||||||
const iso = dateStringToUTCMidnight(v, timezone);
|
|
||||||
if (iso !== value) onChange(iso);
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
return editing ? (
|
|
||||||
<input
|
|
||||||
className="input w-40"
|
|
||||||
type="date"
|
|
||||||
value={v}
|
|
||||||
onChange={(e) => setV(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="link"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
{formatDateInTimezone(value, timezone)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { NavLink } from "react-router-dom";
|
|
||||||
export default function SettingsNav() {
|
|
||||||
const link = (to: string, label: string) =>
|
|
||||||
<NavLink to={to} className={({isActive}) => `link ${isActive ? "link-active" : ""}`}>{label}</NavLink>;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 mb-3 sm:flex-row sm:items-center">
|
|
||||||
<h2 className="section-title m-0">Settings</h2>
|
|
||||||
<div className="flex flex-wrap gap-1 sm:ml-auto">
|
|
||||||
{link("/settings/categories", "Expenses")}
|
|
||||||
{link("/settings/plans", "Fixed Expenses")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
:root {
|
|
||||||
--color-bg: #0b0c10;
|
|
||||||
--color-panel: #111318;
|
|
||||||
--color-fg: #e7e9ee;
|
|
||||||
--color-ink: #2a2e37;
|
|
||||||
--color-accent: #5dd6b2;
|
|
||||||
--radius-xl: 12px;
|
|
||||||
--radius-lg: 10px;
|
|
||||||
--radius-md: 8px;
|
|
||||||
--shadow-1: 0 6px 20px rgba(0,0,0,0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body, #root { height: 100%; }
|
|
||||||
body { margin: 0; background: var(--color-bg); color: var(--color-fg); font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
|
||||||
a { color: inherit; text-decoration: none; }
|
|
||||||
|
|
||||||
.container { width: min(1100px, 100vww); margin-inline: auto; padding: 0 16px; }
|
|
||||||
.muted { opacity: 0.7; }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--color-panel);
|
|
||||||
border: 1px solid var(--color-ink);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: 16px;
|
|
||||||
box-shadow: var(--shadow-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row { display: flex; align-items: center; }
|
|
||||||
.stack { display: grid; gap: 12px; }
|
|
||||||
|
|
||||||
.section-title { font-weight: 700; font-size: 16px; margin-bottom: 10px; }
|
|
||||||
|
|
||||||
.input {
|
|
||||||
background: #0f1116;
|
|
||||||
color: var(--color-fg);
|
|
||||||
border: 1px solid var(--color-ink);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 8px 10px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.input:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
background: var(--color-accent);
|
|
||||||
color: #062016;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn[disabled] { opacity: 0.5; cursor: default; }
|
|
||||||
.badge {
|
|
||||||
background: var(--color-ink);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table { width: 100%; border-collapse: separate; border-spacing: 0 8px; }
|
|
||||||
.table thead th {
|
|
||||||
text-align: left; font-size: 12px; opacity: 0.7; padding: 0 8px;
|
|
||||||
}
|
|
||||||
.table tbody tr {
|
|
||||||
background: var(--color-panel);
|
|
||||||
border: 1px solid var(--color-ink);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
}
|
|
||||||
.table td { padding: 8px; }
|
|
||||||
|
|
||||||
.toast-err {
|
|
||||||
background: #b21d2a;
|
|
||||||
color: white;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border { border: 1px solid var(--color-ink); }
|
|
||||||
.rounded-xl { border-radius: var(--radius-xl); }
|
|
||||||
.divide-y > * + * { border-top: 1px solid var(--color-ink); }
|
|
||||||
|
|
||||||
/* utility-ish */
|
|
||||||
.w-44 { width: 11rem; }
|
|
||||||
.w-56 { width: 14rem; }
|
|
||||||
.w-40 { width: 10rem; }
|
|
||||||
.ml-auto { margin-left: auto; }
|
|
||||||
.mt-2 { margin-top: 0.5rem; }
|
|
||||||
.mt-3 { margin-top: 0.75rem; }
|
|
||||||
.mt-4 { margin-top: 1rem; }
|
|
||||||
.mb-3 { margin-bottom: 0.75rem; }
|
|
||||||
.gap-2 { gap: 0.5rem; }
|
|
||||||
.gap-4 { gap: 1rem; }
|
|
||||||
.flex-wrap { flex-wrap: wrap; }
|
|
||||||
.text-sm { font-size: 0.875rem; }
|
|
||||||
.text-xl { font-size: 1.25rem; }
|
|
||||||
.font-semibold { font-weight: 600; }
|
|
||||||
.font-bold { font-weight: 700; }
|
|
||||||
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
|
||||||
.bg-\[--color-ink\] { background: var(--color-ink); }
|
|
||||||
.bg-\[--color-ink\]\/60 { background: color-mix(in oklab, var(--color-ink), transparent 40%); }
|
|
||||||
.bg-\[--color-panel\] { background: var(--color-panel); }
|
|
||||||
.text-\[--color-fg\] { color: var(--color-fg); }
|
|
||||||
.border-\[--color-ink\] { border-color: var(--color-ink); }
|
|
||||||
.rounded-\[--radius-xl\] { border-radius: var(--radius-xl); }
|
|
||||||
.p-2 { padding: 0.5rem; }
|
|
||||||
.p-3 { padding: 0.75rem; }
|
|
||||||
.p-4 { padding: 1rem; }
|
|
||||||
.p-6 { padding: 1.5rem; }
|
|
||||||
.py-8 { padding-block: 2rem; }
|
|
||||||
.h-14 { height: 3.5rem; }
|
|
||||||
.text-center { text-align: center; }
|
|
||||||
.text-right { text-align: right; }
|
|
||||||
.opacity-70 { opacity: .7; }
|
|
||||||
.grid { display: grid; }
|
|
||||||
.md\:grid-cols-2 { grid-template-columns: 1fr; }
|
|
||||||
.md\:grid-cols-3 { grid-template-columns: 1fr; }
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.md\:grid-cols-2 { grid-template-columns: 1fr 1fr; }
|
|
||||||
.md\:grid-cols-3 { grid-template-columns: 1fr 1fr 1fr; }
|
|
||||||
}
|
|
||||||
.shadow-sm { box-shadow: 0 2px 12px rgba(0,0,0,0.2); }
|
|
||||||
.underline { text-decoration: underline; }
|
|
||||||
.fixed { position: fixed; }
|
|
||||||
.bottom-4 { bottom: 1rem; }
|
|
||||||
.left-1\/2 { left: 50%; }
|
|
||||||
.-translate-x-1\/2 { transform: translateX(-50%); }
|
|
||||||
.z-50 { z-index: 50; }
|
|
||||||
.space-y-2 > * + * { margin-top: .5rem; }
|
|
||||||
.space-y-8 > * + * { margin-top: 2rem; }
|
|
||||||
.space-y-6 > * + * { margin-top: 1.5rem; }
|
|
||||||
.overflow-x-auto { overflow-x: auto; }
|
|
||||||
Reference in New Issue
Block a user