From 9c7f4d51398774f45015573acee1f2b049268fb8 Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Sat, 21 Mar 2026 17:30:11 -0500 Subject: [PATCH] removed unneccesary files --- .env.localdev | 4 +- .vscode/settings.json | 8 - SECURITY_FORGOT_PASSWORD.md | 46 - TIMEZONE_ROLLOVER_EXPLAINED.md | 121 - api/GET | 0 api/allocator | 0 api/check-allocations.cjs | 34 - api/check-overdue.cjs | 49 - api/clients/ts/sdk.ts | 238 -- api/create-multi-overdue-test.cjs | 135 - api/create-multi-overdue-user.cjs | 133 - api/create-test-user.cjs | 91 - api/distributes | 0 api/handles | 0 api/openapi.yaml | 552 ----- api/paginates | 0 api/pnpm-lock.yaml | 2173 ----------------- api/src/allocator.ts | 24 +- api/src/jobs/auto-payments.ts | 36 +- api/src/jobs/rollover.ts | 6 +- api/src/routes/dashboard.ts | 5 +- api/src/routes/fixed-plans.ts | 13 +- api/src/routes/variable-categories.ts | 69 +- api/src/server.ts | 194 +- api/test-income-overdue.sh | 25 - api/test-monthly-income.cjs | 228 -- api/test-overdue-api.sh | 41 - api/test-overdue-payment.cjs | 133 - api/test-simple.sh | 19 - api/tests/helpers.ts | 4 +- api/tests/payment-rollover.test.ts | 21 +- api/tests/rollover.test.ts | 6 +- api/tests/variable-categories.guard.test.ts | 7 + ...riable-categories.manual-rebalance.test.ts | 24 +- backups/skymoney_2026-01-16_204729.dump | 0 backups/skymoney_2026-01-16_205044.dump | 0 backups/skymoney_2026-01-16_205117.dump | 0 backups/skymoney_2026-01-16_205351.dump | 0 backups/skymoney_2026-01-16_205426.dump | 0 backups/skymoney_2026-01-16_205501.dump | 0 backups/skymoney_2026-01-16_205541.dump | 0 backups/skymoney_2026-01-16_205559.dump | 0 backups/skymoney_2026-01-16_205816.dump | 0 backups/skymoney_2026-01-16_205911.dump | 0 backups/skymoney_2026-01-16_210013.dump | 0 backups/skymoney_2026-01-16_210031.dump | 0 backups/skymoney_2026-01-16_210033.dump | 0 backups/skymoney_2026-01-16_210035.dump | 0 backups/skymoney_2026-01-16_210056.dump | Bin 58366 -> 0 bytes backups/skymoney_2026-01-16_210413.dump | 0 backups/skymoney_2026-01-16_210432.dump | 0 backups/skymoney_2026-01-16_210619.dump | Bin 58366 -> 0 bytes bash.exe.stackdump | 55 - cookies.txt | 5 - cookies2.txt | 5 - cookies_debug.txt | 5 - cookies_fixed.txt | 5 - cookies_immediate.txt | 5 - cookies_login.txt | 5 - cookies_test.txt | 5 - docs/api-phase1-move-log.md | 61 - docs/api-phase2-move-log.md | 75 - docs/api-phase3-move-log.md | 65 - docs/api-phase4-move-log.md | 57 - docs/api-phase5-move-log.md | 121 - docs/api-phase6-move-log.md | 100 - docs/api-phase7-move-log.md | 48 - docs/api-phase8-move-log.md | 70 - docs/api-refactor-lightweight-plan.md | 168 -- exporting | 0 irregular_cookies.txt | 5 - package-lock.json | 6 - regular_cookies.txt | 5 - .../A01-Broken-Access-Control.md | 62 - .../A02-Security-Misconfiguration.md | 89 - .../A03-Software-Supply-Chain-Failures.md | 77 - .../A04-Cryptographic-Failures.md | 71 - tests-results-for-OWASP/A05-Injection.md | 50 - .../A06-Insecure-Design.md | 54 - ...ntification-and-Authentication-Failures.md | 79 - ...08-Software-and-Data-Integrity-Failures.md | 49 - ...ecurity-Logging-and-Monitoring-Failures.md | 65 - .../A10-Server-Side-Request-Forgery.md | 50 - tests-results-for-OWASP/README.md | 41 - .../evidence-log-template.md | 100 - .../post-deployment-verification-checklist.md | 161 -- .../residual-risk-backlog.md | 26 - transfer-rebalance-spec.md | 39 - web/README.md | 73 - web/src/pages/settings/CategoriesPage.tsx | 552 ----- web/src/pages/settings/PlansPage.tsx | 747 ------ web/src/pages/settings/_SettingsNav.tsx | 14 - web/src/styles.css.bak | 132 - 93 files changed, 107 insertions(+), 7734 deletions(-) delete mode 100644 .vscode/settings.json delete mode 100644 SECURITY_FORGOT_PASSWORD.md delete mode 100644 TIMEZONE_ROLLOVER_EXPLAINED.md delete mode 100644 api/GET delete mode 100644 api/allocator delete mode 100644 api/check-allocations.cjs delete mode 100644 api/check-overdue.cjs delete mode 100644 api/clients/ts/sdk.ts delete mode 100644 api/create-multi-overdue-test.cjs delete mode 100644 api/create-multi-overdue-user.cjs delete mode 100644 api/create-test-user.cjs delete mode 100644 api/distributes delete mode 100644 api/handles delete mode 100644 api/openapi.yaml delete mode 100644 api/paginates delete mode 100644 api/pnpm-lock.yaml delete mode 100644 api/test-income-overdue.sh delete mode 100644 api/test-monthly-income.cjs delete mode 100644 api/test-overdue-api.sh delete mode 100644 api/test-overdue-payment.cjs delete mode 100644 api/test-simple.sh delete mode 100644 backups/skymoney_2026-01-16_204729.dump delete mode 100644 backups/skymoney_2026-01-16_205044.dump delete mode 100644 backups/skymoney_2026-01-16_205117.dump delete mode 100644 backups/skymoney_2026-01-16_205351.dump delete mode 100644 backups/skymoney_2026-01-16_205426.dump delete mode 100644 backups/skymoney_2026-01-16_205501.dump delete mode 100644 backups/skymoney_2026-01-16_205541.dump delete mode 100644 backups/skymoney_2026-01-16_205559.dump delete mode 100644 backups/skymoney_2026-01-16_205816.dump delete mode 100644 backups/skymoney_2026-01-16_205911.dump delete mode 100644 backups/skymoney_2026-01-16_210013.dump delete mode 100644 backups/skymoney_2026-01-16_210031.dump delete mode 100644 backups/skymoney_2026-01-16_210033.dump delete mode 100644 backups/skymoney_2026-01-16_210035.dump delete mode 100644 backups/skymoney_2026-01-16_210056.dump delete mode 100644 backups/skymoney_2026-01-16_210413.dump delete mode 100644 backups/skymoney_2026-01-16_210432.dump delete mode 100644 backups/skymoney_2026-01-16_210619.dump delete mode 100644 bash.exe.stackdump delete mode 100644 cookies.txt delete mode 100644 cookies2.txt delete mode 100644 cookies_debug.txt delete mode 100644 cookies_fixed.txt delete mode 100644 cookies_immediate.txt delete mode 100644 cookies_login.txt delete mode 100644 cookies_test.txt delete mode 100644 docs/api-phase1-move-log.md delete mode 100644 docs/api-phase2-move-log.md delete mode 100644 docs/api-phase3-move-log.md delete mode 100644 docs/api-phase4-move-log.md delete mode 100644 docs/api-phase5-move-log.md delete mode 100644 docs/api-phase6-move-log.md delete mode 100644 docs/api-phase7-move-log.md delete mode 100644 docs/api-phase8-move-log.md delete mode 100644 docs/api-refactor-lightweight-plan.md delete mode 100644 exporting delete mode 100644 irregular_cookies.txt delete mode 100644 package-lock.json delete mode 100644 regular_cookies.txt delete mode 100644 tests-results-for-OWASP/A01-Broken-Access-Control.md delete mode 100644 tests-results-for-OWASP/A02-Security-Misconfiguration.md delete mode 100644 tests-results-for-OWASP/A03-Software-Supply-Chain-Failures.md delete mode 100644 tests-results-for-OWASP/A04-Cryptographic-Failures.md delete mode 100644 tests-results-for-OWASP/A05-Injection.md delete mode 100644 tests-results-for-OWASP/A06-Insecure-Design.md delete mode 100644 tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md delete mode 100644 tests-results-for-OWASP/A08-Software-and-Data-Integrity-Failures.md delete mode 100644 tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md delete mode 100644 tests-results-for-OWASP/A10-Server-Side-Request-Forgery.md delete mode 100644 tests-results-for-OWASP/README.md delete mode 100644 tests-results-for-OWASP/evidence-log-template.md delete mode 100644 tests-results-for-OWASP/post-deployment-verification-checklist.md delete mode 100644 tests-results-for-OWASP/residual-risk-backlog.md delete mode 100644 transfer-rebalance-spec.md delete mode 100644 web/README.md delete mode 100644 web/src/pages/settings/CategoriesPage.tsx delete mode 100644 web/src/pages/settings/PlansPage.tsx delete mode 100644 web/src/pages/settings/_SettingsNav.tsx delete mode 100644 web/src/styles.css.bak diff --git a/.env.localdev b/.env.localdev index 669409c..3aa99a4 100644 --- a/.env.localdev +++ b/.env.localdev @@ -22,11 +22,11 @@ COOKIE_SECRET= PYjozZs+CxkU+In/FX/EI/5SB5ETAEw2AzCAF+G4Zgc= # Leave unset in local dev so host-only cookie is used. # COOKIE_DOMAIN= AUTH_DISABLED=false -ALLOW_INSECURE_AUTH_FOR_DEV=false +ALLOW_INSECURE_AUTH_FOR_DEV=true SEED_DEFAULT_BUDGET=false BREAK_GLASS_VERIFY_ENABLED=true BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ EMAIL_VERIFY_DEV_CODE_EXPOSE=true UNDER_CONSTRUCTION_ENABLED=false -NODE_ENV=development \ No newline at end of file +NODE_ENV=development \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 3e605e6..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "css.lint.unknownAtRules": "ignore", - "scss.lint.unknownAtRules": "ignore", - "less.lint.unknownAtRules": "ignore", - "cSpell.words": [ - "skymoney" - ] -} \ No newline at end of file diff --git a/SECURITY_FORGOT_PASSWORD.md b/SECURITY_FORGOT_PASSWORD.md deleted file mode 100644 index 1c772f3..0000000 --- a/SECURITY_FORGOT_PASSWORD.md +++ /dev/null @@ -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. diff --git a/TIMEZONE_ROLLOVER_EXPLAINED.md b/TIMEZONE_ROLLOVER_EXPLAINED.md deleted file mode 100644 index f57cf6e..0000000 --- a/TIMEZONE_ROLLOVER_EXPLAINED.md +++ /dev/null @@ -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 -``` diff --git a/api/GET b/api/GET deleted file mode 100644 index e69de29..0000000 diff --git a/api/allocator b/api/allocator deleted file mode 100644 index e69de29..0000000 diff --git a/api/check-allocations.cjs b/api/check-allocations.cjs deleted file mode 100644 index a674173..0000000 --- a/api/check-allocations.cjs +++ /dev/null @@ -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(); diff --git a/api/check-overdue.cjs b/api/check-overdue.cjs deleted file mode 100644 index b76b177..0000000 --- a/api/check-overdue.cjs +++ /dev/null @@ -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(); diff --git a/api/clients/ts/sdk.ts b/api/clients/ts/sdk.ts deleted file mode 100644 index d192413..0000000 --- a/api/clients/ts/sdk.ts +++ /dev/null @@ -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 { - 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( - method: "GET" | "POST" | "PATCH" | "DELETE", - path: string, - body?: unknown, - query?: Record, - headers?: Record - ): Promise { - const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`; - const h: Record = { ...(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("GET", "/dashboard"), - }; - - // ---- Income - income = { - preview: (amountCents: number) => - this.request("POST", "/income/preview", { amountCents }), - create: (amountCents: number) => - this.request("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("GET", "/transactions", undefined, args), - create: (payload: { - kind: TransactionKind; - amountCents: number; - occurredAtISO: string; - categoryId?: string; - planId?: string; - }) => this.request("POST", "/transactions", payload), - }; - - // ---- Variable Categories - variableCategories = { - create: (payload: { - name: string; - percent: number; - isSavings: boolean; - priority: number; - }) => this.request("POST", "/variable-categories", payload), - - update: (id: string, patch: Partial<{ - name: string; - percent: number; - isSavings: boolean; - priority: number; - }>) => this.request("PATCH", `/variable-categories/${encodeURIComponent(id)}`, patch), - - delete: (id: string) => - this.request("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("POST", "/fixed-plans", payload), - - update: (id: string, patch: Partial<{ - name: string; - totalCents: number; - fundedCents: number; - priority: number; - dueOn: string; - cycleStart: string; - }>) => this.request("PATCH", `/fixed-plans/${encodeURIComponent(id)}`, patch), - - delete: (id: string) => - this.request("DELETE", `/fixed-plans/${encodeURIComponent(id)}`), - }; -} - -// ---------- helpers ---------- -function safeJson(s: string) { - try { return JSON.parse(s) } catch { return s } -} diff --git a/api/create-multi-overdue-test.cjs b/api/create-multi-overdue-test.cjs deleted file mode 100644 index 6869931..0000000 --- a/api/create-multi-overdue-test.cjs +++ /dev/null @@ -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(); diff --git a/api/create-multi-overdue-user.cjs b/api/create-multi-overdue-user.cjs deleted file mode 100644 index 693c273..0000000 --- a/api/create-multi-overdue-user.cjs +++ /dev/null @@ -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(); diff --git a/api/create-test-user.cjs b/api/create-test-user.cjs deleted file mode 100644 index 48ad302..0000000 --- a/api/create-test-user.cjs +++ /dev/null @@ -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(); diff --git a/api/distributes b/api/distributes deleted file mode 100644 index e69de29..0000000 diff --git a/api/handles b/api/handles deleted file mode 100644 index e69de29..0000000 diff --git a/api/openapi.yaml b/api/openapi.yaml deleted file mode 100644 index 55d3812..0000000 --- a/api/openapi.yaml +++ /dev/null @@ -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 diff --git a/api/paginates b/api/paginates deleted file mode 100644 index e69de29..0000000 diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml deleted file mode 100644 index 0931c23..0000000 --- a/api/pnpm-lock.yaml +++ /dev/null @@ -1,2173 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@fastify/cookie': - specifier: ^11.0.2 - version: 11.0.2 - '@fastify/cors': - specifier: ^10.1.0 - version: 10.1.0 - '@fastify/jwt': - specifier: ^10.0.0 - version: 10.0.0 - '@fastify/rate-limit': - specifier: ^10.3.0 - version: 10.3.0 - '@prisma/client': - specifier: ^5.22.0 - version: 5.22.0(prisma@5.22.0) - argon2: - specifier: ^0.40.1 - version: 0.40.3 - date-fns-tz: - specifier: ^3.2.0 - version: 3.2.0(date-fns@4.1.0) - fastify: - specifier: ^5.6.2 - version: 5.6.2 - node-cron: - specifier: ^4.2.1 - version: 4.2.1 - zod: - specifier: ^3.23.8 - version: 3.25.76 - devDependencies: - '@types/node': - specifier: ^20.19.25 - version: 20.19.25 - '@types/supertest': - specifier: ^6.0.3 - version: 6.0.3 - prisma: - specifier: ^5.22.0 - version: 5.22.0 - supertest: - specifier: ^6.3.4 - version: 6.3.4 - tsx: - specifier: ^4.20.6 - version: 4.20.6 - typescript: - specifier: ^5.6.3 - version: 5.9.3 - vitest: - specifier: ^2.1.3 - version: 2.1.9(@types/node@20.19.25) - -packages: - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@fastify/ajv-compiler@4.0.5': - resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - - '@fastify/cookie@11.0.2': - resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} - - '@fastify/cors@10.1.0': - resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} - - '@fastify/error@4.2.0': - resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - - '@fastify/fast-json-stringify-compiler@5.0.3': - resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} - - '@fastify/forwarded@3.0.1': - resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} - - '@fastify/jwt@10.0.0': - resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==} - - '@fastify/merge-json-schemas@0.2.1': - resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - - '@fastify/proxy-addr@5.1.0': - resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - - '@fastify/rate-limit@10.3.0': - resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@lukeed/ms@2.0.2': - resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} - engines: {node: '>=8'} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - - '@phc/format@1.0.0': - resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} - engines: {node: '>=10'} - - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - - '@prisma/client@5.22.0': - resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} - engines: {node: '>=16.13'} - peerDependencies: - prisma: '*' - peerDependenciesMeta: - prisma: - optional: true - - '@prisma/debug@5.22.0': - resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} - - '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': - resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} - - '@prisma/engines@5.22.0': - resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} - - '@prisma/fetch-engine@5.22.0': - resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} - - '@prisma/get-platform@5.22.0': - resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} - - '@rollup/rollup-android-arm-eabi@4.53.3': - resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.53.3': - resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.53.3': - resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.53.3': - resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.53.3': - resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.53.3': - resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.53.3': - resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.53.3': - resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.53.3': - resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.53.3': - resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.53.3': - resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.53.3': - resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.53.3': - resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openharmony-arm64@4.53.3': - resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.53.3': - resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.53.3': - resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.53.3': - resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.53.3': - resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} - cpu: [x64] - os: [win32] - - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - - '@types/node@20.19.25': - resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} - - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - - '@types/supertest@6.0.3': - resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} - - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} - - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} - - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - - abstract-logging@2.0.1: - resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - - argon2@0.40.3: - resolution: {integrity: sha512-FrSmz4VeM91jwFvvjsQv9GYp6o/kARWoYKjbjDB2U5io1H3e5X67PYGclFDeQff6UXIhUd4aHR3mxCdBbMMuQw==} - engines: {node: '>=16.17.0'} - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - asn1.js@5.4.1: - resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} - - avvio@9.1.0: - resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} - - bn.js@4.12.2: - resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} - engines: {node: '>=18'} - - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} - engines: {node: '>= 16'} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - - cookie@1.0.2: - resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} - engines: {node: '>=18'} - - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - - date-fns-tz@3.2.0: - resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} - peerDependencies: - date-fns: ^3.0.0 || ^4.0.0 - - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} - engines: {node: '>=12.0.0'} - - fast-decode-uri-component@1.0.1: - resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stringify@6.1.1: - resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==} - - fast-jwt@6.0.2: - resolution: {integrity: sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==} - engines: {node: '>=20'} - - fast-querystring@1.1.2: - resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fastfall@1.5.1: - resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} - engines: {node: '>=0.10.0'} - - fastify-plugin@5.1.0: - resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - - fastify@5.6.2: - resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} - - fastparallel@2.4.1: - resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} - - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - - fastseries@1.7.2: - resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} - - find-my-way@9.3.0: - resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} - engines: {node: '>=20'} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formidable@2.1.5: - resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ipaddr.js@2.2.0: - resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} - engines: {node: '>= 10'} - - json-schema-ref-resolver@3.0.0: - resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - light-my-request@6.6.0: - resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} - - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - - minimalistic-assert@1.0.1: - resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - - mnemonist@0.40.0: - resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-addon-api@8.5.0: - resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} - engines: {node: ^18 || ^20 || >= 21} - - node-cron@4.2.1: - resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} - engines: {node: '>=6.0.0'} - - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - obliterator@2.0.5: - resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} - - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - pino-abstract-transport@2.0.0: - resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} - - pino-std-serializers@7.0.0: - resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} - - pino@10.1.0: - resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==} - hasBin: true - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - prisma@5.22.0: - resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} - engines: {node: '>=16.13'} - hasBin: true - - process-warning@4.0.1: - resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} - engines: {node: '>=0.6'} - - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - ret@0.5.0: - resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} - engines: {node: '>=10'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - - rollup@4.53.3: - resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-regex2@5.0.0: - resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - steed@1.1.3: - resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} - - superagent@8.1.2: - resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} - engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net - - supertest@6.3.4: - resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} - engines: {node: '>=6.4.0'} - deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net - - thread-stream@3.1.0: - resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} - engines: {node: '>=14.0.0'} - - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} - engines: {node: '>=14.0.0'} - - toad-cache@3.7.0: - resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} - engines: {node: '>=12'} - - tsx@4.20.6: - resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} - engines: {node: '>=18.0.0'} - hasBin: true - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - -snapshots: - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@fastify/ajv-compiler@4.0.5': - dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - - '@fastify/cookie@11.0.2': - dependencies: - cookie: 1.0.2 - fastify-plugin: 5.1.0 - - '@fastify/cors@10.1.0': - dependencies: - fastify-plugin: 5.1.0 - mnemonist: 0.40.0 - - '@fastify/error@4.2.0': {} - - '@fastify/fast-json-stringify-compiler@5.0.3': - dependencies: - fast-json-stringify: 6.1.1 - - '@fastify/forwarded@3.0.1': {} - - '@fastify/jwt@10.0.0': - dependencies: - '@fastify/error': 4.2.0 - '@lukeed/ms': 2.0.2 - fast-jwt: 6.0.2 - fastify-plugin: 5.1.0 - steed: 1.1.3 - - '@fastify/merge-json-schemas@0.2.1': - dependencies: - dequal: 2.0.3 - - '@fastify/proxy-addr@5.1.0': - dependencies: - '@fastify/forwarded': 3.0.1 - ipaddr.js: 2.2.0 - - '@fastify/rate-limit@10.3.0': - dependencies: - '@lukeed/ms': 2.0.2 - fastify-plugin: 5.1.0 - toad-cache: 3.7.0 - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@lukeed/ms@2.0.2': {} - - '@noble/hashes@1.8.0': {} - - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - - '@phc/format@1.0.0': {} - - '@pinojs/redact@0.4.0': {} - - '@prisma/client@5.22.0(prisma@5.22.0)': - optionalDependencies: - prisma: 5.22.0 - - '@prisma/debug@5.22.0': {} - - '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} - - '@prisma/engines@5.22.0': - dependencies: - '@prisma/debug': 5.22.0 - '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 - '@prisma/fetch-engine': 5.22.0 - '@prisma/get-platform': 5.22.0 - - '@prisma/fetch-engine@5.22.0': - dependencies: - '@prisma/debug': 5.22.0 - '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 - '@prisma/get-platform': 5.22.0 - - '@prisma/get-platform@5.22.0': - dependencies: - '@prisma/debug': 5.22.0 - - '@rollup/rollup-android-arm-eabi@4.53.3': - optional: true - - '@rollup/rollup-android-arm64@4.53.3': - optional: true - - '@rollup/rollup-darwin-arm64@4.53.3': - optional: true - - '@rollup/rollup-darwin-x64@4.53.3': - optional: true - - '@rollup/rollup-freebsd-arm64@4.53.3': - optional: true - - '@rollup/rollup-freebsd-x64@4.53.3': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.53.3': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.53.3': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-x64-musl@4.53.3': - optional: true - - '@rollup/rollup-openharmony-arm64@4.53.3': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.53.3': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.53.3': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.53.3': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.53.3': - optional: true - - '@types/cookiejar@2.1.5': {} - - '@types/estree@1.0.8': {} - - '@types/methods@1.1.4': {} - - '@types/node@20.19.25': - dependencies: - undici-types: 6.21.0 - - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 20.19.25 - form-data: 4.0.5 - - '@types/supertest@6.0.3': - dependencies: - '@types/methods': 1.1.4 - '@types/superagent': 8.1.9 - - '@vitest/expect@2.1.9': - dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - tinyrainbow: 1.2.0 - - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.25))': - dependencies: - '@vitest/spy': 2.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@20.19.25) - - '@vitest/pretty-format@2.1.9': - dependencies: - tinyrainbow: 1.2.0 - - '@vitest/runner@2.1.9': - dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 - - '@vitest/snapshot@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - magic-string: 0.30.21 - pathe: 1.1.2 - - '@vitest/spy@2.1.9': - dependencies: - tinyspy: 3.0.2 - - '@vitest/utils@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - loupe: 3.2.1 - tinyrainbow: 1.2.0 - - abstract-logging@2.0.1: {} - - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - argon2@0.40.3: - dependencies: - '@phc/format': 1.0.0 - node-addon-api: 8.5.0 - node-gyp-build: 4.8.4 - - asap@2.0.6: {} - - asn1.js@5.4.1: - dependencies: - bn.js: 4.12.2 - inherits: 2.0.4 - minimalistic-assert: 1.0.1 - safer-buffer: 2.1.2 - - assertion-error@2.0.1: {} - - asynckit@0.4.0: {} - - atomic-sleep@1.0.0: {} - - avvio@9.1.0: - dependencies: - '@fastify/error': 4.2.0 - fastq: 1.19.1 - - bn.js@4.12.2: {} - - cac@6.7.14: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 - - check-error@2.1.1: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - component-emitter@1.3.1: {} - - cookie@1.0.2: {} - - cookiejar@2.1.4: {} - - date-fns-tz@3.2.0(date-fns@4.1.0): - dependencies: - date-fns: 4.1.0 - - date-fns@4.1.0: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-eql@5.0.2: {} - - delayed-stream@1.0.0: {} - - dequal@2.0.3: {} - - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@1.7.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - expect-type@1.2.2: {} - - fast-decode-uri-component@1.0.1: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stringify@6.1.1: - dependencies: - '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - json-schema-ref-resolver: 3.0.0 - rfdc: 1.4.1 - - fast-jwt@6.0.2: - dependencies: - '@lukeed/ms': 2.0.2 - asn1.js: 5.4.1 - ecdsa-sig-formatter: 1.0.11 - mnemonist: 0.40.0 - - fast-querystring@1.1.2: - dependencies: - fast-decode-uri-component: 1.0.1 - - fast-safe-stringify@2.1.1: {} - - fast-uri@3.1.0: {} - - fastfall@1.5.1: - dependencies: - reusify: 1.1.0 - - fastify-plugin@5.1.0: {} - - fastify@5.6.2: - dependencies: - '@fastify/ajv-compiler': 4.0.5 - '@fastify/error': 4.2.0 - '@fastify/fast-json-stringify-compiler': 5.0.3 - '@fastify/proxy-addr': 5.1.0 - abstract-logging: 2.0.1 - avvio: 9.1.0 - fast-json-stringify: 6.1.1 - find-my-way: 9.3.0 - light-my-request: 6.6.0 - pino: 10.1.0 - process-warning: 5.0.0 - rfdc: 1.4.1 - secure-json-parse: 4.1.0 - semver: 7.7.3 - toad-cache: 3.7.0 - - fastparallel@2.4.1: - dependencies: - reusify: 1.1.0 - xtend: 4.0.2 - - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - - fastseries@1.7.2: - dependencies: - reusify: 1.1.0 - xtend: 4.0.2 - - find-my-way@9.3.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-querystring: 1.1.2 - safe-regex2: 5.0.0 - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - formidable@2.1.5: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - qs: 6.14.0 - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-tsconfig@4.13.0: - dependencies: - resolve-pkg-maps: 1.0.0 - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - inherits@2.0.4: {} - - ipaddr.js@2.2.0: {} - - json-schema-ref-resolver@3.0.0: - dependencies: - dequal: 2.0.3 - - json-schema-traverse@1.0.0: {} - - light-my-request@6.6.0: - dependencies: - cookie: 1.0.2 - process-warning: 4.0.1 - set-cookie-parser: 2.7.2 - - loupe@3.2.1: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - math-intrinsics@1.1.0: {} - - methods@1.1.2: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@2.6.0: {} - - minimalistic-assert@1.0.1: {} - - mnemonist@0.40.0: - dependencies: - obliterator: 2.0.5 - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - node-addon-api@8.5.0: {} - - node-cron@4.2.1: {} - - node-gyp-build@4.8.4: {} - - object-inspect@1.13.4: {} - - obliterator@2.0.5: {} - - on-exit-leak-free@2.1.2: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - pathe@1.1.2: {} - - pathval@2.0.1: {} - - picocolors@1.1.1: {} - - pino-abstract-transport@2.0.0: - dependencies: - split2: 4.2.0 - - pino-std-serializers@7.0.0: {} - - pino@10.1.0: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 2.0.0 - pino-std-serializers: 7.0.0 - process-warning: 5.0.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.0 - thread-stream: 3.1.0 - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prisma@5.22.0: - dependencies: - '@prisma/engines': 5.22.0 - optionalDependencies: - fsevents: 2.3.3 - - process-warning@4.0.1: {} - - process-warning@5.0.0: {} - - qs@6.14.0: - dependencies: - side-channel: 1.1.0 - - quick-format-unescaped@4.0.4: {} - - real-require@0.2.0: {} - - require-from-string@2.0.2: {} - - resolve-pkg-maps@1.0.0: {} - - ret@0.5.0: {} - - reusify@1.1.0: {} - - rfdc@1.4.1: {} - - rollup@4.53.3: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.3 - '@rollup/rollup-android-arm64': 4.53.3 - '@rollup/rollup-darwin-arm64': 4.53.3 - '@rollup/rollup-darwin-x64': 4.53.3 - '@rollup/rollup-freebsd-arm64': 4.53.3 - '@rollup/rollup-freebsd-x64': 4.53.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 - '@rollup/rollup-linux-arm-musleabihf': 4.53.3 - '@rollup/rollup-linux-arm64-gnu': 4.53.3 - '@rollup/rollup-linux-arm64-musl': 4.53.3 - '@rollup/rollup-linux-loong64-gnu': 4.53.3 - '@rollup/rollup-linux-ppc64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-musl': 4.53.3 - '@rollup/rollup-linux-s390x-gnu': 4.53.3 - '@rollup/rollup-linux-x64-gnu': 4.53.3 - '@rollup/rollup-linux-x64-musl': 4.53.3 - '@rollup/rollup-openharmony-arm64': 4.53.3 - '@rollup/rollup-win32-arm64-msvc': 4.53.3 - '@rollup/rollup-win32-ia32-msvc': 4.53.3 - '@rollup/rollup-win32-x64-gnu': 4.53.3 - '@rollup/rollup-win32-x64-msvc': 4.53.3 - fsevents: 2.3.3 - - safe-buffer@5.2.1: {} - - safe-regex2@5.0.0: - dependencies: - ret: 0.5.0 - - safe-stable-stringify@2.5.0: {} - - safer-buffer@2.1.2: {} - - secure-json-parse@4.1.0: {} - - semver@7.7.3: {} - - set-cookie-parser@2.7.2: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - siginfo@2.0.0: {} - - sonic-boom@4.2.0: - dependencies: - atomic-sleep: 1.0.0 - - source-map-js@1.2.1: {} - - split2@4.2.0: {} - - stackback@0.0.2: {} - - std-env@3.10.0: {} - - steed@1.1.3: - dependencies: - fastfall: 1.5.1 - fastparallel: 2.4.1 - fastq: 1.19.1 - fastseries: 1.7.2 - reusify: 1.1.0 - - superagent@8.1.2: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 2.1.5 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.14.0 - semver: 7.7.3 - transitivePeerDependencies: - - supports-color - - supertest@6.3.4: - dependencies: - methods: 1.1.2 - superagent: 8.1.2 - transitivePeerDependencies: - - supports-color - - thread-stream@3.1.0: - dependencies: - real-require: 0.2.0 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinypool@1.1.1: {} - - tinyrainbow@1.2.0: {} - - tinyspy@3.0.2: {} - - toad-cache@3.7.0: {} - - tsx@4.20.6: - dependencies: - esbuild: 0.25.12 - get-tsconfig: 4.13.0 - optionalDependencies: - fsevents: 2.3.3 - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - vite-node@2.1.9(@types/node@20.19.25): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@20.19.25) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@20.19.25): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.53.3 - optionalDependencies: - '@types/node': 20.19.25 - fsevents: 2.3.3 - - vitest@2.1.9(@types/node@20.19.25): - dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.25)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.2.2 - magic-string: 0.30.21 - pathe: 1.1.2 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@20.19.25) - vite-node: 2.1.9(@types/node@20.19.25) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 20.19.25 - transitivePeerDependencies: - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - wrappy@1.0.2: {} - - xtend@4.0.2: {} - - zod@3.25.76: {} diff --git a/api/src/allocator.ts b/api/src/allocator.ts index 2b1970b..bb3d7f1 100644 --- a/api/src/allocator.ts +++ b/api/src/allocator.ts @@ -297,6 +297,7 @@ async function getInputs( currentFundedCents: true, dueOn: true, priority: true, + fundingMode: true, needsFundingThisPeriod: true, paymentSchedule: true, autoPayEnabled: true, @@ -328,7 +329,8 @@ export function buildPlanStates( userIncomeType?: string, isScheduledIncome?: boolean ): PlanState[] { - const timezone = config.timezone; + const timezone = config.timezone ?? "UTC"; + const firstIncomeDate = config.firstIncomeDate ?? null; const freqDays = frequencyDays[config.incomeFrequency]; // Only handle regular income frequencies @@ -342,7 +344,8 @@ export function buildPlanStates( const remainingCents = Math.max(0, total - funded); const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined; const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true; - const autoFundEnabled = !!p.autoPayEnabled; + const autoFundEnabled = + !p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual"; // Calculate preliminary crisis status to determine if we should override funding restrictions // Use timezone-aware date comparison @@ -357,14 +360,14 @@ export function buildPlanStates( let isPrelimCrisis = false; let dueBeforeNextPayday = false; let daysUntilPayday = 0; - if (isPaymentPlanUser && config.firstIncomeDate) { - const nextPayday = calculateNextPayday(config.firstIncomeDate, config.incomeFrequency, now, timezone); + if (isPaymentPlanUser && firstIncomeDate) { + const nextPayday = calculateNextPayday(firstIncomeDate, config.incomeFrequency, now, timezone); const normalizedNextPayday = getUserMidnight(timezone, nextPayday); daysUntilPayday = Math.max(0, Math.ceil((normalizedNextPayday.getTime() - userNow.getTime()) / DAY_MS)); dueBeforeNextPayday = userDueDate.getTime() < normalizedNextPayday.getTime(); } if (remainingCents >= CRISIS_MINIMUM_CENTS) { - if (isPaymentPlanUser && config.firstIncomeDate) { + if (isPaymentPlanUser && firstIncomeDate) { isPrelimCrisis = daysUntilDuePrelim < daysUntilPayday && fundedPercent < 90; } else { isPrelimCrisis = fundedPercent < 70 && daysUntilDuePrelim <= 14; @@ -430,10 +433,10 @@ export function buildPlanStates( // Calculate payment periods more accurately using firstIncomeDate let cyclesLeft: number; - if (config.firstIncomeDate) { + if (firstIncomeDate) { // Count actual pay dates between now and due date based on the recurring pattern // established by firstIncomeDate (pass timezone for correct date handling) - cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, config.firstIncomeDate, config.incomeFrequency, timezone); + cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, firstIncomeDate, config.incomeFrequency, timezone); } else { // Fallback to old calculation if firstIncomeDate not set cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays)); @@ -1377,7 +1380,9 @@ function computeBudgetAllocation( const availableBudget = inputs.availableBefore; const totalPool = availableBudget + newIncome; - const eligiblePlans = inputs.plans.filter((plan) => plan.autoPayEnabled); + const eligiblePlans = inputs.plans.filter( + (plan) => !plan.fundingMode || String(plan.fundingMode).toLowerCase() !== "manual" + ); const planStates = buildBudgetPlanStates(eligiblePlans, now, inputs.config.timezone); // Calculate total remaining needed across all fixed plans @@ -1505,7 +1510,8 @@ function buildBudgetPlanStates( const userDueDate = getUserMidnightFromDateOnly(timezone, p.dueOn); const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS)); const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined; - const autoFundEnabled = !!p.autoPayEnabled; + const autoFundEnabled = + !p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual"; const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true; // For irregular income, crisis mode triggers earlier (14 days) diff --git a/api/src/jobs/auto-payments.ts b/api/src/jobs/auto-payments.ts index a72ad16..6bb0f75 100644 --- a/api/src/jobs/auto-payments.ts +++ b/api/src/jobs/auto-payments.ts @@ -196,64 +196,64 @@ export function calculateNextPaymentDate( timezone: string ): Date { const next = toZonedTime(currentDate, timezone); - const hours = next.getUTCHours(); - const minutes = next.getUTCMinutes(); - const seconds = next.getUTCSeconds(); - const ms = next.getUTCMilliseconds(); + const hours = next.getHours(); + const minutes = next.getMinutes(); + const seconds = next.getSeconds(); + const ms = next.getMilliseconds(); switch (schedule.frequency) { case "daily": - next.setUTCDate(next.getUTCDate() + 1); + next.setDate(next.getDate() + 1); break; case "weekly": // Move to next occurrence of specified day of week { const targetDay = schedule.dayOfWeek ?? 0; - const currentDay = next.getUTCDay(); + const currentDay = next.getDay(); const daysUntilTarget = (targetDay - currentDay + 7) % 7; - next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7)); + next.setDate(next.getDate() + (daysUntilTarget || 7)); } break; case "biweekly": { - const targetDay = schedule.dayOfWeek ?? next.getUTCDay(); - const currentDay = next.getUTCDay(); + const targetDay = schedule.dayOfWeek ?? next.getDay(); + const currentDay = next.getDay(); let daysUntilTarget = (targetDay - currentDay + 7) % 7; // ensure at least one full week gap to make it biweekly daysUntilTarget = daysUntilTarget === 0 ? 14 : daysUntilTarget + 7; - next.setUTCDate(next.getUTCDate() + daysUntilTarget); + next.setDate(next.getDate() + daysUntilTarget); } break; case "monthly": { - const targetDay = schedule.dayOfMonth ?? next.getUTCDate(); + const targetDay = schedule.dayOfMonth ?? next.getDate(); // Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months. - next.setUTCDate(1); - next.setUTCMonth(next.getUTCMonth() + 1); + next.setDate(1); + next.setMonth(next.getMonth() + 1); const lastDay = getLastDayOfMonth(next); - next.setUTCDate(Math.min(targetDay, lastDay)); + next.setDate(Math.min(targetDay, lastDay)); } break; case "custom": { const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays; - next.setUTCDate(next.getUTCDate() + days); + next.setDate(next.getDate() + days); } break; default: // Fallback to periodDays - next.setUTCDate(next.getUTCDate() + periodDays); + next.setDate(next.getDate() + periodDays); } - next.setUTCHours(hours, minutes, seconds, ms); + next.setHours(hours, minutes, seconds, ms); return fromZonedTime(next, timezone); } function getLastDayOfMonth(date: Date): number { - return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate(); + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); } diff --git a/api/src/jobs/rollover.ts b/api/src/jobs/rollover.ts index 9f7c797..8843e3f 100644 --- a/api/src/jobs/rollover.ts +++ b/api/src/jobs/rollover.ts @@ -4,8 +4,10 @@ import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js"; const addDaysInTimezone = (date: Date, days: number, timezone: string) => { const zoned = toZonedTime(date, timezone); - zoned.setUTCDate(zoned.getUTCDate() + days); - zoned.setUTCHours(0, 0, 0, 0); + // Advance by calendar days in the user's local timezone, then normalize + // to local midnight before converting back to UTC for storage. + zoned.setDate(zoned.getDate() + days); + zoned.setHours(0, 0, 0, 0); return fromZonedTime(zoned, timezone); }; diff --git a/api/src/routes/dashboard.ts b/api/src/routes/dashboard.ts index e177d11..5b54e55 100644 --- a/api/src/routes/dashboard.ts +++ b/api/src/routes/dashboard.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync } from "fastify"; import { getUserMidnightFromDateOnly } from "../allocator.js"; +import { getUserTimezone } from "../services/user-context.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -273,9 +274,7 @@ const dashboardRoutes: FastifyPluginAsync = async (app) => { app.get("/crisis-status", async (req) => { const userId = req.userId; const now = new Date(); - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; + const userTimezone = await getUserTimezone(app.prisma, userId); const { getUserMidnight } = await import("../allocator.js"); const userNow = getUserMidnight(userTimezone, now); diff --git a/api/src/routes/fixed-plans.ts b/api/src/routes/fixed-plans.ts index d115ce6..4571912 100644 --- a/api/src/routes/fixed-plans.ts +++ b/api/src/routes/fixed-plans.ts @@ -6,6 +6,7 @@ import { getUserMidnight, getUserMidnightFromDateOnly, } from "../allocator.js"; +import { getUserTimezone } from "../services/user-context.js"; type RateLimitRouteOptions = { config: { @@ -90,9 +91,7 @@ const fixedPlansRoutes: FastifyPluginAsync = async ( if (!plan) { return reply.code(404).send({ message: "Plan not found" }); } - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; + const userTimezone = await getUserTimezone(app.prisma, userId); await app.prisma.fixedPlan.update({ where: { id: planId }, @@ -662,9 +661,7 @@ const fixedPlansRoutes: FastifyPluginAsync = async ( return reply.code(400).send({ message: "Invalid payload" }); } const userId = req.userId; - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; + const userTimezone = await getUserTimezone(app.prisma, userId); const amountMode = parsed.data.amountMode ?? "fixed"; if (amountMode === "estimated" && parsed.data.estimatedCents === undefined) { @@ -751,9 +748,7 @@ const fixedPlansRoutes: FastifyPluginAsync = async ( } const id = String((req.params as any).id); const userId = req.userId; - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; + const userTimezone = await getUserTimezone(app.prisma, userId); const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId }, diff --git a/api/src/routes/variable-categories.ts b/api/src/routes/variable-categories.ts index a9686c5..5d596df 100644 --- a/api/src/routes/variable-categories.ts +++ b/api/src/routes/variable-categories.ts @@ -1,6 +1,7 @@ -import type { FastifyPluginAsync, FastifyInstance } from "fastify"; +import type { FastifyPluginAsync } from "fastify"; import type { Prisma, PrismaClient } from "@prisma/client"; import { z } from "zod"; +import { ensureBudgetSessionAvailableSynced } from "../services/budget-session.js"; type RateLimitRouteOptions = { config: { @@ -76,52 +77,6 @@ async function assertPercentTotal( } } -async function getLatestBudgetSession(app: FastifyInstance, userId: string) { - return app.prisma.budgetSession.findFirst({ - where: { userId }, - orderBy: { periodStart: "desc" }, - }); -} - -async function ensureBudgetSession( - app: FastifyInstance, - userId: string, - fallbackAvailableCents = 0 -) { - const existing = await getLatestBudgetSession(app, userId); - if (existing) return existing; - - const now = new Date(); - const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0)); - const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)); - - return app.prisma.budgetSession.create({ - data: { - userId, - periodStart: start, - periodEnd: end, - totalBudgetCents: BigInt(Math.max(0, fallbackAvailableCents)), - allocatedCents: 0n, - fundedCents: 0n, - availableCents: BigInt(Math.max(0, fallbackAvailableCents)), - }, - }); -} - -async function ensureBudgetSessionAvailableSynced( - app: FastifyInstance, - userId: string, - availableCents: number -) { - const normalizedAvailableCents = BigInt(Math.max(0, Math.trunc(availableCents))); - const session = await ensureBudgetSession(app, userId, Number(normalizedAvailableCents)); - if ((session.availableCents ?? 0n) === normalizedAvailableCents) return session; - return app.prisma.budgetSession.update({ - where: { id: session.id }, - data: { availableCents: normalizedAvailableCents }, - }); -} - const variableCategoriesRoutes: FastifyPluginAsync = async ( app, opts @@ -132,15 +87,7 @@ const variableCategoriesRoutes: FastifyPluginAsync { try { @@ -169,18 +116,10 @@ const variableCategoriesRoutes: FastifyPluginAsync { const exists = await tx.variableCategory.findFirst({ @@ -271,7 +210,7 @@ const variableCategoriesRoutes: FastifyPluginAsync s + Number(c.balanceCents ?? 0n), 0); - await ensureBudgetSessionAvailableSynced(app, userId, totalBalance); + await ensureBudgetSessionAvailableSynced(app.prisma, userId, totalBalance); return reply.send({ ok: true, @@ -296,7 +235,7 @@ const variableCategoriesRoutes: FastifyPluginAsync s + Number(c.balanceCents ?? 0n), 0); const availableCents = totalBalance; - await ensureBudgetSessionAvailableSynced(app, userId, availableCents); + await ensureBudgetSessionAvailableSynced(app.prisma, userId, availableCents); const targetMap = new Map(); for (const t of parsed.data.targets) { diff --git a/api/src/server.ts b/api/src/server.ts index 899ea47..6d66c6a 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -10,6 +10,11 @@ import { PrismaClient } from "@prisma/client"; import { z } from "zod"; import { getUserMidnightFromDateOnly } from "./allocator.js"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; +import { + computeDepositShares, + computeOverdraftShares, + computeWithdrawShares, +} from "./services/category-shares.js"; import healthRoutes from "./routes/health.js"; import sessionRoutes from "./routes/session.js"; import userRoutes from "./routes/user.js"; @@ -429,199 +434,26 @@ function calculateNextDueDate(currentDueDate: Date, frequency: string, timezone: switch (frequency) { case "weekly": - zoned.setUTCDate(zoned.getUTCDate() + 7); + zoned.setDate(zoned.getDate() + 7); break; case "biweekly": - zoned.setUTCDate(zoned.getUTCDate() + 14); + zoned.setDate(zoned.getDate() + 14); break; case "monthly": { - const targetDay = zoned.getUTCDate(); - const nextMonth = zoned.getUTCMonth() + 1; - const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12); - const nextMonthIndex = nextMonth % 12; - const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate(); - zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay)); + const targetDay = zoned.getDate(); + zoned.setDate(1); + zoned.setMonth(zoned.getMonth() + 1); + const lastDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate(); + zoned.setDate(Math.min(targetDay, lastDay)); break; } default: return base; } - zoned.setUTCHours(0, 0, 0, 0); + zoned.setHours(0, 0, 0, 0); return fromZonedTime(zoned, timezone); } -function jsonBigIntSafe(obj: unknown) { - return JSON.parse( - JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v)) - ); -} - -type PercentCategory = { - id: string; - percent: number; - balanceCents: bigint | null; -}; - -function computePercentShares(categories: PercentCategory[], amountCents: number) { - const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); - if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; - - const shares = categories.map((cat) => { - const raw = (amountCents * cat.percent) / percentTotal; - const floored = Math.floor(raw); - return { - id: cat.id, - balanceCents: Number(cat.balanceCents ?? 0n), - share: floored, - frac: raw - floored, - }; - }); - - let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0); - shares - .slice() - .sort((a, b) => b.frac - a.frac) - .forEach((s) => { - if (remainder > 0) { - s.share += 1; - remainder -= 1; - } - }); - - if (shares.some((s) => s.share > s.balanceCents)) { - return { ok: false as const, reason: "insufficient_balances" }; - } - - return { ok: true as const, shares }; -} - -function computeWithdrawShares(categories: PercentCategory[], amountCents: number) { - const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); - if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; - - const working = categories.map((cat) => ({ - id: cat.id, - percent: cat.percent, - balanceCents: Number(cat.balanceCents ?? 0n), - share: 0, - })); - - let remaining = Math.max(0, Math.floor(amountCents)); - let safety = 0; - - while (remaining > 0 && safety < 1000) { - safety += 1; - const eligible = working.filter((c) => c.balanceCents > 0 && c.percent > 0); - if (eligible.length === 0) break; - - const totalPercent = eligible.reduce((sum, cat) => sum + cat.percent, 0); - if (totalPercent <= 0) break; - - const provisional = eligible.map((cat) => { - const raw = (remaining * cat.percent) / totalPercent; - const floored = Math.floor(raw); - return { - id: cat.id, - raw, - floored, - remainder: raw - floored, - }; - }); - - let sumBase = provisional.reduce((sum, p) => sum + p.floored, 0); - let leftovers = remaining - sumBase; - provisional - .slice() - .sort((a, b) => b.remainder - a.remainder) - .forEach((p) => { - if (leftovers > 0) { - p.floored += 1; - leftovers -= 1; - } - }); - - let allocatedThisRound = 0; - for (const p of provisional) { - const entry = working.find((w) => w.id === p.id); - if (!entry) continue; - const take = Math.min(p.floored, entry.balanceCents); - if (take > 0) { - entry.balanceCents -= take; - entry.share += take; - allocatedThisRound += take; - } - } - - remaining -= allocatedThisRound; - if (allocatedThisRound === 0) break; - } - - if (remaining > 0) { - return { ok: false as const, reason: "insufficient_balances" }; - } - - return { - ok: true as const, - shares: working.map((c) => ({ id: c.id, share: c.share })), - }; -} - -function computeOverdraftShares(categories: PercentCategory[], amountCents: number) { - const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); - if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; - - const shares = categories.map((cat) => { - const raw = (amountCents * cat.percent) / percentTotal; - const floored = Math.floor(raw); - return { - id: cat.id, - share: floored, - frac: raw - floored, - }; - }); - - let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0); - shares - .slice() - .sort((a, b) => b.frac - a.frac) - .forEach((s) => { - if (remainder > 0) { - s.share += 1; - remainder -= 1; - } - }); - - return { ok: true as const, shares }; -} - -function computeDepositShares(categories: PercentCategory[], amountCents: number) { - const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); - if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; - - const shares = categories.map((cat) => { - const raw = (amountCents * cat.percent) / percentTotal; - const floored = Math.floor(raw); - return { - id: cat.id, - share: floored, - frac: raw - floored, - }; - }); - - let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0); - shares - .slice() - .sort((a, b) => b.frac - a.frac) - .forEach((s) => { - if (remainder > 0) { - s.share += 1; - remainder -= 1; - } - }); - - return { ok: true as const, shares }; -} - const DEFAULT_VARIABLE_CATEGORIES = [ { name: "Essentials", percent: 50, priority: 10, isSavings: false }, { name: "Savings", percent: 30, priority: 20, isSavings: true }, diff --git a/api/test-income-overdue.sh b/api/test-income-overdue.sh deleted file mode 100644 index f3ecfe7..0000000 --- a/api/test-income-overdue.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# Login and save cookie -echo " 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 " 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 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 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 diff --git a/api/test-monthly-income.cjs b/api/test-monthly-income.cjs deleted file mode 100644 index d621f12..0000000 --- a/api/test-monthly-income.cjs +++ /dev/null @@ -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('═══════════════════════════════════════════════════════════════'); - diff --git a/api/test-overdue-api.sh b/api/test-overdue-api.sh deleted file mode 100644 index 0ba76e4..0000000 --- a/api/test-overdue-api.sh +++ /dev/null @@ -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}' diff --git a/api/test-overdue-payment.cjs b/api/test-overdue-payment.cjs deleted file mode 100644 index 3622461..0000000 --- a/api/test-overdue-payment.cjs +++ /dev/null @@ -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(); diff --git a/api/test-simple.sh b/api/test-simple.sh deleted file mode 100644 index 0c7da77..0000000 --- a/api/test-simple.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -echo " 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 " Plans BEFORE:" -curl -s -b cookies.txt http://localhost:8080/api/fixed-plans - -echo -e "\n\n 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 Plans AFTER:" -curl -s -b cookies.txt http://localhost:8080/api/fixed-plans - -rm -f cookies.txt diff --git a/api/tests/helpers.ts b/api/tests/helpers.ts index 48077e7..a907545 100644 --- a/api/tests/helpers.ts +++ b/api/tests/helpers.ts @@ -32,8 +32,8 @@ export async function resetUser(userId: string) { export async function ensureUser(userId: string) { await prisma.user.upsert({ where: { id: userId }, - update: {}, - create: { id: userId, email: `${userId}@demo.local` }, + update: { timezone: "UTC" }, + create: { id: userId, email: `${userId}@demo.local`, timezone: "UTC" }, }); } diff --git a/api/tests/payment-rollover.test.ts b/api/tests/payment-rollover.test.ts index 381f551..85814a5 100644 --- a/api/tests/payment-rollover.test.ts +++ b/api/tests/payment-rollover.test.ts @@ -40,27 +40,28 @@ function calculateNextDueDateLikeServer( switch (frequency) { case "weekly": - zoned.setUTCDate(zoned.getUTCDate() + 7); + zoned.setDate(zoned.getDate() + 7); break; case "biweekly": - zoned.setUTCDate(zoned.getUTCDate() + 14); + zoned.setDate(zoned.getDate() + 14); break; case "monthly": { - const targetDay = zoned.getUTCDate(); - const nextMonth = zoned.getUTCMonth() + 1; - const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12); - const nextMonthIndex = nextMonth % 12; + const targetDay = zoned.getDate(); + zoned.setDate(1); + zoned.setMonth(zoned.getMonth() + 1); const lastDay = new Date( - Date.UTC(nextYear, nextMonthIndex + 1, 0) - ).getUTCDate(); - zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay)); + zoned.getFullYear(), + zoned.getMonth() + 1, + 0 + ).getDate(); + zoned.setDate(Math.min(targetDay, lastDay)); break; } default: return base; } - zoned.setUTCHours(0, 0, 0, 0); + zoned.setHours(0, 0, 0, 0); return fromZonedTime(zoned, timezone); } diff --git a/api/tests/rollover.test.ts b/api/tests/rollover.test.ts index e76da10..bb6b5bd 100644 --- a/api/tests/rollover.test.ts +++ b/api/tests/rollover.test.ts @@ -23,7 +23,8 @@ describe("rolloverFixedPlans", () => { select: { id: true }, }); - const results = await rolloverFixedPlans(prisma, "2025-01-10T00:00:00Z"); + // Rollover job only processes after 6 AM in the user's timezone. + const results = await rolloverFixedPlans(prisma, "2025-01-10T10:00:00Z"); const match = results.find((r) => r.planId === plan.id); expect(match?.cyclesAdvanced).toBe(1); expect(match?.deficitCents).toBe(4000); @@ -48,7 +49,8 @@ describe("rolloverFixedPlans", () => { select: { id: true }, }); - const results = await rolloverFixedPlans(prisma, "2025-02-05T00:00:00Z"); + // Rollover job only processes after 6 AM in the user's timezone. + const results = await rolloverFixedPlans(prisma, "2025-02-05T10:00:00Z"); const match = results.find((r) => r.planId === plan.id); expect(match?.cyclesAdvanced).toBe(2); expect(match?.carryForwardCents).toBe(2000); diff --git a/api/tests/variable-categories.guard.test.ts b/api/tests/variable-categories.guard.test.ts index 4f4c088..70e378a 100644 --- a/api/tests/variable-categories.guard.test.ts +++ b/api/tests/variable-categories.guard.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; import appFactory from "./appFactory"; import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers"; import type { FastifyInstance } from "fastify"; +import { randomUUID } from "node:crypto"; let app: FastifyInstance; @@ -34,9 +35,12 @@ describe("Variable Categories guard (sum=100)", () => { }); it("rejects create that would push sum away from 100", async () => { + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ name: "Oops", percent: 10, isSavings: false, priority: 99 }); expect(res.statusCode).toBe(400); @@ -45,9 +49,12 @@ describe("Variable Categories guard (sum=100)", () => { it("rejects update that breaks the sum", async () => { const existing = await prisma.variableCategory.findFirst({ where: { userId: U } }); + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .patch(`/variable-categories/${existing!.id}`) .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ percent: 90 }); expect(res.statusCode).toBe(400); diff --git a/api/tests/variable-categories.manual-rebalance.test.ts b/api/tests/variable-categories.manual-rebalance.test.ts index f25ae15..9c85b72 100644 --- a/api/tests/variable-categories.manual-rebalance.test.ts +++ b/api/tests/variable-categories.manual-rebalance.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import appFactory from "./appFactory"; import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers"; import type { FastifyInstance } from "fastify"; +import { randomUUID } from "node:crypto"; let app: FastifyInstance; @@ -59,10 +60,13 @@ describe("manual rebalance", () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000 + const csrf = randomUUID().replace(/-/g, ""); const postRes = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) - .send({ targets }); + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) + .send({ targets, forceLowerSavings: true }); expect(postRes.statusCode).toBe(200); expect(postRes.body?.availableCents).toBe(10_000); @@ -78,11 +82,14 @@ describe("manual rebalance", () => { it("rebalances when sums match available", async () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000 + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) - .send({ targets }); + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) + .send({ targets, forceLowerSavings: true }); expect(res.statusCode).toBe(200); expect(res.body?.ok).toBe(true); @@ -93,10 +100,13 @@ describe("manual rebalance", () => { it("rejects sum mismatch", async () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 5000 : 1000 })); // 5000 + 3*1000 = 8000 + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ targets }); expect(res.statusCode).toBe(400); @@ -108,10 +118,13 @@ describe("manual rebalance", () => { // savings to 500 (below 20% of 10000 = 2000) const targets = cats.map((c) => ({ id: c.id, targetCents: c.isSavings ? 500 : 3166 })); // 500 + 3*3166 = 9998 -> adjust targets[1].targetCents += 2; // total 10000 + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ targets }); expect(res.statusCode).toBe(400); @@ -120,6 +133,8 @@ describe("manual rebalance", () => { const resOk = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ targets, forceLowerSavings: true }); expect(resOk.statusCode).toBe(200); @@ -129,13 +144,16 @@ describe("manual rebalance", () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 9000 : 333 })); targets[1].targetCents += 1; // sum 10000 + const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ targets }); expect(res.statusCode).toBe(400); - expect(res.body?.code).toBe("OVER_80_PERCENT"); + expect(res.body?.code).toBe("OVER_80_CONFIRM_REQUIRED"); }); }); diff --git a/backups/skymoney_2026-01-16_204729.dump b/backups/skymoney_2026-01-16_204729.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205044.dump b/backups/skymoney_2026-01-16_205044.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205117.dump b/backups/skymoney_2026-01-16_205117.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205351.dump b/backups/skymoney_2026-01-16_205351.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205426.dump b/backups/skymoney_2026-01-16_205426.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205501.dump b/backups/skymoney_2026-01-16_205501.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205541.dump b/backups/skymoney_2026-01-16_205541.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205559.dump b/backups/skymoney_2026-01-16_205559.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205816.dump b/backups/skymoney_2026-01-16_205816.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_205911.dump b/backups/skymoney_2026-01-16_205911.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210013.dump b/backups/skymoney_2026-01-16_210013.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210031.dump b/backups/skymoney_2026-01-16_210031.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210033.dump b/backups/skymoney_2026-01-16_210033.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210035.dump b/backups/skymoney_2026-01-16_210035.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210056.dump b/backups/skymoney_2026-01-16_210056.dump deleted file mode 100644 index 24fa3b72856f9c70388838cad839d4cd5d5c2dfe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58366 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 diff --git a/backups/skymoney_2026-01-16_210413.dump b/backups/skymoney_2026-01-16_210413.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210432.dump b/backups/skymoney_2026-01-16_210432.dump deleted file mode 100644 index e69de29..0000000 diff --git a/backups/skymoney_2026-01-16_210619.dump b/backups/skymoney_2026-01-16_210619.dump deleted file mode 100644 index a8c55561f3992bf5788c672f25c4cc000af65f61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58366 zcmc${2RxPU|35Axd#7X`WMuD=mA&`Q9tX!b_DV()np%`XMYfQgkz{68MnoljV&a_TY~vP$X-@DYCApu(!s0Dn4ix*%&O2pH;V4u)F0 z*g&9mAaM}0p02zwvnUQQcc4ckAF$@z4^S|GZ?=|B9$270g?Eg68UF<6kr5RSZy%^BW z@-TrC84WcxIdxsQ#G`~(R2h!7#Qjj}1MR{!vHISyBw5pPfD2{@*l)CO=HFY(9vadP7DgV*3 z;GzOU0j^&8xP?UEEX2dhFANA9Hc;;61+n39@$hhk-^m&3%BkxpX{c|rmn2YON63CY z0V&CY)HQTLa)wGex;h~GpB>O|i`_mN0Rlq>^Z^%`Up?$;gM6JplLOiqDcPElk_~2* zkdB6OfWZuasyiG9qaM;$a7MEOL&5HV3ITaRA>Lqj4@)N}KiGFSFF?X<0CNMeg#g7M z7Ck*BS&!5I%$Kkw{?Z5V)$t-QrPEL2v#lTbg?6+8zn~BbA<$e2uyD>`d3P`{3#hdp zoXEOHnma1e45+a~Jy_w6ka5x@w4w(|=>U3BPYuL^0|G8)A28U_$&Z=1d6B7-j!{sD9VTz@8{F39l^;^r3Mfg85%mFfDqf)Sul z1;#iW3Rr^!D7b^|yqqlEcVU3I?-sxp4)bUZTR6PC|7y*jVDP}NZ5|L1*|iKpg77s< zIXSriCL7`cg+Bmnr0Nd+j{(YdchRuYpA7J;3H=VuAvS=G^!0=bwz{6G>Nf4^y*$9~ zN;dRJpTVsS*hb0|cnom{1I~@JD+}LgkPpPu-o?ukUI;o5xPKs7Ie95PRb7ybp0+li z+0Av8)Z}z@rPMSLhI52Kkx#?Z<*-RuI=gs5J!Js#^PmS=LF^#V18V?mARr^V9l(hP z0`S}>-eIsDq~0$Y*n=%yQQTqof5x>fNOcqm&>l`J5d9u>nfqr9F{wHySEtQv-8!oaxr4XebDBZsH zF(*tQg-~G2miDr-1AFR#Jv@-`Cm~>+kn-ogR{w%UUBT`U7aJWR}f^xM(r}OoK!;_jAO5DV?oIr4!~s>`3SSq4x`@4p8kc1ayCw z|FMiBIuSj<9JjB1G75wifGL4_fbtMuu#KjZil4O; z_^(9P#tWV*&>@!<>dAe}8Ksju{t}Y%BPv$dckQrxKlxO4R1XlKN0ha9md|K^H8P;Cg1yC@6 zqY9Ws7Z)cmkaF4W-qRg$SrHcLWa;7gtHtj%-p(M~8dBEM53sz5%jG+;TG*5{E&b%6 zFs%eARm4fb(+S9CCAHnq88GqxMBAN-Ia~T_gFW5-q^Kg=;WFg?V9pn&fK%nmk) zETFaia3I(17CJk0*iQp%FTi1hddmMVY8yrcSOb79{)cJowgQCM(oYj^^8RoEP%zlW zV`o5Jdx*zwV?^R0;TYT9m;=tbJIF&kGyskR!^63E{+NrJs zcy#|rA_OOWpP%=tckFl#D|1oR;5^CG_=_hON zin-19)c=Xa;S5LzEFXzCvv=|TB@4bs%7Uc8Bz9PE@4)++Pz$K^7fiUf!~d8KMG)Aq z518Thg~*0LvKYpOy6%=x4@+xgk#Oi?k?@{2|AGx&tl`@Je{puV+3L`U*BY=wb}sII zyJ71N7XnFaC_>SnM8F=}U|`F^8uG`M7m@qj!Pa1itEZm3)3&SfQz-UXXGF8H-=_ag zAoh~szZ8fM|0EE52mVh4VsD54tw8YdAP7X)t~?yj%ltg>B0V5`gQ$>kfZ!deLWWiT zy$AqX3J@pcK09pV;g4dyUEBUosUUh+OAikp7k3*)OAq^fJomrHl{?I81MvXF$4?yy zJoa@6*`GtD|CuG*)@?tNtMoq|Xy%_O(KBaub1nxeBRJSr@I=V2gSFq?-_58Zck{RB zx1FNgKiYQLE~L!6`ANh{ZC68>&tYkb9k6U6?jD}oYx*BNQCBeBcl{qN<@S)Um;;_I z+>ecvGvLr#TXLy`eawwq+#L^zg=}Flteh_}0S_<$vNhm}SlR(}M)dsoe>s(KeMAAs z5E26o7lyx8M6mRjFp!+x{#Rss-(u(PXDNa#w;@$jcrdF5cU|e>Gup4Al!2#zVY0Uc z{XfkM@*wV}bpun|z7TCSzX<%afWDze~y;^Ay*?hLU5whV#Hw+AAB-#CQ754QZZL1S%i=?=KJKvv1y(%lc>D;7S% z(+HUiAQfTl=;7rII#^kdAF;A6a5BIH$RU|qZtvE>w{YP5H@`M-wpsJ%Ld@Y(bFgz> z0RhC`fg>aydy)h0Kt2i>%F5E(@t?;7X5tPw6u+zZ(;7ovd{_|21~$-vBS1F5=^C)B z2XKf5*dzKW&%5>#!4e>smxJ{9FXg4LVh9PIT!mKOm8d3gg{CQM(zKe*wt z0HmJ}?q9+VPi<=o*pVLiTH#&6KTsTir!pFvMn4_2J^N-L7KjZya28>EzY_TNYe$0} z1dFnOBKR&M@bBJ;3Ao+%uYjoPGzflL!^0EEzX4m@up>F!{??v30Ac9Z+S?Y>k!aKA4>58UrdzXSK%1cT?+cWUp+ zo$u7&8+rpp2eRHk8Dd%+C_>Cx14T$O)j;WPR(UrUy{Bkzq8TVYB#R6j5!}rY1C@TI zhJl;?dEZ|J+nL>e%R)N|OjN#I7OF(fLP)Zgzh@#xL@C@3kl=|=zz;>xKtFSn!20&9 z99VV*4hdqQ^siUGoijsadaxrrRmfM4Bw_aZl_w#td|#Raapil`VLz4co`e>#^qoA` z0b~F+*^{bb2kjuWC;bG+Z7=KpTg-OSDyVVKb})N|9J9SSmEVH}&w20FHQNyv93^-T z1z5vA?YS@40X+Pb;s9>;rY&HLN61zHMf*|a*2dnz0bqw=2)aS+G@{r^3bh`XtvL!lJmhC{i>!vpR4 z{+Ceh=>a*Edn)`TlzVyrwrXH+=&5m+-UO)v;Ew7Xb=A* zazZAuSBHl&9ypAViUJK*u}Fx9TEMRZYMua)ljfBOOXr;hePgjg8? zu3ui@u89ytjvHb>yKJ>P`+uS8gmxqpF@!mGIHJ68@*Xy)y($hwQvRZEg#5Vqks9tL#aw zY{xByu;%|kl6>D_^dNZ+Z8;?c_3do=-jf>Ia`JM(yW}!*IzQu{J=TOCmRX0zL_2Rt z%Sh?SNXf$Al-_=I8U*_}9@yKy0FhvCKK#kl4@8)I-m^B}KktLI`{yGhu)PL8J^_Ip zMazAN1!Fy&i2QI1hMb5yYUg((O#ee1kcIVoq1ZDNA_2D_0S9f6kWUY$*8h=iJCp>- z277DjUz2U`T>pk>JLi7jGQh{j&%Z-7#B-6H$cYA|3WT^(n1O}ti`fx&Q})d9FzL(r z9TxlgMxFuNS7lep!+7QYl7M=DKIwy^cleC|nv6Vx2xR0%OiC~#C!?SUKQ}+z4gVRr z?b*Hg1M;Q*4vC+AA}8I?$N#U$ruJu(JxDU-GyQvV?MCRo$Oc!kzxFU$4ky>Pz1w3k z_U#t^0m(9dhr*tIkrQoCh5uJ%`>mDRGan>dCwrdmo9*9_ZrjTJQj~{SIkrPd$G5F8 z_ryB;cBg(FGy1K&Qu#ab?dcmi`Sw(R{aD`4i+b<@5f$dI$H=W7qAU2%7I2T}@>>nM zXOc*0y65SECHxItw+-P=l*l8rt4R?JA^YKU6@a^-d*-!|uGbxgOZ-K7Cj^}H1p!67Z@d6!&-9g4* z%hNfc@Kn-v`8xR{H=s{W+>i(iY9*H{s=tn*r15Qwf%mQ_sg9oCY&_nW*UiF=aoa*o z)$7m)g$9BJ-zH5U3r)+4Gnx74_$o0Szq-HA&n=vJTJpeo>}_^Goi1kbfPD%}~=MDIPnNUFY84DR3V<+OAsrY8vzT z!PWYj1;LM}C9S*!>#iz!SuD|X;v*h^ABVFnA43z;% zuS4XhzhIHRSK!TXjmJf$aW7@ulUHMw%e+;c^K!q77l=u#2~=45_^%Ia25pu`ZoQ=K zmJ^+eXS&{INbX(u+;6#zIL?u0;X4gyUF$=MusF_T+gjzjckc*a;*|3^|M*oLjfNT5 zgZTBfc@pIa+-b{79`Ozfd66=u?{jl8&%0()H}j%D-a4moWh=DJ)sQ~u&ZViZg2S!D z@#Ztl_u``?1^m6}9-dLZBl@BuT<+@$=O(@()8nFdB_Jy%>>dP_?3BkP`5!^-eDcnD z3iR?`n8`LYi;vE0Pppf^dsV*}-;m;a&6w=|Is3Ph#T8a7Ze`CSZNn?;yzEx!;s<_s z8OIs>rx>tTP|rtSU?E+?OFI#F9K=3tZ<2S!jt$b&G9Iv)-{eaWmiOr;*UMCaa)KwU zQ6gvkL=!ejhDK9VBm)8(IRY?9jF0DE?^~T|i5a4h#A)%s>*0@o&=$+F!oI}tt&(P_ zi7=^Q7_5I`5u%PGuKx9C^ed~Sk57m4qK6y0jf)!`;%Iv?8cq9?(MF$gnR`qO6qOk+ z>DjDgemQGS>#uj~)a>n2=Q!p-yRfQ?vBp45lE-0n*{F>vtvIfIk6M{pmEP$(Wl)|d z@V-oK8VfFW)xX+oUK7pMe0}D~$LaPm)s~vaCq=rtsmaVUs7|#6i zCi3&dRo`$U$K%h172a_zP9M7&L_-wwBjn2$Z{1i8iDu1ayc;@cmmLZ!pH?;ahjM#k z5?5G=5_~AbtkGx`RvUjmUqwR{EdQ0L7BzeG=H#edj?3nyzQDluwABy98e?sf8@lm2 zAKg~xe%U*i?&7+(&d_v^psDj4!yQQuM}qu^lXfj1$0w(Is2h!0r`WvBaW|KBol#Wd zG{bdC(Gy*bQw5Ip%$uQ0QeP4X%U4mww*o@vW&^F=*^i5n<2v*k4A*z{k!;ckHjY{w zhbpMwdS)6w_|10N*3zx`&Zj)gqa_c_d0neY0}fqKuXIw z#P7d(HLdK5&l&T+fv3*4LIXqCZv@wSpvzpM;9Sx35b`XaGyat9M=q+x(dnXq#FlSs z^u{kLyM2U;jntlF;Xg0g^r?H{gZ~$i$4AGUGWj~U!V4q=)?ykS;V!70Cw~@@d5?4L zx<|MU_lB*&m87?pVi#O`b%XfF+3&PG%)fQdi7w~PGHGf+gSyBK(+X?v*DG&~8_f&P zOkX}~PjWrs9QC)6Y8;QREH~^QN*ha#F&%aO#+5=986!M|6_0hVpvtEKrMfS;Ls%E2b#c0QksdHyJssajGa9jh@0nL5b{;E$5dW z=Etz=ms-!=)T>FBNB3AK+O$}P*yU;*Z$ZgW<1(VDNF|bRc_|q~HXN%!kH3{N1eMLu z^yZ8o3{fbv^4m<1b$u*3TzP|Bio|&CjdRHb>#aoqI_hvi}-k*R>D}bjdb$F7&A>aNAbd$B=P9=;`D{N{ z&y8IBON7Se3-2V^x1z%X8)h}17ElVrBsJ%h$r#AqOr_ z`lv9c|0O}Rh9Orr>hrf9Bx&!hsb2Bv6E6w+6RdPXZCO$Po2=RyDb_b%ZIlXhn`Cp%Ic8yr5Fpm#IFLa;*s69h54p zujmxg0U0TsI)#6J#QH{5TW*Ed?AE6FV9?T5-sW1+2H{)`v&?g%c#&=rkA<^pH&W=n zFo~&dMp8kzwM?*66@m#KnfoPD9vLL6+T7q}R3%%?H8`D1!Dhc$TGG}!P4&)^OHbX^ zOFEs{%c(6G$KdiOvQ^~;--rsdJccqqw-Co@bgugBOZ0@xCtdaK8E^TXw$poh)^u%M zf8B+G@QTlhc!pl(^-0zeozaT^NRC(WR6+NN-ha3`l6Qkhj|r^9+|k2ew!r|--rbA9vPIQ3r{?7t_`89CiVWY`Gv}} zCZO|tXy~UP_{)T$-5&qBOjU!gW7f*l2Ri?75p2-am z-O?l2Y)qeTtF$p1$$pV;Zb%YhT1^J=mms@+or-cjGh5<$mG&YusQCNXh%gjdT_LPD z7*BvMa*d^x7^AEL;y>#WJCmK6x?-qJP9psF&Kp&*sA_V;N4g5J+^S^dRjYLTP31ix~MeO6{IxN3Dcj*40C#-h@fsKox(Zale1P+z@MH zjv?5B?&_polHM6~;UcXJg))V+x^jA$BEgV{6T%vy^;ofA8c#g~ zjxqJtl-GSB$(Dl`(R5VU;e5@M@my&0=}^^YMyVP!dZOwKLBWO#?WUJZ1J9R>6s75h z#$E2kc`rQQciJz0U6^6<3tejfXSCge8^!t@(9==P-3_6a^v=f@ZdwXKZyLNTifp&) zF6==WL0_0Nd?WUPJZ2!#lzY5@V9|`A#>%M3_k|puuKXv8%&wz?0`dkk%%!x{7DMLB zxO#e-ITgK@Udpz$O1FnU*Up1kGITGwk5^r_IOAwh&59EXd9)SY!HG3H8BkE=DoDtJ zjcQrJrP&)Za>w1lnQ_7)@X~xX`)EJAH#n-8L#JbX!WMoG79QRv z;9;AnOt`^Mii*iS)}8qD8f)!z*u~sL+1UI7I!IM+VU3xv(281VQXJZ2 z_?R;2;mtJBm%Hrzzv&y;`)84Nx!nt@A*NLJkn~V$+?o4AQkq)i8bfsgy-N`ov-Ca%j2ir(mM@p_FOK?B@dcIvde*@1H8c;U2D z5Qmf0=7uO^XHVqMMJ(L6WW)<`Gm!p~u1F<&3HO%r^8DoI-v zN;+jwq5rwlkpTu8K2>AQIby>ljgVu{hW%p|yt6Gpwp_iLLh$M(|Tgwii*1 z1OG9`X+ozA&ihLDF4W|>pV-29trsQy#OtdR)m4X+AFYF-mwPq>DWY*-@SghYOG>Ae zVzn@q1EGq&Yv|DOAloAq#n!f>g`D4#Dpj(*<&p&2;)2gN!lia+s#uQ4SB)Mpf2(U{ zT01RfNVS@EeaNMBQQjXVM;=|a^5_*@DzvvA;0=Mxngd1S{rNF@f(4ap^mFZxQs$0+TlqyAuHKGTG1g_FAO+#Y!-T&&nLsIm=kBoOyB5+r-YOFFmuWnPJ78NbVx8HU7ok;B#N%OG^oG) zVI`iCvz?%hN_rv0Qs|S0#6){dWdfPStY4g(z*D-5g3@Qsu1nAl^>>hY@rsbe!~d5i z>pM@#;{8hfFhJQw>j70gaZISjRDp()$8(RqzseMycy#fc3Lf*?=KR}yx6jS}-5Iv! zq%>6Hg7s6RWUMPJEM>*I60O~tEPUOi@q%X51NM5?*>UG)Vlb2|nn5i?o-0zVb((>Gi5 z8&$0n>L<+cuDL9lnd5jjX=e`U99uij#E+kEIC=q3)Q{xJ67gy4kO-la>#}TP6)a+; z*S61r0Rrl;wVSA)tV!iepeQdpjtPQLU@GI#L$CUDMKr94;Vt#wqh zV6nmdhr+apOd<99dYtKf=(i^1Fv(f5MSrAgZFO+ppVB_n{!q;3*sG=#o8p=uAKe(0 z*TVcxUyxNMe{)+XHIc~S_Issf%w>z|uQ#wlOU!e|ZI8R%`W()j9B}J+s9oXFACmDm z@@qQuC;2oJ1``cTxrEJ+-KzeituP{`!r}Hhdj6{EXne-WP!jChnb6OuLnp_`PRBYv zyW%YJGV(0MSR*knt8r?fzYsqe+#U4}m)a$~@1^S<(P*NScPhF%xjNuS&0o)J#cXPn ze$^B9DhkbU>?UQ%g<#-CQu5Pg9dCCuzg2S1TSDVGt|Xd?*t4~CrLNSND+G*7vJY=j zluiJK!}|xRov1(cNdj?=G5gi)nl`4j5q?5E@sZzN#~3wBMU=*H^>?D1OI^3a7ZrLh zQD$XO=JAojg4o>;47~-G*Xlis;mAOB+4Pe~rNikL^G`TcoY!mFZOypBzYi4M^Zzi=o`u# zPQ$mTkCIJh6>lUi*k-&{2Dc1VxOCAn;A1XE;V9Fof;p}xuOydPf5*$b#N^R(ceb5` zNh<^`rkb6=<(=5|4+gYJwhlgAnO(GTTbGJIXbL7PYSulGh&?8h`LO<5SAXspsfpcX zf`w;2TG5o|E4cC9#2@`@Bd>?9iYdC+?p3Yo_8kQ&NbgE`) z6kNZTP>9l*l@zUda#UAQ7WyTgDp)oJwZiW7BZuHe_EN`My{GYXUp!qStJ*xfk){+5 zUN3vbse4yAy4e#e!i@Edn8fKwa?Y_hw?H#V>X22gRRA$M+K;QKb;3c`Vkl%=O?-9+n@Try zKqso5b%oUD0>8Qp2u;+Q>V;D@GqA)tXknl0Kub6SdLJ7K9W9E*NWe!akf|zgKzFKd zO}suF3`&?ld$8H{13z5XiX-k0Z90Xz1l3}*cdzUDHBrNSU)+2Bahgdk)|vzFP|S{? zecDRZ!#X9A7(f(%J(-Q>`lUki0P&o}vWUB}eo;!o45MVFP*A6H?ya|0?SU!X1T5W_ zDhBg(M6ENA*KNd(_ubByUd~#6|G^|GJ3^vZTOE7hr8zfy-&uCqa01Wz56}4cQoMO- z7>?(s=gB0#t}rE8H3`US`2OX_3+K_&sNk#M@PfePyjNte+cYTqPc06N3oq+_7g;kX zs3(Y`Ts(5YJuWM)sd!*xhC#XZY3(yPiyJyh_Lu{7$?`6Qy<)9G%~wY`f;_XP@tp|* z8IJR}L8AQ8K0L+FdZ7=kCZegZj53w>J*#*`(V4I}+*|8Pn3`83@tawORmQ-|WIZ{> z{A||O6ve6mGZ2Z*#*=74bww9G%F(@HMS(uUzjiD|xkLX3U1CD7b^)3As+707N?c-A zuGW{Bth~V%FY6_nA#d`)Nx(k(sB49L7_2ax%O115jJD{gj29~g$=`eyo^dJ&)T0bd zBMAXjtwVVY9p7s)vOHf3s2UT$^x(r-KSgz=Kx3lc2!=L|v}XKUW|zlo$6aq3(Z_O} z);UqCNv?2OR?cb()uCsQ%H}8mT6$Dkr=B34N)H(pCH?s!;!Ov`w?b8#w2WRhb)`os z)kAJ*jQWS!bR3TwzDrY>kDV0Co=|T1;O=8QmzMt16P2_xcc&@6TVIlR+qTd=O>WT7 zyqy=6j=pSmD=0#bQ{#Mbi4NmO$|sK(CaIGN`(tG9y<^0zWWWRj<~Y4ys}=_+znpdD zrt_GNE?#uiSZLH0!q#UfW`Ql5b(!ZVN=VvcG4km_B{)PcR;@2{-#`EK-SF7FkyD87 z`Ae6F^Pk+^QpPEx)Ss?bUXP&s(ge*<&EN*L<6EVR%bby~j|s?)%c$4O#(ARG9A9P1 zCT8=@)k?jzP|KWW;(JuF_CwOKlMk6}Z?=(Vj-hjphBbV;^m*f1_!tU%_4~YbukYD-P8q{nxnsiimIT@(pT>>pFuDdPo8VeLadU%|p1G82vQ+l< z%P(8rNZ9(vujdd2JNNXu#_|^=_mhM#o(`pyow>Lb3&=Om7>()YZ$}F*Igu5-<`MUF zTf_=1VD9>+Lm78Fy=U#&LRSNt_hS0#JHho0g7k!dZ6KsMD$jx}R=5krm;cBe zq5zKx&obibXzd$EIw!LnU8;T94codeJJr?Oe^&V1TJWMnuI7qixjN-+%NM=W4Ki=F zARhK|BcE2f^oq|Ea#u=Dq2MTIqSs+{s5DDPJ@+S(4H+8=xZ;vG{mwQ=?z&BOgtwzC z_gaYQW}!u;f8=@D5OmY9F9L*0p9Sq-npDmRps&-$+(QjQp|v12^(%k+NHHQ--0PX| zsBF>7Rm&m_>jlB#usQCU=(2fKQC#RTy>(kQo&g7ienCCg34zt4T>Kq`aww*OFG3vq z#2aYpS$%wM-}&M^j-W*ArMzn5Xx2czUd6e@*IDFWg8KBRKD)%3)r@}44*Iv*^O_zr z9!>sFWS<-dc|K(5_raeoz?+KW?&vMN#0JwyGb4tb15gX4mC*+e<&##*K39pj)w~yJ zq`k;%?U1;if{imSC=)D>@{bsa<4$=``hzd4FCrp z#Vk&${!FaHc(OQ^f*|R}J2_icaAGy-*i0wy6KQcMPIpJdt*`;i+&-JWC+|V_!V|#w zDLL;gjGOQ3^q!0~(WbN+uH5h%xX95e?6-i=dZZaLQj#j~Bp<}8`NN30vhkJG$H@qe z{C@pMz1q^Bdz8EuE&B?2=09A#H5}rnmy~_AXwD!b;=|U+{jdJ6Z*v{`B|Qh~bH@5F zRAA!eyBD&-ptdLWNhN6+xK&mcVG-U9Jj~K?(zcmw?4gjf3`uO zXGFi#?Mvc$264w32Ciy|@{nRRAWG-xMLY4buOyO(goh9nie-Yd8yo;&5} z`r2MVPzqf`w~gS@3Nh3BYD|*{phn$+OS+1eZzSnocw0>5wkC>w?Q>Ac;&JN@^*3K_ z3=4A=pS)siV!dZK5Y^U3M(j_mE}nhob~jn|2V(Y%RanZ`6qud`+CPsCq9Cg#Xe*ya zdm<{H!|0c$#gs8!QDgnO{rel$llT1_TpsltIh~!xa533BqmY6a&C`6NL<{@cG=+Da zp4Uaj;A2Y9+4Q21UA9>C>_Ce=^Co`u>eud~`jW!uD;CvvWslgV(V<|MYCqUS>A+gM6Cu=+U& z){H{$etx!~C;5H@^VTfY*b+XQ4_z9%-|6Jk1)>MDp+-4CQmmVv0y455lgV928BtsPMMVm8|q2=PT^9!WZ z`qayXnoaghXL{M0l)q3XV$j>Vlgsj9(ow2R=9(Hl*lW)7#)K!`X2}_;4&xiqwaS|k zTN9SPLYe*Hb=r~s4)&IYV6D!tbGOLX8O~LVEGD_J*~$?{YL$+5hFY`-_*vw1kbj2; z*Gy!*vg%S(qA%@@9u&T))1)s%0!n+@MyarXp0Oyl&ioM%{5>TF`u-(_X~&mOa`-aR z10qWyKng(4+m{)#st6QaFt5SG~T%UW&9mxi^{B(C=`3H-LOq%Oq9_u z`zlCcE=)5ak?K~ITFHC5#JsK_#VQ*H&^g}BKDMR?rDG5r)6s_SPvpX&U47|AV>IFS zKIH}2*$T8SFTD&)#l8CQ@jIeGMrm({tSPe6DXslWf30FKKdWVs!iKIk4Qm0L|(!)2` z!V3INdFMz;`5%gE=m{|r7$}?^oo(0DJyon{&mM|qg_hT0XwrszgpFzV%~++K!AvA| z2(yKvM7QHZ+=R#NceR(hkAH7}t`x+V`OKfK^I~@TMyBUmh~I+*LjIG_KI0q3uUBu_ zhI8H_u!S5OEBTK771Nul)KZ51USBr3H?uhPs@W%QBmSY1$5|W++C3bhwGO&Ug^{;! z31SMKCVkrDQ}(@nWyt7O10R=U3mcpFeR-87ZQDh#UmrmW+P#!hi)!(C8l!HX^qaD7 z*<_0tHM7apm^6PJWK_9(!YN-Tfo_npWL?wfQ+4*Nnn z37k67i7@HnYD&!xPf4d3x5cgr=UM5&lWIO8H<`Y4@;`p|zAtO-HfB@)W5Bl%`pm^r zD{|~IS9bgBXJNAZ<2iT4gM&-SdXExPkG(VS=af)29K#-&n~Kd+F}$gLDn3PUQM8Ms zgQ9bYu7ntTx&J+Z$ol0QmWFdnmL1)n1wX2kf4u*cho10ZvCQ|iWcjalG)cy9)cH!! zFDO8AZv-)v^P{bslad35g!YtcscbmOsLbHHsamB{`76jY7BAktYUfkWXo^l`CP48u z;xf4O<8y1@)qtVH26(taDfyL{soEb#E@w5j^;r>^LhM@Bg7MoPM=HLwoDaCBX}FNc zzK}&6c5M1uN2ag#cx{oZWRj3^W8Gx|&N9LYXoitaC`ouNMNxRV?ejz~XaFq+Ybh%& zdG+;#?^o8sqEw9uxNgsVFzj7wN_Tn4v=Sx%f`_o{(wAGNJ`d95@lRcTV0yi7L4QMm z0%P+*ZVsJS6(+`ep8jLijG70(gj4R0_%YYXd9K2?z%y&n z(+eN^5=C*DO7y@HW{GOP30E;Yjwo9_YbAe+%XT7aWi*LBE;(7>YK92qnoOhHEElVs#RCu2oS?i+roBkp9;7yM#z0LwkwYi*X6} zBeaI7l^w3Krj%($ITrk}nNNcRY6eO|=$z`SIU_rSPq?|71m?;d)f2Cl=EHterD5n} zOnTkh%t!chrPpW+MI^<#0mU*eo|lIXD9Mq5(QQ6}(RhP|LZnC&{TQi9QU93fT38kvZHHGSLm-NpH=ob-BhE$+tH=YF6KWOEB?f z-I9EMm5-%N#)t|mX+2R9l0=vF$ zMi^8|pQvQXi_$7@d}%y!)VQppAYNrDdF02^#R{jQ3%Z-MVSsV+(SEA*ffFz+3@P7D zR^u*xzjEF(cu;7?xrlMZ#^vU#*YpW}S1veHo~TM2SAFLzwv1;Y`*MiWsOt6*NWEHC zw%zXrV^Yx2Jddiu@jlF_>$2~#O0U$tmTTjFFswCZ+*{JsDmko5mqHo#T;j~aTAD#Q z%|$czM~dI0Zu-__^%Bone4{Zo%5rqlmP@UMEXLZ}L-I?!w=U@t&CGfweZz6c5-y3o zxh2G1douQ6R=|bI9N&sNmhI$Mk9LQgbLYP(mVQqROYK2T?dH9K)S7Qt1YH8zSv6VF zhSKAy+|Hp<0~;6B92JhOk7}birX{2{$|cKXv{t5f26d5bwAyUNK13fp!qito-NBr) zITa5&F#!r5U&0;~l(>ttHPvJj-LatK#`fYx-V9$tjYsIyjuUtuMHq8^Mg%WQZI8~g zq)gMjD_UkRJ!Y4X<*}mpOxBc%uxt!lKJiJaiI2K&s>rKK;2W z<}#rE=OKxM5#<&iH&&nW+|19^YMWofpfok@ASi241yrk@3!w)#){8fDV=9Si8^5)`<$1Ai+r(e(h=i==||5#0f!9ECGqDm z4h3^mXM-Mpw|NR;N_jvPyQ<;e$@u8b=S~sI%!fs0JQFQTOF4JCe(0H6C&#R$Grfv^ zXWzDlp2EV?&1c)r zaeM{vSuA)^6SSg0*)eNJRv>-&Et?VPi_`a9PVfd#WexygQIc|M*l{o0myN(>w2DTAnu%}jX)(@#kE>ypl_(RH%Mgt?4M4}S>&DwLP zB8(K%=tq{5^|H#67X81YlHDW9eHcuVG;%?QQU2n$Je7oB8H>iy|WrqacwHgf;gS^ zjIs-R;2T$rs{Mn9%7XH}{!|IL62c$k=xa&VJL~PY?x;L=-*TzsgEWIzz>zqEK;f?w zPB(k9*ZWgq3*5_I5In9z@r!t2)W(Q*^P1H9JXRy5jCOJp@aDf5M%O4^B;&cz{{71- z!sVNsoou(aI(nnb2!f>As6D`&gP+~hhFr#(&rV7k<&?Nkc20$xo44jZ#F5oXLNmS8 z(M|u^iuQQ(^Up0D5oRBsqkEsvaLe&>46E@t?%4?x5u&l9OK)Co!a}8o`NVWJYREi zPI7yuXJ>>0=_DZn%!H6v?3&b!4k{7yRpd;mggI%JELp}WGM)k)zU3b^)#n>Ri%|(X z4bJ%)hTPZy>7Q$jTwY$JYEC>mx+6Noms7`Nd@YXOm0(Pl1gz(PkKJq zpR9b@Jj3CfDV{)aF?vi{#Rmu4k>EKP>mjkV5V0cUpUvjA@Nmuj`WXzYnz5-%S5NcH z<}lmPu5+}ZJ~B?oO?o-bJ1 z+t*`}$gAV~n74bCshmyFkFCM9RKC;lf@UT~oj3K%mjcyE{!LgfN*G=VOV)lUzzgDQ z>Z_CUd~DW+47^%+6ASbc4zQ=OS=I6Y0j3ua-P{o;h>X zbg5*6pu8$GYM!cOK>Dr~1Nk)3y~X_FRV8i-P6*|L8uKe5cL4vt z@2=n5R;py^F~YGVBVhr$z(-#zmC38x$s1iB>n1(}87x8P&3)8I))+sca>(PcKioL3 z{~F}=MRlbZRq~wa1AC|Pqm8a>G@c6Y7sqtp^Y!tOC!zFE-?Y%r1U_0!H|u`+)}6NQqyr2jZlbQwFKq%tYPz&V4=1g&+FqC}&yui`Mth?5Cf9=ijq?{+ z8%il89aojqhv;N8fbGldjF=-&$Cz$SesAI{w>rNJ9Fz(fWp$Ei_X?8jz&AA-Q0~1( zTy1ToAW~wW+G>6(pgE{>cI*7w%wXLXrdv2y3vf6d_8&S_!(J|vjYii@d;yBX~kPU6R>uj#L`M;9JJwYhD>E#;l}CGi2zI|dCW zzxPK&3N$n+~qA z{`P518~bt_o^QqF_+G5M4q@L(xkRd<{ETsi8eJJxy3UU;%xWXD6(Y6fB_CH%Z&WSx z{#ayXUmNdTa>JEdV;d_49&Qg z80!_Nlm*=Ul)Rlj_u9RO+^*Nq`$fGw*LN|m>0Oa)z2C7OCFQ9LTym?5SLda!YOj39 z8_jNbH;Ez181Z$y{(jom@dV|7+2vP{3pkD?J&i5QCl{WiIY-M!{zCmmhRql21&OwJ zYz3_l69;^2W$c%RSITW?-kkSX*sLlI6quZ=X}i?kC%))+%f(DXy|3@?1&uL@4t;Da zv9|FC;&SXKqK4iM5^ASlFcu|+OW31B#iTXFWJcvQ)lFvFw0yI3b!F5~u9#L!Vcdvu zXUHO+RvRaG3EW(1@tU@q9JdxS5_QOI{pK|A9gl0dBw#HCs+{eXe*N>TT*Lae8BC5s z>>yt3ubGTF_EiJt@XJx$++CE4WA)h|pPLZ;>dX8U(`ec3SjnRz^UKl|S=zEd>chBR zP{4JV3`Legd6vT7NHAMQN2A;bH%W;gmFAR>JbsAD@xGTIa{T?OyxPD=xf|r(K7JT4 zd2=xClXy&FO3Q*G=ivRkKn2dhDiu0Yxe;J-4H`98*IuGGlMbD@xIocHkmg}>&+9og z;kXCBDTConGJX)-{3{kiwLGp-uUhZ*akR{Y3w&Yc-;O3kw}Rq&?xV5_#jp<;hORJW z%{qP6-x`u!tXOGXmP@XPYH$yy+-O-LiLowu!d%Q#lZCE}V~W=Rk`l4cjR?vJi0bk- zTIsUBQe>YaqM6jpk9U{6>0Fc%WtZ-h+aOwMi9fsJhH=3XmU>O(0Ilq;=4ZMU*`{Yd zg-mIwP;`cloEA9O;CT)`^7;ltg#U1t8u^tup%2G4&djg)R%V|fDq%2Vdik0~(ruZp zB85(1fjVFRNrMANe%F&AZ!_@(jfeG+Qws|IOJs|}7&3nmV?tx@@yX-#xyJ8?uw=*cN- z-ZP)a!gzzy-9B;CvG`h)+^b;5fSN(ty3Md)b#lM53nx;Xq$Sjd3)^xC7A**gD z8Ikt>PiRU6xN1mH;ao3ALP?EZq5Q*=q}T!eNou`YAbgJ@eA7S z>u*q`RZwJ27xz8tDZNZ-QHf3yF8x))D>y-^vWh|l71ufAJYIz#^d!^u9~oDC7Fka> ziHAp3z9?{8|I+G`O_O5xe%+TV>O^L72jp4=#=Eq^%!F&U?3P))B~i_Z?U!ut@H}~U zF5VT7K^hav-`Dx+y0gqwlK=BX6t*cE|87Av#{1>iPo<5IFrA>!6GTg8Ygz;9aFO=d(9QvfgS9r>p>69vgp@=g2%1>)a>ZyfzbZ;N&X&x?eUDE;mS z&D^@sdTY13H5~o`cbN*KA4LYmkAod!mo{Uo>O}oJr1(sp)KT4feT%A!9J}wboV}2aE3M)XxK9D{gIDEQHwWRmqr(au5T>jXjGdEd!>$2CnT&GS#XM0UXwo4 zTyVPa`kbQk%`@SHZ<=q(T*UKT)?{jre%(;_AfD(%>$lHx5P}!ilJ7_xQ4Vn$2M6+B zdXZcfdMd>|B3c~BD)Rk6J9BtTVMzYass#$>2+zDf_sRbcl|X90jbr@7qTiQ0dqpcs zo8Yt|J>SmnC;<)@5%6vHEYVKO$|LCaSXS;&(Z84gAhEcryYlF+96o3ii$3+|lME%A z5w7kIsn4(ppurR;q!HSu+0Kbf%K}8+LBD50eo^#GkUW%!&&xHSU#|()g4E|Ylf}v) zgniI9=l~3J$rBLKea^~Je-;y9=WJ2qBk1?sQ2z+%r?a*2azol(l|W44_lAHkdxO3=HJ%Avpsf$k9fG7ws+w7BE0b8cg0G)njRi;}~rbq4t5 zUUXDx5JP9f0AsEheTCg}aU#z!G}8cx6vs^2C)D$yz1fNxc#)uK znIzv?u^XC}*8!y?tXrN? ze#hPrGYz_5h9EI+_j-l+ZvtbFH>^7H?~3L|j=R^=Q3>X=9jJ*0H4vCfqQMo8znzwq zGov{(z*<(ZCQr`pjSzdzrzyYZS|B~z0{=+icXxp-Ctu=YZjaM{solWd zVW;hGs7=s6sFCJOy-rrLeXN`z3_%!8;eog;L0;S=$1-bKa7}PSXG8xwcBFwANAnfc zGBL`6mH=Gz(t2+A@94x+E%qRkZd5Y4Mu2IIJdh33+RxPh7KL=H5h_*{q0PiWdmX;D zfGJrf_=6*7NCAb(hx&oQZOI28=_1wATVB~(IYauV0rA(PgwA~h2K#xrYe;z}ffJNS zbcp#97Hu>Td_O}JgwatT5cv}h8D&E8cf|^T`?#X-Hq`~R>q{XBbz$snmIR4E;yNuW zXT}OE`L7eZMC2#_=d|zq}DG1^GxV- zAfIm-UNy&tBlF;%D)fFseRrP{&JLDUeE&ruT3J>0Ihmg}A=@I{3^*GK2jELvxv`k+u?ia>E%6fmBBSM=BN*{I2hu zYlgUd$6t59w z#V7Oy2&s8ajeI`kUbc(~7l#;qg1*e|3tJ;V>44w~g|Oza76ko(Z?^<4;6h-2QpkAm z2R~NptMoH1cbQgu+mLYvnB(O_-X^2_Bh;Os-gO-*;r@NgM~=P=bNl3WAqiuz+&k4+ z^EktD--+_=V-yx4R;T&Q25_8g?5bURvU==RW-+)HxH(t!hS=3$?@JjZC?E92bE+== z8ddjOFKa@w*OA~%r^2mpHJ`6!j5Swc-qh81*PoJw@0;NJA(8;#k~I{ve%Z~VTM#$1 zE>|!#yYFSme9V<2rwveK-|FX6ehTn#-mgg;Ei3t)1U|8u2scFOE0{a`1AUr;&eCel zk~@vlf<7OCU!}`zh*YQde9cg)5Gx_)GIVpD9Z+716>=9qmdNKY%~y0rfFek3N^xP8bthtesQAT5U_AD2h2%dZTU<-*qs&W8e_r-a_-asKx-<9W1UZS2B^!eZ#Jaz_A`-56H5)~$OIJ7e=Jypv_sT`UYoQnbpHn@~wWqs4) zz>nMVXs!2}&$P)YuHCE_hy>q{NdkegVZtF(jYjM6;`J!9$Krz2)aERo$ps$+7=Zh} zywPGi!zP!s3r2Y3j@-lS<=b+ye+UFw1821{<>(9u@K1e!*hhja`Lip@0#R*qmn0*f zwhD1i%>GbX7BUrn_2&6;|B#E0Al3II`$s&8Z(OK!VkW+K`mX%u(|7ltfxcO)=6p&1 z=y-jYmB;4uHZ(i?XQ~ugo~8`V$9em@rlTR`pns6YA|dkWjqcB^Bs5F25<^_2!4?A{ zb)FL|C=BglfFGd_9PkY=kYj-Dlii!H%=xsS&xaL;%~|V=KAGht2Y%BCy2B7N;#plJ zAA>*y3{^xvyANgPdpS8l`ibvpr8@NYwPkg#CK_S!tohFDSfKU5455ud1R`8xo|u(0 zo6aVgb0vRh#e9E~Kdsdi)-J&z%w()G%3-7NFzOIS{)q__i5XBvfkqcft{xMgAxUQD zm@--+w&qW5EEt^J0sWS0fiW_Qbo8`Jl$C?GfjhW3ynO1_MG5%W`rA5ILV*+3Am$_WOTYlCib@klti&eq{6^$;D)2G0Ov z?6bk(lQztk{3qlXQK~oK@vNkLWQy!2dH+~p!S~Zfm;L}bU9jXQk3abEfj?cmdiCo6 z0R@}FCIA2c00031!3_Wa0A~yU0C=38nMsaZNpgnQkEh@TJmI6cdE7u7ZM5x;kBtJP zs)oeAzdj4Hipj>K6iI>vGKokxx9J}|SBPHMTrT;wU9Pn{JvqA2ht$y9T&G?opH)_C zF2&?sULm_y`FKnkviQp1a>vc$OLtNHMuyKxSE0AC8WuJ4_2NfO5XD_9DjeSD<+2$ov=7VTR&N+q+->1I)`oqTkooaChY6EwDR zs{B&8@<{$I=5NV;%S~2{J2yJ!Y1>%G^}f2(*;7%U3<)bCF^((OwrA%)v{SZt0Iz4z-JVTjd zny!tW+?s9HI@09dFUHF8t)}oU)|Qd>jM<&(hs!x_r;7u78ZEUXd(2_HowLPgNXr1B%Xhh^h2;+m~<6&TKfKq zR%58R(^pe}nwFKWGgh^Aa(fO+t-HCgt9N^=)G11H>-S*wxzP+hnZ+xD5`QK(ijVVn z7be%us|-@yd^M9wuW9djKA2dw#8P|s^5#{(C-&vjAHR=3`hTzAe*e20iSt*hu4H^I#j7uimzF?^(#N}FSjV+5%Qb;@Sm{qZ@>Tc<+uKypT|Gf-}jf# z|FggR^B*=$W5GOggDjT(*y40f?_#ezY)x%?F0kOKw2D)eLtiQgacXvn4uunINj?8{ zf#8jfz_ln41Z8;s`LpZ^+M|&M0DPRK9IlT8A!#h@>{NUAA1ixE-#|OmxArfLR6=aI zmFOfW+0~w154EwDz7-~G70s74m5`27xlb^5>InNbq~1gfyU7`ZXwo<=&Ek4yK7Fk79OizGbsY&YQaQKAup}4w4w1ON zBN#!NCD;2T<4s692r)ftPo}ve6qG2=@9ncJsco&R=A4JF?54oTxtYR2+qfm6msMo! z_uKF;q}(OBTFPN&x3wA}vgH)HSUQQtqLss6kxLkdxY?gcGQk7s;>xt@qhP$h@8n&M zQn)?C@9IkAsC@#mF&Uzd>O%v1{Z&y!c>A#}mFsl>fVtb+8;g zFrK5>)CWZtDeGLh0_d|u$^|a4@?7SGYDD8Fr3Y`dbdXZsY$uOu{2?g?>h12oI7vv5V<{B6kFMvf4Lg>#AVFdz-Cw@o7} zGaLPg+QvoUGwfBJ#5_zU3)5%X{w2GJpgba2x1sryeA#W-sQx zJjez{dxx}QZ9B9vy!HL;78AD^wcnTZ4g^}$h@jqX<+hD9+4Po?Z)`?O2r6mwD$H)4 zG!Z;wq?N|^VQ_btEPqlhYrQ->RI_jz`;7p1**EmZ*jzw%@Q0;5PyG%{t!~aexH?cs49SLGXB=PH*>R zy#)c#Bm}cF5&e`AJM^d{hJrT85lu1E1-YDB%yphLuSEd^ICpq5Ej!yHv z-JpJ-))hzC1f#s~?yQjQP{OvFdF=S#6>b^#r7w zUgRnYn>vQo?0K|w1$Wtidrd2CEicc`@E-9@4`~c#1;Xi32l#el+g37pan(JtSM(G} zWD14-7^WV8fT_sHg$aC}Kw|3YFz^!C|D3ExNJ?xr6Tx{MYkq~K9L`48rPN;$e7)*% zz7N^C_v$|a0-!abjc7I4=r*p+(YSD;Hi?Say^^N66*MFn2ts)2<~1y4~t|9R&J8)?k($MKtlj zK_iZcifnjEiRSdKiXlleEoagWgbHHMEUYn!7uoco-G{uJop^SJ0h8MpXv(S)3&2PM zsWutov{A2J8-@VMezu{xz^9D{BaFJIkL0Q2_1WRDK9Q14qS#mrv6$bji?BEu$9n4=m>+9Rm$dPZ;MkS0Wl_WQ`#31p%%bPg21u3RLYM&UNIh)U~ zcM_<Q4nNz35QdyjrJLxeyC-S zp7!iXWTKBS?*Na!-br(VxkE*1_orTkzC6~T?umP)tN_E+b44wp=k!?ZQF0C&u@Rf1 z^At=|dR+@7vyArE5sGaaz8Evv&Gj5~z*yPKxf*O35Jd2zrxHz*S);+XXQ!dsyWEfK zZ6h1And~rcgIFWmR!KCW6B$d{rR}TKL+rv1irUjZa51!HR2kxSE>_Fmm-ViZMWZro z7IAfRuMP2m0*R*useaXpY5Q1J>+^*-*&|u0g4_PgJcWxErsmJd)vE%mpsR+p7w-QT$g=)1HDd*~O6>s7T z^JgvrVrvPOtfNCKTj}ag=<2SC0X84r1RPq*29?z)F&UNYzUXHtdet@-++pvZ_K?>W zgFLor*r{6rjQx`&?*Y>y4=jDr1UHTF<@oam(f^qSbC0RXr# zI(7u-z^7jx)O~V@Y1yMbsF@o?)H^>1>cj+)KcDD6R@UdBmh|xL&8P2go?kvic%eTn z#4?k=O*$O~-S*MgCM;h*`f~g=mOf&B{^Imquu}Z22c|!C%FJpt!vsenTF`4J47Ae6 znn$qk@X20tQPJ;%{q613r%(S2HfmCB000000098Q4FCWDR0aS5c$~GD%Wk8`5rx;( zQ|JYX=&tJOs^&6TWfK?yvNJH~E6haJ7?K<$Z=dsv9K*xmWF*ETX+eNu{~zme>B% zhNXi-)k?{uQgctV*haG0MoVd@X$>8vkgx~Cb4)-IcQZLI}V&FM2dDF;z9Bu z$Uchg{#BojwLQ!a&%eyau`@AAV`NIv3MF-+W~-BAJSDGQEVSK^f8E7^pWWJC;&0vM z8Aj%nXq4^4b!93=Ct1sjV@ad0{AN(4$i(G7qzyFXeM+)x^`SoY`Js+~{QCUz^L%_c zKU_DGm))OE@83Vo{_(LNACC3q`0?>@e|+puvp&xgr`T9angF4ppxg$vP$$*mjayuC zmT(iN9YWZ1;;(bE{!h*^f6r;0^C-?#)C_$PCeN8s*}^RiFV)BUIrX)kw%4q?b^P}n zAAYI-`GmN*S?0Q^*Le?-{#Z#h`Z=3Y5+|b z7`7BK5p@=!utu#jYl~cN;Y+#h_iEV7lJI%qK~`fGD*~dnD_ptybW%4wDOab8j}e5F zwJg~N?UFkk81t>^OKvl7SoujZJ0!U#G<&2%%3Bc}^P0vV#uVnxYDv&TjX4aoh3X;auElZD4?CrsXK9M{*qz z(yii>g3M^U)rL(Cm1c*mB%g)av_Nxuww|FiFyr5-VbYsJA2jcU`O>7hV^A`qF0-Lb zZDM(PWg0QesX5F7eFs_=M{$2XKK%16&V4$nR;k9eE2a1a`OAEMIa6nI z?PdrCefTE(01|j@FA^IptQto4HtJONc>n*&v%eFf-myq1F!fdu6VtX~X&Qx@(`r?d z_Ph&GmZBsi-L_N#-#Iq9HNq*ivIbhe~UWvKv$m`o3W3w_!u(aqySeb9S4;lT>uW?g&n5 zP6*Rwc#KFRvhy#YfB5O+<9R4nKv%^wscu8Db-q@pn+ZZdwZRalTd0M%?Z2w}e*kqe z?h)K%adu#g3LqRdMnjf>ZnXv3!MN*$@6NjIO`MhBwXk7D5;piwOy!dLRrS@cCiEyJ z@FJdJlSw?cc?lF@1G&^vdFF48h*Zz3W~K4Ex)lpi4Za*2s+?<%jEmZh*uIA(UMV*E z+gKD^9?`Ho2f4vw1&KSQ6><0CZxkCRa2JHz5NpB_9n_F@IG3anQdtADS(Bk{){*Eo zTb%F{XY@EmY|v6Rh~foxf^?wRdS$SUmrlW#j(L0J8XYDpPOZf0?!#NRt3v(v7hLLI zj`R71K6#ATkq)G1PzeGC2CUOqA}W}ms=lj?%!^5<$+#?~t*Ow(j_Y%sBGcj3mjzeZ zq$J4|DUp-{3!F5#mkk|yD;3q4oK<6)F~nWEg8t|FbiRH@a}TJZSi_gc?HxigGh`dT z6E{51X=kO4+0aNvVu#v{McyWpjqWqY$hnM5=yG7#Ub;3V++7V@RJc#H*bz>+p!}E& zMAvGqmD6xvK>y|O1^IlQ=X=vxh75;-($tdPezEwC9EQzXxx*jk zFgD-2*F&4OG-^j_IbN;x$ySw`@_L|Fg00000 z0098L4FCWD01yBGc$}?V*>2;yvVHFJ73aZkx{5gu0S>S|Px3q)7r3BA%JU@8{`yNL zy}Q$K9OrDL5p)v7MlnlOtJW%#(L^ADeCcwHkV}+et~5S#0~A2WL*ygOf9%a}znrdi z>Ns7E|JpwV>*Wsw5OpDPk^DEn&lEos@g$J^kxPe)6GPX>Ey^J&kv>fSiog3C5at5tLJ%(t+hvg_ zEb${iu6d^vonY-u8*L|zhT9g)TvG|XWt!Bc{WDKBg2`eu8aBgxc9&Mk!)|yHer>r( zomW1sWKw@bCeMgHBmBe|`%zx&%a1!Kz?Xjj{C+d~H(<{I*pD~hWG43i@jAwaFk}H> zz8eAvT;hioB^4;wV66xN7zsmPuyKEaW1oV%pDxXHy)y28yFu&VAC~jUtbaTl23!BQC?1`2 z*iH83Q9Ik-aAmfEzYUeR8`&nwJ?udI?~ga$BIm7l|Cd^g&BE+r^Z@r|0BoHxBDhPS zl&+x6qDN%t6A+?+zh>57`?1;GR2X@N@)^h{O5r0&>Nr!#pKbJ~` zDz$6u3gg~zdfHzHE0x6Q$1~^6^0LLMEgq)u2RV|$? z<+A(TBhlowaY!tSI**mq@ zt!beG4h?YvcO!p<*nh=PZ?a7yg_JPDrGjIZFeO|S04oZ@08trY27U8_ANm847m|om z8NKv7@xMSZQ8e0x zQw*KPdgn(gmQN)zkclDB=e*RGUy4qUY*h}6rEUz$a^Z-mzieO3o{DWST;Gj+zuo;e zd1OLA@cqD*)L76ArLHekXuk#a(Lt*-p?MFQh09qVO%%iS3oQ;X{R}Ps1RhJPV^HYq zj}wZ!BR)B2vzMA@!c#)}D(~dBV}2fVqFE^{1Ao%&kZSE_DJR=r47c;H&8#IH4tBSZ34hU< zop^y?E~cfSXo#-0bQ_!P#GTg`iXOx`Sr6+nlD$IY*>M zgYn(SU#h$h)%iP(8Ty*XdE=VE3QZ!6bbZ5tYm8t#V8WMH*3KsD#oj&?@z;2%ri(u? z0X!yDM4zdAV)!8@5S;uj-N329oBAZX*81Y z>{M|&Zw)f1L8qCaYBeMG;srF9Sf1r3aQWvQO%&D z+WHyu)6%_3r|_^?T-=|6;7?vc@IfwFkMsz)%Fvs%*VjGo>~Jc~E>rDFj*b(&Fm>7| z=Njc!?RI@y3~f}_FIABqXS2LBcT`>NFJ#b9cJD_1CSF1!@LAvsH&8D%$ZaNB72wjq zdO%A`27sI9w^7l4sur#~+O$&s;w@?z+!3FDuN|{Tp!wpGT`qOYLt!rqVbC6R&Z%)? z-JS3E$Lw%gU!2p?=qk9J}ETp?DUk)clsfzXaWxbm;&^U1z zks%8FeY(X|1!kA~lVZsBiA;_sCB z--#n^LkmM&otn7}g)cT&xze|4oLNV0IMzhNSKDF*_$z7uwZDPF0xe&j4SMM1STYKa zV{gSt-CI=JV%BNry=J0UYOFwc*a_xp;qmddKe;F%cT2czcNaN*!T{Bq3Y2ZDzZ?0F zRsJ*A3#@~qFa$0RcsyLQ>EHvbToM|IYz*J$>WdnZ0Z9a)P<~KHJob`^JQMI_YwZza zp{$BYV^rwn`m^QbDx=nEQ%@zJNww-Dr&VpW*Oz7GJUD^KTzB-iKW@sS%4EK*tR{h3 zdHd7d$X}`?{)n(84Sj042Y`@R&;sHrYbjkWbciMMt;U3pG^9lfAuP~G8uE3`ARdy5 z#|Ex7EwN!XTSJvyd0V7eyxH!pfjcZ3t5&(k*HP)zJDY9+(I_>UE~~J$ZJ9Mp5A_a2 z8Ha6#cO!q1s~_Qofu>rjz_tBC*pdT*D{QZGg$Xf&Mp7+;uj-{&+$4N}n|$2kwyK$l z=(M#%Y??{=wd^VYxS1#_0A-^;FG|sx@ILF$Go5aIo}Ue657$;T6CI;)yGb_H{BGng zWf}W|UV@MGCI|Q zjS1Lszrf2h#ZrmFAzz$NQc>eV%G=ARdftX;R1VUU)ToN~vwrAUE8SO@!#;O6@;9ZM zFDS7xlUs%-RvHM-tUQ-89titJbA$oAv3MU#gP*90`;_*wc1Rv3+uR$1Y&$&)w`8>3 z&I`lbg=CtES;p_SWnQ@`Y|^h)mxKIzCXJ3cg~{rQIWnz{^ykT zwh>V4Wk_JlldIV0324QE9}hx9Wu;VzIo0|LoMt-!`_LD^;bA7kBe+Oyz)%dv#4irf zskh?|Iy@ytP+lQVAG?E0=~}xs`wdwt>lvqXQ!YD8xkyL(T~O|<4!gUNzbVNOuxiTJ z*bM?@djO)?^%)CX&W)ndXI$Ls&rdkb;>O`e!@M`Ex3c!3-zkrSZe^H@oT*xt%U&(a zAu`)c$s-H#rtqID0hjIcC50^S6ebtB~wp+EI8uG-;{3JEZ6Q~XCfE=c=PpN z@z+m_b)XjSHXR9gru@m8$#?~y{OF(Ul`vt2qf)M5)~pgAgsuuCbt$#}9RM6Ec|#U= z8P+~Sn+cykLx~?h!)`@^q$340?06XVOY`~YG*0xU!_w$HYZXoo_Ltrg6t{V!WuKER z^*hT!q(*hGlRwu*^={WIo~?WgKmxQ>f^+vQkvp^F4NCD{fP(q@KCP|!V&W_nOd=2RQ_miU8KR; zz~FA=?_vpz0}XuR3TZ_mp_*GaXYrDN28?P0*sWarq$$O?iM!4Gb@c*2+)u%kYCAXD zr6-%-Kq}4GPFU@Y6FEA{8`01izc$j9$%Q9#3@p^Btvcm4?sj&AZMnBFB{KqdBY#^{ zQji4F0OfKRA2I;}bY)xcfts^w}Pvg~NOX`j$w zx4I@_Dxoq=TuF)7St0b&N+tI}kkrLe1N((j8JKIQ9CV9Gzh8XclzvD4E?%;oh%-j ziA85;1!A$Q#KCJW_$hl~*J`*(IqfCP4EToDOHtFNoSxpm^(*o>B^kuRLSyA3*FxJQ z6U4eX5xPM6%n+sc2ZP>EI86fUQ0}MM%jzL{$Z6ML7WQrb+YC}A!8^G$$>ciL1uvV8 z%eKguxNSAfsh>?Y_m^fVsP=OEL$|ca;8UZyx)#f;_Xg#6kX%m*)poYY zMsK-1*ITD+sSqDYCyg9TPUlARZsc!DHv=qrQyo}0N8>YOgt$JJ z$~7U@z-X&3!>@CiMGXn~IcoTE1C6e7yBM5Cb*Y*%vl?G!o#D8d$dz}6)4Ji!E~^u5 zb7TzMf6o+I`2h~FcH_-tmmtVofr0O8n|+iS#i+ai z03Znf0PUs`yr9Jp_#7?vc#53so4j4j+N(xRBR>IWkEf2!L_a;P(<#TznKSasSD0jp+;`UeM@2X5Lfus_+p^4`PjD%L#a_#!Q0+!8ep8b7- z>I;qF1vmcTPU)M~^T%1HX01-@xT#MsUFx60vu@18Tq-e1*Y?~AYiQr8=VqBjFX{?! zFv%CTu;Hmza#fpv)oh1h_ofm2iu`SjfCyvZfyB~(yvd^jTbU+40)s+kVYDCN%|P_y zM(_fg0CxvfuZtJ#A*HRNQD)m%u*=2khSn{?)a4qc66@T-OOA$>oKsI$T4h-4Y{4Me zobQ@#jdR_4VKngmebUZq zD|$6*3>$srfSSBO)RL9t)hWHv$XwC!zyC z6Zk~g!^seHM*eI|3&q8;GR8xd-WNB!QvyXrvK(QQ)D79~oD@CPGRuMpf-=;p-ep#w z&k$M>UY>qHZ9gM_OCvBCqd?krsWgbsf5ei5#`AZsf>7chkl^P0$7jT5JBheI8G2be zCy!6k9>Wf*u6ney33^$_iy zvQ@*D(`KSEX4ZusJ5gt}=*;UyG0CIh3~&02Vu@sugLHE<9+%F8(ekE3e?|T_Vna|% zj056^NX8p1h`35o+ZQx4oN9&`zp2rC#Aaa=;=Vz6S-gCFkEzz<`EHXI_p;nd&d{P} z+Cj!z_wk{1S@UYEZ%U(bFj-aWhikfLh~H}FXQ((o%=>z~+Xa~$q5CWHHxZllOG*oE z@lv)C5b1~38wAh|2v(Ny7`K|_%fx0;lkC%EgHHfG?4n_HcfvwlT2Y%8haFn#=(3ke zLQX8I?Fh-{RKwtzu+Es~(pX?yUxC=~~}5kMxU)-dXbol8Wii$I+#QO+;eCnzj{GgerU)lEzIrJ_n`Fcw z>I#Hx?pWm&gcNZfKc3;fpk=H4bF`#mg*wY5{y+JX>R11g1;Xz}$6 zV~r>Fg;0v!bkBQqMtOWI^fS=l6MVe$IctK`IXPYTmpwWd?_p{ior=vwM<)HlB8rxy zOo8W`J3eFegUM#b2)DY;dG6wjmu%2Bmu2^E)Lz&Ac(-6)7-mz0{H&oT%VmiMjoLOoUQ!LaNx8a?+XKhT4+$Ac z)S{Uyx7B%_^0Zzr`fZh~?shl+^H<>SAu}r~fMF)EZ8JWY$(V3`Ls4u*g8*rW6_-E0 z@}hU=D&DP_JrOB?o~yX`qknX+!bZ2xDIPbd_;M1mk6aiET%f7YkXbgCHwPu}k}(7f za&VW7DSQU-D;ESY<6=PTXJ@K8AkDQ$wx>=r;T=5(^gH>6T7bqN60Q9N6EL0UOP$kc zQ)AI6oyiS%$9&`csOER%Zy{qJ#(Rgrrn(BSeTpGu!(E}7aL}1_s=e#nOCP!y!*f}uN^*T$D!Xbo zlk6tLoO4*f(Jb@+RO#=?-!(_|1@R43w&R=lP(|pwzHRv~^c9E5_pQ3T88_VH6^k0O z9se`b&=bTDbF5Eyy(;Rr3wT*72GTL(8rq%`sUSyD_qy>`^{})rCp9yh+O@iif@Wjy zEJn%k9NM>j($+m`%adRv>17jr9Iu!wkVD#n9z)SRblQxKUIbM|!smUN5tIM-^x-jVo~ z_x_c#n1TOjCqF-Z?8C$(A4W+SA6=I&Mi^Li9M90XHZH;f5(cQENdNqs2=6aHzW!x` z__!T?vcL8tEYA3F+7@elD?`*}Qm9|nC)ph*n%7LWVC7qFp6o<&jFRoqxp_F&s{9=0 za{GFeaaNA%>@&gpJw7Vp1HwP=KK@RcUtiq2i2b2G{p0e3uZ>Rv*IM{^TB+kCH}Up- zAdF27#BS;Kj+T2#BYAf-;kAar1rHSl& z<6}-==QJ~l=%lIi3awURKkqDxv|L_||A-7(~FVK-oV)Sh_SK!Z;~14%$j zHsHVaigO=Tx9-Ucx}gS2s*{XlJa+6IWR_`1oVJ=(WtouUFs-&``m7;_?Uxt1`V2Yy z8BHl^o8U7)+Bq2SdvsfJ^etR}wEy|#x1ayGoVAy`{=A&CCF#HX{Xc&iaY*;W8XxvF zp~yC^m0?otrAVnYut2K(-tS^;c37D$n>*S4xGu?&YdQMG>KQr_1zyCh3@V0t*tZq~EKH;U=sxv`K zGbN0H)BI|&xhg%Mt^QxHu6lj_KVM(pcV|;C)5PhCMJ!_KO3S2}Vy!do;_Bsw4@7Jt=R zydADzb?rWSAEw5Iffb|awQc^@PB)zN>ejoLqmL+!RV@@At( z0f3|Md%y*h;PJGAan<^+WHhR+-P_l=HlWZ)y#z56Hu$UA}n(>2PWXc%kQ#_m>S zZ@-sy*X}cG=3usKt*>hO%GRt^5*<#A;r#bHCKVHnK*-8L!>{xMnm%Ri2`kw%tea;`zaqqY8CSb9&m|Lu;)S8``hort z+QiA(rMm6e%Inwl&!5+CCPN7g)DUsjV9U^HYt@#u=_&LhX3`ip4^ z>==9W6??Fy^v`7NrL6J_7BA3Cm5H=yO||4TukN=8?MJp}>pw{uFe(vDn5JXD$ewO` z@Xc6W9b&%6+VgC2O&R&HHf?{he@FtgsZzy~X_5`0=qV8Z*ZkSqe|yE=?-oxTFjpbb ztJ2pH5FwM0ZYqGrzGe4xW&S(A2?o)G>#E~Nn+VV3TBM}mL*WTUmRGQ`z^!%g?mm)N z2Fv4Xl0bdLU^^OV$eE3go`0vXup0}efM#C#!S2hbS~RW&k7D$=Yn>T*c<8f%(COL| zvzeGtr9$ouTm=QslOEqfa6>*m#KSrT}WT{fukAbQJsOC=NO99|YtdE~iv8eJO{U{sA{$zy5Ci`;YZc zVx-8eL8MVGeHLk+H6xyIciUa@uu zC2zsBuxQX_ZeLSJeYr0rA}oBxrV>5C5kh03MwE@Oqtw-iOXz1dZX_aAOM-3((NT65 ztWE=HDn&*qIryEVof&v85&QCSOlsFxh314#>=O~!5joR0$}9&S)6l{_wB=8=sX20py=*0J_z@Yv8Sq-4{} z9F5w#!r#h1GjPMey`_a@BwX~`dK=t;sNmohSH?*@=6pGLxOW5qbcMGy2u4+&Klsgn zz86b&-a~1O8$C{KR6 zM^Bs?xPekQh;l2aTK6DKq8W5~xH+#bNPrr(n?+D?Ws@W*y`w%`*0ktgJ6Zu|@nk0R zW&)&a$&x^EZSbgCKy^~sTX2%i4x(t!Ox!?e9({_}kqm;3hDN{X6MhTYcp1py6y2GD zr%>v{XfY`D5vXSvw1Q@J&ZhxEb!@ zBS2yzmc`OAo$@(g$_!bF0FRw1g@}@d3>6(QI#tDFa#vcq8?9`6B>4OSxi|eFW|)E> zI%CS+(f6soFoqYn`VL^mk&V0I9s;_$1e2gQ;HAd1F)aEc@Zn~Od6qK+&ykx5yEb&* zmgxsUw^Cz`Cil3D?=e*$*f(ObPjAL(z`G!(UEI%GqpUqY0nk5?4jQ3E#K}MY!xIymi(Ox2fQq}Q5 z2Q_M))rf^RBRkOc?WzsZimp>BMHK_M=m$yYKdKrh&-7iveMauaTi_8L5;lT3*hHJK zcn{HnqGCt-nT;Fdu5OZ&0>5(v7 zKc?+E15h#*(L~*{ezl}roQr(3UtkhkC60j-_O&RRgh;lavH`IaPU{aqt&rW-DTX5( zH<(l;E}xRBDXlhSG(n!uxPXrkSKe=bX5a>sDm2xGL~x;0RBC`@A()K?^+v`;$?mH; zfMi*ihf`f@hxVscO(xf{y=rP-vB?&k&&heSvQ8Ed4yJfC+ay9r!qQ?&XEtsyNx&m? z=QIhO18%?;i`Ua&DfY5&I~-oq}K9o2se|a4i-|OJ?p_h3Lw58G4)Ze&?G^)z{S>-CwOo% zKubz15uWI4&HD>BIdxDJi9oZH5LS3zS5+`~Q;NK!_3O;W4V!dmw2|R4QADd@=wTnL zY*@5S$dTy!nSmQNN#m!UCyxbrNZg|1i^NjCq%5NkqKra;5V(yf)xq#4QoRv4Xc$7f zq}klFa(ky4Y@yi|jaA(iB7tm?g}nH^!wkKRbY|lQxe@XcwTWm}bq*5I!W3_&%XSr^ z&LE0M2A(4~N@NTb<-w^qoZ6mg&G0hZtZcZXdO2TM3W@=B)e9Wto0330sb`O5qy3Ui zrV{tknb1&Ag{|f0P+1WAi5dvC+k9r@2DvBwoM%Z+4gidEDHh8jWRzoaD&{G z8JHl$bU*a+2mGa~ngfgo4nej&Qx;^y_6R>vcy~Y;+^m=c6xRVW_xagK=^eRAnBsai zbLt#GB}1j}64TUnq#Uh;GaEO^owjW@=vlGLP8;afs}o{kmkk?kK+!leaD&`1j8O3| zjA1vWdP*=hR))CJ(7Sz&i-^E-Caz3T4_QK>Dr+-~M7L{}wDuRsJu32M>p|M=`g)Tf zKbAm;=LH|mlFm%rAa^Y^98Z7O=?hu1DJ5zUa=A8O)E$00Gw>9-<7hLnihtI~AJP$%kvjkb8@)`uS+SQOD2uhW|xwwp;*VuCNny1#(!BC63c8eI?UNiG2k zs0Vs9&6BDgKAqXPf$bp)0lnZxS?}ElVBA%i1=(R(bm7|N%)oQl&K~_|(VE$A7ywd` zI3v|-L|b(p4m(^cL%w6RuyR#~35HNr)c4*jrA+_)?quyVY)jluc|9y%LMJl({3)ur;L^WK6?5ihW^3Jzl2w92vNQ?byN^P0zTkGWt287?V`$ zwiQ~X(0LlKvsgusEt+QQd>m!zwJUfo*7#>XyDatw+fdcIG;}TnZHw#@ zn0-C7aRb{y02DoC(7PZIZfn^@S;p@gDz%+*9%lyb2sgYs>r``yViNCddR$_b4pMLko&rE#;L(WbG16Q+x6ltaaW?`W(Mlq=QI7x~s;WgQcFnw}69+ZO7v-2>^wTU+ z1ssV)A;IAoMRJ%l!`Qw+BsY)>N$=8SMLi#(^x`W{8+nA$LbdD6#tkCnj_e12X*r1` z2|D4D->aHClc0PwtJpgPvPCI2b_ddcfWEJS;{EZz87>xwXoe8SYfNup97P9ZTZtIePAk+_ z3XLmnuIRrQ0eDwxmjX;9esmx<^-t629c$7Us*=N*jhhhwu7Jm@nyvUB%0fyNdZ!zM ztR)8)sC{JMW(1&93Ky&1!&NvF{pR{ZCPTGXa^?Jkp`0$Ifa>G|&#HO=MfbPcB|Du1GRCw@I>J%O!f z25tucI(*a{?IHJDh_WTQ21cotzJ0 z9`s9wr8baOLk|aJdHova%*NfNlGUv?da#yWNq!V?Wf~12w&nX|~FOy;$Iw#T-HA_*{(ownK4U7YQxZRn7ryKxCZOhxo zr9ZtCL%r6ORC@LfuSiNIF1^U*^_#u2^;-DS&mi=`0}qz5qvY;?96tN_PW1X#Qffrq z|M*y`Svs-n^t)`g9-^EX_&CLI@7E00PTA?%D*F!_=n@xv9&!lZ4$h%@j@JKN3E`ar zkV)`$V{K6SpqIYohOkrHzQNaPG=^oeZ^v-o%dR9HNg}}MgpIroaAG&qk)M;nQ z`av2!6mdnV85A5*4E(>eGaENUx4iUPwdfI%3bTM>KjP4D)Zf#*ZF?Wt$@ zwbA*Uj-rCmb7#nVYjlXFx5KU`XdvZNOY|$$QuPiKGCTLjHy~iX|B+3GH#*tc)=7wd zN~brzr|95R@oDhzq{Fe~nSmc^%q!V*v#;D?cZki0>d`N|P|<8ZdQ2HC10Ol-QQ_ zg922zUQe7}FO)>%ifz!k;+6|%20nJ@ZwACxCmDua^!|Ue&{PD8*|`KT$0zY}X5dqh z#l0Q`{QX~l`st_t18Q(|9RL6T00031y$t{W0PP3>0C=38Sj}#uISxF#pQ7&z1tLX> zVjg0jU;sluRBtfpBx5Iy>3#aGFLt_}z>~uUIRy4+nWZADis2ZetF_R(NszflFp(Ct z=h$d9(xto6toH6HldPHx#T0wS6P!o}4c3xdEzC7B(VC5B*V>AT=c&uPyU7rS4yje%QY}&Gzh{fiNwUPaw2grkYB?g(@gW6mYbLDd5JaWl4S4hMSbBHm&K( zV@0K5EBfeUs!4M020+%eEU`4YgNSN^cWBZD1?4N%+njGR-+)Uc`VE2$)toc$+noD# zcQ`(SjPdy0_t)TD9we3GnUm_ZYvK}^R|?_+cD#)+(Ilp+c#Ug^n$cf{X^iFGxWzb@ zR_STh-c->~5@|FskKN1Yyc)B$oRm|Va-9a*Q~UYdAKqj6a7uyH=n0GWO1ZIz z1*Hj60mx>~p(-_nK(!QoCncd9NAVR?86J6hOVlW~VJ@{N?-4`Ze0Xcy`vv{DI4cyf z4(``)6!(zwdtM}B^e@}&;qlpzXtv>ZPtTwB8fV<~3DYKk(xQfu*&t`xgBcVVWzRyT zF01IZW&=o>8N-5x;8=7L+HuKhX1P?(5GR?XSI8sy9+Syr6-Zz07fOJeEw`!MQoX?^ z)Govr1eF9eI1_aLIR5mx2T4EL;jZ<2-w!Clx5vZJkI*g-6^n3)v&faP=Aa&UEf3O? zg+9FocF)GKgTJ{e)H#Z<^jON2!qC3~d6x>gsOT*P{k>#evguTTd=1w;d#`m48P_jZ zxYP@g!|bJmc7(wF4NOn>4WjUPfB*Qy51;XrP{(E zZMw_Ue62F4q_i~hGJ<|_T@g1>CD$P}CU^5b*qnNU@)b9b>5KgzY$wBE>ZKlcyq`)B3@OOQA7sbxWtcm;4UgRI;`C9|*w45qaXJAyiunPXn1Wu1=MsMJ=P zvEpT#Et7W2RZDdvWQSBzugePn<^kQS<$4vk!FmA&WWWBrS&sjJ7`)EWXHc?~$mcN7 zx;369VA!`P18Umr99Ck1kB}v>0_~X!<%@#5S8Y>0`b6Y`siKRxEM9%~f$k!n@L_NW zt(ub{)9&MAqw6iln=D!{72$Pyl;Zz8J?BFSGJS$3D9g=i$Re8wWrHvPWr)yhRkF7%5~B8DqtH@F9C6q$S1DW%fp1N?vFo@&;I>0EC@38 z=BjfbgryPC>@}b~3GhY@$YzF!jD$?G>q`Yu#|E@5futF8ii9E!#dm`qlsre7$e$C| zx=f+&){rNNJF#D_0x3i5B{>Q6!=mt=?~t?o-QnQhUhdoBJX48a&(zIYeS%>jBFF9PIM(vpA+=08fB%rlJ(qM zUj0MQ3vy(-A&5?q_k)CFn|=X$o2A!gyE;6lgCa6ygT*49bm0Rz`{T>g)8pZKmOGs%rt##`kOUc6Q6$1fdxY8{%T*pLKoNBKG9;p2sNHMW->RrLc*m%| z-x`3bzMCLqq4DST$8}k_JPyGozNG|CjtzzbB&uyGVTJ~e4WlNs6;u$2cTlxVh@uQV zZ&)-dwl?Tq&zK&E9l}B(5sf2q#deq^I2A~vxu~P1e@9FE8hNWCLTwS8%4I^eZII$w z7G9Hr|NZ0-N8dmBVL0o1016nL0b0N`jUj_Q2wN&{q1{uGESeNy~I6-IV1nS^_ zz4*iUxf#>%EOd3KPNxZg*x8XFJX=Uh0Z{<)8#)ylrafTXv7mdwuDW%PJN81hvyon_u-j-i$UMmSznO{zP}rwV zYti_R&zE~(ISLf}kD z*V}H2_A+F18U*#i`iwMy@Y;J%3>8^fu&T6CaWe(!FqdCdkbdQ~U9QMY%+QqI8G^CN zCXjzxkzUi?^WAs<@8iSAu6uWgY=V52J!BU{miIJ mEHA@{h#4I|p#e*^~ab`SRt<{{Sc&sBZuO0000Ub8wpg diff --git a/bash.exe.stackdump b/bash.exe.stackdump deleted file mode 100644 index df4fb20..0000000 --- a/bash.exe.stackdump +++ /dev/null @@ -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 diff --git a/cookies.txt b/cookies.txt deleted file mode 100644 index 8418b9e..0000000 --- a/cookies.txt +++ /dev/null @@ -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 diff --git a/cookies2.txt b/cookies2.txt deleted file mode 100644 index 8f0a3c9..0000000 --- a/cookies2.txt +++ /dev/null @@ -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 diff --git a/cookies_debug.txt b/cookies_debug.txt deleted file mode 100644 index aef419c..0000000 --- a/cookies_debug.txt +++ /dev/null @@ -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 diff --git a/cookies_fixed.txt b/cookies_fixed.txt deleted file mode 100644 index 0ad602b..0000000 --- a/cookies_fixed.txt +++ /dev/null @@ -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 diff --git a/cookies_immediate.txt b/cookies_immediate.txt deleted file mode 100644 index 588e0aa..0000000 --- a/cookies_immediate.txt +++ /dev/null @@ -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 diff --git a/cookies_login.txt b/cookies_login.txt deleted file mode 100644 index 95aa001..0000000 --- a/cookies_login.txt +++ /dev/null @@ -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 diff --git a/cookies_test.txt b/cookies_test.txt deleted file mode 100644 index ec7ac90..0000000 --- a/cookies_test.txt +++ /dev/null @@ -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 diff --git a/docs/api-phase1-move-log.md b/docs/api-phase1-move-log.md deleted file mode 100644 index 0fd1da4..0000000 --- a/docs/api-phase1-move-log.md +++ /dev/null @@ -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. diff --git a/docs/api-phase2-move-log.md b/docs/api-phase2-move-log.md deleted file mode 100644 index 956c9d9..0000000 --- a/docs/api-phase2-move-log.md +++ /dev/null @@ -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. diff --git a/docs/api-phase3-move-log.md b/docs/api-phase3-move-log.md deleted file mode 100644 index 6b1cb8f..0000000 --- a/docs/api-phase3-move-log.md +++ /dev/null @@ -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. diff --git a/docs/api-phase4-move-log.md b/docs/api-phase4-move-log.md deleted file mode 100644 index 776eff0..0000000 --- a/docs/api-phase4-move-log.md +++ /dev/null @@ -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. diff --git a/docs/api-phase5-move-log.md b/docs/api-phase5-move-log.md deleted file mode 100644 index a7a71b7..0000000 --- a/docs/api-phase5-move-log.md +++ /dev/null @@ -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. diff --git a/docs/api-phase6-move-log.md b/docs/api-phase6-move-log.md deleted file mode 100644 index f0e1591..0000000 --- a/docs/api-phase6-move-log.md +++ /dev/null @@ -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. diff --git a/docs/api-phase7-move-log.md b/docs/api-phase7-move-log.md deleted file mode 100644 index d57474a..0000000 --- a/docs/api-phase7-move-log.md +++ /dev/null @@ -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. diff --git a/docs/api-phase8-move-log.md b/docs/api-phase8-move-log.md deleted file mode 100644 index 2607359..0000000 --- a/docs/api-phase8-move-log.md +++ /dev/null @@ -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. diff --git a/docs/api-refactor-lightweight-plan.md b/docs/api-refactor-lightweight-plan.md deleted file mode 100644 index 9b746c1..0000000 --- a/docs/api-refactor-lightweight-plan.md +++ /dev/null @@ -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. diff --git a/exporting b/exporting deleted file mode 100644 index e69de29..0000000 diff --git a/irregular_cookies.txt b/irregular_cookies.txt deleted file mode 100644 index 1db519a..0000000 --- a/irregular_cookies.txt +++ /dev/null @@ -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 diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 46fb1a6..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "SkyMoney", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/regular_cookies.txt b/regular_cookies.txt deleted file mode 100644 index c82ff17..0000000 --- a/regular_cookies.txt +++ /dev/null @@ -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 diff --git a/tests-results-for-OWASP/A01-Broken-Access-Control.md b/tests-results-for-OWASP/A01-Broken-Access-Control.md deleted file mode 100644 index 538fa6b..0000000 --- a/tests-results-for-OWASP/A01-Broken-Access-Control.md +++ /dev/null @@ -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` diff --git a/tests-results-for-OWASP/A02-Security-Misconfiguration.md b/tests-results-for-OWASP/A02-Security-Misconfiguration.md deleted file mode 100644 index a4b8c8d..0000000 --- a/tests-results-for-OWASP/A02-Security-Misconfiguration.md +++ /dev/null @@ -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`. diff --git a/tests-results-for-OWASP/A03-Software-Supply-Chain-Failures.md b/tests-results-for-OWASP/A03-Software-Supply-Chain-Failures.md deleted file mode 100644 index 4e06fce..0000000 --- a/tests-results-for-OWASP/A03-Software-Supply-Chain-Failures.md +++ /dev/null @@ -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. diff --git a/tests-results-for-OWASP/A04-Cryptographic-Failures.md b/tests-results-for-OWASP/A04-Cryptographic-Failures.md deleted file mode 100644 index a7e07d4..0000000 --- a/tests-results-for-OWASP/A04-Cryptographic-Failures.md +++ /dev/null @@ -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) diff --git a/tests-results-for-OWASP/A05-Injection.md b/tests-results-for-OWASP/A05-Injection.md deleted file mode 100644 index f739c72..0000000 --- a/tests-results-for-OWASP/A05-Injection.md +++ /dev/null @@ -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. diff --git a/tests-results-for-OWASP/A06-Insecure-Design.md b/tests-results-for-OWASP/A06-Insecure-Design.md deleted file mode 100644 index 4e84506..0000000 --- a/tests-results-for-OWASP/A06-Insecure-Design.md +++ /dev/null @@ -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. diff --git a/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md b/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md deleted file mode 100644 index 4709888..0000000 --- a/tests-results-for-OWASP/A07-Identification-and-Authentication-Failures.md +++ /dev/null @@ -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. diff --git a/tests-results-for-OWASP/A08-Software-and-Data-Integrity-Failures.md b/tests-results-for-OWASP/A08-Software-and-Data-Integrity-Failures.md deleted file mode 100644 index 0db6d30..0000000 --- a/tests-results-for-OWASP/A08-Software-and-Data-Integrity-Failures.md +++ /dev/null @@ -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. diff --git a/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md b/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md deleted file mode 100644 index 22fbe9b..0000000 --- a/tests-results-for-OWASP/A09-Security-Logging-and-Monitoring-Failures.md +++ /dev/null @@ -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. diff --git a/tests-results-for-OWASP/A10-Server-Side-Request-Forgery.md b/tests-results-for-OWASP/A10-Server-Side-Request-Forgery.md deleted file mode 100644 index d6d9c18..0000000 --- a/tests-results-for-OWASP/A10-Server-Side-Request-Forgery.md +++ /dev/null @@ -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. diff --git a/tests-results-for-OWASP/README.md b/tests-results-for-OWASP/README.md deleted file mode 100644 index 48f3d75..0000000 --- a/tests-results-for-OWASP/README.md +++ /dev/null @@ -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. diff --git a/tests-results-for-OWASP/evidence-log-template.md b/tests-results-for-OWASP/evidence-log-template.md deleted file mode 100644 index b96f91b..0000000 --- a/tests-results-for-OWASP/evidence-log-template.md +++ /dev/null @@ -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` diff --git a/tests-results-for-OWASP/post-deployment-verification-checklist.md b/tests-results-for-OWASP/post-deployment-verification-checklist.md deleted file mode 100644 index 1c9edae..0000000 --- a/tests-results-for-OWASP/post-deployment-verification-checklist.md +++ /dev/null @@ -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 --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://:@127.0.0.1:5432/${RESTORE_DB}" \ -DATABASE_URL="postgres://:@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. diff --git a/tests-results-for-OWASP/residual-risk-backlog.md b/tests-results-for-OWASP/residual-risk-backlog.md deleted file mode 100644 index fa77569..0000000 --- a/tests-results-for-OWASP/residual-risk-backlog.md +++ /dev/null @@ -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. diff --git a/transfer-rebalance-spec.md b/transfer-rebalance-spec.md deleted file mode 100644 index a336e8e..0000000 --- a/transfer-rebalance-spec.md +++ /dev/null @@ -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. diff --git a/web/README.md b/web/README.md deleted file mode 100644 index d2e7761..0000000 --- a/web/README.md +++ /dev/null @@ -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... - }, - }, -]) -``` diff --git a/web/src/pages/settings/CategoriesPage.tsx b/web/src/pages/settings/CategoriesPage.tsx deleted file mode 100644 index 9b1de90..0000000 --- a/web/src/pages/settings/CategoriesPage.tsx +++ /dev/null @@ -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 ( -
- {label}: {total}% -
- ); -} - -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([]); - 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) => { - 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 ( -
- -
Loading…
-
- ); - if (error || !data) { - return ( -
- -

Couldn't load expenses.

- -
- ); - } - - return ( -
-
- - -
-

Expenses

-

- Decide how every dollar is divided. Percentages must always - add up to 100%. -

-
- - {/* Add form */} -
- setName(e.target.value)} - /> - setPercent(e.target.value)} - /> - setPriority(e.target.value)} - /> - - -
- -
-
- - {cats.length === 0 ? ( -
- No expenses yet. -
- ) : ( - - - - - - - - - - - - - - - - {order - .map((id) => cats.find((c) => c.id === id)) - .filter(Boolean) - .map((c) => ( - - - - - - - - - - ))} - - -
Name%PrioritySavingsBalance
- - ⋮⋮ - - - - onEdit(c!.id, { name: v }) - } - /> - - - onEdit(c!.id, { percent: v }) - } - /> - - - onEdit(c!.id, { priority: v }) - } - /> - - - onEdit(c!.id, { isSavings: v }) - } - /> - {c!.isSavings && ( - - Savings - - )} - - - - -
-
- )} - - {/* Guard if total != 100 */} - {total !== 100 && ( -
- Percents must sum to 100% for allocations. - Current total: {total}%. -
- )} -
-
- ); -} - -/* --- 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 ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -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 ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -function InlineEditCheckbox({ - checked, - onChange, -}: { - checked: boolean; - onChange: (v: boolean) => void; -}) { - return ( - - ); -} - -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 ( - - {children} - - ); -} - -function onDragOrderApply(ids: string[]) { - return ids.map((id, idx) => ({ id, priority: idx + 1 })); -} diff --git a/web/src/pages/settings/PlansPage.tsx b/web/src/pages/settings/PlansPage.tsx deleted file mode 100644 index 939a070..0000000 --- a/web/src/pages/settings/PlansPage.tsx +++ /dev/null @@ -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 ( - - Overdue - - ); - if (d <= 7) return Due in {d}d; - return ( - - ); -} - -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 ( -
- -
Loading…
-
- ); - - if (error || !data) { - return ( -
- -

Couldn't load fixed expenses.

- -
- ); - } - - 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 ( -
-
- - - {/* Header */} -
-

- Fixed expenses -

-

- Long-term goals and obligations you’re funding over - time. -

-
- - {/* KPI strip */} -
- - - - - - - - - - - - {overallPctFunded}% - - -
- - {/* Overall progress bar */} -
-
- All fixed expenses funded - - {overallPctFunded}% of target - -
-
-
-
-
- - {/* Add form */} -
- setName(e.target.value)} - /> - setTotal(e.target.value)} - /> - setFunded(e.target.value)} - /> - setPriority(e.target.value)} - /> - setDue(e.target.value)} - /> - - -
- - {/* Auto-payment configuration */} - {autoPayEnabled && ( -
-

Auto-Fund Schedule

-
- - - {(frequency === "weekly" || frequency === "biweekly") && ( - - )} - - {frequency === "monthly" && ( - - )} - - {frequency === "custom" && ( - - )} - - -
-

- Automatic payments will only occur if the expense is funded to at least the minimum percentage. -

-
- )} - - {/* Table */} - {data.fixedPlans.length === 0 ? ( -
- No fixed expenses yet. -
- ) : ( - - - - - - - - - - - - - - - - {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 ( - - - - - - - - - - - - ); - })} - -
NameDuePriorityFundedTotalRemainingStatusAuto-Pay
- - onEdit(p.id, { name: v }) - } - /> - - - onEdit(p.id, { dueOn: iso }) - } - /> - - - onEdit(p.id, { priority: n }) - } - /> - -
- - onEdit(p.id, { - fundedCents: Math.max( - 0, - Math.min(cents, p.totalCents), - ), - }) - } - /> -
- {pctFunded}% funded -
-
-
-
- - onEdit(p.id, { - totalCents: Math.max(cents, 0), - fundedCents: Math.min( - p.fundedCents, - cents, - ), - }) - } - /> - -
-
- - - - -
- - {p.autoPayEnabled ? 'Enabled' : 'Disabled'} - - {p.autoPayEnabled && p.paymentSchedule && ( - - {p.paymentSchedule.frequency === 'custom' - ? `Every ${p.paymentSchedule.customDays} days` - : p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1) - } - - )} -
-
- -
- )} -
-
- ); -} - -/* --- Small presentational helpers --- */ - -function KpiCard({ - label, - children, -}: { - label: string; - children: ReactNode; -}) { - return ( -
-

{label}

-
{children}
-
- ); -} - -function FundingBar({ pct }: { pct: number }) { - const clamped = Math.min(100, Math.max(0, pct)); - return ( -
-
-
- ); -} - -/* --- 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 ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -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 ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -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 ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} - -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 ? ( - setV(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === "Enter" && commit()} - autoFocus - /> - ) : ( - - ); -} diff --git a/web/src/pages/settings/_SettingsNav.tsx b/web/src/pages/settings/_SettingsNav.tsx deleted file mode 100644 index 27c0343..0000000 --- a/web/src/pages/settings/_SettingsNav.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { NavLink } from "react-router-dom"; -export default function SettingsNav() { - const link = (to: string, label: string) => - `link ${isActive ? "link-active" : ""}`}>{label}; - return ( -
-

Settings

-
- {link("/settings/categories", "Expenses")} - {link("/settings/plans", "Fixed Expenses")} -
-
- ); -} diff --git a/web/src/styles.css.bak b/web/src/styles.css.bak deleted file mode 100644 index 1086306..0000000 --- a/web/src/styles.css.bak +++ /dev/null @@ -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; }