From f4f0ae5df2ae0e982a8fe6f963d297c6a692c4b8 Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Sun, 18 Jan 2026 00:00:44 -0600 Subject: [PATCH] final touches for beta skymoney (at least i think) --- .env.example | 23 + .gitignore | 7 + Caddyfile.prod | 28 + README.md | 20 +- TIMEZONE_ROLLOVER_EXPLAINED.md | 121 + api/Dockerfile | 8 +- api/check-allocations.cjs | 34 + api/check-overdue.cjs | 49 + api/clients/ts/sdk.ts | 8 +- api/create-multi-overdue-test.cjs | 135 + api/create-multi-overdue-user.cjs | 133 + api/create-test-user.cjs | 91 + api/openapi.yaml | 8 +- api/package-lock.json | 255 ++ api/package.json | 9 +- api/pnpm-lock.yaml | 1607 +++++++- .../20251116055748_user_update/migration.sql | 3 + .../migration.sql | 9 + .../migration.sql | 5 + .../migration.sql | 4 + .../migration.sql | 4 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 6 + .../migration.sql | 14 + .../migration.sql | 26 + .../migration.sql | 32 + .../migration.sql | 3 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 1 + .../migration.sql | 8 + .../migration.sql | 2 + .../migration.sql | 5 + .../migration.sql | 2 + .../migration.sql | 4 + .../migration.sql | 2 + .../migration.sql | 2 + api/prisma/schema.prisma | 79 +- api/prisma/seed.ts | 33 +- api/src/allocator.ts | 1728 +++++++- api/src/env.ts | 52 +- api/src/jobs/auto-payments.ts | 259 ++ api/src/jobs/rollover.ts | 129 + api/src/plugins/user-stub.ts | 23 - api/src/routes/fixed-plans.ts | 114 +- api/src/routes/income-preview.ts | 148 +- api/src/routes/transactions.ts | 16 +- api/src/routes/variable-categories.ts | 154 +- api/src/scripts/manage-plan.ts | 73 + api/src/scripts/run-rollover.ts | 50 + api/src/scripts/setup-frontend-test-user.ts | 274 ++ api/src/scripts/test-dashboard-edge.ts | 266 ++ api/src/scripts/test-early-funding.ts | 204 + api/src/scripts/test-final-funding.ts | 182 + api/src/scripts/test-onboarding-edge.ts | 199 + .../scripts/test-overdue-reconciliation.ts | 206 + api/src/scripts/test-payment-flow.ts | 289 ++ api/src/scripts/test-timezone-jobs.ts | 145 + api/src/server.ts | 3545 +++++++++++++++-- api/src/worker/auto-payments.ts | 46 + api/src/worker/rollover.ts | 39 + 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/allocator.test.ts | 249 +- api/tests/auth.routes.test.ts | 83 + api/tests/auto-payments.test.ts | 338 ++ api/tests/budget-allocation.test.ts | 132 + api/tests/irregular-income-simple.test.ts | 180 + api/tests/payment-rollover.test.ts | 230 ++ api/tests/rollover.test.ts | 63 + api/tests/setup.ts | 14 +- api/tests/transactions.test.ts | 159 +- api/vitest.config.ts | 7 +- 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 + deploy/nginx/skymoneybudget.com.conf | 39 + docker-compose.yml | 39 +- exporting | 0 irregular_cookies.txt | 5 + regular_cookies.txt | 5 + scripts/backup.sh | 25 + scripts/restore.sh | 37 + web/index.html | 30 + web/package-lock.json | 88 +- web/package.json | 5 +- web/src/App.tsx | 69 +- web/src/api/budget.ts | 77 + web/src/api/categories.ts | 26 +- web/src/api/client.ts | 30 - web/src/api/fixedPlans.ts | 98 +- web/src/api/http.ts | 85 +- web/src/api/transactions.ts | 31 +- web/src/assets/SkyMoneyLogo.png | Bin 0 -> 101240 bytes web/src/components/CurrencyInput.tsx | 80 +- web/src/components/EarlyFundingModal.tsx | 81 + .../components/EarlyPaymentPromptModal.tsx | 83 + .../components/FundingConfirmationModal.tsx | 143 + web/src/components/NavBar.tsx | 102 + web/src/components/OnboardingTracker.tsx | 398 ++ web/src/components/Pagination.tsx | 2 - web/src/components/PaydayOverlay.tsx | 214 + .../components/PaymentConfirmationModal.tsx | 51 + .../components/PaymentReconciliationModal.tsx | 239 ++ web/src/components/PercentGuard.tsx | 3 +- web/src/components/RequireAuth.tsx | 35 + web/src/components/SessionTimeoutWarning.tsx | 44 + web/src/components/ThemeToggle.tsx | 30 + web/src/components/Toast.tsx | 2 +- web/src/components/UserSwitcher.tsx | 25 - .../components/charts/FixedFundingBars.tsx | 107 +- .../components/charts/MonthlyTrendChart.tsx | 58 + .../charts/VariableAllocationDonut.tsx | 85 +- web/src/components/ui.tsx | 2 +- web/src/hooks/useAuthSession.ts | 16 + web/src/hooks/useCategories.ts | 13 +- web/src/hooks/useDashboard.ts | 48 +- web/src/hooks/useFixedPlans.ts | 8 +- web/src/hooks/useInView.ts | 30 + web/src/hooks/useIncome.ts | 24 +- web/src/hooks/useIncomeHistory.ts | 18 + web/src/hooks/useSessionTimeout.ts | 104 + web/src/hooks/useTransactions.ts | 81 +- web/src/hooks/useTransactionsQuery.tsx | 15 + web/src/index.css | 128 - web/src/main.tsx | 165 +- web/src/pages/DashboardPage.tsx | 1443 ++++++- web/src/pages/HealthPage.tsx | 222 +- web/src/pages/IncomePage.tsx | 797 +++- web/src/pages/LoginPage.tsx | 136 + web/src/pages/OnboardingPage.tsx | 1423 +++++++ web/src/pages/RegisterPage.tsx | 170 + web/src/pages/SpendPage.tsx | 653 ++- web/src/pages/TransactionsPage.tsx | 370 +- web/src/pages/settings/AccountSettings.tsx | 515 +++ web/src/pages/settings/CategoriesPage.tsx | 537 ++- web/src/pages/settings/CategoriesSettings.tsx | 650 +++ web/src/pages/settings/PlansPage.tsx | 723 +++- web/src/pages/settings/PlansSettings.tsx | 894 +++++ web/src/pages/settings/ReconcileSettings.tsx | 137 + web/src/pages/settings/SettingsPage.tsx | 213 + web/src/pages/settings/ThemeSettings.tsx | 145 + web/src/pages/settings/_SettingsNav.tsx | 10 +- web/src/styles.css | 2239 ++++++++++- web/src/styles.css.bak | 132 + web/src/theme/useTheme.ts | 59 + web/src/utils/allocatorPreview.ts | 2 + web/src/utils/funding.ts | 101 + web/src/utils/timezone.ts | 156 + web/tests/allocatorPreview.test.ts | 5 +- web/tests/funding.test.ts | 32 + web/tests/onboarding-tracker.test.ts | 218 + web/vite.config.ts | 12 +- 161 files changed, 26016 insertions(+), 1966 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile.prod create mode 100644 TIMEZONE_ROLLOVER_EXPLAINED.md create mode 100644 api/check-allocations.cjs create mode 100644 api/check-overdue.cjs create mode 100644 api/create-multi-overdue-test.cjs create mode 100644 api/create-multi-overdue-user.cjs create mode 100644 api/create-test-user.cjs create mode 100644 api/prisma/migrations/20251116055748_user_update/migration.sql create mode 100644 api/prisma/migrations/20251116075012_transaction_relations/migration.sql create mode 100644 api/prisma/migrations/20251117052432_add_savings_target/migration.sql create mode 100644 api/prisma/migrations/20251117054048_transaction_notes_receipts/migration.sql create mode 100644 api/prisma/migrations/20251119033730_fixed_plan_rollover/migration.sql create mode 100644 api/prisma/migrations/20251119041313_user_display_name/migration.sql create mode 100644 api/prisma/migrations/20251120000000_income_event_note/migration.sql create mode 100644 api/prisma/migrations/20251122072606_add_auto_payment_fields/migration.sql create mode 100644 api/prisma/migrations/20251122234020_add_funding_system/migration.sql create mode 100644 api/prisma/migrations/20251123000247_remove_irregular_income/migration.sql create mode 100644 api/prisma/migrations/20251123004713_add_irregular_income_support/migration.sql create mode 100644 api/prisma/migrations/20251126184718_add_funding_tracking_fields/migration.sql create mode 100644 api/prisma/migrations/20251126185552_add_first_income_date/migration.sql create mode 100644 api/prisma/migrations/20251127063618_add_islocked_to_variable_category/migration.sql create mode 100644 api/prisma/migrations/20251127063710_add_islocked_to_variable_category/migration.sql create mode 100644 api/prisma/migrations/20251127064553_remove_islocked_field/migration.sql create mode 100644 api/prisma/migrations/20251127065651_add_frequency_to_fixed_plan/migration.sql create mode 100644 api/prisma/migrations/20251201065909_add_scheduled_income_tracking/migration.sql create mode 100644 api/prisma/migrations/20251217235644_add_user_timezone/migration.sql create mode 100644 api/prisma/migrations/20251218035554_add_overdue_tracking/migration.sql create mode 100644 api/prisma/migrations/20251227004924_add_is_auto_payment/migration.sql create mode 100644 api/prisma/migrations/20251229024700_add_fixed_expense_percentage/migration.sql create mode 100644 api/src/jobs/auto-payments.ts create mode 100644 api/src/jobs/rollover.ts delete mode 100644 api/src/plugins/user-stub.ts create mode 100644 api/src/scripts/manage-plan.ts create mode 100644 api/src/scripts/run-rollover.ts create mode 100644 api/src/scripts/setup-frontend-test-user.ts create mode 100644 api/src/scripts/test-dashboard-edge.ts create mode 100644 api/src/scripts/test-early-funding.ts create mode 100644 api/src/scripts/test-final-funding.ts create mode 100644 api/src/scripts/test-onboarding-edge.ts create mode 100644 api/src/scripts/test-overdue-reconciliation.ts create mode 100644 api/src/scripts/test-payment-flow.ts create mode 100644 api/src/scripts/test-timezone-jobs.ts create mode 100644 api/src/worker/auto-payments.ts create mode 100644 api/src/worker/rollover.ts create mode 100644 api/test-income-overdue.sh create mode 100644 api/test-monthly-income.cjs create mode 100644 api/test-overdue-api.sh create mode 100644 api/test-overdue-payment.cjs create mode 100644 api/test-simple.sh create mode 100644 api/tests/auth.routes.test.ts create mode 100644 api/tests/auto-payments.test.ts create mode 100644 api/tests/budget-allocation.test.ts create mode 100644 api/tests/irregular-income-simple.test.ts create mode 100644 api/tests/payment-rollover.test.ts create mode 100644 api/tests/rollover.test.ts create mode 100644 cookies.txt create mode 100644 cookies2.txt create mode 100644 cookies_debug.txt create mode 100644 cookies_fixed.txt create mode 100644 cookies_immediate.txt create mode 100644 cookies_login.txt create mode 100644 cookies_test.txt create mode 100644 deploy/nginx/skymoneybudget.com.conf create mode 100644 exporting create mode 100644 irregular_cookies.txt create mode 100644 regular_cookies.txt create mode 100644 scripts/backup.sh create mode 100644 scripts/restore.sh create mode 100644 web/src/api/budget.ts delete mode 100644 web/src/api/client.ts create mode 100644 web/src/assets/SkyMoneyLogo.png create mode 100644 web/src/components/EarlyFundingModal.tsx create mode 100644 web/src/components/EarlyPaymentPromptModal.tsx create mode 100644 web/src/components/FundingConfirmationModal.tsx create mode 100644 web/src/components/NavBar.tsx create mode 100644 web/src/components/OnboardingTracker.tsx create mode 100644 web/src/components/PaydayOverlay.tsx create mode 100644 web/src/components/PaymentConfirmationModal.tsx create mode 100644 web/src/components/PaymentReconciliationModal.tsx create mode 100644 web/src/components/RequireAuth.tsx create mode 100644 web/src/components/SessionTimeoutWarning.tsx create mode 100644 web/src/components/ThemeToggle.tsx delete mode 100644 web/src/components/UserSwitcher.tsx create mode 100644 web/src/components/charts/MonthlyTrendChart.tsx create mode 100644 web/src/hooks/useAuthSession.ts create mode 100644 web/src/hooks/useInView.ts create mode 100644 web/src/hooks/useIncomeHistory.ts create mode 100644 web/src/hooks/useSessionTimeout.ts delete mode 100644 web/src/index.css create mode 100644 web/src/pages/LoginPage.tsx create mode 100644 web/src/pages/OnboardingPage.tsx create mode 100644 web/src/pages/RegisterPage.tsx create mode 100644 web/src/pages/settings/AccountSettings.tsx create mode 100644 web/src/pages/settings/CategoriesSettings.tsx create mode 100644 web/src/pages/settings/PlansSettings.tsx create mode 100644 web/src/pages/settings/ReconcileSettings.tsx create mode 100644 web/src/pages/settings/SettingsPage.tsx create mode 100644 web/src/pages/settings/ThemeSettings.tsx create mode 100644 web/src/styles.css.bak create mode 100644 web/src/theme/useTheme.ts create mode 100644 web/src/utils/funding.ts create mode 100644 web/src/utils/timezone.ts create mode 100644 web/tests/funding.test.ts create mode 100644 web/tests/onboarding-tracker.test.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..79f0d72 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# App +NODE_ENV=development +PORT=8080 +CORS_ORIGIN=http://localhost:5173 +AUTH_DISABLED=false +SEED_DEFAULT_BUDGET=false +ROLLOVER_SCHEDULE_CRON=0 6 * * * + +# Database (app runtime) +POSTGRES_DB=skymoney +POSTGRES_USER=skymoney_app +POSTGRES_PASSWORD=change-me +DATABASE_URL=postgres://skymoney_app:change-me@postgres:5432/skymoney + +# Database (backup/restore on host) +BACKUP_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney +RESTORE_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney_restore_test +ADMIN_DATABASE_URL=postgres://postgres:change-me@127.0.0.1:5432/postgres + +# Auth secrets (min 32 chars) +JWT_SECRET=replace-with-32+-chars +COOKIE_SECRET=replace-with-32+-chars +COOKIE_DOMAIN=skymoneybudget.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6942daf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Secrets +.env +.env.* +!.env.example + +# Local backups +backups/ diff --git a/Caddyfile.prod b/Caddyfile.prod new file mode 100644 index 0000000..0e9fca0 --- /dev/null +++ b/Caddyfile.prod @@ -0,0 +1,28 @@ +# Caddyfile.prod — production (HTTPS) + +{ + email admin@skymoneybudget.com +} + +skymoneybudget.com { + encode zstd gzip + + # Security headers + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + } + + # Serve static SPA + root * /var/www/skymoney/dist + file_server + + # SPA fallback + try_files {path} /index.html + + # Proxy API + handle_path /api/* { + reverse_proxy 127.0.0.1:8081 + } +} diff --git a/README.md b/README.md index 71c2091..7163b6e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,7 @@ TODO: -Better the logo -add caddy files -add db routes and set up db -test api with docker - -Test DB - -test functionality - -simulate results in scheduling - -ensure security -build UI and UX -add new logo -build theme -interactivity with tailwind -make better Read me -add comments to code -get ben to post it +UI changes and UX: + +onboarding: +Pressing enter submits and goes to next step + diff --git a/TIMEZONE_ROLLOVER_EXPLAINED.md b/TIMEZONE_ROLLOVER_EXPLAINED.md new file mode 100644 index 0000000..f57cf6e --- /dev/null +++ b/TIMEZONE_ROLLOVER_EXPLAINED.md @@ -0,0 +1,121 @@ +# Timezone-Aware Job Scheduling + +## How It Works + +### Cron Schedule (Every 15 Minutes) +``` +*/15 * * * * → Runs at: 00:00, 00:15, 00:30, 00:45, 01:00, 01:15, ... +``` + +### Each Run: +1. **Query** all candidate plans (across ALL users, all timezones) +2. **For each plan**, check: "Is it past target hour in THIS user's timezone?" +3. **If yes** → process the plan +4. **If no** → skip until next run (15 min later) + +## Real World Example: Rollover at 6 AM Local Time + +### Timeline Across Timezones (Dec 17, 2025) + +| UTC Time | Los Angeles (UTC-8) | New York (UTC-5) | London (UTC+0) | Tokyo (UTC+9) | Action | +|----------|---------------------|------------------|----------------|---------------|--------| +| 21:00 Dec 16 | 1:00 PM Dec 16 | 4:00 PM Dec 16 | 9:00 PM Dec 16 | **6:00 AM Dec 17** | ✅ Process Tokyo users | +| 00:00 Dec 17 | 4:00 PM Dec 16 | 7:00 PM Dec 16 | 12:00 AM Dec 17 | 9:00 AM Dec 17 | (Tokyo already done) | +| 06:00 Dec 17 | 10:00 PM Dec 16 | 1:00 AM Dec 17 | **6:00 AM Dec 17** | 3:00 PM Dec 17 | ✅ Process London users | +| 11:00 Dec 17 | 3:00 AM Dec 17 | **6:00 AM Dec 17** | 11:00 AM Dec 17 | 8:00 PM Dec 17 | ✅ Process NYC users | +| 14:00 Dec 17 | **6:00 AM Dec 17** | 9:00 AM Dec 17 | 2:00 PM Dec 17 | 11:00 PM Dec 17 | ✅ Process LA users | + +### Processing Window +- **With 15-min cron**: Users processed within **0-15 minutes** after their local 6 AM +- **With hourly cron**: Users processed within **0-60 minutes** after their local 6 AM +- **With 5-min cron**: Users processed within **0-5 minutes** after their local 6 AM + +## Why This Approach? + +### ✅ Advantages +1. **No per-user scheduling** needed - single cron handles all users +2. **Automatic timezone handling** - works for any timezone without config +3. **Scalable** - adding users doesn't increase job complexity +4. **Self-correcting** - if a job misses a run, next run catches it + +### ⚠️ Considerations +1. **Small delay** - Users processed within 15 min (not exactly at 6:00 AM) +2. **Query overhead** - Queries all candidate plans every 15 min +3. **Database filtering** - Good indexes on `dueOn` and `nextPaymentDate` are important + +### 🔄 Alternative Approach (Not Implemented) +Store each user's next run time as UTC timestamp: +```sql +nextRolloverAt = '2025-12-17T21:00:00Z' -- for Tokyo user's 6 AM +``` +Then query: `WHERE nextRolloverAt <= NOW()` + +**Trade-offs:** +- ✅ Exact timing - no delay +- ✅ More efficient query - index on single timestamp column +- ❌ More complex - need to update nextRolloverAt after each run +- ❌ DST complications - need to recalculate when timezone rules change + +## Configuration + +### Environment Variables +```bash +# Rollover: default = every 15 minutes +ROLLOVER_SCHEDULE_CRON="*/15 * * * *" + +# Auto-payment: default = every 15 minutes +AUTO_PAYMENT_SCHEDULE_CRON="*/15 * * * *" + +# For high-precision (every 5 minutes): +ROLLOVER_SCHEDULE_CRON="*/5 * * * *" + +# For lower load (hourly): +ROLLOVER_SCHEDULE_CRON="0 * * * *" +``` + +### Cron Format +``` +* * * * * +│ │ │ │ │ +│ │ │ │ └─ Day of week (0-7, both 0 and 7 = Sunday) +│ │ │ └─── Month (1-12) +│ │ └───── Day of month (1-31) +│ └─────── Hour (0-23) +└───────── Minute (0-59) + +Examples: +*/15 * * * * → Every 15 minutes +0 * * * * → Every hour at :00 +0 6 * * * → Once daily at 6 AM UTC +*/5 * * * * → Every 5 minutes +``` + +## Testing + +### Test Specific Timezone +```bash +# 1. Change user timezone +docker compose exec -T postgres psql -U app -d skymoney -c \ + "UPDATE \"User\" SET timezone = 'Asia/Tokyo' WHERE id = 'user-id';" + +# 2. Run test script +npx tsx src/scripts/test-timezone-jobs.ts user-id +``` + +### Simulate Specific UTC Time +```typescript +import { rolloverFixedPlans } from "./src/jobs/rollover.js"; + +// Simulate running at 21:00 UTC (= 6 AM Tokyo) +await rolloverFixedPlans(prisma, "2025-12-17T21:00:00Z", { dryRun: true }); +``` + +### Test Different Timezones +```bash +# Tokyo (UTC+9) - 6 AM = 21:00 UTC previous day +npx tsx -e "import { rolloverFixedPlans } from './src/jobs/rollover.js'; ..." + +# Los Angeles (UTC-8) - 6 AM = 14:00 UTC same day +# London (UTC+0) - 6 AM = 06:00 UTC same day +# New York (UTC-5) - 6 AM = 11:00 UTC same day +``` diff --git a/api/Dockerfile b/api/Dockerfile index 1d82da0..e48c8cb 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -17,8 +17,10 @@ FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production -# optional but nice -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +# optional but nice (health check uses wget) +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates wget \ + && rm -rf /var/lib/apt/lists/* # 1) deps: prod node_modules COPY --from=deps /app/node_modules ./node_modules @@ -35,4 +37,4 @@ COPY entrypoint.sh ./entrypoint.sh RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh EXPOSE 8080 -CMD ["/app/entrypoint.sh"] \ No newline at end of file +CMD ["/app/entrypoint.sh"] diff --git a/api/check-allocations.cjs b/api/check-allocations.cjs new file mode 100644 index 0000000..a674173 --- /dev/null +++ b/api/check-allocations.cjs @@ -0,0 +1,34 @@ +const {PrismaClient} = require('@prisma/client'); + +async function checkAllocations() { + const p = new PrismaClient(); + + try { + const user = await p.user.findUnique({ + where: { email: 'test@skymoney.com' } + }); + + const income = await p.incomeEvent.findFirst({ + where: { userId: user.id }, + orderBy: { postedAt: 'desc' }, + include: { allocations: true } + }); + + console.log('\n💵 LATEST INCOME:', Number(income.amountCents)/100); + console.log('\n📊 ALLOCATIONS:'); + + for (const a of income.allocations) { + if (a.kind === 'fixed') { + const plan = await p.fixedPlan.findUnique({ where: { id: a.toId } }); + console.log(' Fixed -', plan.name + ':', Number(a.amountCents)/100); + } else if (a.kind === 'variable') { + const cat = await p.variableCategory.findUnique({ where: { id: a.toId } }); + console.log(' Variable -', cat.name + ':', Number(a.amountCents)/100); + } + } + } finally { + await p.$disconnect(); + } +} + +checkAllocations(); diff --git a/api/check-overdue.cjs b/api/check-overdue.cjs new file mode 100644 index 0000000..b76b177 --- /dev/null +++ b/api/check-overdue.cjs @@ -0,0 +1,49 @@ +// Script to check overdue status of test user +const { PrismaClient } = require('@prisma/client'); + +async function main() { + const prisma = new PrismaClient(); + + try { + const user = await prisma.user.findUnique({ + where: { email: 'test@skymoney.com' } + }); + + if (!user) { + console.log('❌ Test user not found. Run create-test-user.cjs first.'); + return; + } + + console.log('✅ Found test user:', user.email); + + const plans = await prisma.fixedPlan.findMany({ + where: { userId: user.id }, + select: { + id: true, + name: true, + totalCents: true, + fundedCents: true, + isOverdue: true, + overdueAmount: true, + overdueSince: true, + }, + }); + + console.log('\n📋 Fixed Plans:'); + for (const plan of plans) { + console.log(`\n ${plan.name}:`); + console.log(` Total: $${Number(plan.totalCents) / 100}`); + console.log(` Funded: $${Number(plan.fundedCents) / 100}`); + console.log(` Overdue: ${plan.isOverdue ? 'YES' : 'NO'}`); + if (plan.isOverdue) { + console.log(` Overdue Amount: $${plan.overdueAmount / 100}`); + console.log(` Overdue Since: ${plan.overdueSince}`); + } + } + + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/api/clients/ts/sdk.ts b/api/clients/ts/sdk.ts index f0f78d4..d192413 100644 --- a/api/clients/ts/sdk.ts +++ b/api/clients/ts/sdk.ts @@ -88,7 +88,6 @@ export type FetchLike = typeof fetch; export type SDKOptions = { baseUrl?: string; - userId?: string; fetch?: FetchLike; requestIdFactory?: () => string; // to set x-request-id if desired }; @@ -107,14 +106,9 @@ export class SkyMoney { readonly baseUrl: string; private readonly f: FetchLike; private readonly reqId?: () => string; - userId?: string; constructor(opts: SDKOptions = {}) { this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, ""); - this.userId = opts.userId ?? ( - // Try localStorage if present (browser) - typeof localStorage !== "undefined" ? localStorage.getItem("x-user-id") || undefined : undefined - ); this.f = opts.fetch || fetch; this.reqId = opts.requestIdFactory; } @@ -128,12 +122,12 @@ export class SkyMoney { ): Promise { const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`; const h: Record = { ...(headers || {}) }; - if (this.userId) h["x-user-id"] = this.userId; if (this.reqId) h["x-request-id"] = this.reqId(); const hasBody = body !== undefined && body !== null; const res = await this.f(url, { method, + credentials: "include", headers: { ...(hasBody ? { "content-type": "application/json" } : {}), ...h, diff --git a/api/create-multi-overdue-test.cjs b/api/create-multi-overdue-test.cjs new file mode 100644 index 0000000..6869931 --- /dev/null +++ b/api/create-multi-overdue-test.cjs @@ -0,0 +1,135 @@ +const argon2 = require('argon2'); +const { PrismaClient } = require('@prisma/client'); + +async function createTestUser() { + const prisma = new PrismaClient({ + datasourceUrl: 'postgres://app:app@localhost:5432/skymoney' + }); + + try { + // Delete existing test user if exists + await prisma.user.deleteMany({ + where: { email: 'test@skymoney.com' } + }); + console.log('✓ Cleaned up old test user'); + + // Create user + const hash = await argon2.hash('password123'); + const user = await prisma.user.create({ + data: { + email: 'test@skymoney.com', + passwordHash: hash, + displayName: 'Test User', + timezone: 'America/New_York' + } + }); + console.log('✓ Created user:', user.id); + + // Create categories (must total 100%) + await prisma.variableCategory.create({ + data: { + userId: user.id, + name: 'Groceries', + percent: 50, + balanceCents: 150000n // $1500 + } + }); + await prisma.variableCategory.create({ + data: { + userId: user.id, + name: 'Other', + percent: 50, + balanceCents: 150000n // $1500 + } + }); + console.log('✓ Created categories (100% total)'); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Create 3 overdue bills with different overdue dates (oldest first priority) + + // 1. RENT - Overdue 5 days ago (OLDEST = HIGHEST PRIORITY) + const rentOverdue = new Date(today); + rentOverdue.setDate(rentOverdue.getDate() - 5); + await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: 'Rent', + totalCents: 150000n, // $1500 total + fundedCents: 100000n, // $1000 funded + currentFundedCents: 100000n, + dueOn: rentOverdue, + cycleStart: rentOverdue, + frequency: 'monthly', + needsFundingThisPeriod: true, + isOverdue: true, + overdueAmount: 50000n, // $500 overdue + overdueSince: rentOverdue + } + }); + console.log('✓ Rent: $1500 total, $500 overdue (5 days ago - OLDEST)'); + + // 2. UTILITIES - Overdue 3 days ago (SECOND PRIORITY) + const utilOverdue = new Date(today); + utilOverdue.setDate(utilOverdue.getDate() - 3); + await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: 'Utilities', + totalCents: 20000n, // $200 total + fundedCents: 10000n, // $100 funded + currentFundedCents: 10000n, + dueOn: utilOverdue, + cycleStart: utilOverdue, + frequency: 'monthly', + needsFundingThisPeriod: true, + isOverdue: true, + overdueAmount: 10000n, // $100 overdue + overdueSince: utilOverdue + } + }); + console.log('✓ Utilities: $200 total, $100 overdue (3 days ago)'); + + // 3. PHONE - Overdue 1 day ago (NEWEST = LOWEST PRIORITY) + const phoneOverdue = new Date(today); + phoneOverdue.setDate(phoneOverdue.getDate() - 1); + await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: 'Phone', + totalCents: 10000n, // $100 total + fundedCents: 5000n, // $50 funded + currentFundedCents: 5000n, + dueOn: phoneOverdue, + cycleStart: phoneOverdue, + frequency: 'monthly', + needsFundingThisPeriod: true, + isOverdue: true, + overdueAmount: 5000n, // $50 overdue + overdueSince: phoneOverdue + } + }); + console.log('✓ Phone: $100 total, $50 overdue (1 day ago - NEWEST)'); + + console.log('\n✅ Multi-overdue test user ready!'); + console.log(' Email: test@skymoney.com'); + console.log(' Password: password123'); + console.log('\n OVERDUE BILLS (priority order):'); + console.log(' 1. Rent: $500 (5 days overdue)'); + console.log(' 2. Utilities: $100 (3 days overdue)'); + console.log(' 3. Phone: $50 (1 day overdue)'); + console.log(' TOTAL OVERDUE: $650'); + console.log('\n Test scenarios:'); + console.log(' - Post $500 income → Should pay Rent only'); + console.log(' - Post $600 income → Should pay Rent ($500) + Utilities ($100)'); + console.log(' - Post $700 income → Should pay all 3 overdue bills'); + + } catch (error) { + console.error('❌ Error:', error.message); + } finally { + await prisma.$disconnect(); + } +} + +createTestUser(); diff --git a/api/create-multi-overdue-user.cjs b/api/create-multi-overdue-user.cjs new file mode 100644 index 0000000..693c273 --- /dev/null +++ b/api/create-multi-overdue-user.cjs @@ -0,0 +1,133 @@ +// Create test user with MULTIPLE overdue bills +const argon2 = require('argon2'); +const { PrismaClient } = require('@prisma/client'); + +async function main() { + const prisma = new PrismaClient({ + datasourceUrl: 'postgres://app:app@localhost:5432/skymoney' + }); + + try { + const email = 'test@skymoney.com'; + const password = 'password123'; + + // Clean up existing user + await prisma.user.deleteMany({ where: { email } }); + console.log('✓ Cleaned up old test user'); + + // Create user + const passwordHash = await argon2.hash(password); + const user = await prisma.user.create({ + data: { + email, + passwordHash, + displayName: 'Test User', + incomeFrequency: 'biweekly', + totalBudgetCents: BigInt(300000), // $3000 + timezone: 'America/New_York', + }, + }); + console.log('✓ Created user:', user.id); + + // Create income source + await prisma.incomeEvent.create({ + data: { + id: '00000000-0000-0000-0000-000000000001', + userId: user.id, + postedAt: new Date(), + amountCents: BigInt(300000), + note: 'Initial budget', + }, + }); + console.log('✓ Created income: $3000'); + + // Create categories + await prisma.variableCategory.createMany({ + data: [ + { userId: user.id, name: 'Groceries', percent: 50, priority: 1, balanceCents: BigInt(150000) }, + { userId: user.id, name: 'Other', percent: 50, priority: 2, balanceCents: BigInt(150000) }, + ], + }); + console.log('✓ Created categories (100% total)'); + + const today = new Date(); + today.setHours(6, 0, 0, 0); // 6am today + + const threeDaysAgo = new Date(today); + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); + + const oneWeekAgo = new Date(today); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + // Create THREE overdue bills with different dates + // 1. Rent - $1500, $1000 funded, $500 overdue (oldest - 7 days ago) + await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: 'Rent', + cycleStart: oneWeekAgo, + dueOn: today, + totalCents: BigInt(150000), // $1500 + fundedCents: BigInt(100000), // $1000 funded + currentFundedCents: BigInt(100000), + priority: 1, + isOverdue: true, + overdueAmount: BigInt(50000), // $500 overdue + overdueSince: oneWeekAgo, + }, + }); + + // 2. Utilities - $200, $100 funded, $100 overdue (3 days ago) + await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: 'Utilities', + cycleStart: threeDaysAgo, + dueOn: today, + totalCents: BigInt(20000), // $200 + fundedCents: BigInt(10000), // $100 funded + currentFundedCents: BigInt(10000), + priority: 2, + isOverdue: true, + overdueAmount: BigInt(10000), // $100 overdue + overdueSince: threeDaysAgo, + }, + }); + + // 3. Phone - $100, $50 funded, $50 overdue (today) + await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: 'Phone', + cycleStart: today, + dueOn: today, + totalCents: BigInt(10000), // $100 + fundedCents: BigInt(5000), // $50 funded + currentFundedCents: BigInt(5000), + priority: 3, + isOverdue: true, + overdueAmount: BigInt(5000), // $50 overdue + overdueSince: today, + }, + }); + + console.log('✓ Created 3 overdue plans:'); + console.log(' - Rent: $1500 total, $1000 funded, $500 overdue (7 days ago)'); + console.log(' - Utilities: $200 total, $100 funded, $100 overdue (3 days ago)'); + console.log(' - Phone: $100 total, $50 funded, $50 overdue (today)'); + console.log('\n✅ Test user ready!'); + console.log(' Email: test@skymoney.com'); + console.log(' Password: password123'); + console.log(' Total overdue: $650'); + console.log('\n💡 Post $1000 income to see priority order:'); + console.log(' 1st: Rent $500 (oldest)'); + console.log(' 2nd: Utilities $100'); + console.log(' 3rd: Phone $50'); + console.log(' Remaining $350 → normal allocation'); + + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/api/create-test-user.cjs b/api/create-test-user.cjs new file mode 100644 index 0000000..48ad302 --- /dev/null +++ b/api/create-test-user.cjs @@ -0,0 +1,91 @@ +const argon2 = require('argon2'); +const { PrismaClient } = require('@prisma/client'); + +async function createTestUser() { + const prisma = new PrismaClient({ + datasourceUrl: 'postgres://app:app@localhost:5432/skymoney' + }); + + try { + // Delete existing test user if exists + await prisma.user.deleteMany({ + where: { email: 'test@skymoney.com' } + }); + console.log('✓ Cleaned up old test user'); + + // Create user + const hash = await argon2.hash('password123'); + const user = await prisma.user.create({ + data: { + email: 'test@skymoney.com', + passwordHash: hash, + displayName: 'Test User', + timezone: 'America/New_York' + } + }); + console.log('✓ Created user:', user.id); + + // Create income + await prisma.incomeEvent.create({ + data: { + userId: user.id, + amountCents: 300000n, // $3000 + postedAt: new Date(), + isScheduledIncome: true + } + }); + console.log('✓ Created income: $3000'); + + // Create categories (must total 100%) + await prisma.variableCategory.create({ + data: { + userId: user.id, + name: 'Groceries', + percent: 50, + balanceCents: 150000n // $1500 + } + }); + await prisma.variableCategory.create({ + data: { + userId: user.id, + name: 'Other', + percent: 50, + balanceCents: 0n + } + }); + console.log('✓ Created categories (100% total)'); + + // Create rent bill due today - PARTIALLY FUNDED & OVERDUE + const today = new Date(); + today.setHours(0, 0, 0, 0); + + await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: 'Rent', + totalCents: 150000n, // $1500 total + fundedCents: 100000n, // $1000 funded (partial) + currentFundedCents: 100000n, // $1000 available + dueOn: today, + cycleStart: today, + frequency: 'monthly', + needsFundingThisPeriod: true, + isOverdue: true, // Marked overdue + overdueAmount: 50000n, // $500 outstanding + overdueSince: new Date() + } + }); + console.log('✓ Created Rent plan: $1500 total, $1000 funded, $500 overdue'); + console.log('\n✅ Test user ready!'); + console.log(' Email: test@skymoney.com'); + console.log(' Password: password123'); + console.log(' Rent: $1500 due today (partially funded, overdue)'); + + } catch (error) { + console.error('❌ Error:', error.message); + } finally { + await prisma.$disconnect(); + } +} + +createTestUser(); diff --git a/api/openapi.yaml b/api/openapi.yaml index e88f4a5..55d3812 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4,8 +4,9 @@ info: version: 0.1.0 description: | Fastify backend for budgeting/allocations. - Most endpoints accept an optional `x-user-id` header; when omitted, the server - defaults to `demo-user-1`. + Authentication uses secure httpOnly session cookies (Fastify JWT). During tests + or local development you can set `AUTH_DISABLED=1` to use the legacy `x-user-id` + header for impersonation, but production relies on the session cookie. servers: - url: http://localhost:8080 @@ -323,6 +324,9 @@ components: in: header name: x-user-id required: false + description: | + Dev/test-only tenant selector when AUTH_DISABLED=1. Production requests rely + on the session cookie instead and should omit this header. schema: { type: string } description: Override the stubbed user id for the request. RequestId: diff --git a/api/package-lock.json b/api/package-lock.json index 803e7f5..36bcdbb 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -8,10 +8,15 @@ "name": "skymoney-api", "version": "0.1.0", "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^10.1.0", + "@fastify/jwt": "^10.0.0", "@fastify/rate-limit": "^10.3.0", "@prisma/client": "^5.22.0", + "argon2": "^0.40.1", + "date-fns-tz": "^3.2.0", "fastify": "^5.6.2", + "node-cron": "^4.2.1", "zod": "^3.23.8" }, "devDependencies": { @@ -487,6 +492,26 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, "node_modules/@fastify/cors": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", @@ -558,6 +583,29 @@ ], "license": "MIT" }, + "node_modules/@fastify/jwt": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz", + "integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.2.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^6.0.2", + "fastify-plugin": "^5.0.1", + "steed": "^1.1.3" + } + }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", @@ -657,6 +705,15 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1246,6 +1303,21 @@ } } }, + "node_modules/argon2": { + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.40.3.tgz", + "integrity": "sha512-FrSmz4VeM91jwFvvjsQv9GYp6o/kARWoYKjbjDB2U5io1H3e5X67PYGclFDeQff6UXIhUd4aHR3mxCdBbMMuQw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -1253,6 +1325,18 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1289,6 +1373,12 @@ "fastq": "^1.17.1" } }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1396,6 +1486,26 @@ "dev": true, "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1469,6 +1579,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1623,6 +1742,21 @@ "rfdc": "^1.2.0" } }, + "node_modules/fast-jwt": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.0.2.tgz", + "integrity": "sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -1655,6 +1789,18 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fastify": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.6.2.tgz", @@ -1704,6 +1850,16 @@ ], "license": "MIT" }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -1713,6 +1869,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, "node_modules/find-my-way": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.3.0.tgz", @@ -1892,6 +2058,12 @@ "node": ">= 0.4" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", @@ -2036,6 +2208,12 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/mnemonist": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", @@ -2071,6 +2249,35 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2352,6 +2559,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", @@ -2380,6 +2607,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -2539,6 +2772,19 @@ "dev": true, "license": "MIT" }, + "node_modules/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", @@ -3283,6 +3529,15 @@ "dev": true, "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/api/package.json b/api/package.json index cce8e38..f7f51ea 100644 --- a/api/package.json +++ b/api/package.json @@ -10,7 +10,9 @@ "migrate": "prisma migrate dev", "seed": "prisma db seed", "test": "vitest --run", - "test:watch": "vitest" + "test:watch": "vitest", + "rollover": "tsx src/scripts/run-rollover.ts", + "plan:manage": "tsx src/scripts/manage-plan.ts" }, "prisma": { "seed": "tsx --tsconfig prisma/tsconfig.seed.json prisma/seed.ts" @@ -25,10 +27,15 @@ "vitest": "^2.1.3" }, "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^10.1.0", + "@fastify/jwt": "^10.0.0", "@fastify/rate-limit": "^10.3.0", "@prisma/client": "^5.22.0", + "argon2": "^0.40.1", + "date-fns-tz": "^3.2.0", "fastify": "^5.6.2", + "node-cron": "^4.2.1", "zod": "^3.23.8" } } diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 7302ef9..0931c23 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -8,130 +8,259 @@ importers: .: dependencies: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 '@fastify/cors': - specifier: ^10.0.0 + 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.20.0 + 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: ^4.26.2 - version: 4.29.1 + 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.11.30 - version: 20.19.24 + specifier: ^20.19.25 + version: 20.19.25 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 prisma: - specifier: ^5.20.0 + specifier: ^5.22.0 version: 5.22.0 + supertest: + specifier: ^6.3.4 + version: 6.3.4 tsx: - specifier: ^4.19.0 + 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'} @@ -144,6 +273,12 @@ packages: 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'} @@ -156,6 +291,12 @@ packages: 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'} @@ -168,44 +309,101 @@ packages: 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@3.6.0': - resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} + '@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@3.4.1': - resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - '@fastify/fast-json-stringify-compiler@4.3.0': - resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} - '@fastify/merge-json-schemas@0.1.1': - resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + '@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==} @@ -234,20 +432,166 @@ packages: '@prisma/get-platform@5.22.0': resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} - '@types/node@20.19.24': - resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} + '@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@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -259,24 +603,141 @@ packages: 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@8.4.0: - resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==} + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} + 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 - fast-content-type-parse@1.1.0: - resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} + 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==} @@ -284,59 +745,160 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-json-stringify@5.16.1: - resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} + 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-uri@2.4.0: - resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==} + 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@4.29.1: - resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==} + 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==} - find-my-way@8.2.2: - resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==} - engines: {node: '>=14'} + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} + 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==} - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} - json-schema-ref-resolver@1.0.1: - resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + 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@5.14.0: - resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} + 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==} @@ -344,30 +906,47 @@ packages: 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@9.14.0: - resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + 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@3.0.0: - resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + 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==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} + 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==} @@ -383,8 +962,8 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - ret@0.4.3: - resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} reusify@1.1.0: @@ -394,15 +973,26 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - safe-regex2@3.1.0: - resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==} + 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'} - secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + 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==} @@ -412,16 +1002,76 @@ packages: 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'} @@ -439,109 +1089,289 @@ packages: 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@3.6.0': + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - fast-uri: 2.4.0 + 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@3.4.1': {} + '@fastify/error@4.2.0': {} - '@fastify/fast-json-stringify-compiler@4.3.0': + '@fastify/fast-json-stringify-compiler@5.0.3': dependencies: - fast-json-stringify: 5.16.1 + fast-json-stringify: 6.1.1 - '@fastify/merge-json-schemas@0.1.1': + '@fastify/forwarded@3.0.1': {} + + '@fastify/jwt@10.0.0': dependencies: - fast-deep-equal: 3.1.3 + '@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': {} @@ -570,15 +1400,135 @@ snapshots: dependencies: '@prisma/debug': 5.22.0 - '@types/node@20.19.24': + '@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 - abstract-logging@2.0.1: {} + '@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 - ajv-formats@2.1.1(ajv@8.17.1): + '@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: - ajv: 8.17.1 + 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: @@ -591,14 +1541,139 @@ snapshots: 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@8.4.0: + avvio@9.1.0: dependencies: - '@fastify/error': 3.4.1 + '@fastify/error': 4.2.0 fastq: 1.19.1 - cookie@0.7.2: {} + 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: @@ -629,99 +1704,211 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - fast-content-type-parse@1.1.0: {} + 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@5.16.1: + fast-json-stringify@6.1.1: dependencies: - '@fastify/merge-json-schemas': 0.1.1 + '@fastify/merge-json-schemas': 0.2.1 ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) - fast-deep-equal: 3.1.3 - fast-uri: 2.4.0 - json-schema-ref-resolver: 1.0.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-uri@2.4.0: {} + 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@4.29.1: + fastify@5.6.2: dependencies: - '@fastify/ajv-compiler': 3.6.0 - '@fastify/error': 3.4.1 - '@fastify/fast-json-stringify-compiler': 4.3.0 + '@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: 8.4.0 - fast-content-type-parse: 1.1.0 - fast-json-stringify: 5.16.1 - find-my-way: 8.2.2 - light-my-request: 5.14.0 - pino: 9.14.0 - process-warning: 3.0.0 - proxy-addr: 2.0.7 + 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: 2.7.0 + 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 - find-my-way@8.2.2: + 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: 3.1.0 + safe-regex2: 5.0.0 - forwarded@0.2.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 - ipaddr.js@1.9.1: {} + gopd@1.2.0: {} - json-schema-ref-resolver@1.0.1: + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: dependencies: - fast-deep-equal: 3.1.3 + 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@5.14.0: + light-my-request@6.6.0: dependencies: - cookie: 0.7.2 - process-warning: 3.0.0 + 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@9.14.0: + pino@10.1.0: dependencies: '@pinojs/redact': 0.4.0 atomic-sleep: 1.0.0 @@ -735,20 +1922,25 @@ snapshots: 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@3.0.0: {} + process-warning@4.0.1: {} process-warning@5.0.0: {} - proxy-addr@2.0.7: + qs@6.14.0: dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 + side-channel: 1.1.0 quick-format-unescaped@4.0.4: {} @@ -758,34 +1950,142 @@ snapshots: resolve-pkg-maps@1.0.0: {} - ret@0.4.3: {} + ret@0.5.0: {} reusify@1.1.0: {} rfdc@1.4.1: {} - safe-regex2@3.1.0: + rollup@4.53.3: dependencies: - ret: 0.4.3 + '@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: {} - secure-json-parse@2.7.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: @@ -799,4 +2099,75 @@ snapshots: 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/prisma/migrations/20251116055748_user_update/migration.sql b/api/prisma/migrations/20251116055748_user_update/migration.sql new file mode 100644 index 0000000..171739b --- /dev/null +++ b/api/prisma/migrations/20251116055748_user_update/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "passwordHash" TEXT, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/api/prisma/migrations/20251116075012_transaction_relations/migration.sql b/api/prisma/migrations/20251116075012_transaction_relations/migration.sql new file mode 100644 index 0000000..2d81c83 --- /dev/null +++ b/api/prisma/migrations/20251116075012_transaction_relations/migration.sql @@ -0,0 +1,9 @@ +-- AddForeignKey +ALTER TABLE "Transaction" +ADD CONSTRAINT "Transaction_categoryId_fkey" +FOREIGN KEY ("categoryId") REFERENCES "VariableCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Transaction" +ADD CONSTRAINT "Transaction_planId_fkey" +FOREIGN KEY ("planId") REFERENCES "FixedPlan"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/api/prisma/migrations/20251117052432_add_savings_target/migration.sql b/api/prisma/migrations/20251117052432_add_savings_target/migration.sql new file mode 100644 index 0000000..7704dce --- /dev/null +++ b/api/prisma/migrations/20251117052432_add_savings_target/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "VariableCategory" ADD COLUMN "savingsTargetCents" BIGINT; diff --git a/api/prisma/migrations/20251117054048_transaction_notes_receipts/migration.sql b/api/prisma/migrations/20251117054048_transaction_notes_receipts/migration.sql new file mode 100644 index 0000000..bf20b9e --- /dev/null +++ b/api/prisma/migrations/20251117054048_transaction_notes_receipts/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Transaction" ADD COLUMN "isReconciled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "note" TEXT, +ADD COLUMN "receiptUrl" TEXT; diff --git a/api/prisma/migrations/20251119033730_fixed_plan_rollover/migration.sql b/api/prisma/migrations/20251119033730_fixed_plan_rollover/migration.sql new file mode 100644 index 0000000..280537b --- /dev/null +++ b/api/prisma/migrations/20251119033730_fixed_plan_rollover/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "FixedPlan" ADD COLUMN "autoRollover" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "lastRollover" TIMESTAMP(3), +ADD COLUMN "periodDays" INTEGER NOT NULL DEFAULT 30; diff --git a/api/prisma/migrations/20251119041313_user_display_name/migration.sql b/api/prisma/migrations/20251119041313_user_display_name/migration.sql new file mode 100644 index 0000000..ad10302 --- /dev/null +++ b/api/prisma/migrations/20251119041313_user_display_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "displayName" TEXT; diff --git a/api/prisma/migrations/20251120000000_income_event_note/migration.sql b/api/prisma/migrations/20251120000000_income_event_note/migration.sql new file mode 100644 index 0000000..297b10c --- /dev/null +++ b/api/prisma/migrations/20251120000000_income_event_note/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "IncomeEvent" ADD COLUMN "note" TEXT; diff --git a/api/prisma/migrations/20251122072606_add_auto_payment_fields/migration.sql b/api/prisma/migrations/20251122072606_add_auto_payment_fields/migration.sql new file mode 100644 index 0000000..054587e --- /dev/null +++ b/api/prisma/migrations/20251122072606_add_auto_payment_fields/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "FixedPlan" ADD COLUMN "autoPayEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "lastAutoPayment" TIMESTAMP(3), +ADD COLUMN "maxRetryAttempts" INTEGER NOT NULL DEFAULT 3, +ADD COLUMN "nextPaymentDate" TIMESTAMP(3), +ADD COLUMN "paymentSchedule" JSONB; diff --git a/api/prisma/migrations/20251122234020_add_funding_system/migration.sql b/api/prisma/migrations/20251122234020_add_funding_system/migration.sql new file mode 100644 index 0000000..9d0ed96 --- /dev/null +++ b/api/prisma/migrations/20251122234020_add_funding_system/migration.sql @@ -0,0 +1,14 @@ +-- CreateEnum +CREATE TYPE "FundingStrategy" AS ENUM ('tight', 'moderate', 'comfortable'); + +-- CreateEnum +CREATE TYPE "IncomeFrequency" AS ENUM ('weekly', 'biweekly', 'monthly', 'irregular'); + +-- AlterTable +ALTER TABLE "FixedPlan" ADD COLUMN "currentFundedCents" BIGINT NOT NULL DEFAULT 0, +ADD COLUMN "lastFundingDate" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "fundingStrategy" "FundingStrategy" NOT NULL DEFAULT 'moderate', +ADD COLUMN "incomeFrequency" "IncomeFrequency" NOT NULL DEFAULT 'irregular', +ADD COLUMN "typicalIncomeCents" BIGINT; diff --git a/api/prisma/migrations/20251123000247_remove_irregular_income/migration.sql b/api/prisma/migrations/20251123000247_remove_irregular_income/migration.sql new file mode 100644 index 0000000..64a4ccf --- /dev/null +++ b/api/prisma/migrations/20251123000247_remove_irregular_income/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - The values [irregular] on the enum `IncomeFrequency` will be removed. If these variants are still used in the database, this will fail. + - You are about to drop the column `fundingStrategy` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `typicalIncomeCents` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "IncomeFrequency_new" AS ENUM ('weekly', 'biweekly', 'monthly'); +ALTER TABLE "User" ALTER COLUMN "incomeFrequency" DROP DEFAULT; +ALTER TABLE "User" ALTER COLUMN "incomeFrequency" TYPE "IncomeFrequency_new" USING ("incomeFrequency"::text::"IncomeFrequency_new"); +ALTER TYPE "IncomeFrequency" RENAME TO "IncomeFrequency_old"; +ALTER TYPE "IncomeFrequency_new" RENAME TO "IncomeFrequency"; +DROP TYPE "IncomeFrequency_old"; +ALTER TABLE "User" ALTER COLUMN "incomeFrequency" SET DEFAULT 'biweekly'; +COMMIT; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "fundingStrategy", +DROP COLUMN "typicalIncomeCents", +ALTER COLUMN "incomeFrequency" SET DEFAULT 'biweekly'; + +-- DropEnum +DROP TYPE "FundingStrategy"; diff --git a/api/prisma/migrations/20251123004713_add_irregular_income_support/migration.sql b/api/prisma/migrations/20251123004713_add_irregular_income_support/migration.sql new file mode 100644 index 0000000..3c79f29 --- /dev/null +++ b/api/prisma/migrations/20251123004713_add_irregular_income_support/migration.sql @@ -0,0 +1,32 @@ +-- CreateEnum +CREATE TYPE "IncomeType" AS ENUM ('regular', 'irregular'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "budgetPeriod" TEXT NOT NULL DEFAULT 'monthly', +ADD COLUMN "incomeType" "IncomeType" NOT NULL DEFAULT 'regular', +ADD COLUMN "totalBudgetCents" BIGINT; + +-- CreateTable +CREATE TABLE "BudgetSession" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "periodStart" TIMESTAMP(3) NOT NULL, + "periodEnd" TIMESTAMP(3) NOT NULL, + "totalBudgetCents" BIGINT NOT NULL, + "allocatedCents" BIGINT NOT NULL DEFAULT 0, + "fundedCents" BIGINT NOT NULL DEFAULT 0, + "availableCents" BIGINT NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BudgetSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "BudgetSession_userId_periodStart_idx" ON "BudgetSession"("userId", "periodStart"); + +-- CreateIndex +CREATE UNIQUE INDEX "BudgetSession_userId_periodStart_key" ON "BudgetSession"("userId", "periodStart"); + +-- AddForeignKey +ALTER TABLE "BudgetSession" ADD CONSTRAINT "BudgetSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/api/prisma/migrations/20251126184718_add_funding_tracking_fields/migration.sql b/api/prisma/migrations/20251126184718_add_funding_tracking_fields/migration.sql new file mode 100644 index 0000000..b228527 --- /dev/null +++ b/api/prisma/migrations/20251126184718_add_funding_tracking_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "FixedPlan" ADD COLUMN "lastFundedPayPeriod" TIMESTAMP(3), +ADD COLUMN "needsFundingThisPeriod" BOOLEAN NOT NULL DEFAULT true; diff --git a/api/prisma/migrations/20251126185552_add_first_income_date/migration.sql b/api/prisma/migrations/20251126185552_add_first_income_date/migration.sql new file mode 100644 index 0000000..5401a68 --- /dev/null +++ b/api/prisma/migrations/20251126185552_add_first_income_date/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "firstIncomeDate" TIMESTAMP(3); diff --git a/api/prisma/migrations/20251127063618_add_islocked_to_variable_category/migration.sql b/api/prisma/migrations/20251127063618_add_islocked_to_variable_category/migration.sql new file mode 100644 index 0000000..6a574d6 --- /dev/null +++ b/api/prisma/migrations/20251127063618_add_islocked_to_variable_category/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "VariableCategory" ADD COLUMN "isLocked" BOOLEAN NOT NULL DEFAULT false; diff --git a/api/prisma/migrations/20251127063710_add_islocked_to_variable_category/migration.sql b/api/prisma/migrations/20251127063710_add_islocked_to_variable_category/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/api/prisma/migrations/20251127063710_add_islocked_to_variable_category/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/api/prisma/migrations/20251127064553_remove_islocked_field/migration.sql b/api/prisma/migrations/20251127064553_remove_islocked_field/migration.sql new file mode 100644 index 0000000..13af7b4 --- /dev/null +++ b/api/prisma/migrations/20251127064553_remove_islocked_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `isLocked` on the `VariableCategory` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "VariableCategory" DROP COLUMN "isLocked"; diff --git a/api/prisma/migrations/20251127065651_add_frequency_to_fixed_plan/migration.sql b/api/prisma/migrations/20251127065651_add_frequency_to_fixed_plan/migration.sql new file mode 100644 index 0000000..96785e2 --- /dev/null +++ b/api/prisma/migrations/20251127065651_add_frequency_to_fixed_plan/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "FixedPlan" ADD COLUMN "frequency" TEXT; diff --git a/api/prisma/migrations/20251201065909_add_scheduled_income_tracking/migration.sql b/api/prisma/migrations/20251201065909_add_scheduled_income_tracking/migration.sql new file mode 100644 index 0000000..ea2e10c --- /dev/null +++ b/api/prisma/migrations/20251201065909_add_scheduled_income_tracking/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "IncomeEvent" ADD COLUMN "isScheduledIncome" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "pendingScheduledIncome" BOOLEAN NOT NULL DEFAULT false; diff --git a/api/prisma/migrations/20251217235644_add_user_timezone/migration.sql b/api/prisma/migrations/20251217235644_add_user_timezone/migration.sql new file mode 100644 index 0000000..bfff550 --- /dev/null +++ b/api/prisma/migrations/20251217235644_add_user_timezone/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'America/New_York'; diff --git a/api/prisma/migrations/20251218035554_add_overdue_tracking/migration.sql b/api/prisma/migrations/20251218035554_add_overdue_tracking/migration.sql new file mode 100644 index 0000000..b3a7a7a --- /dev/null +++ b/api/prisma/migrations/20251218035554_add_overdue_tracking/migration.sql @@ -0,0 +1,4 @@ +-- Add overdue tracking fields to FixedPlan +ALTER TABLE "FixedPlan" ADD COLUMN "isOverdue" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "FixedPlan" ADD COLUMN "overdueAmount" BIGINT NOT NULL DEFAULT 0; +ALTER TABLE "FixedPlan" ADD COLUMN "overdueSince" TIMESTAMP(3); \ No newline at end of file diff --git a/api/prisma/migrations/20251227004924_add_is_auto_payment/migration.sql b/api/prisma/migrations/20251227004924_add_is_auto_payment/migration.sql new file mode 100644 index 0000000..d6c91f3 --- /dev/null +++ b/api/prisma/migrations/20251227004924_add_is_auto_payment/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Transaction" ADD COLUMN "isAutoPayment" BOOLEAN NOT NULL DEFAULT false; diff --git a/api/prisma/migrations/20251229024700_add_fixed_expense_percentage/migration.sql b/api/prisma/migrations/20251229024700_add_fixed_expense_percentage/migration.sql new file mode 100644 index 0000000..0431490 --- /dev/null +++ b/api/prisma/migrations/20251229024700_add_fixed_expense_percentage/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "fixedExpensePercentage" INTEGER NOT NULL DEFAULT 40; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 67e7fe0..1c58467 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -9,16 +9,40 @@ datasource db { url = env("DATABASE_URL") } +enum IncomeFrequency { + weekly + biweekly + monthly +} + +enum IncomeType { + regular + irregular +} + model User { - id String @id @default(uuid()) - email String @unique - createdAt DateTime @default(now()) + id String @id @default(uuid()) + email String @unique + passwordHash String? + displayName String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + incomeFrequency IncomeFrequency @default(biweekly) + incomeType IncomeType @default(regular) + firstIncomeDate DateTime? // Track when user's first income was received for accurate pay period calculation + pendingScheduledIncome Boolean @default(false) // Flag set when user dismisses payday overlay, cleared when paycheck is entered + timezone String @default("America/New_York") // IANA timezone identifier for accurate date calculations + fixedExpensePercentage Int @default(40) // Irregular income: percent of new income to auto-fund fixed expenses + totalBudgetCents BigInt? + budgetPeriod String @default("monthly") variableCategories VariableCategory[] fixedPlans FixedPlan[] incomes IncomeEvent[] allocations Allocation[] transactions Transaction[] + budgetSessions BudgetSession[] } model VariableCategory { @@ -30,6 +54,8 @@ model VariableCategory { priority Int @default(100) isSavings Boolean @default(false) balanceCents BigInt @default(0) + savingsTargetCents BigInt? + transactions Transaction[] @relation("TransactionCategory") @@unique([userId, name]) @@index([userId, priority]) @@ -44,9 +70,31 @@ model FixedPlan { dueOn DateTime totalCents BigInt fundedCents BigInt @default(0) + currentFundedCents BigInt @default(0) priority Int @default(100) fundingMode String @default("auto-on-deposit") scheduleJson Json? + periodDays Int @default(30) + frequency String? // "one-time", "weekly", "biweekly", "monthly" + autoRollover Boolean @default(true) + lastRollover DateTime? + lastFundingDate DateTime? + lastFundedPayPeriod DateTime? // Track when plan was last funded in a pay period + needsFundingThisPeriod Boolean @default(true) // Simple flag to track funding needs + + // Auto-payment fields + autoPayEnabled Boolean @default(false) + paymentSchedule Json? // { frequency: "monthly", dayOfMonth: 1, minFundingPercent: 100 } + nextPaymentDate DateTime? + lastAutoPayment DateTime? + maxRetryAttempts Int @default(3) + + // Overdue tracking fields + isOverdue Boolean @default(false) + overdueAmount BigInt @default(0) + overdueSince DateTime? + + transactions Transaction[] @relation("TransactionPlan") @@unique([userId, name]) @@index([userId, dueOn]) @@ -59,6 +107,8 @@ model IncomeEvent { user User @relation(fields: [userId], references: [id], onDelete: Cascade) postedAt DateTime amountCents BigInt + note String? + isScheduledIncome Boolean @default(false) // True if this is a regular paycheck (vs bonus/extra income) allocations Allocation[] @@index([userId, postedAt]) @@ -83,8 +133,31 @@ model Transaction { occurredAt DateTime kind String categoryId String? + category VariableCategory? @relation("TransactionCategory", fields: [categoryId], references: [id], onDelete: SetNull) planId String? + plan FixedPlan? @relation("TransactionPlan", fields: [planId], references: [id], onDelete: SetNull) amountCents BigInt + note String? + receiptUrl String? + isAutoPayment Boolean @default(false) + isReconciled Boolean @default(false) @@index([userId, occurredAt]) } + +model BudgetSession { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + periodStart DateTime + periodEnd DateTime + totalBudgetCents BigInt + allocatedCents BigInt @default(0) + fundedCents BigInt @default(0) + availableCents BigInt @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, periodStart]) + @@index([userId, periodStart]) +} diff --git a/api/prisma/seed.ts b/api/prisma/seed.ts index bd1e7fa..cf47708 100644 --- a/api/prisma/seed.ts +++ b/api/prisma/seed.ts @@ -11,22 +11,39 @@ async function main() { // 1) User await prisma.user.upsert({ where: { id: userId }, - create: { id: userId, email: "demo@example.com" }, - update: {}, + create: { + id: userId, + email: "demo@example.com", + incomeFrequency: "biweekly" + }, + update: { incomeFrequency: "biweekly" }, }); // 2) Variable categories (sum = 100) const categories = [ - { name: "Savings", percent: 40, isSavings: true, priority: 10 }, - { name: "Needs", percent: 40, isSavings: false, priority: 20 }, - { name: "Wants", percent: 20, isSavings: false, priority: 30 }, + { name: "Savings", percent: 40, isSavings: true, priority: 10, target: cents(5000) }, + { name: "Needs", percent: 40, isSavings: false, priority: 20 }, + { name: "Wants", percent: 20, isSavings: false, priority: 30 }, ]; for (const c of categories) { await prisma.variableCategory.upsert({ where: { userId_name: { userId, name: c.name } }, - create: { userId, name: c.name, percent: c.percent, isSavings: c.isSavings, priority: c.priority, balanceCents: 0n }, - update: { percent: c.percent, isSavings: c.isSavings, priority: c.priority }, + create: { + userId, + name: c.name, + percent: c.percent, + isSavings: c.isSavings, + priority: c.priority, + balanceCents: 0n, + savingsTargetCents: c.target ?? null, + }, + update: { + percent: c.percent, + isSavings: c.isSavings, + priority: c.priority, + savingsTargetCents: c.target ?? null, + }, }); } @@ -82,4 +99,4 @@ main().catch((e) => { process.exit(1); }).finally(async () => { await prisma.$disconnect(); -}); \ No newline at end of file +}); diff --git a/api/src/allocator.ts b/api/src/allocator.ts index 8866cd6..2b1970b 100644 --- a/api/src/allocator.ts +++ b/api/src/allocator.ts @@ -1,154 +1,1660 @@ -import type { PrismaClient } from "@prisma/client"; +import type { + PrismaClient, + Prisma, + IncomeFrequency, +} from "@prisma/client"; +import { toZonedTime, fromZonedTime } from 'date-fns-tz'; +import { addDays } from "date-fns"; + +const DAY_MS = 86_400_000; /** - * Allocate income across fixed plans (need-first) and variable categories (largest remainder). - * - * @param db Prisma client (or tx) - * @param userId string - * @param amountCents number (>= 0) - * @param postedAtISO string ISO timestamp for the income event - * @param incomeId string id to use for IncomeEvent + Allocation FK + * Get the current date/time in the user's timezone, normalized to start of day + * @exported for use in server.ts and other modules */ +export function getUserMidnight(timezone: string, date: Date = new Date()): Date { + // Convert to user's timezone + const zonedDate = toZonedTime(date, timezone); + // Set to midnight in their timezone + zonedDate.setHours(0, 0, 0, 0); + // Convert back to UTC for storage/comparison + return fromZonedTime(zonedDate, timezone); +} + +export function getUserMidnightFromDateOnly(timezone: string, date: Date): Date { + const zoned = toZonedTime(date, timezone); + zoned.setHours(0, 0, 0, 0); + return fromZonedTime(zoned, timezone); +} + +export function getUserDateRangeFromDateOnly( + timezone: string, + from?: string, + to?: string +): { gte?: Date; lt?: Date } { + const range: { gte?: Date; lt?: Date } = {}; + if (from) { + range.gte = getUserMidnightFromDateOnly(timezone, new Date(from)); + } + if (to) { + const endDate = getUserMidnightFromDateOnly(timezone, new Date(to)); + range.lt = addDays(endDate, 1); + } + return range; +} + +type FixedAllocation = { fixedPlanId: string; amountCents: number; source: "income" | "available" }; +type VariableAllocation = { variableCategoryId: string; amountCents: number }; + +/** + * Calculate the next expected payday based on first income date and frequency + */ +export function calculateNextPayday( + firstIncomeDate: Date, + frequency: IncomeFrequency, + fromDate: Date = new Date(), + timezone: string = 'UTC' +): Date { + // Normalize dates to user's midnight for date-only comparison + const normalizedFrom = getUserMidnight(timezone, fromDate); + const nextPayDate = getUserMidnight(timezone, firstIncomeDate); + + // Get the target day in the USER'S timezone, not server local time + const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone); + const targetDay = zonedFirstIncome.getDate(); + + if (process.env.NODE_ENV !== "production") { + console.log(`🗓️ calculateNextPayday:`, { + firstIncomeDate: firstIncomeDate.toISOString(), + frequency, + fromDate: fromDate.toISOString(), + normalizedFrom: normalizedFrom.toISOString(), + startingNextPayDate: nextPayDate.toISOString(), + targetDay, + timezone, + }); + } + + // Advance to the next pay date on or after fromDate + let iterations = 0; + while (nextPayDate < normalizedFrom) { + if (frequency === 'monthly') { + // For monthly: advance by actual month, preserving day of month + // Work in user's timezone to avoid date boundary issues + const zonedPayDate = toZonedTime(nextPayDate, timezone); + zonedPayDate.setMonth(zonedPayDate.getMonth() + 1); + // Handle months with fewer days (e.g., Jan 31 -> Feb 28) + const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate(); + zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth)); + zonedPayDate.setHours(0, 0, 0, 0); + // Convert back to UTC + const newPayDate = fromZonedTime(zonedPayDate, timezone); + nextPayDate.setTime(newPayDate.getTime()); + } else { + // For weekly/biweekly: advance by fixed days (timezone-safe since we're adding days) + const freqDays = frequencyDays[frequency]; + nextPayDate.setDate(nextPayDate.getDate() + freqDays); + } + iterations++; + } + + if (process.env.NODE_ENV !== "production") { + console.log(`🗓️ calculateNextPayday result:`, { + iterations, + nextPayDate: nextPayDate.toISOString(), + isToday: nextPayDate.getTime() === normalizedFrom.getTime(), + }); + } + + return nextPayDate; +} + +/** + * Check if a date is within the payday window (±1 day) + */ +export function isWithinPaydayWindow( + date: Date, + expectedPayday: Date, + windowDays: number = 1, + timezone: string = 'UTC' +): boolean { + // Normalize both dates to user's midnight for date-only comparison + const normalizedDate = getUserMidnight(timezone, date); + const normalizedPayday = getUserMidnight(timezone, expectedPayday); + + const diffMs = Math.abs(normalizedDate.getTime() - normalizedPayday.getTime()); + const diffDays = diffMs / DAY_MS; + const isWithin = diffDays <= windowDays; + + if (process.env.NODE_ENV !== "production") { + console.log(`📍 isWithinPaydayWindow:`, { + date: date.toISOString(), + normalizedDate: normalizedDate.toISOString(), + expectedPayday: expectedPayday.toISOString(), + normalizedPayday: normalizedPayday.toISOString(), + diffMs, + diffDays, + windowDays, + isWithin, + }); + } + + return isWithin; +} + +type CrisisPlan = { + id: string; + name: string; + remainingCents: number; + daysUntilDue: number; + priority: number; + allocatedCents: number; +}; + +type PlanState = { + id: string; + name: string; + totalCents: number; + fundedCents: number; + dueOn: Date; + priority: number; + remainingCents: number; + daysUntilDue: number; + desiredThisIncome: number; + isCrisis: boolean; + allocatedThisRun: number; + needsFundingThisPeriod: boolean; + hasPaymentSchedule: boolean; + autoFundEnabled: boolean; + isOverdue: boolean; + overdueAmount: number; + overdueSince: Date | null; +}; + +type AllocationComputation = { + fixedAllocations: FixedAllocation[]; + variableAllocations: VariableAllocation[]; + remainingUnallocatedCents: number; + availableBudgetAfterCents: number; + crisis: { + active: boolean; + pulledFromAvailableCents: number; + plans: CrisisPlan[]; + }; + planStatesAfter: PlanState[]; + overduePaid?: { + totalAmount: number; + plans: Array<{ id: string; name: string; amountPaid: number }>; + }; +}; + +const frequencyDays: Record = { + weekly: 7, + biweekly: 14, + monthly: 30, +}; + +/** + * Count the number of pay periods between two dates based on the recurring pattern. + * For weekly/biweekly: counts occurrences of the specific day of week. + * For monthly: counts occurrences of the specific day of month. + * All dates are interpreted in the user's timezone. + */ +export function countPayPeriodsBetween( + startDate: Date, + endDate: Date, + firstIncomeDate: Date, + frequency: IncomeFrequency, + timezone: string = 'UTC' +): number { + let count = 0; + + // Normalize to user's timezone + const nextPayDate = getUserMidnight(timezone, firstIncomeDate); + const normalizedStart = getUserMidnight(timezone, startDate); + const normalizedEnd = getUserMidnight(timezone, endDate); + + // Get the target day in the USER'S timezone + const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone); + const targetDay = zonedFirstIncome.getDate(); + + // Helper to advance date by one period + const advanceByPeriod = () => { + 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]); + } + }; + + // Advance to the first pay date on or after startDate + while (nextPayDate < normalizedStart) { + advanceByPeriod(); + } + + // Count all pay dates up to (but not including) the end date + while (nextPayDate < normalizedEnd) { + count++; + advanceByPeriod(); + } + + // Ensure at least 1 period to avoid division by zero + return Math.max(1, count); +} + +async function getAvailableBudgetCents( + tx: PrismaClient | Prisma.TransactionClient, + userId: string +): Promise { + const [incomeAgg, allocAgg] = await Promise.all([ + tx.incomeEvent.aggregate({ where: { userId }, _sum: { amountCents: true } }), + tx.allocation.aggregate({ where: { userId }, _sum: { amountCents: true } }), + ]); + const income = Number(incomeAgg._sum?.amountCents ?? 0n); + const allocated = Number(allocAgg._sum?.amountCents ?? 0n); + return Math.max(0, income - allocated); +} + +async function getUserConfig( + tx: PrismaClient | Prisma.TransactionClient, + userId: string +): Promise<{ incomeFrequency: IncomeFrequency; firstIncomeDate: Date | null; timezone: string }> { + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { + incomeFrequency: true, + firstIncomeDate: true, + timezone: true, + }, + }); + return { + incomeFrequency: user?.incomeFrequency ?? "biweekly", + firstIncomeDate: user?.firstIncomeDate ?? null, + timezone: user?.timezone ?? "America/New_York", + }; +} + +async function getInputs( + tx: PrismaClient | Prisma.TransactionClient, + userId: string +) { + const [plans, cats, config, availableBefore, user] = await Promise.all([ + tx.fixedPlan.findMany({ + where: { userId }, + orderBy: [{ priority: "asc" }, { dueOn: "asc" }], + select: { + id: true, + name: true, + totalCents: true, + fundedCents: true, + currentFundedCents: true, + dueOn: true, + priority: true, + needsFundingThisPeriod: true, + paymentSchedule: true, + autoPayEnabled: true, + isOverdue: true, + overdueAmount: true, + overdueSince: true, + }, + }), + tx.variableCategory.findMany({ + where: { userId }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + select: { id: true, name: true, percent: true, isSavings: true, priority: true, balanceCents: true }, + }), + getUserConfig(tx, userId), + getAvailableBudgetCents(tx, userId), + tx.user.findUnique({ + where: { id: userId }, + select: { incomeType: true }, + }), + ]); + + return { plans, cats, config, availableBefore, user }; +} + +export function buildPlanStates( + plans: Awaited>["plans"], + config: { incomeFrequency: IncomeFrequency; firstIncomeDate: Date | null; timezone: string }, + now: Date, + userIncomeType?: string, + isScheduledIncome?: boolean +): PlanState[] { + const timezone = config.timezone; + const freqDays = frequencyDays[config.incomeFrequency]; + + // Only handle regular income frequencies + if (!freqDays) { + throw new Error(`Unsupported income frequency: ${config.incomeFrequency}`); + } + + return plans.map((p) => { + const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0n); + const total = Number(p.totalCents ?? 0n); + const remainingCents = Math.max(0, total - funded); + const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined; + const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true; + const autoFundEnabled = !!p.autoPayEnabled; + + // Calculate preliminary crisis status to determine if we should override funding restrictions + // Use timezone-aware date comparison + const userNow = getUserMidnight(timezone, now); + const userDueDate = getUserMidnightFromDateOnly(timezone, p.dueOn); + const daysUntilDuePrelim = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS)); + const fundedPercent = total > 0 ? (funded / total) * 100 : 100; + const CRISIS_MINIMUM_CENTS = 1000; // $10 minimum + + // Check if this is a crisis situation (quick check before full calculation) + const isPaymentPlanUser = userIncomeType === "regular" && hasPaymentSchedule; + let isPrelimCrisis = false; + let dueBeforeNextPayday = false; + let daysUntilPayday = 0; + if (isPaymentPlanUser && config.firstIncomeDate) { + const nextPayday = calculateNextPayday(config.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) { + isPrelimCrisis = daysUntilDuePrelim < daysUntilPayday && fundedPercent < 90; + } else { + isPrelimCrisis = fundedPercent < 70 && daysUntilDuePrelim <= 14; + } + } + + // Phase 2: For regular users with payment plans, only fund if: + // 1. needsFundingThisPeriod is true, AND + // 2. This is a scheduled income (regular paycheck) OR it's crisis mode (always fund crisis) + const shouldSkipFunding = isPaymentPlanUser && + !isPrelimCrisis && // Crisis overrides funding restrictions + !dueBeforeNextPayday && // Fund now if due before next payday + (!needsFundingThisPeriod || (needsFundingThisPeriod && !isScheduledIncome)); + + // If auto-fund is disabled, do not allocate from income (unless overdue handling already did). + if (!autoFundEnabled && !p.isOverdue) { + return { + id: p.id, + name: p.name, + totalCents: total, + fundedCents: funded, + dueOn: p.dueOn, + priority: p.priority, + remainingCents, + daysUntilDue: daysUntilDuePrelim, + desiredThisIncome: 0, + isCrisis: false, + allocatedThisRun: 0, + needsFundingThisPeriod, + hasPaymentSchedule, + autoFundEnabled, + isOverdue: p.isOverdue ?? false, + overdueAmount: Number(p.overdueAmount ?? 0n), + overdueSince: p.overdueSince ?? null, + }; + } + + // If plan is fully funded OR should skip funding, return zero-allocation state + if (remainingCents === 0 || shouldSkipFunding) { + return { + id: p.id, + name: p.name, + totalCents: total, + fundedCents: funded, + dueOn: p.dueOn, + priority: p.priority, + remainingCents: shouldSkipFunding ? remainingCents : 0, + daysUntilDue: 0, + desiredThisIncome: 0, + isCrisis: false, + allocatedThisRun: 0, + needsFundingThisPeriod, + hasPaymentSchedule, + autoFundEnabled, + isOverdue: p.isOverdue ?? false, + overdueAmount: Number(p.overdueAmount ?? 0n), + overdueSince: p.overdueSince ?? null, + }; + } + + // Use timezone-aware calculation for daysUntilDue + const daysUntilDue = daysUntilDuePrelim; // Already calculated with timezone + + // Calculate payment periods more accurately using firstIncomeDate + let cyclesLeft: number; + if (config.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); + } else { + // Fallback to old calculation if firstIncomeDate not set + cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays)); + } + + const perIncome = Math.ceil(remainingCents / cyclesLeft); + const desiredThisIncome = Math.min(remainingCents, perIncome); + + // Determine crisis status based on user type and funding situation (reuse from preliminary check) + const isCrisis = isPrelimCrisis; + + return { + id: p.id, + name: p.name, + totalCents: total, + fundedCents: funded, + dueOn: p.dueOn, + priority: p.priority, + remainingCents, + daysUntilDue, + desiredThisIncome, + isCrisis, + allocatedThisRun: 0, + needsFundingThisPeriod, + hasPaymentSchedule, + autoFundEnabled, + isOverdue: p.isOverdue ?? false, + overdueAmount: Number(p.overdueAmount ?? 0n), + overdueSince: p.overdueSince ?? null, + }; + }); +} + +function distributeToFixed( + plans: PlanState[], + incomePool: number, + availablePool: number +): { + allocations: FixedAllocation[]; + updatedPlans: PlanState[]; + incomeLeft: number; + availableLeft: number; + crisis: CrisisPlan[]; + pulledFromAvailable: number; +} { + const allocations: FixedAllocation[] = []; + const updatedPlans = plans.map((p) => ({ ...p })); + let incomeLeft = incomePool; + let availableLeft = availablePool; + let pulledFromAvailable = 0; + + // PRIORITY 1: Overdue bills (oldest first) + const overduePlans = updatedPlans + .filter((p) => p.isOverdue && p.overdueAmount > 0) + .sort((a, b) => { + // Sort by overdue date (oldest first - FIFO) + if (a.overdueSince && b.overdueSince) { + const aTime = a.overdueSince.getTime(); + const bTime = b.overdueSince.getTime(); + if (aTime !== bTime) return aTime - bTime; + } + // Fallback to priority if same overdue date + if (a.priority !== b.priority) return a.priority - b.priority; + return a.name.localeCompare(b.name); + }); + + // Fund overdue bills FIRST with ALL available income + for (const plan of overduePlans) { + const target = plan.overdueAmount; + let allocated = 0; + + const fromIncome = Math.min(incomeLeft, target); + incomeLeft -= fromIncome; + allocated += fromIncome; + + // Overdue bills get ALL income until fully paid + if (allocated > 0) { + allocations.push({ fixedPlanId: plan.id, amountCents: fromIncome, source: "income" }); + plan.remainingCents = Math.max(0, plan.remainingCents - allocated); + plan.fundedCents += allocated; + plan.allocatedThisRun += allocated; + } + } + + // PRIORITY 2: Crisis plans (only after overdue is handled) + const crisisCandidates = updatedPlans + .filter((p) => !p.isOverdue && p.autoFundEnabled && p.isCrisis && p.remainingCents > 0) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue; + return a.name.localeCompare(b.name); + }); + + for (const plan of crisisCandidates) { + const target = plan.remainingCents; + let allocated = 0; + + const fromIncome = Math.min(incomeLeft, target); + incomeLeft -= fromIncome; + allocated += fromIncome; + + const stillNeed = target - allocated; + let fromAvailable = 0; + if (stillNeed > 0 && availableLeft > 0) { + fromAvailable = Math.min(availableLeft, stillNeed); + availableLeft -= fromAvailable; + allocated += fromAvailable; + pulledFromAvailable += fromAvailable; + } + + if (allocated > 0) { + if (fromIncome > 0) allocations.push({ fixedPlanId: plan.id, amountCents: fromIncome, source: "income" }); + if (fromAvailable > 0) allocations.push({ fixedPlanId: plan.id, amountCents: fromAvailable, source: "available" }); + plan.remainingCents = Math.max(0, plan.remainingCents - allocated); + plan.fundedCents += allocated; + plan.allocatedThisRun += allocated; + } + } + + // PRIORITY 3: Regular plans (not overdue, not crisis) + const activePlans = updatedPlans.filter((p) => !p.isOverdue && p.autoFundEnabled && p.remainingCents > 0 && !p.isCrisis); + const totalDesired = activePlans.reduce((sum, p) => sum + Math.min(p.desiredThisIncome, p.remainingCents), 0); + + if (incomeLeft > 0 && totalDesired > 0) { + const base = new Array(activePlans.length).fill(0); + const tie: Array<{ idx: number; remainder: number; priority: number; days: number; name: string }> = []; + let sumBase = 0; + + activePlans.forEach((p, idx) => { + const desired = Math.min(p.desiredThisIncome, p.remainingCents); + const exact = (incomeLeft * desired) / totalDesired; + const floor = Math.floor(exact); + // Cap the allocation at the desired amount to prevent over-allocation + base[idx] = Math.min(floor, desired); + sumBase += base[idx]; + tie.push({ idx, remainder: exact - floor, priority: p.priority, days: p.daysUntilDue, name: p.name }); + }); + + let leftovers = incomeLeft - sumBase; + tie.sort((a, b) => { + if (a.remainder !== b.remainder) return b.remainder - a.remainder; + if (a.priority !== b.priority) return a.priority - b.priority; + if (a.days !== b.days) return a.days - b.days; + return a.name.localeCompare(b.name); + }); + for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) { + const planIdx = tie[i].idx; + const plan = activePlans[planIdx]; + const desired = Math.min(plan.desiredThisIncome, plan.remainingCents); + // Only add leftover if we haven't reached the desired amount + if (base[planIdx] < desired) { + base[planIdx]++; + } + } + + activePlans.forEach((p, idx) => { + const give = base[idx] || 0; + if (give <= 0) return; + allocations.push({ fixedPlanId: p.id, amountCents: give, source: "income" }); + p.remainingCents = Math.max(0, p.remainingCents - give); + p.fundedCents += give; + p.allocatedThisRun += give; + incomeLeft -= give; + }); + } + + const crisisReport: CrisisPlan[] = crisisCandidates.map((p) => ({ + id: p.id, + name: p.name, + remainingCents: Math.max(0, p.remainingCents), + daysUntilDue: p.daysUntilDue, + priority: p.priority, + allocatedCents: p.allocatedThisRun, + })); + + return { + allocations, + updatedPlans, + incomeLeft, + availableLeft, + crisis: crisisReport, + pulledFromAvailable, + }; +} + +function allocateVariables( + cats: Awaited>["cats"], + incomePool: number +): { allocations: VariableAllocation[]; remainingIncome: number } { + if (incomePool <= 0 || cats.length === 0) { + return { allocations: [], remainingIncome: incomePool }; + } + + const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0); + if (totalPercent <= 0) { + return { allocations: [], remainingIncome: incomePool }; + } + + // Step 1: Handle negative balances first (deficit recovery) + let remainingPool = incomePool; + const deficitRecovery: { id: string; amount: number }[] = []; + + cats.forEach((c) => { + const currentBalance = Number(c.balanceCents); + if (currentBalance < 0 && remainingPool > 0) { + const deficitAmount = Math.min(Math.abs(currentBalance), remainingPool); + deficitRecovery.push({ id: c.id, amount: deficitAmount }); + remainingPool -= deficitAmount; + } + }); + + // Step 2: Distribute remaining pool by percentages with balance awareness + const norm = cats.map((c) => ({ + ...c, + percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0, + currentBalance: Number(c.balanceCents), + })); + + const base: number[] = new Array(norm.length).fill(0); + const tie: Array<{ + idx: number; + remainder: number; + isSavings: boolean; + priority: number; + name: string; + needsBoost: boolean; + }> = []; + let sumBase = 0; + + // Calculate base allocations with balance consideration + norm.forEach((c, idx) => { + // Add any deficit recovery first + const deficitAmount = deficitRecovery.find(d => d.id === c.id)?.amount || 0; + + // Calculate percentage-based allocation + const percentageAmount = Math.floor((remainingPool * (c.percent || 0)) / 100); + + const totalAmount = deficitAmount + percentageAmount; + base[idx] = totalAmount; + sumBase += totalAmount; + + // Track remainder and whether this category needs a boost + const exact = (remainingPool * (c.percent || 0)) / 100; + const remainder = exact - percentageAmount; + + // Categories with very low balances relative to their percentage get priority + const expectedBalance = (incomePool * (c.percent || 0)) / 200; // Half of ideal allocation as threshold + const needsBoost = c.currentBalance + deficitAmount < expectedBalance; + + tie.push({ + idx, + remainder, + isSavings: !!c.isSavings, + priority: c.priority, + name: c.name, + needsBoost + }); + }); + + // Step 3: Distribute any remaining cents intelligently + let leftovers = incomePool - sumBase; + tie.sort((a, b) => { + // Priority order: categories that need boost > savings > remainder amount > priority > name + if (a.needsBoost !== b.needsBoost) return a.needsBoost ? -1 : 1; + if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; + if (a.remainder !== b.remainder) return b.remainder - a.remainder; + if (a.priority !== b.priority) return a.priority - b.priority; + return a.name.localeCompare(b.name); + }); + + for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) { + base[tie[i].idx]++; + } + + // Step 4: Create final allocations + const allocations: VariableAllocation[] = []; + norm.forEach((c, idx) => { + const give = base[idx] || 0; + if (give > 0) allocations.push({ variableCategoryId: c.id, amountCents: give }); + }); + + return { allocations, remainingIncome: leftovers }; +} + +function computeAllocation( + inputs: Awaited>, + amountCents: number, + postedAt: Date, + isScheduledIncome: boolean = false +): AllocationComputation { + const amt = Math.max(0, Math.floor(amountCents | 0)); + const incomePoolStart = amt; + let incomePool = amt; + let availablePool = inputs.availableBefore; + + const planStates = buildPlanStates(inputs.plans, inputs.config, postedAt, inputs.user?.incomeType, isScheduledIncome); + const { + allocations: fixedAllocations, + updatedPlans, + incomeLeft, + availableLeft, + crisis, + pulledFromAvailable, + } = distributeToFixed(planStates, incomePool, availablePool); + + incomePool = incomeLeft; + availablePool = availableLeft; + + const { allocations: variableAllocations, remainingIncome } = allocateVariables(inputs.cats, incomePool); + incomePool = remainingIncome; + + const totalAllocated = + fixedAllocations.reduce((s, a) => s + a.amountCents, 0) + + variableAllocations.reduce((s, a) => s + a.amountCents, 0); + + const availableBudgetAfterCents = Math.max(0, inputs.availableBefore + incomePoolStart - totalAllocated); + + return { + fixedAllocations, + variableAllocations, + remainingUnallocatedCents: Math.max(0, incomePool), + availableBudgetAfterCents, + crisis: { + active: crisis.some((p) => p.remainingCents > 0 && p.daysUntilDue <= 7), + pulledFromAvailableCents: pulledFromAvailable, + plans: crisis, + }, + planStatesAfter: updatedPlans, + }; +} + +async function applyAllocations( + tx: Prisma.TransactionClient, + userId: string, + incomeId: string, + postedAt: Date, + result: AllocationComputation, + markFundedThisPeriod: boolean +): Promise> { + // Fixed plans + const planUpdates = new Map(); + result.fixedAllocations.forEach((a) => { + planUpdates.set(a.fixedPlanId, (planUpdates.get(a.fixedPlanId) ?? 0) + a.amountCents); + }); + + const fullyFundedPlans: Array<{id: string, name: string, dueOn: Date}> = []; + + for (const [planId, amount] of planUpdates) { + const amt = Math.max(0, Math.floor(amount | 0)); + if (amt <= 0) continue; + + // Check if plan will be fully funded after this allocation + const plan = await tx.fixedPlan.findUnique({ + where: { id: planId }, + select: { + name: true, + totalCents: true, + currentFundedCents: true, + fundedCents: true, + dueOn: true, + frequency: true + }, + }); + + const currentFunded = Number(plan?.currentFundedCents ?? plan?.fundedCents ?? 0n); + const total = Number(plan?.totalCents ?? 0n); + const newFunded = currentFunded + amt; + const isFullyFunded = newFunded >= total; + + // Track plans that just reached 100% and are recurring + if (isFullyFunded && plan?.frequency && plan.frequency !== "one-time") { + const today = new Date(postedAt); + today.setHours(0, 0, 0, 0); + const dueDate = new Date(plan.dueOn); + dueDate.setHours(0, 0, 0, 0); + + // Only show modal if due date is in the future (not today) + if (dueDate > today) { + fullyFundedPlans.push({ + id: planId, + name: plan.name, + dueOn: plan.dueOn + }); + } + } + + await tx.fixedPlan.update({ + where: { id: planId }, + data: { + fundedCents: { increment: BigInt(amt) }, + currentFundedCents: { increment: BigInt(amt) }, + lastFundingDate: postedAt, + lastFundedPayPeriod: postedAt, + // Mark the plan funded for this pay period when using a scheduled income + needsFundingThisPeriod: markFundedThisPeriod ? false : !isFullyFunded, + }, + }); + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(amt), + incomeId, + }, + }); + } + + // Variable categories + for (const alloc of result.variableAllocations) { + const amt = Math.max(0, Math.floor(alloc.amountCents | 0)); + if (amt <= 0) continue; + await tx.variableCategory.update({ + where: { id: alloc.variableCategoryId }, + data: { balanceCents: { increment: BigInt(amt) } }, + }); + await tx.allocation.create({ + data: { + userId, + kind: "variable", + toId: alloc.variableCategoryId, + amountCents: BigInt(amt), + incomeId, + }, + }); + } + + return fullyFundedPlans; +} + +export async function previewAllocation( + db: PrismaClient, + userId: string, + amountCents: number, + postedAtISO?: string, + isScheduledIncome: boolean = false +) { + const postedAt = postedAtISO ? new Date(postedAtISO) : new Date(); + const inputs = await getInputs(db, userId); + return computeAllocation(inputs, amountCents, postedAt, isScheduledIncome); +} + +export async function previewIrregularAllocation( + db: PrismaClient, + userId: string, + amountCents: number, + fixedExpensePercentage: number, + postedAtISO?: string +) { + const postedAt = postedAtISO ? new Date(postedAtISO) : new Date(); + const inputs = await getInputs(db, userId); + return computeBudgetAllocation(inputs, amountCents, fixedExpensePercentage, postedAt); +} + +// Helper to check if we should reset funding flags for a new pay period +async function checkAndResetPayPeriodFlags( + tx: PrismaClient | Prisma.TransactionClient, + userId: string, + postedAt: Date +) { + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { incomeFrequency: true, incomeType: true }, + }); + + // Only for regular income users + if (user?.incomeType !== "regular") return; + + const frequency = user.incomeFrequency; + const daysPerPeriod = frequencyDays[frequency]; + + // Get the most recent income event before this one + const lastIncome = await tx.incomeEvent.findFirst({ + where: { + userId, + postedAt: { lt: postedAt }, + }, + orderBy: { postedAt: "desc" }, + }); + + // If this is the first income, or if enough days have passed for a new period + if (!lastIncome) return; + + const daysSinceLastIncome = Math.floor((postedAt.getTime() - lastIncome.postedAt.getTime()) / DAY_MS); + + // If we've crossed into a new pay period, reset the flags + if (daysSinceLastIncome >= daysPerPeriod) { + await tx.fixedPlan.updateMany({ + where: { + userId, + needsFundingThisPeriod: false, + }, + data: { + needsFundingThisPeriod: true, + }, + }); + } +} + +/** + * Process overdue bills first when new income arrives + * Returns the amount paid to overdue bills and the remaining income + */ +async function processOverdueBills( + tx: Prisma.TransactionClient, + userId: string, + incomeId: string, + availableIncome: number, + postedAt: Date +): Promise<{ totalPaidToOverdue: number; overduePlansPaid: Array<{ id: string; name: string; amountPaid: number }>; remainingIncome: number }> { + // Query all overdue bills, ordered by oldest first + const overduePlans = await tx.fixedPlan.findMany({ + where: { + userId, + isOverdue: true, + overdueAmount: { gt: 0 }, + }, + orderBy: { + overdueSince: 'asc', // Pay oldest overdue bills first + }, + select: { + id: true, + name: true, + overdueAmount: true, + fundedCents: true, + currentFundedCents: true, + }, + }); + + if (overduePlans.length === 0) { + return { totalPaidToOverdue: 0, overduePlansPaid: [], remainingIncome: availableIncome }; + } + + let remaining = availableIncome; + let totalPaid = 0; + const paidPlans: Array<{ id: string; name: string; amountPaid: number }> = []; + + for (const plan of overduePlans) { + if (remaining <= 0) break; + + const overdueAmount = Number(plan.overdueAmount); + const amountToPay = Math.min(overdueAmount, remaining); + + if (amountToPay > 0) { + // Create allocation record for tracking + await tx.allocation.create({ + data: { + userId, + kind: 'fixed', + toId: plan.id, + amountCents: BigInt(amountToPay), + incomeId, + }, + }); + + // Update the plan: add to funded amounts and reduce overdue + const newOverdueAmount = overdueAmount - amountToPay; + const newFundedCents = (plan.fundedCents ?? 0n) + BigInt(amountToPay); + const newCurrentFundedCents = (plan.currentFundedCents ?? plan.fundedCents ?? 0n) + BigInt(amountToPay); + + await tx.fixedPlan.update({ + where: { id: plan.id }, + data: { + fundedCents: newFundedCents, + currentFundedCents: newCurrentFundedCents, + overdueAmount: newOverdueAmount, + isOverdue: newOverdueAmount > 0, // Clear flag if fully paid + lastFundingDate: postedAt, + }, + }); + + totalPaid += amountToPay; + remaining -= amountToPay; + paidPlans.push({ id: plan.id, name: plan.name, amountPaid: amountToPay }); + } + } + + return { + totalPaidToOverdue: totalPaid, + overduePlansPaid: paidPlans, + remainingIncome: remaining, + }; +} + export async function allocateIncome( db: PrismaClient, userId: string, amountCents: number, postedAtISO: string, - incomeId: string -): Promise<{ - fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }>; - variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>; - remainingUnallocatedCents: number; -}> { + incomeId: string, + note?: string | null, + isScheduledIncome: boolean = false +): Promise { + const postedAt = new Date(postedAtISO); const amt = Math.max(0, Math.floor(amountCents | 0)); return await db.$transaction(async (tx) => { - // 1) Ensure the IncomeEvent exists to satisfy FK on Allocation + // Check if this is the user's first income event + const previousIncomeCount = await tx.incomeEvent.count({ + where: { userId }, + }); + const isFirstIncome = previousIncomeCount === 0; + + // Check if we need to reset pay period flags + await checkAndResetPayPeriodFlags(tx, userId, postedAt); + + const [inputs, userFlags] = await Promise.all([ + getInputs(tx, userId), + tx.user.findUnique({ + where: { id: userId }, + select: { pendingScheduledIncome: true, incomeType: true, firstIncomeDate: true, incomeFrequency: true, timezone: true, fixedExpensePercentage: true }, + }), + ]); + const isRegularUser = userFlags?.incomeType === "regular"; + if (!isRegularUser) { + const fixedExpensePercentage = userFlags?.fixedExpensePercentage ?? 40; + const irregularResult = await applyIrregularIncomeInTx( + tx as Prisma.TransactionClient, + userId, + amt, + fixedExpensePercentage, + postedAt, + incomeId, + note + ); + return { + ...mapBudgetResultToAllocation(irregularResult), + fullyFundedPlans: [], + overduePaid: { totalAmount: 0, plans: [] }, + }; + } + const hasPendingScheduledIncome = + !!userFlags?.pendingScheduledIncome && isRegularUser; + let isPayday = false; + if (isRegularUser && userFlags?.firstIncomeDate && userFlags?.incomeFrequency) { + const userTz = userFlags?.timezone || inputs.config.timezone || "America/New_York"; + const nextPayday = calculateNextPayday( + userFlags.firstIncomeDate, + userFlags.incomeFrequency, + postedAt, + userTz + ); + isPayday = isWithinPaydayWindow(postedAt, nextPayday, 0, userTz); + } + const finalIsScheduledIncome = + isScheduledIncome || hasPendingScheduledIncome || (isRegularUser && isPayday && !hasPendingScheduledIncome); + await tx.incomeEvent.upsert({ where: { id: incomeId }, - update: {}, // idempotent in case route created it already + update: {}, create: { id: incomeId, userId, - postedAt: new Date(postedAtISO), + postedAt, amountCents: BigInt(amt), + note: note ?? null, + isScheduledIncome: finalIsScheduledIncome, }, }); - // 2) Load current fixed plans + variable categories - const [plans, cats] = await Promise.all([ - tx.fixedPlan.findMany({ - where: { userId }, - orderBy: [{ priority: "asc" }, { dueOn: "asc" }], - select: { - id: true, - totalCents: true, - fundedCents: true, - priority: true, - dueOn: true, - }, - }), - tx.variableCategory.findMany({ - where: { userId }, - orderBy: [{ priority: "asc" }, { name: "asc" }], - select: { id: true, name: true, percent: true, isSavings: true, priority: true }, - }), - ]); + // Clear pendingScheduledIncome flag if this is a scheduled income + if (finalIsScheduledIncome) { + await tx.user.update({ + where: { id: userId }, + data: { pendingScheduledIncome: false }, + }); + } - let remaining = amt; + // STEP 1: Pay overdue bills first + const overdueResult = await processOverdueBills(tx, userId, incomeId, amt, postedAt); + + // STEP 2: Re-fetch inputs after overdue payments to get updated plan states + const updatedInputs = overdueResult.totalPaidToOverdue > 0 + ? await getInputs(tx, userId) + : inputs; + + // STEP 3: Allocate remaining income normally + // For first income, treat it as scheduled income to fund payment plans during onboarding + const effectiveIsScheduledIncome = isFirstIncome || finalIsScheduledIncome; + const result = computeAllocation(updatedInputs, overdueResult.remainingIncome, postedAt, effectiveIsScheduledIncome); + const fullyFundedPlans = await applyAllocations( + tx as Prisma.TransactionClient, + userId, + incomeId, + postedAt, + result, + effectiveIsScheduledIncome + ); + + return { + ...result, + fullyFundedPlans, + // Add overdue payment info to result + overduePaid: { + totalAmount: overdueResult.totalPaidToOverdue, + plans: overdueResult.overduePlansPaid, + }, + }; + }); +} - // 3) Fixed pass: fund by priority then due date up to need - const fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }> = []; - for (const p of plans) { - if (remaining <= 0) break; - const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n); - const need = Number(needBig > 0n ? needBig : 0n); - if (need <= 0) continue; +function aggregateOverrides(overrides: Array<{ id: string; amountCents: number }>) { + const map = new Map(); + for (const { id, amountCents } of overrides) { + if (!id) continue; + const amt = Math.max(0, Math.floor(amountCents | 0)); + if (amt <= 0) continue; + map.set(id, (map.get(id) ?? 0) + amt); + } + return map; +} - const give = Math.min(need, remaining); - if (give > 0) { - // apply fundedCents +export async function allocateIncomeManual( + db: PrismaClient, + userId: string, + amountCents: number, + postedAtISO: string, + incomeId: string, + overrides: Array<{ type: "fixed" | "variable"; id: string; amountCents: number }>, + note?: string | null +): Promise { + const amt = Math.max(0, Math.floor(amountCents | 0)); + const postedAt = new Date(postedAtISO); + const overrideTotal = overrides.reduce((sum, o) => sum + Math.max(0, Math.floor(o.amountCents | 0)), 0); + if (overrideTotal > amt) { + const err: any = new Error("Override amounts exceed deposit"); + err.statusCode = 400; + err.code = "OVERRIDE_EXCEEDS_AMOUNT"; + throw err; + } + + const fixedMap = aggregateOverrides( + overrides.filter((o) => o.type === "fixed").map((o) => ({ id: o.id, amountCents: o.amountCents })) + ); + const variableMap = aggregateOverrides( + overrides.filter((o) => o.type === "variable").map((o) => ({ id: o.id, amountCents: o.amountCents })) + ); + + return await db.$transaction(async (tx) => { + const inputsBefore = await getInputs(tx, userId); + + await tx.incomeEvent.upsert({ + where: { id: incomeId }, + update: {}, + create: { + id: incomeId, + userId, + postedAt, + amountCents: BigInt(amt), + note: note ?? null, + }, + }); + + if (fixedMap.size > 0) { + const plans = await tx.fixedPlan.findMany({ + where: { userId, id: { in: Array.from(fixedMap.keys()) } }, + select: { id: true, fundedCents: true, currentFundedCents: true, totalCents: true }, + }); + const planById = new Map(plans.map((p) => [p.id, p])); + + for (const [planId, amount] of fixedMap) { + const plan = planById.get(planId); + if (!plan) { + const err: any = new Error(`Unknown plan ${planId}`); + err.statusCode = 400; + err.code = "PLAN_NOT_FOUND"; + throw err; + } + const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const total = Number(plan.totalCents ?? 0n); + const need = Math.max(0, total - funded); + if (amount > need) { + const err: any = new Error(`Amount exceeds remaining need for plan ${planId}`); + err.statusCode = 400; + err.code = "PLAN_OVERFUND"; + throw err; + } + const fundedValue = (plan.fundedCents ?? 0n) + BigInt(amount); + const currentFundedValue = + (plan.currentFundedCents ?? plan.fundedCents ?? 0n) + BigInt(amount); await tx.fixedPlan.update({ - where: { id: p.id }, - data: { fundedCents: (p.fundedCents ?? 0n) + BigInt(give) }, + where: { id: planId }, + data: { + fundedCents: fundedValue, + currentFundedCents: currentFundedValue, + lastFundingDate: postedAt, + }, }); - - // audit allocation row await tx.allocation.create({ data: { userId, kind: "fixed", - toId: p.id, - amountCents: BigInt(give), - incomeId, // FK now valid + toId: planId, + amountCents: BigInt(amount), + incomeId, }, }); - - fixedAllocations.push({ fixedPlanId: p.id, amountCents: give }); - remaining -= give; } } - // 4) Variable pass: largest remainder w/ savings-first tiebreak - const variableAllocations: Array<{ variableCategoryId: string; amountCents: number }> = []; - if (remaining > 0 && cats.length > 0) { - const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0); - const norm = totalPercent === 100 - ? cats - : cats.map(c => ({ - ...c, - percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0, - })); - - const base = new Array(norm.length).fill(0); - const tie = [] as Array<{ idx: number; remainder: number; isSavings: boolean; priority: number; name: string }>; - let sumBase = 0; - - norm.forEach((c, idx) => { - const exact = (remaining * (c.percent || 0)) / 100; - const floor = Math.floor(exact); - base[idx] = floor; - sumBase += floor; - tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name }); + if (variableMap.size > 0) { + const categories = await tx.variableCategory.findMany({ + where: { userId, id: { in: Array.from(variableMap.keys()) } }, + select: { id: true }, }); + const categorySet = new Set(categories.map((c) => c.id)); - let leftovers = remaining - sumBase; - tie.sort((a, b) => { - if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first - if (a.remainder !== b.remainder) return b.remainder - a.remainder; - if (a.priority !== b.priority) return a.priority - b.priority; - return a.name.localeCompare(b.name); - }); - for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx]++; - - for (let i = 0; i < norm.length; i++) { - const give = base[i] || 0; - if (give > 0) { - const c = norm[i]; - await tx.variableCategory.update({ - where: { id: c.id }, - data: { balanceCents: { increment: BigInt(give) } }, - }); - await tx.allocation.create({ - data: { - userId, - kind: "variable", - toId: c.id, - amountCents: BigInt(give), - incomeId, - }, - }); - variableAllocations.push({ variableCategoryId: c.id, amountCents: give }); + for (const [catId, amount] of variableMap) { + if (!categorySet.has(catId)) { + const err: any = new Error(`Unknown category ${catId}`); + err.statusCode = 400; + err.code = "CATEGORY_NOT_FOUND"; + throw err; } + await tx.variableCategory.update({ + where: { id: catId }, + data: { balanceCents: { increment: BigInt(amount) } }, + }); + await tx.allocation.create({ + data: { + userId, + kind: "variable", + toId: catId, + amountCents: BigInt(amount), + incomeId, + }, + }); } - - remaining = leftovers; } + const manualFixedAllocations = Array.from(fixedMap.entries()).map(([fixedPlanId, amountCents]) => ({ + fixedPlanId, + amountCents, + source: "income" as const, + })); + const manualVariableAllocations = Array.from(variableMap.entries()).map( + ([variableCategoryId, amountCents]) => ({ + variableCategoryId, + amountCents, + }) + ); + + const remainingBeforeAuto = Math.max(0, amt - overrideTotal); + if (remainingBeforeAuto <= 0) { + const inputs = await getInputs(tx, userId); + return { + fixedAllocations: manualFixedAllocations, + variableAllocations: manualVariableAllocations, + remainingUnallocatedCents: 0, + availableBudgetAfterCents: Math.max(0, inputsBefore.availableBefore + amt - overrideTotal), + crisis: { active: false, pulledFromAvailableCents: 0, plans: [] }, + planStatesAfter: buildPlanStates(inputs.plans, inputs.config, postedAt, inputs.user?.incomeType, false), + }; + } + + const inputs = await getInputs(tx, userId); + const adjustedInputs = { ...inputs, availableBefore: inputsBefore.availableBefore }; + const result = computeAllocation(adjustedInputs, remainingBeforeAuto, postedAt, false); + await applyAllocations(tx as Prisma.TransactionClient, userId, incomeId, postedAt, result, false); + return { - fixedAllocations, - variableAllocations, - remainingUnallocatedCents: Math.max(0, remaining), + ...result, + fixedAllocations: [...manualFixedAllocations, ...result.fixedAllocations], + variableAllocations: [...manualVariableAllocations, ...result.variableAllocations], }; }); -} \ No newline at end of file +} + +// ===== Budget Allocation for Irregular Income ===== + +type BudgetAllocationResult = { + fixedAllocations: FixedAllocation[]; + variableAllocations: VariableAllocation[]; + totalBudgetCents: number; + fundedBudgetCents: number; + availableBudgetCents: number; + remainingBudgetCents: number; + crisis: { + active: boolean; + plans: CrisisPlan[]; + }; + planStatesAfter: PlanState[]; +}; + +async function getBudgetSession( + tx: PrismaClient | Prisma.TransactionClient, + userId: string, + periodStart: Date +): Promise { + const periodEnd = new Date(periodStart); + periodEnd.setMonth(periodEnd.getMonth() + 1); // Assume monthly periods for now + + return await tx.budgetSession.findUnique({ + where: { + userId_periodStart: { + userId, + periodStart, + }, + }, + }); +} + +async function createOrUpdateBudgetSession( + tx: PrismaClient | Prisma.TransactionClient, + userId: string, + periodStart: Date, + totalBudgetCents: number +): Promise { + const periodEnd = new Date(periodStart); + periodEnd.setMonth(periodEnd.getMonth() + 1); + + return await tx.budgetSession.upsert({ + where: { + userId_periodStart: { + userId, + periodStart, + }, + }, + create: { + userId, + periodStart, + periodEnd, + totalBudgetCents: BigInt(totalBudgetCents), + allocatedCents: 0n, + fundedCents: 0n, + availableCents: 0n, + }, + update: { + totalBudgetCents: BigInt(totalBudgetCents), + }, + }); +} + +function computeBudgetAllocation( + inputs: Awaited>, + newIncomeCents: number, + fixedExpensePercentage: number, + now: Date +): BudgetAllocationResult { + const newIncome = Math.max(0, Math.floor(newIncomeCents || 0)); + const availableBudget = inputs.availableBefore; + const totalPool = availableBudget + newIncome; + + const eligiblePlans = inputs.plans.filter((plan) => plan.autoPayEnabled); + const planStates = buildBudgetPlanStates(eligiblePlans, now, inputs.config.timezone); + + // Calculate total remaining needed across all fixed plans + const totalRemainingNeeded = planStates.reduce((sum, p) => sum + p.remainingCents, 0); + + // Fixed expenses get a percentage of NEW income, but capped at what's actually needed + const fixedPercentage = Math.max(0, Math.min(100, fixedExpensePercentage)) / 100; + const idealFixedAllocation = Math.floor(newIncome * fixedPercentage); + const fixedAllocationPool = Math.min(idealFixedAllocation, totalRemainingNeeded); + + // Variables get the remaining pool (available budget + remaining new income) + const variableAllocationPool = totalPool - fixedAllocationPool; + + // Distribute fixed allocation pool among fixed plans based on priority and crisis + const { allocations: fixedAllocations, updatedPlans, crisis, unusedMoney } = + distributeBudgetToFixed(planStates, fixedAllocationPool); + + // Only reallocate unused fixed money to variables if there are NO fixed plans at all + const shouldReallocateUnused = planStates.length === 0; + const actualVariablePool = variableAllocationPool + (shouldReallocateUnused ? unusedMoney : 0); + const { allocations: variableAllocations, remainingIncome } = + allocateVariables(inputs.cats, actualVariablePool); + + const totalFixedAllocated = fixedAllocations.reduce((sum: number, a: FixedAllocation) => sum + a.amountCents, 0); + const totalVariableAllocated = variableAllocations.reduce((sum: number, a: VariableAllocation) => sum + a.amountCents, 0); + + return { + fixedAllocations, + variableAllocations, + totalBudgetCents: newIncome, + fundedBudgetCents: totalFixedAllocated, + availableBudgetCents: totalVariableAllocated, + remainingBudgetCents: remainingIncome + (shouldReallocateUnused ? 0 : unusedMoney), + crisis: { + active: crisis.some((p: CrisisPlan) => p.remainingCents > 0 && p.daysUntilDue <= 14), + plans: crisis, + }, + planStatesAfter: updatedPlans, + }; +} + +function distributeBudgetToFixed( + planStates: PlanState[], + fixedAllocationPool: number +): { + allocations: FixedAllocation[]; + updatedPlans: PlanState[]; + crisis: CrisisPlan[]; + unusedMoney: number; +} { + const allocations: FixedAllocation[] = []; + const updatedPlans = planStates.map((p) => ({ ...p })); + let remainingPool = fixedAllocationPool; + + // Crisis plans get first priority (due within 14 days) + const crisisPlans = updatedPlans + .filter((p) => p.isCrisis && p.remainingCents > 0) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue; + return a.name.localeCompare(b.name); + }); + + // Fund crisis plans first, up to their full need + for (const plan of crisisPlans) { + const allocation = Math.min(remainingPool, plan.remainingCents); + if (allocation > 0) { + allocations.push({ fixedPlanId: plan.id, amountCents: allocation, source: "income" }); + remainingPool -= allocation; + plan.allocatedThisRun = allocation; + plan.remainingCents -= allocation; + plan.fundedCents += allocation; + } + } + + // Remaining pool goes to non-crisis plans proportionally + const regularPlans = updatedPlans.filter((p) => !p.isCrisis && p.remainingCents > 0); + const totalRegularNeeded = regularPlans.reduce((sum, p) => sum + p.remainingCents, 0); + + if (remainingPool > 0 && totalRegularNeeded > 0) { + for (const plan of regularPlans) { + const proportion = plan.remainingCents / totalRegularNeeded; + const allocation = Math.floor(remainingPool * proportion); + if (allocation > 0) { + allocations.push({ fixedPlanId: plan.id, amountCents: allocation, source: "income" }); + plan.allocatedThisRun = allocation; + plan.remainingCents -= allocation; + plan.fundedCents += allocation; + } + } + } + + const crisisReport: CrisisPlan[] = crisisPlans.map((p) => ({ + id: p.id, + name: p.name, + remainingCents: p.remainingCents, + daysUntilDue: p.daysUntilDue, + priority: p.priority, + allocatedCents: p.allocatedThisRun, + })); + + return { + allocations, + updatedPlans, + crisis: crisisReport, + unusedMoney: remainingPool, + }; +} + +function buildBudgetPlanStates( + plans: Awaited>["plans"], + now: Date, + timezone: string +): PlanState[] { + // For irregular income: + // - Longer crisis window (14 days instead of 7) + // - No frequency-based calculations + // - Desired funding is simply the remaining amount needed + + return plans.map((p) => { + const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0n); + const total = Number(p.totalCents ?? 0n); + const remainingCents = Math.max(0, total - funded); + const userNow = getUserMidnight(timezone, now); + 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 needsFundingThisPeriod = p.needsFundingThisPeriod ?? true; + + // For irregular income, crisis mode triggers earlier (14 days) + const isCrisis = remainingCents > 0 && daysUntilDue <= 14; + + // Desired funding is the full remaining amount needed + const desiredThisIncome = remainingCents; + + return { + id: p.id, + name: p.name, + totalCents: total, + fundedCents: funded, + dueOn: p.dueOn, + priority: p.priority, + remainingCents, + daysUntilDue, + desiredThisIncome, + isCrisis, + allocatedThisRun: 0, + needsFundingThisPeriod, + hasPaymentSchedule, + autoFundEnabled, + isOverdue: p.isOverdue ?? false, + overdueAmount: Number(p.overdueAmount ?? 0n), + overdueSince: p.overdueSince ?? null, + }; + }); +} + +export async function allocateBudget( + db: PrismaClient, + userId: string, + newIncomeCents: number, + fixedExpensePercentage: number = 30, + postedAtISO?: string +): Promise { + const amt = Math.max(0, Math.floor(newIncomeCents || 0)); + const postedAt = postedAtISO ? new Date(postedAtISO) : new Date(); + + return await db.$transaction(async (tx) => { + const inputs = await getInputs(tx, userId); + + const result = computeBudgetAllocation(inputs, amt, fixedExpensePercentage, postedAt); + + // Note: For irregular income, this is typically a preview/planning operation + // Actual funding would happen when income is received via allocateIncome + + return result; + }); +} + +function mapBudgetResultToAllocation(result: BudgetAllocationResult): AllocationComputation { + return { + fixedAllocations: result.fixedAllocations, + variableAllocations: result.variableAllocations, + remainingUnallocatedCents: result.remainingBudgetCents, + availableBudgetAfterCents: result.availableBudgetCents, + crisis: { + active: result.crisis.active, + pulledFromAvailableCents: 0, + plans: result.crisis.plans, + }, + planStatesAfter: result.planStatesAfter, + }; +} + +async function applyIrregularIncomeInTx( + tx: Prisma.TransactionClient, + userId: string, + amountCents: number, + fixedExpensePercentage: number, + postedAt: Date, + incomeId: string, + note?: string | null +): Promise { + const amt = Math.max(0, Math.floor(amountCents | 0)); + const inputs = await getInputs(tx, userId); + + await tx.incomeEvent.upsert({ + where: { id: incomeId }, + update: {}, + create: { + id: incomeId, + userId, + postedAt, + amountCents: BigInt(amt), + note: note ?? null, + isScheduledIncome: false, + }, + }); + + const result = computeBudgetAllocation(inputs, amt, fixedExpensePercentage, postedAt); + + for (const alloc of result.fixedAllocations) { + const allocAmount = Math.max(0, Math.floor(alloc.amountCents || 0)); + if (allocAmount <= 0) continue; + await tx.fixedPlan.update({ + where: { id: alloc.fixedPlanId }, + data: { + fundedCents: { increment: BigInt(allocAmount) }, + currentFundedCents: { increment: BigInt(allocAmount) }, + lastFundingDate: postedAt, + }, + }); + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: alloc.fixedPlanId, + amountCents: BigInt(allocAmount), + incomeId, + }, + }); + } + + for (const alloc of result.variableAllocations) { + const allocAmount = Math.max(0, Math.floor(alloc.amountCents || 0)); + if (allocAmount <= 0) continue; + await tx.variableCategory.update({ + where: { id: alloc.variableCategoryId }, + data: { balanceCents: { increment: BigInt(allocAmount) } }, + }); + await tx.allocation.create({ + data: { + userId, + kind: "variable", + toId: alloc.variableCategoryId, + amountCents: BigInt(allocAmount), + incomeId, + }, + }); + } + + return result; +} + +// Function for actually applying irregular income when it arrives +export async function applyIrregularIncome( + db: PrismaClient, + userId: string, + amountCents: number, + fixedExpensePercentage: number, + postedAtISO: string, + incomeId: string, + note?: string | null +): Promise { + const postedAt = new Date(postedAtISO); + return await db.$transaction(async (tx) => + applyIrregularIncomeInTx(tx, userId, amountCents, fixedExpensePercentage, postedAt, incomeId, note) + ); +} diff --git a/api/src/env.ts b/api/src/env.ts index 46dab41..410a8b4 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -1,26 +1,62 @@ -// api/src/env.ts import { z } from "zod"; +const BoolFromEnv = z + .union([z.boolean(), z.string()]) + .transform((val) => { + if (typeof val === "boolean") return val; + const normalized = val.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; + }); + const Env = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), PORT: z.coerce.number().int().positive().default(8080), HOST: z.string().default("0.0.0.0"), DATABASE_URL: z.string().min(1), - - // Comma-separated list of allowed origins; empty => allow all (dev) CORS_ORIGIN: z.string().optional(), - - // 🔹 New: rate-limit knobs (have defaults so typing is happy) RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + JWT_SECRET: z.string().min(32), + COOKIE_SECRET: z.string().min(32), + COOKIE_DOMAIN: z.string().optional(), + AUTH_DISABLED: BoolFromEnv.optional().default(false), + SEED_DEFAULT_BUDGET: BoolFromEnv.default(true), + SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30), }); -export const env = Env.parse({ +const rawEnv = { NODE_ENV: process.env.NODE_ENV, PORT: process.env.PORT, HOST: process.env.HOST, DATABASE_URL: process.env.DATABASE_URL, -CORS_ORIGIN: "http://localhost:5173,http://127.0.0.1:5173", + CORS_ORIGIN: process.env.CORS_ORIGIN, RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS, -}); + JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me", + COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me", + COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, + AUTH_DISABLED: process.env.AUTH_DISABLED, + SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET, +}; + +const parsed = Env.parse(rawEnv); + +if (parsed.NODE_ENV === "production") { + if (!parsed.CORS_ORIGIN) { + throw new Error("CORS_ORIGIN must be set in production."); + } + if (rawEnv.AUTH_DISABLED && parsed.AUTH_DISABLED) { + throw new Error("AUTH_DISABLED cannot be enabled in production."); + } + if (parsed.SEED_DEFAULT_BUDGET) { + throw new Error("SEED_DEFAULT_BUDGET must be disabled in production."); + } + if (parsed.JWT_SECRET.includes("dev-jwt-secret-change-me")) { + throw new Error("JWT_SECRET must be set to a strong value in production."); + } + if (parsed.COOKIE_SECRET.includes("dev-cookie-secret-change-me")) { + throw new Error("COOKIE_SECRET must be set to a strong value in production."); + } +} + +export const env = parsed; diff --git a/api/src/jobs/auto-payments.ts b/api/src/jobs/auto-payments.ts new file mode 100644 index 0000000..a72ad16 --- /dev/null +++ b/api/src/jobs/auto-payments.ts @@ -0,0 +1,259 @@ +import { PrismaClient, Prisma } from "@prisma/client"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type PaymentSchedule = { + frequency: "monthly" | "weekly" | "biweekly" | "daily" | "custom"; + dayOfMonth?: number; // For monthly (1-31) + dayOfWeek?: number; // For weekly/biweekly (0=Sunday, 6=Saturday) + everyNDays?: number; // For custom cadence (fallback to periodDays) + minFundingPercent: number; // 0-100, minimum funding required before auto-pay +}; + +export type AutoPaymentReport = { + planId: string; + userId: string; + name: string; + paymentAmountCents: number; + success: boolean; + error?: string; + retryCount: number; + nextRetryDate?: string; +}; + +const isProd = process.env.NODE_ENV === "production"; + +/** + * Check if auto-payment should run for a user based on their timezone + * Auto-payment runs at 9 AM in the user's timezone + */ +function shouldProcessPaymentForUser(userTimezone: string, asOf: Date): boolean { + const zonedTime = toZonedTime(asOf, userTimezone); + const hour = zonedTime.getHours(); + // Process if we're past 9 AM in user's timezone + return hour >= 9; +} + +/** + * Process auto-scheduled payments for fixed plans + */ +export async function processAutoPayments( + prisma: PrismaClient, + asOfInput?: Date | string, + { dryRun = false }: { dryRun?: boolean } = {} +): Promise { + const asOf = asOfInput ? new Date(asOfInput) : new Date(); + + // Find plans with auto-payments enabled and due for payment + const candidates = await prisma.fixedPlan.findMany({ + where: { + autoPayEnabled: true, + nextPaymentDate: { lte: asOf }, + paymentSchedule: { not: Prisma.DbNull }, + }, + orderBy: { nextPaymentDate: "asc" }, + select: { + id: true, + userId: true, + name: true, + totalCents: true, + fundedCents: true, + currentFundedCents: true, + paymentSchedule: true, + nextPaymentDate: true, + lastAutoPayment: true, + maxRetryAttempts: true, + periodDays: true, + user: { + select: { + timezone: true, + }, + }, + }, + }); + + const reports: AutoPaymentReport[] = []; + + for (const plan of candidates) { + // Check if it's time for auto-payment in this user's timezone + const userTimezone = plan.user.timezone ?? "America/New_York"; + if (!shouldProcessPaymentForUser(userTimezone, asOf)) { + if (!isProd) { + console.log( + `[auto-payment] Skipping plan ${plan.id} for user ${plan.userId} - not yet 9 AM in ${userTimezone}` + ); + } + continue; + } + + const schedule = plan.paymentSchedule as PaymentSchedule | null; + if (!schedule) continue; + const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const total = Number(plan.totalCents ?? 0n); + const fundingPercent = total > 0 ? (funded / total) * 100 : 0; + const minFunding = schedule.minFundingPercent ?? 100; + + // Check if plan meets minimum funding requirement + if (fundingPercent < minFunding) { + reports.push({ + planId: plan.id, + userId: plan.userId, + name: plan.name, + paymentAmountCents: 0, + success: false, + error: `Insufficient funding: ${fundingPercent.toFixed(1)}% < ${minFunding}%`, + retryCount: 0, + }); + + // Schedule next retry (1 day later) + const nextRetry = new Date(asOf.getTime() + DAY_MS); + if (!dryRun) { + await prisma.fixedPlan.update({ + where: { id: plan.id }, + data: { nextPaymentDate: nextRetry }, + }); + } + continue; + } + + // Calculate payment amount (use full funded amount) + const paymentAmount = funded; + + try { + if (!dryRun) { + // Create the payment transaction + await prisma.$transaction(async (tx) => { + // Create fixed_payment transaction + await tx.transaction.create({ + data: { + userId: plan.userId, + kind: "fixed_payment", + amountCents: BigInt(paymentAmount), + occurredAt: asOf, + planId: plan.id, + note: `Auto-payment (${schedule.frequency})`, + isAutoPayment: true, + }, + }); + + // Update plan funding + await tx.fixedPlan.update({ + where: { id: plan.id }, + data: { + fundedCents: BigInt(funded - paymentAmount), + currentFundedCents: BigInt(funded - paymentAmount), + lastAutoPayment: asOf, + nextPaymentDate: calculateNextPaymentDate(asOf, schedule, plan.periodDays, userTimezone), + }, + }); + }); + } + + reports.push({ + planId: plan.id, + userId: plan.userId, + name: plan.name, + paymentAmountCents: paymentAmount, + success: true, + retryCount: 0, + }); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + reports.push({ + planId: plan.id, + userId: plan.userId, + name: plan.name, + paymentAmountCents: paymentAmount, + success: false, + error: errorMessage, + retryCount: 0, + }); + + // Schedule retry (1 hour later) + const nextRetry = new Date(asOf.getTime() + 60 * 60 * 1000); + if (!dryRun) { + await prisma.fixedPlan.update({ + where: { id: plan.id }, + data: { nextPaymentDate: nextRetry }, + }); + } + } + } + + return reports; +} + +/** + * Calculate the next payment date based on schedule + */ +export function calculateNextPaymentDate( + currentDate: Date, + schedule: PaymentSchedule, + periodDays: number, + timezone: string +): Date { + const next = toZonedTime(currentDate, timezone); + const hours = next.getUTCHours(); + const minutes = next.getUTCMinutes(); + const seconds = next.getUTCSeconds(); + const ms = next.getUTCMilliseconds(); + + switch (schedule.frequency) { + case "daily": + next.setUTCDate(next.getUTCDate() + 1); + break; + + case "weekly": + // Move to next occurrence of specified day of week + { + const targetDay = schedule.dayOfWeek ?? 0; + const currentDay = next.getUTCDay(); + const daysUntilTarget = (targetDay - currentDay + 7) % 7; + next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7)); + } + break; + + case "biweekly": + { + const targetDay = schedule.dayOfWeek ?? next.getUTCDay(); + const currentDay = next.getUTCDay(); + let daysUntilTarget = (targetDay - currentDay + 7) % 7; + // ensure at least one full week gap to make it biweekly + daysUntilTarget = daysUntilTarget === 0 ? 14 : daysUntilTarget + 7; + next.setUTCDate(next.getUTCDate() + daysUntilTarget); + } + break; + + case "monthly": + { + const targetDay = schedule.dayOfMonth ?? next.getUTCDate(); + // Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months. + next.setUTCDate(1); + next.setUTCMonth(next.getUTCMonth() + 1); + const lastDay = getLastDayOfMonth(next); + next.setUTCDate(Math.min(targetDay, lastDay)); + } + break; + + case "custom": + { + const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays; + next.setUTCDate(next.getUTCDate() + days); + } + break; + + default: + // Fallback to periodDays + next.setUTCDate(next.getUTCDate() + periodDays); + } + + next.setUTCHours(hours, minutes, seconds, ms); + return fromZonedTime(next, timezone); +} + +function getLastDayOfMonth(date: Date): number { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate(); +} diff --git a/api/src/jobs/rollover.ts b/api/src/jobs/rollover.ts new file mode 100644 index 0000000..9f7c797 --- /dev/null +++ b/api/src/jobs/rollover.ts @@ -0,0 +1,129 @@ +import { PrismaClient } from "@prisma/client"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; +import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js"; + +const addDaysInTimezone = (date: Date, days: number, timezone: string) => { + const zoned = toZonedTime(date, timezone); + zoned.setUTCDate(zoned.getUTCDate() + days); + zoned.setUTCHours(0, 0, 0, 0); + return fromZonedTime(zoned, timezone); +}; + +export type RolloverReport = { + planId: string; + userId: string; + name: string; + cyclesAdvanced: number; + deficitCents: number; + carryForwardCents: number; + nextDueOn: string; +}; + +const isProd = process.env.NODE_ENV === "production"; + +/** + * Check if rollover should run for a user based on their timezone + * Rollover runs at 6 AM in the user's timezone + */ +function shouldProcessRolloverForUser(userTimezone: string, asOf: Date): boolean { + const zonedTime = toZonedTime(asOf, userTimezone); + const hour = zonedTime.getHours(); + // Process if we're past 6 AM in user's timezone + return hour >= 6; +} + +export async function rolloverFixedPlans( + prisma: PrismaClient, + asOfInput?: Date | string, + { dryRun = false }: { dryRun?: boolean } = {} +): Promise { + const asOf = asOfInput ? new Date(asOfInput) : new Date(); + + // First, get all candidate plans with user timezone + const candidates = await prisma.fixedPlan.findMany({ + where: { + autoRollover: true, + periodDays: { gt: 0 }, + dueOn: { lte: asOf }, + }, + orderBy: { dueOn: "asc" }, + select: { + id: true, + userId: true, + name: true, + dueOn: true, + cycleStart: true, + periodDays: true, + totalCents: true, + fundedCents: true, + user: { + select: { + timezone: true, + }, + }, + }, + }); + + const reports: RolloverReport[] = []; + + for (const plan of candidates) { + // Check if it's time for rollover in this user's timezone + const userTimezone = plan.user.timezone ?? "America/New_York"; + if (!shouldProcessRolloverForUser(userTimezone, asOf)) { + if (!isProd) { + console.log( + `[rollover] Skipping plan ${plan.id} for user ${plan.userId} - not yet 6 AM in ${userTimezone}` + ); + } + continue; + } + + const asOfUser = getUserMidnight(userTimezone, asOf); + let dueOn = getUserMidnightFromDateOnly(userTimezone, plan.dueOn); + let cycleStart = getUserMidnightFromDateOnly(userTimezone, plan.cycleStart); + let funded = plan.fundedCents ?? 0n; + const total = plan.totalCents ?? 0n; + const period = Math.max(plan.periodDays ?? 30, 1); + let cycles = 0; + let finalDeficit = 0n; + let finalCarry = 0n; + + while (dueOn <= asOfUser) { + const deficit = funded < total ? total - funded : 0n; + const carry = funded > total ? funded - total : 0n; + finalDeficit = deficit; + finalCarry = carry; + funded = carry; + cycleStart = dueOn; + dueOn = addDaysInTimezone(dueOn, period, userTimezone); + cycles += 1; + } + + if (cycles === 0) continue; + + reports.push({ + planId: plan.id, + userId: plan.userId, + name: plan.name, + cyclesAdvanced: cycles, + deficitCents: Number(finalDeficit), + carryForwardCents: Number(finalCarry), + nextDueOn: dueOn.toISOString(), + }); + + if (!dryRun) { + await prisma.fixedPlan.update({ + where: { id: plan.id }, + data: { + fundedCents: funded, + cycleStart, + dueOn, + lastRollover: asOf, + needsFundingThisPeriod: true, // Reset flag for new cycle + }, + }); + } + } + + return reports; +} diff --git a/api/src/plugins/user-stub.ts b/api/src/plugins/user-stub.ts deleted file mode 100644 index cccf2ce..0000000 --- a/api/src/plugins/user-stub.ts +++ /dev/null @@ -1,23 +0,0 @@ -import fp from "fastify-plugin"; -import { prisma } from "../prisma.js"; - -declare module "fastify" { - interface FastifyRequest { - userId: string; - } -} - -export default fp(async (app) => { - app.addHook("onRequest", async (req) => { - // Dev-only stub: use header if provided, else default - const hdr = req.headers["x-user-id"]; - req.userId = typeof hdr === "string" && hdr.trim() ? hdr.trim() : "demo-user-1"; - - // Ensure the user exists (avoids FK P2003 on first write) - await prisma.user.upsert({ - where: { id: req.userId }, - update: {}, - create: { id: req.userId, email: `${req.userId}@demo.local` }, - }); - }); -}); \ No newline at end of file diff --git a/api/src/routes/fixed-plans.ts b/api/src/routes/fixed-plans.ts index 400013d..589c95a 100644 --- a/api/src/routes/fixed-plans.ts +++ b/api/src/routes/fixed-plans.ts @@ -1,6 +1,15 @@ import { FastifyPluginAsync } from "fastify"; import { z } from "zod"; import { prisma } from "../prisma.js"; +import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; + +const PaymentSchedule = z.object({ + frequency: z.enum(["monthly", "weekly", "biweekly", "daily"]), + dayOfMonth: z.number().int().min(1).max(31).optional(), + dayOfWeek: z.number().int().min(0).max(6).optional(), + minFundingPercent: z.number().min(0).max(100).default(100), +}); const NewPlan = z.object({ name: z.string().min(1).max(120), @@ -8,6 +17,9 @@ const NewPlan = z.object({ fundedCents: z.number().int().min(0).default(0), priority: z.number().int().min(0).max(10_000), dueOn: z.string().datetime(), // ISO + frequency: z.enum(["one-time", "weekly", "biweekly", "monthly"]).optional(), + autoPayEnabled: z.boolean().default(false), + paymentSchedule: PaymentSchedule.optional(), }); const PatchPlan = NewPlan.partial(); const IdParam = z.object({ id: z.string().min(1) }); @@ -22,26 +34,81 @@ function validateFunding(total: bigint, funded: bigint) { } } +function calculateNextPaymentDate(dueDate: Date, schedule: any, timezone: string): Date { + const base = getUserMidnightFromDateOnly(timezone, dueDate); + const next = toZonedTime(base, timezone); + + switch (schedule.frequency) { + case "daily": + next.setUTCDate(next.getUTCDate() + 1); + break; + case "weekly": { + const targetDay = schedule.dayOfWeek ?? 0; + const currentDay = next.getUTCDay(); + const daysUntilTarget = (targetDay - currentDay + 7) % 7; + next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7)); + break; + } + case "monthly": { + const targetDay = schedule.dayOfMonth ?? next.getUTCDate(); + const nextMonth = next.getUTCMonth() + 1; + const nextYear = next.getUTCFullYear() + Math.floor(nextMonth / 12); + const nextMonthIndex = nextMonth % 12; + const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate(); + next.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay)); + break; + } + default: + next.setUTCDate(next.getUTCDate() + 30); // fallback + } + + next.setUTCHours(0, 0, 0, 0); + return fromZonedTime(next, timezone); +} + const plugin: FastifyPluginAsync = async (app) => { // CREATE - app.post("/api/fixed-plans", async (req, reply) => { + app.post("/fixed-plans", async (req, reply) => { const userId = req.userId; const parsed = NewPlan.safeParse(req.body); if (!parsed.success) return reply.status(400).send({ error: "INVALID_BODY", details: parsed.error.flatten() }); + const userTimezone = + (await prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + "America/New_York"; const totalBI = bi(parsed.data.totalCents); const fundedBI = bi(parsed.data.fundedCents); validateFunding(totalBI, fundedBI); + // Calculate next payment date if auto-pay is enabled + const nextPaymentDate = parsed.data.autoPayEnabled && parsed.data.paymentSchedule + ? calculateNextPaymentDate(new Date(parsed.data.dueOn), parsed.data.paymentSchedule, userTimezone) + : null; + + // Extract frequency from explicit field or paymentSchedule + let frequency = parsed.data.frequency; + if (!frequency && parsed.data.paymentSchedule?.frequency) { + const scheduleFreq = parsed.data.paymentSchedule.frequency; + if (scheduleFreq === "monthly" || scheduleFreq === "weekly" || scheduleFreq === "biweekly") { + frequency = scheduleFreq; + } + } + const rec = await prisma.fixedPlan.create({ data: { userId, name: parsed.data.name, priority: parsed.data.priority, - dueOn: new Date(parsed.data.dueOn), + dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn)), + frequency: frequency || null, totalCents: totalBI, fundedCents: fundedBI, - cycleStart: new Date(), // required by your schema + currentFundedCents: fundedBI, + cycleStart: getUserMidnight(userTimezone, new Date()), // required by your schema + autoPayEnabled: parsed.data.autoPayEnabled ?? false, + paymentSchedule: parsed.data.paymentSchedule || undefined, + nextPaymentDate, + lastFundingDate: fundedBI > 0 ? new Date() : null, }, select: { id: true }, }); @@ -49,12 +116,15 @@ const plugin: FastifyPluginAsync = async (app) => { }); // UPDATE - app.patch("/api/fixed-plans/:id", async (req, reply) => { + app.patch("/fixed-plans/:id", async (req, reply) => { const userId = req.userId; const pid = IdParam.safeParse(req.params); const patch = PatchPlan.safeParse(req.body); if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() }); if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() }); + const userTimezone = + (await prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + "America/New_York"; const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } }); if (!existing) return reply.status(404).send({ error: "NOT_FOUND" }); @@ -63,22 +133,43 @@ const plugin: FastifyPluginAsync = async (app) => { const nextFunded = patch.data.fundedCents !== undefined ? bi(patch.data.fundedCents) : (existing.fundedCents as bigint); validateFunding(nextTotal, nextFunded); - await prisma.fixedPlan.update({ - where: { id: pid.data.id }, + // Calculate next payment date if auto-pay settings changed + const nextPaymentDate = (patch.data.autoPayEnabled !== undefined || patch.data.paymentSchedule !== undefined) + ? ((patch.data.autoPayEnabled ?? existing.autoPayEnabled) && (patch.data.paymentSchedule ?? existing.paymentSchedule)) + ? calculateNextPaymentDate( + patch.data.dueOn ? new Date(patch.data.dueOn) : existing.dueOn, + patch.data.paymentSchedule ?? existing.paymentSchedule, + userTimezone + ) + : null + : undefined; + + const updated = await prisma.fixedPlan.updateMany({ + where: { id: pid.data.id, userId }, data: { ...(patch.data.name !== undefined ? { name: patch.data.name } : null), ...(patch.data.priority !== undefined ? { priority: patch.data.priority } : null), - ...(patch.data.dueOn !== undefined ? { dueOn: new Date(patch.data.dueOn) } : null), + ...(patch.data.dueOn !== undefined ? { dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.dueOn)) } : null), ...(patch.data.totalCents !== undefined ? { totalCents: bi(patch.data.totalCents) } : null), - ...(patch.data.fundedCents !== undefined ? { fundedCents: bi(patch.data.fundedCents) } : null), + ...(patch.data.fundedCents !== undefined + ? { + fundedCents: bi(patch.data.fundedCents), + currentFundedCents: bi(patch.data.fundedCents), + lastFundingDate: new Date(), + } + : null), + ...(patch.data.autoPayEnabled !== undefined ? { autoPayEnabled: patch.data.autoPayEnabled } : null), + ...(patch.data.paymentSchedule !== undefined ? { paymentSchedule: patch.data.paymentSchedule } : null), + ...(nextPaymentDate !== undefined ? { nextPaymentDate } : null), }, }); + if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" }); return reply.send({ ok: true }); }); // DELETE - app.delete("/api/fixed-plans/:id", async (req, reply) => { + app.delete("/fixed-plans/:id", async (req, reply) => { const userId = req.userId; const pid = IdParam.safeParse(req.params); if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() }); @@ -86,9 +177,10 @@ const plugin: FastifyPluginAsync = async (app) => { const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } }); if (!existing) return reply.status(404).send({ error: "NOT_FOUND" }); - await prisma.fixedPlan.delete({ where: { id: pid.data.id } }); + const deleted = await prisma.fixedPlan.deleteMany({ where: { id: pid.data.id, userId } }); + if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" }); return reply.send({ ok: true }); }); }; -export default plugin; \ No newline at end of file +export default plugin; diff --git a/api/src/routes/income-preview.ts b/api/src/routes/income-preview.ts index 1182157..b0476b7 100644 --- a/api/src/routes/income-preview.ts +++ b/api/src/routes/income-preview.ts @@ -1,82 +1,98 @@ import { FastifyInstance } from "fastify"; import { z } from "zod"; +import { previewAllocation, previewIrregularAllocation } from "../allocator.js"; -const Body = z.object({ amountCents: z.number().int().nonnegative() }); +const Body = z.object({ + amountCents: z.number().int().nonnegative(), + occurredAtISO: z.string().datetime().optional(), +}); export default async function incomePreviewRoutes(app: FastifyInstance) { + type PlanPreview = { + id: string; + name: string; + dueOn: Date; + totalCents: number; + fundedCents: number; + remainingCents: number; + daysUntilDue: number; + allocatedThisRun: number; + isCrisis: boolean; + }; + + type PreviewResult = { + fixedAllocations: Array<{ fixedPlanId: string; amountCents: number; source?: string }>; + variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>; + planStatesAfter: PlanPreview[]; + availableBudgetAfterCents: number; + remainingUnallocatedCents: number; + crisis: { active: boolean; plans: Array<{ id: string; name: string; remainingCents: number; daysUntilDue: number; priority: number; allocatedCents: number }> }; + }; + app.post("/income/preview", async (req, reply) => { const parsed = Body.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" }); const userId = req.userId; - let remaining = Math.max(0, parsed.data.amountCents | 0); + const user = await app.prisma.user.findUnique({ + where: { id: userId }, + select: { incomeType: true, fixedExpensePercentage: true }, + }); - const [plans, cats] = await Promise.all([ - app.prisma.fixedPlan.findMany({ - where: { userId }, - orderBy: [{ priority: "asc" }, { dueOn: "asc" }], - select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true }, - }), - app.prisma.variableCategory.findMany({ - where: { userId }, - orderBy: [{ priority: "asc" }, { name: "asc" }], - select: { id: true, name: true, percent: true, isSavings: true, priority: true }, - }), - ]); + let result: PreviewResult; - // Fixed pass - const fixed: Array<{ id: string; name: string; amountCents: number }> = []; - for (const p of plans) { - if (remaining <= 0) break; - const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n); - const need = Number(needBig > 0n ? needBig : 0n); - if (need <= 0) continue; - const give = Math.min(need, remaining); - fixed.push({ id: p.id, name: p.name, amountCents: give }); - remaining -= give; + if (user?.incomeType === "irregular") { + const rawResult = await previewIrregularAllocation( + app.prisma, + userId, + parsed.data.amountCents, + user.fixedExpensePercentage ?? 40, + parsed.data.occurredAtISO + ); + result = { + fixedAllocations: rawResult.fixedAllocations, + variableAllocations: rawResult.variableAllocations, + planStatesAfter: rawResult.planStatesAfter, + availableBudgetAfterCents: rawResult.availableBudgetCents, + remainingUnallocatedCents: rawResult.remainingBudgetCents, + crisis: rawResult.crisis, + }; + } else { + const rawResult = await previewAllocation( + app.prisma, + userId, + parsed.data.amountCents, + parsed.data.occurredAtISO + ); + result = { + fixedAllocations: rawResult.fixedAllocations, + variableAllocations: rawResult.variableAllocations, + planStatesAfter: rawResult.planStatesAfter, + availableBudgetAfterCents: rawResult.availableBudgetAfterCents, + remainingUnallocatedCents: rawResult.remainingUnallocatedCents, + crisis: rawResult.crisis, + }; } - // Variable pass — largest remainder with savings-first tiebreak - const variable: Array<{ id: string; name: string; amountCents: number }> = []; - if (remaining > 0 && cats.length > 0) { - const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0); - const normCats = - totalPercent === 100 - ? cats - : cats.map((c) => ({ - ...c, - percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0, - })); + const fixedPreview = result.planStatesAfter.map((p) => ({ + id: p.id, + name: p.name, + dueOn: p.dueOn.toISOString(), + totalCents: p.totalCents, + fundedCents: p.fundedCents, + remainingCents: p.remainingCents, + daysUntilDue: p.daysUntilDue, + allocatedThisRun: p.allocatedThisRun, + isCrisis: p.isCrisis, + })); - const base: number[] = new Array(normCats.length).fill(0); - const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = []; - let sumBase = 0; - - normCats.forEach((c, idx) => { - const exact = (remaining * c.percent) / 100; - const floor = Math.floor(exact); - base[idx] = floor; - sumBase += floor; - tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name }); - }); - - let leftovers = remaining - sumBase; - tie.sort((a, b) => { - if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; // savings first - if (a.remainder !== b.remainder) return b.remainder - a.remainder; - if (a.priority !== b.priority) return a.priority - b.priority; - return a.name.localeCompare(b.name); - }); - for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1; - - normCats.forEach((c, idx) => { - const give = base[idx] || 0; - if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give }); - }); - - remaining = leftovers; - } - - return { fixed, variable, unallocatedCents: Math.max(0, remaining) }; + return { + fixedAllocations: result.fixedAllocations, + variableAllocations: result.variableAllocations, + fixedPreview, + availableBudgetAfterCents: result.availableBudgetAfterCents, + crisis: result.crisis, + unallocatedCents: result.remainingUnallocatedCents, + }; }); -} \ No newline at end of file +} diff --git a/api/src/routes/transactions.ts b/api/src/routes/transactions.ts index a5770e3..8f8c3ff 100644 --- a/api/src/routes/transactions.ts +++ b/api/src/routes/transactions.ts @@ -1,6 +1,7 @@ // api/src/routes/transactions.ts import fp from "fastify-plugin"; import { z } from "zod"; +import { getUserDateRangeFromDateOnly } from "../allocator.js"; const Query = z.object({ from: z @@ -19,10 +20,13 @@ const Query = z.object({ export default fp(async function transactionsRoute(app) { app.get("/transactions", async (req, reply) => { - const userId = - typeof req.userId === "string" - ? req.userId - : String(req.userId ?? "demo-user-1"); + if (typeof req.userId !== "string") { + return reply.code(401).send({ message: "Unauthorized" }); + } + const userId = req.userId; + const userTimezone = + (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + "America/New_York"; const parsed = Query.safeParse(req.query); if (!parsed.success) { @@ -34,9 +38,7 @@ export default fp(async function transactionsRoute(app) { const where: any = { userId }; if (from || to) { - where.occurredAt = {}; - if (from) where.occurredAt.gte = new Date(`${from}T00:00:00.000Z`); - if (to) where.occurredAt.lte = new Date(`${to}T23:59:59.999Z`); + where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, from, to); } if (kind) where.kind = kind; diff --git a/api/src/routes/variable-categories.ts b/api/src/routes/variable-categories.ts index c5857d1..8c87e45 100644 --- a/api/src/routes/variable-categories.ts +++ b/api/src/routes/variable-categories.ts @@ -13,6 +13,39 @@ const NewCat = z.object({ const PatchCat = NewCat.partial(); const IdParam = z.object({ id: z.string().min(1) }); +function computeBalanceTargets( + categories: Array<{ id: string; percent: number }>, + totalBalance: number +) { + const percentTotal = categories.reduce((sum, c) => sum + (c.percent || 0), 0); + if (percentTotal <= 0) { + return { ok: false as const, reason: "no_percent" }; + } + + const targets = categories.map((cat) => { + const raw = (totalBalance * cat.percent) / percentTotal; + const floored = Math.floor(raw); + return { + id: cat.id, + target: floored, + frac: raw - floored, + }; + }); + + let remainder = totalBalance - targets.reduce((sum, t) => sum + t.target, 0); + targets + .slice() + .sort((a, b) => b.frac - a.frac) + .forEach((t) => { + if (remainder > 0) { + t.target += 1; + remainder -= 1; + } + }); + + return { ok: true as const, targets }; +} + async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: string) { const g = await tx.variableCategory.groupBy({ by: ["userId"], @@ -20,66 +53,135 @@ async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: strin _sum: { percent: true }, }); const sum = g[0]?._sum.percent ?? 0; - if (sum !== 100) { - const err: any = new Error(`Percents must sum to 100 (got ${sum}).`); + + // Allow partial states during onboarding (< 100%), but enforce exact 100% when sum would be >= 100% + if (sum > 100) { + const err = new Error(`Percents cannot exceed 100 (got ${sum}).`) as any; err.statusCode = 400; - err.code = "PERCENT_TOTAL_NOT_100"; + err.code = "PERCENT_TOTAL_OVER_100"; throw err; } + + // For now, allow partial completion during onboarding + // The frontend will ensure 100% total before finishing onboarding } const plugin: FastifyPluginAsync = async (app) => { // CREATE - app.post("/api/variable-categories", async (req, reply) => { + app.post("/variable-categories", async (req, reply) => { const userId = req.userId; const body = NewCat.safeParse(req.body); if (!body.success) return reply.status(400).send({ error: "INVALID_BODY", details: body.error.flatten() }); - const created = await prisma.$transaction(async (tx) => { - const rec = await tx.variableCategory.create({ - data: { ...body.data, userId }, + const normalizedName = body.data.name.trim().toLowerCase(); + try { + const result = await prisma.variableCategory.create({ + data: { ...body.data, userId, name: normalizedName }, select: { id: true }, }); - await assertPercentTotal100(tx, userId); - return rec; - }); - - return reply.status(201).send(created); + return reply.status(201).send(result); + } catch (error: any) { + if (error.code === 'P2002') { + return reply.status(400).send({ error: 'DUPLICATE_NAME', message: `Category name '${body.data.name}' already exists` }); + } + throw error; + } }); // UPDATE - app.patch("/api/variable-categories/:id", async (req, reply) => { + app.patch("/variable-categories/:id", async (req, reply) => { const userId = req.userId; const pid = IdParam.safeParse(req.params); const patch = PatchCat.safeParse(req.body); if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() }); if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() }); - await prisma.$transaction(async (tx) => { - const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } }); - if (!exists) return reply.status(404).send({ error: "NOT_FOUND" }); - await tx.variableCategory.update({ where: { id: pid.data.id }, data: patch.data }); - await assertPercentTotal100(tx, userId); + const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } }); + if (!exists) return reply.status(404).send({ error: "NOT_FOUND" }); + + const updateData = { + ...patch.data, + ...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}), + }; + const updated = await prisma.variableCategory.updateMany({ + where: { id: pid.data.id, userId }, + data: updateData, }); - + if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" }); return reply.send({ ok: true }); }); + + // DELETE - app.delete("/api/variable-categories/:id", async (req, reply) => { + app.delete("/variable-categories/:id", async (req, reply) => { const userId = req.userId; const pid = IdParam.safeParse(req.params); if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() }); - await prisma.$transaction(async (tx) => { - const exists = await tx.variableCategory.findFirst({ where: { id: pid.data.id, userId } }); - if (!exists) return reply.status(404).send({ error: "NOT_FOUND" }); - await tx.variableCategory.delete({ where: { id: pid.data.id } }); - await assertPercentTotal100(tx, userId); + const exists = await prisma.variableCategory.findFirst({ where: { id: pid.data.id, userId } }); + if (!exists) return reply.status(404).send({ error: "NOT_FOUND" }); + + const deleted = await prisma.variableCategory.deleteMany({ + where: { id: pid.data.id, userId }, }); + if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" }); return reply.send({ ok: true }); }); + + // REBALANCE balances based on current percents + app.post("/variable-categories/rebalance", async (req, reply) => { + const userId = req.userId; + const categories = await prisma.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + if (categories.length === 0) { + return reply.send({ ok: true, applied: false }); + } + + const hasNegative = categories.some( + (c) => Number(c.balanceCents ?? 0n) < 0 + ); + if (hasNegative) { + return reply.code(400).send({ + ok: false, + code: "NEGATIVE_BALANCE", + message: "Cannot rebalance while a category has a negative balance.", + }); + } + + const totalBalance = categories.reduce( + (sum, c) => sum + Number(c.balanceCents ?? 0n), + 0 + ); + if (totalBalance <= 0) { + return reply.send({ ok: true, applied: false }); + } + + const targetResult = computeBalanceTargets(categories, totalBalance); + if (!targetResult.ok) { + return reply.code(400).send({ + ok: false, + code: "NO_PERCENT", + message: "No percent totals available to rebalance.", + }); + } + + await prisma.$transaction( + targetResult.targets.map((t) => + prisma.variableCategory.update({ + where: { id: t.id }, + data: { balanceCents: BigInt(t.target) }, + }) + ) + ); + + return reply.send({ ok: true, applied: true, totalBalance }); + }); }; -export default plugin; \ No newline at end of file +export default plugin; diff --git a/api/src/scripts/manage-plan.ts b/api/src/scripts/manage-plan.ts new file mode 100644 index 0000000..fb32fd8 --- /dev/null +++ b/api/src/scripts/manage-plan.ts @@ -0,0 +1,73 @@ +import { PrismaClient } from "@prisma/client"; + +function parseArgs() { + const args = process.argv.slice(2); + const parsed: Record = {}; + for (const arg of args) { + if (arg.startsWith("--")) { + const [key, value] = arg.slice(2).split("="); + parsed[key] = value === undefined ? true : value; + } + } + return parsed; +} + +const prisma = new PrismaClient(); + +async function main() { + const args = parseArgs(); + const planId = args["planId"] as string | undefined; + + if (!planId) { + const plans = await prisma.fixedPlan.findMany({ + orderBy: { dueOn: "asc" }, + take: 10, + select: { + id: true, + userId: true, + name: true, + dueOn: true, + cycleStart: true, + periodDays: true, + totalCents: true, + fundedCents: true, + }, + }); + console.log( + JSON.stringify( + plans, + (_k, v) => (typeof v === "bigint" ? v.toString() : v), + 2 + ) + ); + return; + } + + const data: any = {}; + if (args["dueOn"]) data.dueOn = new Date(String(args["dueOn"])); + if (args["cycleStart"]) data.cycleStart = new Date(String(args["cycleStart"])); + if (args["periodDays"]) data.periodDays = Number(args["periodDays"]); + if (args["fundedCents"]) data.fundedCents = BigInt(args["fundedCents"]); + if (args["totalCents"]) data.totalCents = BigInt(args["totalCents"]); + + if (Object.keys(data).length === 0) { + console.log("No fields provided to update."); + return; + } + + const updated = await prisma.fixedPlan.update({ + where: { id: planId }, + data, + select: { id: true, name: true, dueOn: true, cycleStart: true, fundedCents: true }, + }); + console.log("Updated plan:", updated); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/api/src/scripts/run-rollover.ts b/api/src/scripts/run-rollover.ts new file mode 100644 index 0000000..467be34 --- /dev/null +++ b/api/src/scripts/run-rollover.ts @@ -0,0 +1,50 @@ +import { PrismaClient } from "@prisma/client"; +import { rolloverFixedPlans } from "../jobs/rollover.js"; + +const prisma = new PrismaClient(); + +function parseArgs() { + const args = process.argv.slice(2); + const parsed: Record = {}; + for (const arg of args) { + if (arg.startsWith("--")) { + const [key, value] = arg.slice(2).split("="); + if (value === undefined) { + parsed[key] = true; + } else { + parsed[key] = value; + } + } + } + return parsed; +} + +async function main() { + const args = parseArgs(); + const asOfRaw = (args["asOf"] as string | undefined) ?? undefined; + const asOf = asOfRaw ? new Date(asOfRaw) : new Date(); + const dryRun = Boolean(args["dry-run"] ?? args["dryRun"]); + + const results = await rolloverFixedPlans(prisma, asOf, { dryRun }); + console.log( + JSON.stringify( + { + asOf: asOf.toISOString(), + dryRun, + processed: results.length, + results, + }, + null, + 2 + ) + ); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/api/src/scripts/setup-frontend-test-user.ts b/api/src/scripts/setup-frontend-test-user.ts new file mode 100644 index 0000000..a8b79a9 --- /dev/null +++ b/api/src/scripts/setup-frontend-test-user.ts @@ -0,0 +1,274 @@ +import { PrismaClient } from "@prisma/client"; +import * as argon2 from "argon2"; + +const prisma = new PrismaClient(); + +async function setupFrontendTestUser() { + console.log("\n🔧 Setting up frontend test user...\n"); + + const email = "test@skymoney.com"; + const password = "password123"; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Delete existing test user if exists + await prisma.user.deleteMany({ + where: { email }, + }); + + // Hash password + const passwordHash = await argon2.hash(password); + + // Create user + const user = await prisma.user.create({ + data: { + email, + passwordHash, + displayName: "Test User", + incomeType: "regular", + incomeFrequency: "biweekly", + totalBudgetCents: 200000n, // $2,000 + firstIncomeDate: today, + timezone: "America/New_York", + }, + }); + + console.log(`✅ Created user: ${user.email}`); + console.log(` User ID: ${user.id}`); + console.log(` Password: ${password}\n`); + + // Create variable categories + const savings = await prisma.variableCategory.create({ + data: { + userId: user.id, + name: "Savings", + percent: 30, + balanceCents: 60000n, // $600 + isSavings: true, + priority: 1, + }, + }); + + const groceries = await prisma.variableCategory.create({ + data: { + userId: user.id, + name: "Groceries", + percent: 40, + balanceCents: 80000n, // $800 + isSavings: false, + priority: 2, + }, + }); + + const entertainment = await prisma.variableCategory.create({ + data: { + userId: user.id, + name: "Entertainment", + percent: 30, + balanceCents: 60000n, // $600 + isSavings: false, + priority: 3, + }, + }); + + console.log(`✅ Created variable categories:`); + console.log(` - Savings: 30% ($600 balance)`); + console.log(` - Groceries: 40% ($800 balance)`); + console.log(` - Entertainment: 30% ($600 balance)\n`); + + // Create fixed plans + + // Scenario 1: Rent - DUE TODAY, fully funded (test payment reconciliation) + const dueToday = new Date(today); + const rent = await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: "Rent", + totalCents: 150000n, // $1,500 + fundedCents: 150000n, // Fully funded + currentFundedCents: 150000n, + dueOn: dueToday, + frequency: "monthly", + autoPayEnabled: false, + needsFundingThisPeriod: false, + priority: 10, + cycleStart: new Date(), + }, + }); + + console.log(`✅ Created Rent plan:`); + console.log(` - Total: $1,500`); + console.log(` - Funded: $1,500 (100%)`); + console.log(` - Due: TODAY (${dueToday.toDateString()})`); + console.log(` - Status: Ready for payment reconciliation!\n`); + + // Scenario 2: Car Insurance - DUE TODAY, partially funded (test final funding attempt) + const carInsurance = await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: "Car Insurance", + totalCents: 40000n, // $400 + fundedCents: 25000n, // $250 funded + currentFundedCents: 25000n, + dueOn: dueToday, + frequency: "monthly", + autoPayEnabled: false, + needsFundingThisPeriod: false, + priority: 20, + cycleStart: new Date(), + }, + }); + + console.log(`✅ Created Car Insurance plan:`); + console.log(` - Total: $400`); + console.log(` - Funded: $250 (62.5%)`); + console.log(` - Due: TODAY (${dueToday.toDateString()})`); + console.log(` - Status: Will test final funding attempt!\n`); + + // Scenario 3: Phone Bill - Not due yet, partially funded + const nextWeek = new Date(today); + nextWeek.setDate(nextWeek.getDate() + 7); + const phoneBill = await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: "Phone Bill", + totalCents: 8000n, // $80 + fundedCents: 5000n, // $50 funded + currentFundedCents: 5000n, + dueOn: nextWeek, + frequency: "monthly", + autoPayEnabled: false, + needsFundingThisPeriod: true, + priority: 30, + cycleStart: new Date(), + }, + }); + + console.log(`✅ Created Phone Bill plan:`); + console.log(` - Total: $80`); + console.log(` - Funded: $50 (62.5%)`); + console.log(` - Due: Next week (${nextWeek.toDateString()})\n`); + + // Create income event and allocations + const incomeEvent = await prisma.incomeEvent.create({ + data: { + userId: user.id, + amountCents: 200000n, // $2,000 + postedAt: new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago + isScheduledIncome: true, + }, + }); + + console.log(`✅ Created income event:`); + console.log(` - Amount: $2,000`); + console.log(` - Posted: 2 days ago\n`); + + // Create allocations (showing where the money went) + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "fixed", + toId: rent.id, + amountCents: 150000n, + incomeId: incomeEvent.id, + }, + }); + + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "fixed", + toId: carInsurance.id, + amountCents: 25000n, + incomeId: incomeEvent.id, + }, + }); + + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "fixed", + toId: phoneBill.id, + amountCents: 5000n, + incomeId: incomeEvent.id, + }, + }); + + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "variable", + toId: savings.id, + amountCents: 6000n, + incomeId: incomeEvent.id, + }, + }); + + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "variable", + toId: groceries.id, + amountCents: 8000n, + incomeId: incomeEvent.id, + }, + }); + + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "variable", + toId: entertainment.id, + amountCents: 6000n, + incomeId: incomeEvent.id, + }, + }); + + console.log(`✅ Created allocations (total $2,000 distributed)\n`); + + // Calculate available budget + const totalIncome = 200000; + const totalAllocated = 150000 + 25000 + 5000 + 6000 + 8000 + 6000; + const availableBudget = totalIncome - totalAllocated; + + console.log(`📊 Budget Summary:`); + console.log(` - Total Income: $2,000`); + console.log(` - Total Allocated: $${totalAllocated / 100}`); + console.log(` - Available Budget: $${availableBudget / 100}\n`); + + console.log(`🎯 TEST SCENARIOS:\n`); + console.log(`1️⃣ RENT - Payment Reconciliation Modal:`); + console.log(` - Navigate to Dashboard`); + console.log(` - Should see "Rent" with 100% funded, due TODAY`); + console.log(` - Modal should ask: "Was the full amount ($1,500) paid?"`); + console.log(` - Test: Click "Yes, Full Amount" → Should create transaction & rollover\n`); + + console.log(`2️⃣ CAR INSURANCE - Attempt Final Funding:`); + console.log(` - Should see "Car Insurance" with 62.5% funded, due TODAY`); + console.log(` - Modal should attempt to fund from available budget first`); + console.log(` - Available: $0, Needed: $150`); + console.log(` - Should mark as OVERDUE with $150 remaining`); + console.log(` - Modal should show: "Could not fully fund. $250/$400 funded."`); + console.log(` - Test: Click "Partial: $100" → Should refund $150 to available\n`); + + console.log(`3️⃣ PHONE BILL - Regular funding:`); + console.log(` - Due next week (not yet showing payment modal)`); + console.log(` - Add income to test allocation to overdue bills\n`); + + console.log(`📧 LOGIN CREDENTIALS:`); + console.log(` Email: ${email}`); + console.log(` Password: ${password}\n`); + + console.log(`🌐 Frontend URL: http://localhost:5174\n`); + + await prisma.$disconnect(); +} + +setupFrontendTestUser() + .catch((e) => { + console.error("❌ Error:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/api/src/scripts/test-dashboard-edge.ts b/api/src/scripts/test-dashboard-edge.ts new file mode 100644 index 0000000..f4b2ac0 --- /dev/null +++ b/api/src/scripts/test-dashboard-edge.ts @@ -0,0 +1,266 @@ +#!/usr/bin/env tsx +import { PrismaClient } from "@prisma/client"; +import * as argon2 from "argon2"; +import { addDays, startOfDay } from "date-fns"; + +const prisma = new PrismaClient(); +const PASSWORD = "password"; + +type Scenario = { + name: string; + timezone: string; + incomeFrequency: "weekly" | "biweekly" | "monthly"; + firstIncomeDate: Date; + incomeCents: number; + variableCats: Array<{ name: string; percent: number; isSavings?: boolean }>; + fixedPlans: Array<{ + name: string; + totalCents: number; + fundedCents: number; + dueOn: Date; + frequency: "monthly" | "weekly" | "biweekly"; + autoPayEnabled?: boolean; + paymentSchedule?: Record; + isOverdue?: boolean; + overdueAmount?: number; + overdueSince?: Date | null; + }>; +}; + +function zonedStartOfDay(date: Date, timeZone: string) { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date); + const getPart = (type: string) => parts.find((part) => part.type === type)?.value ?? ""; + const year = Number(getPart("year")); + const month = Number(getPart("month")); + const day = Number(getPart("day")); + const utcMidnight = new Date(Date.UTC(year, month - 1, day, 0, 0, 0)); + const tzDateStr = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(utcMidnight); + const [mdy, hms] = tzDateStr.split(", "); + const [mm, dd, yyyy] = mdy.split("/"); + const [hh, mi, ss] = hms.split(":"); + const tzAsUtc = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(mi), Number(ss)); + const offsetMs = tzAsUtc - utcMidnight.getTime(); + return new Date(utcMidnight.getTime() - offsetMs); +} + +async function createScenario(s: Scenario) { + const timestamp = Date.now(); + const email = `test-dashboard-${s.name.toLowerCase().replace(/\s+/g, "-")}-${timestamp}@test.com`; + const hashed = await argon2.hash(PASSWORD); + + const user = await prisma.user.create({ + data: { + email, + passwordHash: hashed, + timezone: s.timezone, + incomeType: "regular", + incomeFrequency: s.incomeFrequency, + firstIncomeDate: s.firstIncomeDate, + totalBudgetCents: BigInt(0), + }, + }); + + const cats = []; + for (const cat of s.variableCats) { + const created = await prisma.variableCategory.create({ + data: { + userId: user.id, + name: cat.name, + percent: cat.percent, + isSavings: !!cat.isSavings, + balanceCents: 0n, + }, + }); + cats.push(created); + } + + // Seed income and allocate to variable balances + await prisma.incomeEvent.create({ + data: { + userId: user.id, + amountCents: BigInt(s.incomeCents), + postedAt: startOfDay(new Date()), + note: "Seed income", + }, + }); + + const catTotalPercent = s.variableCats.reduce((sum, c) => sum + c.percent, 0); + for (const cat of cats) { + const percent = s.variableCats.find((c) => c.name === cat.name)?.percent ?? 0; + const share = Math.floor((s.incomeCents * percent) / catTotalPercent); + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "variable", + toId: cat.id, + amountCents: BigInt(share), + }, + }); + await prisma.variableCategory.update({ + where: { id: cat.id }, + data: { balanceCents: BigInt(share) }, + }); + } + + for (const plan of s.fixedPlans) { + await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: plan.name, + totalCents: BigInt(plan.totalCents), + fundedCents: BigInt(plan.fundedCents), + currentFundedCents: BigInt(plan.fundedCents), + cycleStart: startOfDay(new Date()), + dueOn: plan.dueOn, + frequency: plan.frequency, + autoPayEnabled: plan.autoPayEnabled ?? true, + paymentSchedule: plan.paymentSchedule ?? { frequency: plan.frequency, minFundingPercent: 100 }, + isOverdue: plan.isOverdue ?? false, + overdueAmount: BigInt(plan.overdueAmount ?? 0), + overdueSince: plan.overdueSince ?? null, + needsFundingThisPeriod: true, + }, + }); + } + + return { user, email }; +} + +async function main() { + const today = startOfDay(new Date()); + const tomorrow = addDays(today, 1); + const laToday = zonedStartOfDay(new Date(), "America/Los_Angeles"); + + const scenarios: Scenario[] = [ + { + name: "Multi Due Same Day", + timezone: "America/Chicago", + incomeFrequency: "weekly", + firstIncomeDate: tomorrow, + incomeCents: 150000, + variableCats: [ + { name: "Savings", percent: 30, isSavings: true }, + { name: "Food", percent: 40 }, + { name: "Misc", percent: 30 }, + ], + fixedPlans: [ + { name: "Rent", totalCents: 120000, fundedCents: 0, dueOn: today, frequency: "monthly" }, + { name: "Phone", totalCents: 8000, fundedCents: 0, dueOn: today, frequency: "monthly" }, + ], + }, + { + name: "Due+Overdue", + timezone: "America/Chicago", + incomeFrequency: "biweekly", + firstIncomeDate: tomorrow, + incomeCents: 80000, + variableCats: [ + { name: "Savings", percent: 20, isSavings: true }, + { name: "Food", percent: 50 }, + { name: "Gas", percent: 30 }, + ], + fixedPlans: [ + { + name: "Insurance", + totalCents: 50000, + fundedCents: 20000, + dueOn: addDays(today, -2), + frequency: "monthly", + isOverdue: true, + overdueAmount: 30000, + overdueSince: addDays(today, -2), + }, + { + name: "Utilities", + totalCents: 10000, + fundedCents: 0, + dueOn: today, + frequency: "monthly", + }, + ], + }, + { + name: "Income+Due Same Day", + timezone: "America/Los_Angeles", + incomeFrequency: "weekly", + firstIncomeDate: laToday, + incomeCents: 120000, + variableCats: [ + { name: "Savings", percent: 30, isSavings: true }, + { name: "Food", percent: 40 }, + { name: "Misc", percent: 30 }, + ], + fixedPlans: [ + { name: "Car", totalCents: 40000, fundedCents: 0, dueOn: laToday, frequency: "monthly" }, + ], + }, + { + name: "Fully Funded Due", + timezone: "America/Chicago", + incomeFrequency: "biweekly", + firstIncomeDate: tomorrow, + incomeCents: 60000, + variableCats: [ + { name: "Savings", percent: 20, isSavings: true }, + { name: "Food", percent: 40 }, + { name: "Misc", percent: 40 }, + ], + fixedPlans: [ + { name: "Internet", totalCents: 12000, fundedCents: 12000, dueOn: today, frequency: "monthly" }, + ], + }, + { + name: "Partial Available", + timezone: "America/New_York", + incomeFrequency: "weekly", + firstIncomeDate: tomorrow, + incomeCents: 20000, + variableCats: [ + { name: "Savings", percent: 20, isSavings: true }, + { name: "Food", percent: 50 }, + { name: "Gas", percent: 30 }, + ], + fixedPlans: [ + { name: "Subscription", totalCents: 25000, fundedCents: 0, dueOn: today, frequency: "monthly" }, + ], + }, + ]; + + console.log("\n=== Dashboard Edge Test Setup ===\n"); + + for (const scenario of scenarios) { + const { user, email } = await createScenario(scenario); + console.log(`Scenario: ${scenario.name}`); + console.log(` Email: ${email}`); + console.log(` Password: ${PASSWORD}`); + console.log(` User ID: ${user.id}`); + console.log(` Timezone: ${scenario.timezone}`); + console.log(` Income: regular ${scenario.incomeFrequency}`); + console.log(""); + } + + console.log("Use these users to validate dashboard edge flows."); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/api/src/scripts/test-early-funding.ts b/api/src/scripts/test-early-funding.ts new file mode 100644 index 0000000..c17740f --- /dev/null +++ b/api/src/scripts/test-early-funding.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env tsx +/** + * Test Script: Early Funding Feature + * + * This script helps test the complete payment → early funding → refunding cycle + * + * Usage: + * npx tsx src/scripts/test-early-funding.ts + */ + +import { PrismaClient } from "@prisma/client"; +import { randomUUID } from "crypto"; +import argon2 from "argon2"; + +const prisma = new PrismaClient(); + +async function main() { + console.log("\n=== 🧪 Early Funding Feature Test ===\n"); + + // Step 1: Create test user with payment plan + const userId = randomUUID(); + const email = `test-early-funding-${Date.now()}@test.com`; + const password = "password"; + const passwordHash = await argon2.hash(password); + + console.log("📝 Step 1: Creating test user with payment plan..."); + await prisma.user.create({ + data: { + id: userId, + email, + passwordHash, + displayName: "Test User", + incomeType: "regular", + incomeFrequency: "biweekly", + firstIncomeDate: new Date("2025-12-20"), // Next payday Dec 20 + timezone: "America/Chicago", + totalBudgetCents: 300000n, // $3,000 monthly budget + }, + }); + console.log(`✅ Created user: ${email} (${userId})`); + console.log(` Income: Biweekly, Next payday: Dec 20, 2025\n`); + + // Step 1.5: Create a variable category (needed for hasBudgetSetup) + await prisma.variableCategory.create({ + data: { + id: randomUUID(), + userId, + name: "Savings", + percent: 100, // 100% of variable budget + priority: 1, + isSavings: true, + }, + }); + console.log("💾 Created Savings category (100% variable budget)\n"); + + // Step 2: Create recurring rent bill + const rentId = randomUUID(); + await prisma.fixedPlan.create({ + data: { + id: rentId, + userId, + name: "Rent", + totalCents: 150000, // $1,500 + fundedCents: 0n, + currentFundedCents: 0n, + dueOn: new Date("2025-12-28"), // Due Dec 28 + cycleStart: new Date("2025-12-01"), + frequency: "monthly", + periodDays: 30, + priority: 1, + autoRollover: true, + needsFundingThisPeriod: true, // Should be funded + paymentSchedule: { + frequency: "monthly", + dayOfMonth: 28, + minFundingPercent: 100, + }, + }, + }); + console.log("🏠 Step 2: Created Rent bill"); + console.log(` Amount: $1,500 | Due: Dec 28, 2025`); + console.log(` Status: needsFundingThisPeriod = true\n`); + + // Step 3: Fund the rent (simulate payday) + const incomeId = randomUUID(); + await prisma.incomeEvent.create({ + data: { + id: incomeId, + userId, + amountCents: 150000n, // $1,500 + postedAt: new Date("2025-12-17"), + isScheduledIncome: true, + }, + }); + + await prisma.fixedPlan.update({ + where: { id: rentId }, + data: { + fundedCents: 150000n, + currentFundedCents: 150000n, + needsFundingThisPeriod: false, // Fully funded, no longer needs funding + }, + }); + + await prisma.allocation.create({ + data: { + userId, + kind: "fixed", + toId: rentId, + amountCents: 150000n, + incomeId, + }, + }); + + console.log("💰 Step 3: Funded rent with Dec 17 paycheck"); + console.log(` Funded: $1,500 / $1,500 (100%)`); + console.log(` Status: needsFundingThisPeriod = false\n`); + + console.log("=" .repeat(70)); + console.log("\n🎯 TEST SCENARIOS:\n"); + + console.log("📍 SCENARIO A: User opts for EARLY FUNDING"); + console.log("-".repeat(70)); + console.log("1. User sees modal: 'Start Funding Early?'"); + console.log("2. User clicks: 'Yes, Start Now'"); + console.log("3. API call: PATCH /fixed-plans/{rentId}/early-funding"); + console.log(" Body: { enableEarlyFunding: true }"); + console.log("4. Database: needsFundingThisPeriod = true"); + console.log("5. Next paycheck (Dec 20): Rent gets funded ✅"); + console.log(""); + + console.log("📍 SCENARIO B: User opts to WAIT"); + console.log("-".repeat(70)); + console.log("1. User sees modal: 'Start Funding Early?'"); + console.log("2. User clicks: 'Wait Until Rollover'"); + console.log("3. No API call made"); + console.log("4. Database: needsFundingThisPeriod = false (unchanged)"); + console.log("5. Next paycheck (Dec 20): Rent NOT funded ❌"); + console.log("6. Jan 28 rollover: needsFundingThisPeriod = true"); + console.log("7. Paycheck after Jan 28: Rent gets funded ✅"); + console.log(""); + + console.log("=" .repeat(70)); + console.log("\n🧪 MANUAL TESTING STEPS:\n"); + + console.log("1️⃣ Login as: " + email); + console.log("2️⃣ Go to Spend page"); + console.log("3️⃣ Select 'Pay Fixed Expense'"); + console.log("4️⃣ Select 'Rent' plan"); + console.log("5️⃣ Enter amount: $1,500"); + console.log("6️⃣ Click 'Record'"); + console.log("7️⃣ Modal should appear: 'Start Funding Early?'"); + console.log("8️⃣ Test both buttons:\n"); + + console.log(" Option A: Click 'Yes, Start Now'"); + console.log(" → Check DB: needsFundingThisPeriod should be TRUE"); + console.log(" → Add income: $1,500 on Dec 20"); + console.log(" → Verify: Rent receives allocation\n"); + + console.log(" Option B: Click 'Wait Until Rollover'"); + console.log(" → Check DB: needsFundingThisPeriod should be FALSE"); + console.log(" → Add income: $1,500 on Dec 20"); + console.log(" → Verify: Rent receives NO allocation"); + console.log(" → Rollover runs Jan 28"); + console.log(" → Add income after Jan 28"); + console.log(" → Verify: Rent receives allocation\n"); + + console.log("=" .repeat(70)); + console.log("\n📊 DATABASE VERIFICATION COMMANDS:\n"); + + console.log("-- Check plan status"); + console.log(`SELECT name, "fundedCents", "totalCents", "dueOn", "needsFundingThisPeriod"`); + console.log(`FROM "FixedPlan" WHERE id = '${rentId}';`); + console.log(""); + + console.log("-- Check last allocation"); + console.log(`SELECT kind, "amountCents", "createdAt"`); + console.log(`FROM "Allocation" WHERE "userId" = '${userId}'`); + console.log(`ORDER BY "createdAt" DESC LIMIT 5;`); + console.log(""); + + console.log("-- Check transactions"); + console.log(`SELECT kind, "amountCents", "occurredAt", note`); + console.log(`FROM "Transaction" WHERE "userId" = '${userId}'`); + console.log(`ORDER BY "occurredAt" DESC;`); + console.log(""); + + console.log("=" .repeat(70)); + console.log("\n✅ Test user created successfully!"); + console.log(`📧 Email: ${email}`); + console.log(`🔑 Password: ${password}`); + console.log(`🆔 User ID: ${userId}`); + console.log(`🏠 Rent Plan ID: ${rentId}`); + console.log(""); + console.log("🚀 Ready to test in the UI!"); + console.log(""); +} + +main() + .catch((err) => { + console.error("❌ Error:", err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/api/src/scripts/test-final-funding.ts b/api/src/scripts/test-final-funding.ts new file mode 100644 index 0000000..3cfc306 --- /dev/null +++ b/api/src/scripts/test-final-funding.ts @@ -0,0 +1,182 @@ +/** + * Test script for attempt-final-funding endpoint + * + * This tests the logic that runs when a payment modal opens: + * 1. If bill not fully funded -> attempts to pull from available budget + * 2. If available budget can cover -> fully funds it + * 3. If available budget insufficient -> takes all available, marks overdue + */ + +import { PrismaClient } from "@prisma/client"; +import { allocateIncome } from "../allocator.js"; + +const prisma = new PrismaClient(); + +async function main() { + const testEmail = `test-final-funding-${Date.now()}@test.com`; + console.log(`\n🧪 Testing attempt-final-funding endpoint with user: ${testEmail}\n`); + + // 1. Create test user + const user = await prisma.user.create({ + data: { + email: testEmail, + displayName: "Test Final Funding", + incomeType: "regular", + incomeFrequency: "biweekly", + timezone: "America/New_York", + }, + }); + console.log(`✅ Created test user: ${user.id}`); + + // 2. Create variable categories (for available budget calculation) + await prisma.variableCategory.createMany({ + data: [ + { userId: user.id, name: "Essentials", percent: 50, priority: 10, isSavings: false, balanceCents: 0n }, + { userId: user.id, name: "Savings", percent: 30, priority: 20, isSavings: true, balanceCents: 0n }, + { userId: user.id, name: "Fun", percent: 20, priority: 30, isSavings: false, balanceCents: 0n }, + ], + }); + console.log(`✅ Created variable categories`); + + // 3. Create test rent plan ($1,500, due tomorrow) + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setUTCHours(0, 0, 0, 0); + + const rentPlan = await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: "Rent", + totalCents: 150000n, // $1,500 + fundedCents: 100000n, // $1,000 already funded + currentFundedCents: 100000n, + priority: 10, + cycleStart: new Date(), + dueOn: tomorrow, + fundingMode: "auto-on-deposit", + needsFundingThisPeriod: true, + }, + }); + console.log(`✅ Created rent plan: $1,500 total, $1,000 funded, $500 remaining`); + + // 4. Add initial income to simulate funded amount + await prisma.incomeEvent.create({ + data: { + userId: user.id, + amountCents: 200000n, // $2,000 income + postedAt: new Date(), + }, + }); + + // Allocate to rent to simulate the $1,000 funded + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "fixed", + toId: rentPlan.id, + amountCents: 100000n, // $1,000 to rent + }, + }); + + // Allocate rest to variable categories to simulate spending/allocation + const essentials = await prisma.variableCategory.findFirst({ where: { userId: user.id, name: "Essentials" } }); + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "variable", + toId: essentials!.id, + amountCents: 70000n, // $700 + }, + }); + + console.log(`✅ Created income event: $2,000`); + console.log(` Allocated: $1,000 to rent, $700 to essentials`); + console.log(` Available budget: $300\n`); + + // 5. TEST CASE 1: Available budget ($300) < Remaining ($500) + console.log("📋 TEST CASE 1: Partial funding from available budget"); + console.log(" Remaining needed: $500"); + console.log(" Available budget: $300"); + console.log(" Expected: Take all $300, mark overdue with $200\n"); + + const response1 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, { + method: "POST", + headers: { + "x-user-id": user.id, + "Content-Type": "application/json", + }, + }); + + const result1 = await response1.json(); + console.log("Response:", JSON.stringify(result1, null, 2)); + + if (result1.status === "overdue" && result1.fundedCents === 130000 && result1.overdueAmount === 20000) { + console.log("✅ TEST 1 PASSED: Correctly funded $300 and marked $200 overdue\n"); + } else { + console.log("❌ TEST 1 FAILED: Unexpected result\n"); + } + + // 6. TEST CASE 2: Available budget can fully fund + // Add more income to create sufficient available budget + await prisma.incomeEvent.create({ + data: { + userId: user.id, + amountCents: 50000n, // $500 more income + postedAt: new Date(), + }, + }); + console.log(`💰 Added $500 more income`); + console.log(` New available budget: $500`); + console.log(` Remaining on rent: $200 (overdue)\n`); + + console.log("📋 TEST CASE 2: Full funding from available budget"); + console.log(" Remaining needed: $200"); + console.log(" Available budget: $500"); + console.log(" Expected: Fund full $200, clear overdue\n"); + + const response2 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, { + method: "POST", + headers: { + "x-user-id": user.id, + "Content-Type": "application/json", + }, + }); + + const result2 = await response2.json(); + console.log("Response:", JSON.stringify(result2, null, 2)); + + if (result2.status === "fully_funded" && result2.fundedCents === 150000 && result2.isOverdue === false) { + console.log("✅ TEST 2 PASSED: Correctly fully funded and cleared overdue\n"); + } else { + console.log("❌ TEST 2 FAILED: Unexpected result\n"); + } + + // 7. TEST CASE 3: Already fully funded + console.log("📋 TEST CASE 3: Already fully funded"); + console.log(" Expected: No changes, return fully_funded status\n"); + + const response3 = await fetch(`http://localhost:8080/api/fixed-plans/${rentPlan.id}/attempt-final-funding`, { + method: "POST", + headers: { + "x-user-id": user.id, + "Content-Type": "application/json", + }, + }); + + const result3 = await response3.json(); + console.log("Response:", JSON.stringify(result3, null, 2)); + + if (result3.status === "fully_funded" && result3.fundedCents === 150000) { + console.log("✅ TEST 3 PASSED: Correctly identified as fully funded\n"); + } else { + console.log("❌ TEST 3 FAILED: Unexpected result\n"); + } + + console.log("\n🎉 All tests completed!"); + console.log(`\nTest user: ${testEmail}`); + console.log(`User ID: ${user.id}`); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/api/src/scripts/test-onboarding-edge.ts b/api/src/scripts/test-onboarding-edge.ts new file mode 100644 index 0000000..7a10d17 --- /dev/null +++ b/api/src/scripts/test-onboarding-edge.ts @@ -0,0 +1,199 @@ +#!/usr/bin/env tsx +import { PrismaClient } from "@prisma/client"; +import * as argon2 from "argon2"; +import { addDays, startOfDay } from "date-fns"; + +const prisma = new PrismaClient(); + +const PASSWORD = "password"; + +type Scenario = { + name: string; + timezone: string; + incomeType: "regular" | "irregular"; + incomeFrequency: "weekly" | "biweekly" | "monthly" | null; + firstIncomeDate: Date | null; + fixedPlans: Array<{ + name: string; + totalCents: number; + dueOn: Date; + frequency?: "weekly" | "biweekly" | "monthly" | "one-time"; + autoPayEnabled?: boolean; + paymentSchedule?: Record | null; + }>; + variableCategories: Array<{ + name: string; + percent: number; + isSavings?: boolean; + }>; +}; + +async function createScenario(s: Scenario) { + const timestamp = Date.now(); + const email = `test-onboarding-${s.name.toLowerCase().replace(/\s+/g, "-")}-${timestamp}@test.com`; + const hashed = await argon2.hash(PASSWORD); + + const user = await prisma.user.create({ + data: { + email, + passwordHash: hashed, + timezone: s.timezone, + incomeType: s.incomeType, + incomeFrequency: s.incomeFrequency ?? undefined, + firstIncomeDate: s.firstIncomeDate ?? undefined, + totalBudgetCents: 100000, // $1,000 placeholder + }, + }); + + for (const cat of s.variableCategories) { + await prisma.variableCategory.create({ + data: { + userId: user.id, + name: cat.name, + percent: cat.percent, + isSavings: !!cat.isSavings, + }, + }); + } + + for (const plan of s.fixedPlans) { + await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: plan.name, + totalCents: plan.totalCents, + fundedCents: 0, + currentFundedCents: 0, + cycleStart: new Date().toISOString(), + dueOn: plan.dueOn.toISOString(), + frequency: plan.frequency ?? null, + autoPayEnabled: !!plan.autoPayEnabled, + paymentSchedule: plan.paymentSchedule ?? undefined, + }, + }); + } + + return { user, email }; +} + +async function main() { + const today = startOfDay(new Date()); + const tomorrow = addDays(today, 1); + + const scenarios: Scenario[] = [ + { + name: "Timezone Tomorrow", + timezone: "America/Los_Angeles", + incomeType: "regular", + incomeFrequency: "weekly", + firstIncomeDate: tomorrow, + fixedPlans: [ + { + name: "Rent", + totalCents: 120000, + dueOn: addDays(today, 29), + frequency: "monthly", + autoPayEnabled: true, + paymentSchedule: { frequency: "monthly", minFundingPercent: 100 }, + }, + ], + variableCategories: [ + { name: "Savings A", percent: 20, isSavings: true }, + { name: "Savings B", percent: 10, isSavings: true }, + { name: "Food", percent: 40 }, + { name: "Gas", percent: 30 }, + ], + }, + { + name: "Month End", + timezone: "America/Chicago", + incomeType: "regular", + incomeFrequency: "monthly", + firstIncomeDate: new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 31)), + fixedPlans: [ + { + name: "Insurance", + totalCents: 50000, + dueOn: new Date(Date.UTC(today.getUTCFullYear(), 0, 31)), + frequency: "monthly", + autoPayEnabled: true, + paymentSchedule: { frequency: "monthly", dayOfMonth: 31, minFundingPercent: 100 }, + }, + ], + variableCategories: [ + { name: "Savings", percent: 25, isSavings: true }, + { name: "Food", percent: 50 }, + { name: "Misc", percent: 25 }, + ], + }, + { + name: "Irregular No Auto", + timezone: "America/New_York", + incomeType: "irregular", + incomeFrequency: null, + firstIncomeDate: null, + fixedPlans: [ + { + name: "Subscription", + totalCents: 1200, + dueOn: addDays(today, 14), + frequency: "monthly", + autoPayEnabled: false, + paymentSchedule: null, + }, + ], + variableCategories: [ + { name: "Savings", percent: 30, isSavings: true }, + { name: "Food", percent: 40 }, + { name: "Entertainment", percent: 30 }, + ], + }, + { + name: "Due Today Underfunded", + timezone: "America/Chicago", + incomeType: "regular", + incomeFrequency: "biweekly", + firstIncomeDate: tomorrow, + fixedPlans: [ + { + name: "Phone", + totalCents: 25000, + dueOn: today, + frequency: "monthly", + autoPayEnabled: true, + paymentSchedule: { frequency: "monthly", minFundingPercent: 100 }, + }, + ], + variableCategories: [ + { name: "Savings", percent: 20, isSavings: true }, + { name: "Food", percent: 40 }, + { name: "Gas", percent: 20 }, + { name: "Misc", percent: 20 }, + ], + }, + ]; + + console.log("\n=== Onboarding Edge Test Setup ===\n"); + + for (const scenario of scenarios) { + const { user, email } = await createScenario(scenario); + console.log(`Scenario: ${scenario.name}`); + console.log(` Email: ${email}`); + console.log(` Password: ${PASSWORD}`); + console.log(` User ID: ${user.id}`); + console.log(` Timezone: ${scenario.timezone}`); + console.log(` Income: ${scenario.incomeType} ${scenario.incomeFrequency ?? ""}`.trim()); + console.log(""); + } + + console.log("Use these users to verify onboarding edge cases and dashboard follow-up behavior."); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/api/src/scripts/test-overdue-reconciliation.ts b/api/src/scripts/test-overdue-reconciliation.ts new file mode 100644 index 0000000..0a0803d --- /dev/null +++ b/api/src/scripts/test-overdue-reconciliation.ts @@ -0,0 +1,206 @@ +/** + * Test script for Payment Reconciliation System with Overdue tracking + * + * Tests: + * 1. Full payment → Rollover + * 2. Partial payment → Refund + Mark overdue + * 3. No payment → Mark overdue + * 4. Overdue priority in next income allocation + */ + +import { PrismaClient } from "@prisma/client"; +import * as argon2 from "argon2"; + +const prisma = new PrismaClient(); + +async function main() { + const timestamp = Date.now(); + const email = `test-overdue-${timestamp}@test.com`; + const password = "testpassword123"; + + console.log("🧪 Testing Payment Reconciliation System"); + console.log("========================================\n"); + + // 1. Create test user + console.log("1️⃣ Creating test user..."); + const passwordHash = await argon2.hash(password); + const user = await prisma.user.create({ + data: { + email, + passwordHash, + displayName: "Overdue Test User", + incomeFrequency: "biweekly", + incomeType: "regular", + timezone: "America/New_York", + firstIncomeDate: new Date(), + }, + }); + console.log(`✅ User created: ${email}\n`); + + // 2. Create fixed plan (Rent) + console.log("2️⃣ Creating Rent plan ($1,500)..."); + const rent = await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: "Rent", + totalCents: 150000n, // $1,500 + fundedCents: 100000n, // $1,000 funded + currentFundedCents: 100000n, + priority: 10, + cycleStart: new Date(), + dueOn: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), // 5 days from now + fundingMode: "auto-on-deposit", + frequency: "monthly", + needsFundingThisPeriod: true, + }, + }); + console.log(`✅ Rent plan created (ID: ${rent.id})`); + console.log(` Funded: $1,000 / $1,500\n`); + + // 3. Create variable categories + console.log("3️⃣ Creating variable categories..."); + await prisma.variableCategory.createMany({ + data: [ + { userId: user.id, name: "Essentials", percent: 50, priority: 10, balanceCents: 50000n }, + { userId: user.id, name: "Savings", percent: 30, priority: 20, isSavings: true, balanceCents: 30000n }, + { userId: user.id, name: "Fun", percent: 20, priority: 30, balanceCents: 20000n }, + ], + }); + console.log("✅ Variable categories created\n"); + + // 4. Record initial income to establish available budget + console.log("4️⃣ Recording initial income ($2,000)..."); + const income1 = await prisma.incomeEvent.create({ + data: { + userId: user.id, + postedAt: new Date(), + amountCents: 200000n, + note: "Initial income", + }, + }); + + // Create allocations for the funded amount + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "fixed", + toId: rent.id, + amountCents: 100000n, + incomeId: income1.id, + }, + }); + console.log("✅ Income recorded + $1,000 allocated to Rent\n"); + + // 5. TEST SCENARIO A: Partial Payment + console.log("🧪 TEST SCENARIO A: Partial Payment ($1,000 paid out of $1,500)"); + console.log("-----------------------------------------------------------"); + + const partialPayment = await prisma.transaction.create({ + data: { + userId: user.id, + occurredAt: new Date(), + kind: "fixed_payment", + amountCents: 100000n, // Only paid $1,000 + planId: rent.id, + note: "Partial payment test", + }, + }); + + const rentAfterPartial = await prisma.fixedPlan.findUnique({ + where: { id: rent.id }, + }); + + console.log(`✅ Partial payment recorded: $${Number(partialPayment.amountCents) / 100}`); + console.log(` Plan status:`); + console.log(` - fundedCents: $${Number(rentAfterPartial?.fundedCents) / 100}`); + console.log(` - isOverdue: ${rentAfterPartial?.isOverdue}`); + console.log(` - overdueAmount: $${Number(rentAfterPartial?.overdueAmount ?? 0n) / 100}\n`); + + // 6. TEST SCENARIO B: Overdue Priority in Allocation + console.log("🧪 TEST SCENARIO B: Next Income Should Prioritize Overdue"); + console.log("--------------------------------------------------------"); + + console.log("📥 Posting new income ($500) - using direct allocator..."); + + // Use direct allocator function instead of API + const { allocateIncome } = await import("../allocator.js"); + const allocationResult = await allocateIncome( + prisma, + user.id, + 50000, // $500 + new Date().toISOString(), + "test-income-2", + "Test income after overdue", + true // isScheduledIncome + ); + + console.log("✅ Income allocated"); + console.log(` Fixed allocations:`, JSON.stringify(allocationResult.fixedAllocations, null, 2)); + + const rentAfterIncome = await prisma.fixedPlan.findUnique({ + where: { id: rent.id }, + }); + + console.log(` Rent plan after allocation:`); + console.log(` - overdueAmount: $${Number(rentAfterIncome?.overdueAmount ?? 0n) / 100}`); + console.log(` - fundedCents: $${Number(rentAfterIncome?.fundedCents) / 100}\n`); + + // 7. TEST SCENARIO C: Mark as Unpaid + console.log("🧪 TEST SCENARIO C: Mark Bill as Unpaid"); + console.log("---------------------------------------"); + + // Create another plan to test mark-unpaid + const utilities = await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: "Utilities", + totalCents: 20000n, // $200 + fundedCents: 15000n, // $150 funded + currentFundedCents: 15000n, + priority: 20, + cycleStart: new Date(), + dueOn: new Date(), // Due today + fundingMode: "auto-on-deposit", + frequency: "monthly", + }, + }); + + // Mark as unpaid directly via Prisma + const fundedAmount = Number(utilities.currentFundedCents); + const totalAmount = Number(utilities.totalCents); + const remainingBalance = totalAmount - fundedAmount; + + await prisma.fixedPlan.update({ + where: { id: utilities.id }, + data: { + isOverdue: true, + overdueAmount: BigInt(Math.max(0, remainingBalance)), + overdueSince: new Date(), + needsFundingThisPeriod: true, + }, + }); + + console.log("✅ Marked utilities as unpaid"); + console.log(` Remaining balance: $${remainingBalance / 100}`); + + const utilitiesAfter = await prisma.fixedPlan.findUnique({ + where: { id: utilities.id }, + }); + console.log(` - isOverdue: ${utilitiesAfter?.isOverdue}`); + console.log(` - overdueAmount: $${Number(utilitiesAfter?.overdueAmount ?? 0n) / 100}`); + console.log(` - overdueSince: ${utilitiesAfter?.overdueSince}\n`); + + console.log("✅ All tests completed successfully!"); + console.log("\n📊 Final State:"); + console.log(` Test user: ${email}`); + console.log(` User ID: ${user.id}`); +} + +main() + .catch((e) => { + console.error("❌ Test failed:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/api/src/scripts/test-payment-flow.ts b/api/src/scripts/test-payment-flow.ts new file mode 100644 index 0000000..881c13c --- /dev/null +++ b/api/src/scripts/test-payment-flow.ts @@ -0,0 +1,289 @@ +#!/usr/bin/env tsx +import { PrismaClient } from "@prisma/client"; +import * as argon2 from "argon2"; +import { addDays, startOfDay } from "date-fns"; + +const prisma = new PrismaClient(); + +async function main() { + console.log("\n=== 🧪 Payment Flow Test Setup ===\n"); + + const timestamp = Date.now(); + const email = `test-payment-flow-${timestamp}@test.com`; + const password = "password"; + const hashedPassword = await argon2.hash(password); + + // Calculate dates + const today = startOfDay(new Date()); + const nextPayday = addDays(today, 3); // Dec 20 + const rentDue = addDays(today, 11); // Dec 28 + + console.log("📝 Step 1: Creating test user..."); + const user = await prisma.user.create({ + data: { + email, + passwordHash: hashedPassword, + timezone: "America/Los_Angeles", + incomeType: "regular", + incomeFrequency: "biweekly", + firstIncomeDate: nextPayday.toISOString(), + totalBudgetCents: 300000, // $3,000 total budget + }, + }); + console.log(`✅ Created user: ${email} (${user.id})`); + console.log(` Total Budget: $3,000 | Next payday: ${nextPayday.toLocaleDateString()}\n`); + + console.log("💾 Step 2: Creating budget categories..."); + + // Create variable categories + const savingsCategory = await prisma.variableCategory.create({ + data: { + userId: user.id, + name: "Savings", + percent: 20, + isSavings: true, + }, + }); + + const groceriesCategory = await prisma.variableCategory.create({ + data: { + userId: user.id, + name: "Groceries", + percent: 50, + isSavings: false, + }, + }); + + const entertainmentCategory = await prisma.variableCategory.create({ + data: { + userId: user.id, + name: "Entertainment", + percent: 30, + isSavings: false, + }, + }); + + console.log(`✅ Created 3 variable categories`); + console.log(` - Savings: 20%`); + console.log(` - Groceries: 50%`); + console.log(` - Entertainment: 30%\n`); + + console.log("🏠 Step 3: Creating fixed expense (Rent)..."); + const rentPlan = await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: "Rent", + totalCents: 150000, // $1,500 + fundedCents: 75000, // $750 (50% funded) + cycleStart: today.toISOString(), + dueOn: rentDue.toISOString(), + frequency: "monthly", + needsFundingThisPeriod: true, + }, + }); + console.log(`✅ Created Rent bill`); + console.log(` Amount: $1,500 | Funded: $750 (50%)`); + console.log(` Due: ${rentDue.toLocaleDateString()}`); + console.log(` Status: needsFundingThisPeriod = true\n`); + + console.log("💰 Step 4: Adding income to create available budget..."); + + // Add income event (not transaction - backend checks IncomeEvent table!) + const incomeAmount = 200000; // $2,000 + await prisma.incomeEvent.create({ + data: { + userId: user.id, + amountCents: incomeAmount, + postedAt: today, + note: "Test paycheck", + }, + }); + + // Calculate allocations (simplified - in real flow this is done by allocator) + // Rent needs $750 more, gets funded + // Variable gets: $2,000 - $750 = $1,250 + + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "fixed", + toId: rentPlan.id, + amountCents: 75000, // Fund the remaining $750 + }, + }); + + await prisma.fixedPlan.update({ + where: { id: rentPlan.id }, + data: { + fundedCents: 150000, // Now 100% funded + currentFundedCents: 150000, // Dashboard reads from this + }, + }); + + // Variable allocation: $1,250 + const variableAmount = 125000; + const savingsAmount = Math.floor(variableAmount * 0.20); // $250 + const groceriesAmount = Math.floor(variableAmount * 0.50); // $625 + const entertainmentAmount = Math.floor(variableAmount * 0.30); // $375 + + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "variable", + toId: savingsCategory.id, + amountCents: savingsAmount, + }, + }); + + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "variable", + toId: groceriesCategory.id, + amountCents: groceriesAmount, + }, + }); + + await prisma.allocation.create({ + data: { + userId: user.id, + kind: "variable", + toId: entertainmentCategory.id, + amountCents: entertainmentAmount, + }, + }); + + // Update variable category balances (dashboard reads from these) + await prisma.variableCategory.update({ + where: { id: savingsCategory.id }, + data: { balanceCents: savingsAmount }, + }); + + await prisma.variableCategory.update({ + where: { id: groceriesCategory.id }, + data: { balanceCents: groceriesAmount }, + }); + + await prisma.variableCategory.update({ + where: { id: entertainmentCategory.id }, + data: { balanceCents: entertainmentAmount }, + }); + + console.log(`✅ Recorded income: $${(incomeAmount / 100).toFixed(2)}`); + console.log(` Rent funded: +$750 → 100% complete`); + console.log(` Variable budget: $1,250`); + console.log(` - Savings: $250 (20%)`); + console.log(` - Groceries: $625 (50%)`); + console.log(` - Entertainment: $375 (30%)\n`); + + console.log("======================================================================\n"); + console.log("🎯 TEST SCENARIOS:\n"); + + console.log("📍 SCENARIO 1: Pay Rent from fundedCents + available"); + console.log("----------------------------------------------------------------------"); + console.log("Current state:"); + console.log(" - Rent: $1,500 total, $1,500 funded (100%)"); + console.log(" - Available budget: $1,250 (all in variable categories)"); + console.log(""); + console.log("Action: Pay Rent $1,500"); + console.log("Expected behavior:"); + console.log(" ✅ Takes $1,500 from fundedCents"); + console.log(" ✅ Takes $0 from available"); + console.log(" ✅ Modal appears: 'Start funding early?'"); + console.log(" ✅ No confirmation needed (not depleting variable)\n"); + + console.log("📍 SCENARIO 2: Pay more than funded (triggers confirmation)"); + console.log("----------------------------------------------------------------------"); + console.log("Setup: Reset rent to $500 funded"); + console.log(""); + console.log("Action: Pay Rent $1,500"); + console.log("Expected behavior:"); + console.log(" ✅ Takes $500 from fundedCents"); + console.log(" ✅ Needs $1,000 from available ($1,250 total)"); + console.log(" ⚠️ Would deplete 80% of variable balance"); + console.log(" ⚠️ CONFIRMATION_REQUIRED modal appears"); + console.log(" ✅ User confirms → payment succeeds"); + console.log(" ✅ Negative allocation tracks -$1,000 from variable\n"); + + console.log("📍 SCENARIO 3: Add income that brings bill to 100%"); + console.log("----------------------------------------------------------------------"); + console.log("Setup: Create new bill '$400 Car Insurance' with $0 funded"); + console.log(""); + console.log("Action: Record income $500"); + console.log("Expected behavior:"); + console.log(" ✅ Allocator funds Car Insurance: $400"); + console.log(" ✅ Bill reaches 100% funded"); + console.log(" ✅ Modal appears: 'Start funding early for Car Insurance?'"); + console.log(" ✅ Remaining $100 goes to variable\n"); + + // Create the car insurance bill for testing scenario 3 + console.log("🚗 Creating Car Insurance bill for Scenario 3..."); + const carInsurance = await prisma.fixedPlan.create({ + data: { + userId: user.id, + name: "Car Insurance", + totalCents: 40000, // $400 + fundedCents: 0, + cycleStart: today.toISOString(), + dueOn: addDays(today, 15).toISOString(), + frequency: "monthly", + needsFundingThisPeriod: true, + }, + }); + console.log(`✅ Created Car Insurance: $400, $0 funded\n`); + + console.log("======================================================================\n"); + console.log("🧪 MANUAL TESTING INSTRUCTIONS:\n"); + + console.log("1️⃣ Login at http://localhost:5174"); + console.log(` Email: ${email}`); + console.log(` Password: ${password}\n`); + + console.log("2️⃣ SCENARIO 1: Test normal payment"); + console.log(" → Go to Spend page"); + console.log(" → Pay 'Rent' $1,500"); + console.log(" → Should see early funding modal only (no warning)\n"); + + console.log("3️⃣ SCENARIO 2: Test confirmation modal"); + console.log(" → First, manually reset rent funded amount to $500:"); + console.log(` UPDATE "FixedPlan" SET "fundedCents" = 50000 WHERE id = '${rentPlan.id}';`); + console.log(" → Pay 'Rent' $1,500"); + console.log(" → Should see CONFIRMATION modal first"); + console.log(" → Confirm payment"); + console.log(" → Then see early funding modal\n"); + + console.log("4️⃣ SCENARIO 3: Test income modal"); + console.log(" → Go to Income page"); + console.log(" → Record income $500"); + console.log(" → Should see early funding modal for 'Car Insurance'\n"); + + console.log("======================================================================\n"); + console.log("📊 DATABASE VERIFICATION:\n"); + + console.log("-- Check rent status"); + console.log(`SELECT name, "fundedCents", "totalCents", "needsFundingThisPeriod"`); + console.log(`FROM "FixedPlan" WHERE id = '${rentPlan.id}';\n`); + + console.log("-- Check available budget (should decrease after payment from available)"); + console.log(`SELECT kind, "categoryId", "amountCents"`); + console.log(`FROM "Allocation" WHERE "userId" = '${user.id}'`); + console.log(`ORDER BY "createdAt" DESC LIMIT 10;\n`); + + console.log("-- Check transactions"); + console.log(`SELECT kind, "amountCents", "planId", note`); + console.log(`FROM "Transaction" WHERE "userId" = '${user.id}'`); + console.log(`ORDER BY "occurredAt" DESC;\n`); + + console.log("======================================================================\n"); + console.log("✅ Test user created successfully!"); + console.log(`📧 Email: ${email}`); + console.log(`🔑 Password: ${password}`); + console.log(`🆔 User ID: ${user.id}`); + console.log(`🏠 Rent ID: ${rentPlan.id}`); + console.log(`🚗 Car Insurance ID: ${carInsurance.id}`); + console.log("\n🚀 Ready to test all payment scenarios!\n"); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/api/src/scripts/test-timezone-jobs.ts b/api/src/scripts/test-timezone-jobs.ts new file mode 100644 index 0000000..5e8a36d --- /dev/null +++ b/api/src/scripts/test-timezone-jobs.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env tsx +/** + * Test script to simulate running scheduled jobs at different times/timezones + * + * Usage: + * npx tsx src/scripts/test-timezone-jobs.ts + */ + +import { PrismaClient } from "@prisma/client"; +import { rolloverFixedPlans } from "../jobs/rollover.js"; +import { processAutoPayments } from "../jobs/auto-payments.js"; +import { toZonedTime } from "date-fns-tz"; + +const prisma = new PrismaClient(); + +function checkMidnight(date: Date | null, timezone: string, label: string) { + if (!date) return { ok: true, label, reason: "missing" }; + const zoned = toZonedTime(date, timezone); + const isMidnight = + zoned.getHours() === 0 && + zoned.getMinutes() === 0 && + zoned.getSeconds() === 0 && + zoned.getMilliseconds() === 0; + return { + ok: isMidnight, + label, + zoned: zoned.toISOString(), + }; +} + +async function main() { + console.log("\n=== Timezone Job Testing ===\n"); + + // Get test user + const userId = process.argv[2]; + if (!userId) { + console.error("Usage: npx tsx src/scripts/test-timezone-jobs.ts "); + process.exit(1); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, timezone: true, firstIncomeDate: true }, + }); + + if (!user) { + console.error(`User ${userId} not found`); + process.exit(1); + } + + console.log(`Testing for user: ${user.email}`); + const userTimezone = user.timezone ?? "America/New_York"; + console.log(`User timezone: ${userTimezone}\n`); + + const plans = await prisma.fixedPlan.findMany({ + where: { userId }, + select: { id: true, name: true, dueOn: true, nextPaymentDate: true, cycleStart: true }, + orderBy: [{ priority: "asc" }, { dueOn: "asc" }], + }); + + console.log("=== DATE NORMALIZATION CHECKS (local midnight expected) ===\n"); + const checks = [ + checkMidnight(user.firstIncomeDate ?? null, userTimezone, "user.firstIncomeDate"), + ]; + plans.forEach((plan) => { + checks.push(checkMidnight(plan.dueOn, userTimezone, `plan:${plan.name}:dueOn`)); + checks.push(checkMidnight(plan.cycleStart, userTimezone, `plan:${plan.name}:cycleStart`)); + if (plan.nextPaymentDate) { + checks.push(checkMidnight(plan.nextPaymentDate, userTimezone, `plan:${plan.name}:nextPaymentDate`)); + } + }); + + let hasIssues = false; + for (const check of checks) { + if (!check.ok) { + hasIssues = true; + console.log(`❌ ${check.label} not at local midnight (${check.zoned})`); + } + } + if (!hasIssues) { + console.log("✅ All date-only fields are stored at local midnight.\n"); + } else { + console.log("\n⚠️ Some date-only fields are not normalized to local midnight.\n"); + process.exitCode = 1; + } + + // Test different UTC times to see when jobs would run + const testTimes = [ + "2025-12-17T00:00:00Z", // Midnight UTC + "2025-12-17T06:00:00Z", // 6 AM UTC + "2025-12-17T12:00:00Z", // Noon UTC + "2025-12-17T18:00:00Z", // 6 PM UTC + "2025-12-17T23:00:00Z", // 11 PM UTC + ]; + + console.log("=== ROLLOVER JOB (should run at 6 AM user time) ===\n"); + for (const utcTime of testTimes) { + const asOf = new Date(utcTime); + const userTime = toZonedTime(asOf, userTimezone); + const userHour = userTime.getHours(); + const shouldRun = userHour >= 6; + + console.log(`UTC: ${utcTime}`); + console.log(` User time: ${userTime.toLocaleString('en-US', { timeZone: userTimezone })}`); + console.log(` User hour: ${userHour}`); + console.log(` Would run: ${shouldRun ? '✅ YES' : '❌ NO (before 6 AM)'}\n`); + } + + console.log("\n=== AUTO-PAYMENT JOB (should run at 9 AM user time) ===\n"); + for (const utcTime of testTimes) { + const asOf = new Date(utcTime); + const userTime = toZonedTime(asOf, userTimezone); + const userHour = userTime.getHours(); + const shouldRun = userHour >= 9; + + console.log(`UTC: ${utcTime}`); + console.log(` User time: ${userTime.toLocaleString('en-US', { timeZone: userTimezone })}`); + console.log(` User hour: ${userHour}`); + console.log(` Would run: ${shouldRun ? '✅ YES' : '❌ NO (before 9 AM)'}\n`); + } + + // Actually test rollover with dry-run + console.log("\n=== TESTING ROLLOVER (DRY RUN) ===\n"); + const rolloverTestTime = "2025-12-17T22:00:00Z"; // 7 AM Tokyo time (should run) + console.log(`Testing at: ${rolloverTestTime}`); + const rolloverResults = await rolloverFixedPlans(prisma, rolloverTestTime, { dryRun: true }); + console.log(`Plans found for rollover: ${rolloverResults.length}`); + if (rolloverResults.length > 0) { + console.log("Plans:", rolloverResults.map(r => ({ name: r.name, cycles: r.cyclesAdvanced }))); + } + + // Actually test auto-payment with dry-run + console.log("\n=== TESTING AUTO-PAYMENT (DRY RUN) ===\n"); + const paymentTestTime = "2025-12-18T01:00:00Z"; // 10 AM Tokyo time (should run) + console.log(`Testing at: ${paymentTestTime}`); + const paymentResults = await processAutoPayments(prisma, paymentTestTime, { dryRun: true }); + console.log(`Plans found for auto-payment: ${paymentResults.length}`); + if (paymentResults.length > 0) { + console.log("Plans:", paymentResults.map(r => ({ name: r.name, success: r.success, error: r.error }))); + } +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/api/src/server.ts b/api/src/server.ts index bb55e5f..f5bb163 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -1,55 +1,387 @@ -// api/src/server.ts -import Fastify from "fastify"; +import Fastify, { type FastifyInstance } from "fastify"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; +import fastifyCookie from "@fastify/cookie"; +import fastifyJwt from "@fastify/jwt"; +import argon2 from "argon2"; +import { randomUUID } from "node:crypto"; import { env } from "./env.js"; -import { PrismaClient } from "@prisma/client"; +import { PrismaClient, Prisma } from "@prisma/client"; import { z } from "zod"; -import { allocateIncome } from "./allocator.js"; +import { allocateIncome, allocateIncomeManual, previewAllocation, allocateBudget, applyIrregularIncome, countPayPeriodsBetween, getUserMidnight, getUserMidnightFromDateOnly, getUserDateRangeFromDateOnly } from "./allocator.js"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; +import { rolloverFixedPlans } from "./jobs/rollover.js"; +export type AppConfig = typeof env; +const openPaths = new Set(["/health", "/health/db", "/auth/login", "/auth/register"]); +const mutationRateLimit = { + config: { + rateLimit: { + max: 60, + timeWindow: 60_000, + }, + }, +}; +const pathOf = (url: string) => (url.split("?")[0] || "/"); +const CSRF_COOKIE = "csrf"; +const CSRF_HEADER = "x-csrf-token"; +const HASH_OPTIONS: argon2.Options & { raw?: false } = { + type: argon2.argon2id, + memoryCost: 19_456, + timeCost: 3, + parallelism: 1, +}; declare module "fastify" { - interface FastifyInstance { prisma: PrismaClient } - interface FastifyRequest { userId: string } + interface FastifyInstance { + prisma: PrismaClient; + ensureUser(userId: string): Promise; + } + interface FastifyRequest { + userId: string; + } } +export async function buildApp(overrides: Partial = {}): Promise { + const config = { ...env, ...overrides } as AppConfig; + const isProd = config.NODE_ENV === "production"; + const cookieDomain = config.COOKIE_DOMAIN || undefined; + + const app = Fastify({ + logger: true, + requestIdHeader: "x-request-id", + genReqId: (req) => { + const hdr = req.headers["x-request-id"]; + if (typeof hdr === "string" && hdr.length <= 64) return hdr; + return Math.random().toString(36).slice(2) + Date.now().toString(36); + }, + }); + const toBig = (n: number | string | bigint) => BigInt(n); +const parseCurrencyToCents = (value: string): number => { + const cleaned = value.replace(/[^0-9.]/g, ""); + const [whole, fraction = ""] = cleaned.split("."); + const normalized = + fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole; + const parsed = Number.parseFloat(normalized || "0"); + return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; +}; const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s); -const isoStart = (d: string) => new Date(`${d}T00:00:00.000Z`); -const isoEnd = (d: string) => new Date(`${d}T23:59:59.999Z`); +const addMonths = (date: Date, months: number) => { + const next = new Date(date); + next.setMonth(next.getMonth() + months); + return next; +}; + +const logDebug = (app: FastifyInstance, message: string, data?: Record) => { + if (!isProd) { + app.log.info(data ?? {}, message); + } +}; + +const ensureCsrfCookie = (reply: any, existing?: string) => { + const token = existing ?? randomUUID().replace(/-/g, ""); + reply.setCookie(CSRF_COOKIE, token, { + httpOnly: false, + sameSite: "lax", + secure: config.NODE_ENV === "production", + path: "/", + ...(cookieDomain ? { domain: cookieDomain } : {}), + }); + return token; +}; + +/** + * Calculate the next due date based on frequency for rollover + */ +function calculateNextDueDate(currentDueDate: Date, frequency: string, timezone: string = "UTC"): Date { + const base = getUserMidnightFromDateOnly(timezone, currentDueDate); + const zoned = toZonedTime(base, timezone); + + switch (frequency) { + case "weekly": + zoned.setUTCDate(zoned.getUTCDate() + 7); + break; + case "biweekly": + zoned.setUTCDate(zoned.getUTCDate() + 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)); + break; + } + default: + return base; + } + + zoned.setUTCHours(0, 0, 0, 0); + return fromZonedTime(zoned, timezone); +} +const monthKey = (date: Date) => + `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; +const monthLabel = (date: Date) => + date.toLocaleString("en-US", { month: "short", year: "numeric" }); +function buildMonthBuckets(count: number, now = new Date()) { + const buckets: Array<{ key: string; label: string; start: Date; end: Date }> = []; + const current = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + for (let i = count - 1; i >= 0; i--) { + const start = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - i, 1)); + const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1)); + buckets.push({ key: monthKey(start), label: monthLabel(start), start, end }); + } + return buckets; +} +const DAY_MS = 24 * 60 * 60 * 1000; function jsonBigIntSafe(obj: unknown) { - return JSON.parse(JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v))); + return JSON.parse( + JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v)) + ); } -const app = Fastify({ - logger: true, - requestIdHeader: "x-request-id", - genReqId: (req) => { - const hdr = req.headers["x-request-id"]; - if (typeof hdr === "string" && hdr.length <= 64) return hdr; - return Math.random().toString(36).slice(2) + Date.now().toString(36); - }, -}); +type PercentCategory = { + id: string; + percent: number; + balanceCents: bigint | null; +}; -// CORS -await app.register(cors, { +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 }, + { name: "Fun", percent: 20, priority: 30, isSavings: false }, +] as const; + +const DEFAULT_FIXED_PLANS = [ + { name: "Rent", totalCents: 120_000, priority: 10 }, +] as const; + +async function seedDefaultBudget(prisma: PrismaClient, userId: string) { + const [catCount, planCount] = await Promise.all([ + prisma.variableCategory.count({ where: { userId } }), + prisma.fixedPlan.count({ where: { userId } }), + ]); + if (catCount > 0 && planCount > 0) return; + + const now = new Date(); + const nextDue = addMonths(new Date(now.getFullYear(), now.getMonth(), 1), 1); + + await prisma.$transaction(async (tx) => { + if (catCount === 0) { + await tx.variableCategory.createMany({ + data: DEFAULT_VARIABLE_CATEGORIES.map((cat, idx) => ({ + userId, + name: cat.name, + percent: cat.percent, + priority: cat.priority + idx, + isSavings: cat.isSavings, + balanceCents: 0n, + })), + }); + } + if (planCount === 0) { + await Promise.all( + DEFAULT_FIXED_PLANS.map((plan, idx) => + tx.fixedPlan.create({ + data: { + userId, + name: plan.name, + totalCents: toBig(plan.totalCents), + fundedCents: 0n, + currentFundedCents: 0n, + priority: plan.priority + idx, + cycleStart: now, + dueOn: nextDue, + fundingMode: "auto-on-deposit", + }, + }) + ) + ); + } + }); +} + + await app.register(cors, { origin: (() => { - if (!env.CORS_ORIGIN) return true; // dev: allow all - const allow = env.CORS_ORIGIN.split(",").map(s => s.trim()).filter(Boolean); + if (!config.CORS_ORIGIN) return true; + const allow = config.CORS_ORIGIN.split(",") + .map((s) => s.trim()) + .filter(Boolean); return (origin, cb) => { - if (!origin) return cb(null, true); // curl/health + if (!origin) return cb(null, true); cb(null, allow.includes(origin)); }; })(), credentials: true, }); -// Rate limit (light) -await app.register(rateLimit, { - max: env.RATE_LIMIT_MAX, - timeWindow: env.RATE_LIMIT_WINDOW_MS, + await app.register(rateLimit, { + max: config.RATE_LIMIT_MAX, + timeWindow: config.RATE_LIMIT_WINDOW_MS, hook: "onRequest", allowList: (req) => { const ip = (req.ip || "").replace("::ffff:", ""); @@ -57,30 +389,296 @@ await app.register(rateLimit, { }, }); -// Prisma + await app.register(fastifyCookie, { secret: config.COOKIE_SECRET }); + await app.register(fastifyJwt, { + secret: config.JWT_SECRET, + cookie: { cookieName: "session", signed: false }, + sign: { + expiresIn: `${config.SESSION_TIMEOUT_MINUTES}m`, + }, +}); + { const prisma = new PrismaClient(); app.decorate("prisma", prisma); app.addHook("onClose", async () => prisma.$disconnect()); } -// Auth stub + ensure user exists + set x-request-id header ONCE -app.addHook("onRequest", async (req, reply) => { - const headerId = req.headers["x-user-id"]; - if (typeof headerId === "string" && headerId.trim()) req.userId = headerId.trim(); - else req.userId = "demo-user-1"; - - // echo the request id (no per-request hook registration) - if (req.id) reply.header("x-request-id", String(req.id)); - +app.decorate("ensureUser", async (userId: string) => { await app.prisma.user.upsert({ - where: { id: req.userId }, + where: { id: userId }, update: {}, - create: { id: req.userId, email: `${req.userId}@demo.local` }, + create: { id: userId, email: `${userId}@demo.local`, displayName: null }, }); + if (config.SEED_DEFAULT_BUDGET) { + await seedDefaultBudget(app.prisma, userId); + } +}); + + app.addHook("onRequest", async (req, reply) => { + reply.header("x-request-id", String(req.id ?? "")); + const path = pathOf(req.url ?? ""); + + // Open paths don't require authentication + if (openPaths.has(path)) { + return; + } + + // If auth is disabled, require x-user-id header (no more demo-user-1 fallback) + if (config.AUTH_DISABLED) { + const userIdHeader = req.headers["x-user-id"]?.toString().trim(); + if (!userIdHeader) { + return reply.code(401).send({ error: "No user ID provided" }); + } + req.userId = userIdHeader; + await app.ensureUser(req.userId); + return; + } + try { + const { sub } = await req.jwtVerify<{ sub: string }>(); + req.userId = sub; + await app.ensureUser(req.userId); + } catch { + return reply + .code(401) + .send({ + ok: false, + code: "UNAUTHENTICATED", + message: "Login required", + requestId: String(req.id ?? ""), + }); + } +}); + + app.addHook("preHandler", async (req, reply) => { + const path = pathOf(req.url ?? ""); + const method = req.method.toUpperCase(); + if (method === "GET" || method === "HEAD" || method === "OPTIONS") { + return; + } + if (path === "/auth/login" || path === "/auth/register") { + return; + } + const cookieToken = (req.cookies as any)?.[CSRF_COOKIE]; + const headerToken = typeof req.headers[CSRF_HEADER] === "string" ? req.headers[CSRF_HEADER] : undefined; + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + return reply.code(403).send({ ok: false, code: "CSRF", message: "Invalid CSRF token" }); + } +}); + +const AuthBody = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +const AllocationOverrideSchema = z.object({ + type: z.enum(["fixed", "variable"]), + id: z.string().min(1), + amountCents: z.number().int().nonnegative(), +}); + +app.post( + "/auth/register", + { + config: { + rateLimit: { + max: 10, + timeWindow: 60_000, + }, + }, + }, + async (req, reply) => { + const parsed = AuthBody.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" }); + const { email, password } = parsed.data; + const normalizedEmail = email.toLowerCase(); + const existing = await app.prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true }, + }); + if (existing) { + return reply + .code(409) + .send({ ok: false, code: "EMAIL_IN_USE", message: "Email already registered" }); + } + const hash = await argon2.hash(password, HASH_OPTIONS); + const user = await app.prisma.user.create({ + data: { + email: normalizedEmail, + passwordHash: hash, + displayName: email.split("@")[0] || null, + }, + }); + if (config.SEED_DEFAULT_BUDGET) { + await seedDefaultBudget(app.prisma, user.id); + } + const token = await reply.jwtSign({ sub: user.id }); + const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; // Convert to seconds + reply.setCookie("session", token, { + httpOnly: true, + sameSite: "lax", + secure: config.NODE_ENV === "production", + path: "/", + maxAge, + ...(cookieDomain ? { domain: cookieDomain } : {}), + }); + ensureCsrfCookie(reply); + return { ok: true }; +}); + +app.post( + "/auth/login", + { + config: { + rateLimit: { + max: 10, + timeWindow: 60_000, + }, + }, + }, + async (req, reply) => { + const parsed = AuthBody.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" }); + const { email, password } = parsed.data; + const user = await app.prisma.user.findUnique({ + where: { email: email.toLowerCase() }, + }); + if (!user?.passwordHash) return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + const valid = await argon2.verify(user.passwordHash, password); + if (!valid) return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + await app.ensureUser(user.id); + const token = await reply.jwtSign({ sub: user.id }); + const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; // Convert to seconds + reply.setCookie("session", token, { + httpOnly: true, + sameSite: "lax", + secure: config.NODE_ENV === "production", + path: "/", + maxAge, + ...(cookieDomain ? { domain: cookieDomain } : {}), + }); + ensureCsrfCookie(reply); + return { ok: true }; +}); + +app.post("/auth/logout", async (_req, reply) => { + reply.clearCookie("session", { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: config.NODE_ENV === "production", + ...(cookieDomain ? { domain: cookieDomain } : {}), + }); + return { ok: true }; +}); + +app.post("/auth/refresh", async (req, reply) => { + // Generate a new token to extend the session + const userId = req.userId; + const token = await reply.jwtSign({ sub: userId }); + const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; + reply.setCookie("session", token, { + httpOnly: true, + sameSite: "lax", + secure: config.NODE_ENV === "production", + path: "/", + maxAge, + ...(cookieDomain ? { domain: cookieDomain } : {}), + }); + ensureCsrfCookie(reply, (req.cookies as any)?.[CSRF_COOKIE]); + return { ok: true, expiresInMinutes: config.SESSION_TIMEOUT_MINUTES }; +}); + +app.get("/auth/session", async (req, reply) => { + if (!(req.cookies as any)?.[CSRF_COOKIE]) { + ensureCsrfCookie(reply); + } + const user = await app.prisma.user.findUnique({ + where: { id: req.userId }, + select: { email: true, displayName: true }, + }); + return { + ok: true, + userId: req.userId, + email: user?.email ?? null, + displayName: user?.displayName ?? null, + }; +}); + +app.patch("/me", async (req, reply) => { + const Body = z.object({ + displayName: z.string().trim().min(1).max(120), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const updated = await app.prisma.user.update({ + where: { id: req.userId }, + data: { displayName: parsed.data.displayName.trim() }, + select: { id: true, email: true, displayName: true }, + }); + return { ok: true, userId: updated.id, email: updated.email, displayName: updated.displayName }; +}); + +app.patch("/me/password", async (req, reply) => { + const Body = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(8), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid password data" }); + } + + const user = await app.prisma.user.findUnique({ + where: { id: req.userId }, + select: { passwordHash: true }, + }); + + if (!user?.passwordHash) { + return reply.code(401).send({ ok: false, message: "No password set" }); + } + + // Verify current password + const valid = await argon2.verify(user.passwordHash, parsed.data.currentPassword); + if (!valid) { + return reply.code(401).send({ ok: false, message: "Current password is incorrect" }); + } + + // Hash new password + const newHash = await argon2.hash(parsed.data.newPassword, HASH_OPTIONS); + + // Update password + await app.prisma.user.update({ + where: { id: req.userId }, + data: { passwordHash: newHash }, + }); + + return { ok: true, message: "Password updated successfully" }; +}); + +app.patch("/me/income-frequency", async (req, reply) => { + const Body = z.object({ + incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid income frequency data" }); + } + const updated = await app.prisma.user.update({ + where: { id: req.userId }, + data: { + incomeFrequency: parsed.data.incomeFrequency, + }, + select: { id: true, incomeFrequency: true }, + }); + return { + ok: true, + incomeFrequency: updated.incomeFrequency, + }; }); -// BigInt-safe JSON (single onSend) app.addHook("preSerialization", (_req, _reply, payload, done) => { try { if (payload && typeof payload === "object") { @@ -91,147 +689,797 @@ app.addHook("preSerialization", (_req, _reply, payload, done) => { } return done(null, payload); } catch { - // If anything goes sideways, keep the original payload return done(null, payload); } }); - app.setErrorHandler((err, req, reply) => { - // Map prisma/validation-ish errors to 400 by default const status = (typeof (err as any).statusCode === "number" && (err as any).statusCode) || (typeof (err as any).status === "number" && (err as any).status) || (typeof (err as any).code === "string" && (err as any).code.startsWith("P2") ? 400 : 500); - // Never leak stacks to client const body = { ok: false, code: (err as any).code ?? "INTERNAL", message: - status >= 500 - ? "Something went wrong" - : (err as any).message ?? "Bad request", + status >= 500 ? "Something went wrong" : (err as any).message ?? "Bad request", requestId: String(req.id ?? ""), }; - // Log full error with request context req.log.error({ err, requestId: req.id }, "request failed"); reply.code(status).send(body); }); -// 404 JSON app.setNotFoundHandler((req, reply) => { reply.code(404).send({ ok: false, code: "NOT_FOUND", message: `No route: ${req.method} ${req.url}`, - requestId: String(req.id ?? ""), }); }); -// ───────────── Health ───────────── +app.post("/admin/rollover", async (req, reply) => { + if (!config.AUTH_DISABLED) { + return reply.code(403).send({ ok: false, message: "Forbidden" }); + } + const Body = z.object({ + asOf: z.string().datetime().optional(), + dryRun: z.boolean().optional(), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const asOf = parsed.data.asOf ? new Date(parsed.data.asOf) : new Date(); + const dryRun = parsed.data.dryRun ?? false; + const results = await rolloverFixedPlans(app.prisma, asOf, { dryRun }); + return { ok: true, asOf: asOf.toISOString(), dryRun, processed: results.length, results }; +}); + +// ----- Health ----- app.get("/health", async () => ({ ok: true })); app.get("/health/db", async () => { const start = Date.now(); - const [{ now }] = await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now"); + const [{ now }] = + await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now"); const latencyMs = Date.now() - start; return { ok: true, nowISO: now.toISOString(), latencyMs }; }); -// ───────────── Dashboard ───────────── +// ----- Dashboard ----- app.get("/dashboard", async (req) => { const userId = req.userId; + const monthsBack = 6; + const buckets = buildMonthBuckets(monthsBack); + const rangeStart = buckets[0]?.start ?? new Date(); + const now = new Date(); + const dashboardTxKinds = ["variable_spend", "fixed_payment"]; - const [cats, plans, txs, agg] = await Promise.all([ + const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, spendTxs, user] = await Promise.all([ app.prisma.variableCategory.findMany({ - where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }] + where: { userId }, + orderBy: [{ priority: "asc" }, { name: "asc" }], }), app.prisma.fixedPlan.findMany({ - where: { userId }, orderBy: [{ priority: "asc" }, { dueOn: "asc" }] + where: { userId }, + orderBy: [{ priority: "asc" }, { dueOn: "asc" }], }), app.prisma.transaction.findMany({ - where: { userId }, orderBy: { occurredAt: "desc" }, take: 50, - select: { id: true, kind: true, amountCents: true, occurredAt: true } + where: { userId, kind: { in: dashboardTxKinds } }, + orderBy: { occurredAt: "desc" }, + take: 50, + select: { id: true, kind: true, amountCents: true, occurredAt: true }, }), app.prisma.incomeEvent.aggregate({ - where: { userId }, _sum: { amountCents: true } + where: { userId }, + _sum: { amountCents: true }, + }), + app.prisma.allocation.aggregate({ + where: { userId }, + _sum: { amountCents: true }, + }), + app.prisma.incomeEvent.findMany({ + where: { userId, postedAt: { gte: rangeStart } }, + select: { postedAt: true, amountCents: true }, + }), + app.prisma.transaction.findMany({ + where: { + userId, + kind: { in: dashboardTxKinds }, + occurredAt: { gte: rangeStart }, + }, + select: { occurredAt: true, amountCents: true }, + }), + app.prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, displayName: true, incomeFrequency: true, incomeType: true, timezone: true, firstIncomeDate: true, fixedExpensePercentage: true }, }), ]); - const totals = { - incomeCents: Number(agg._sum?.amountCents ?? 0n), - variableBalanceCents: Number(cats.reduce((s, c) => s + (c.balanceCents ?? 0n), 0n)), - fixedRemainingCents: Number(plans.reduce((s, p) => { - const rem = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n); - return s + (rem > 0n ? rem : 0n); - }, 0n)), - }; - const percentTotal = cats.reduce((s, c) => s + c.percent, 0); + const totalIncomeCents = Number(agg._sum?.amountCents ?? 0n); + const totalAllocatedCents = Number(allocAgg._sum?.amountCents ?? 0n); + const availableBudgetCents = Math.max(0, totalIncomeCents - totalAllocatedCents); - return { totals, variableCategories: cats, fixedPlans: plans, recentTransactions: txs, percentTotal }; -}); - -// ───────────── Income (allocate) ───────────── -app.post("/income", async (req, reply) => { - const Body = z.object({ amountCents: z.number().int().nonnegative() }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" }); - - const userId = req.userId; - const nowISO = new Date().toISOString(); - const amountCentsNum = parsed.data.amountCents; - - const income = await app.prisma.incomeEvent.create({ - data: { userId, postedAt: new Date(nowISO), amountCents: toBig(amountCentsNum) }, - select: { id: true }, + // Import timezone-aware helper for consistent date calculations + const { getUserMidnight, calculateNextPayday } = await import("./allocator.js"); + const userTimezone = user?.timezone || "America/New_York"; + const userNow = getUserMidnight(userTimezone, now); + const upcomingCutoff = new Date(userNow.getTime() + 14 * DAY_MS); + + const fixedPlans = plans.map((plan) => { + const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const total = Number(plan.totalCents ?? 0n); + const remainingCents = Math.max(0, total - funded); + + // Use timezone-aware date comparison for consistency with allocator + const userDueDate = getUserMidnightFromDateOnly(userTimezone, plan.dueOn); + const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS)); + const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0; + const fundedPercent = total > 0 ? (funded / total) * 100 : 100; + + // Use same crisis logic as allocator for consistency + const CRISIS_MINIMUM_CENTS = 1000; + const isPaymentPlanUser = user?.incomeType === "regular" && plan.paymentSchedule !== null; + let isCrisis = false; + + if (remainingCents >= CRISIS_MINIMUM_CENTS) { + if (isPaymentPlanUser && user?.firstIncomeDate) { + // Crisis if due BEFORE next payday AND not mostly funded + const nextPayday = calculateNextPayday(user.firstIncomeDate, user.incomeFrequency, now, userTimezone); + const daysUntilPayday = Math.max(0, Math.ceil((nextPayday.getTime() - userNow.getTime()) / DAY_MS)); + isCrisis = daysUntilDue < daysUntilPayday && fundedPercent < 90; + } else { + // For irregular income users + isCrisis = fundedPercent < 70 && daysUntilDue <= 14; + } + } + + return { + ...plan, + fundedCents: funded, + currentFundedCents: funded, + remainingCents, + daysUntilDue, + percentFunded, + isCrisis, + }; }); - const result = await allocateIncome(app.prisma, userId, amountCentsNum, nowISO, income.id); + const variableBalanceCents = Number( + cats.reduce((sum, cat) => sum + (cat.balanceCents ?? 0n), 0n) + ); + const fixedFundedCents = Number( + fixedPlans.reduce((sum, plan) => sum + plan.fundedCents, 0) + ); + const currentTotalBalance = variableBalanceCents + fixedFundedCents; + + const totals = { + incomeCents: currentTotalBalance, // Changed: show current balance instead of lifetime income + availableBudgetCents, + variableBalanceCents, + fixedRemainingCents: Number( + fixedPlans.reduce((sum, plan) => sum + Math.max(0, plan.remainingCents), 0) + ), + }; + const percentTotal = cats.reduce((sum, cat) => sum + cat.percent, 0); + + const incomeByMonth = new Map(); + incomeEvents.forEach((evt) => { + const key = monthKey(evt.postedAt); + incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n)); + }); + const spendByMonth = new Map(); + spendTxs.forEach((tx) => { + const key = monthKey(tx.occurredAt); + spendByMonth.set(key, (spendByMonth.get(key) ?? 0) + Number(tx.amountCents ?? 0n)); + }); + const monthlyTrend = buckets.map((bucket) => ({ + monthKey: bucket.key, + label: bucket.label, + incomeCents: incomeByMonth.get(bucket.key) ?? 0, + spendCents: spendByMonth.get(bucket.key) ?? 0, + })); + + const upcomingPlans = fixedPlans + .map((plan) => ({ ...plan, due: getUserMidnightFromDateOnly(userTimezone, plan.dueOn) })) + .filter( + (plan) => + plan.remainingCents > 0 && + plan.due >= userNow && + plan.due <= upcomingCutoff + ) + .sort((a, b) => a.due.getTime() - b.due.getTime()) + .map((plan) => ({ + id: plan.id, + name: plan.name, + dueOn: plan.due.toISOString(), + remainingCents: plan.remainingCents, + percentFunded: plan.percentFunded, + daysUntilDue: plan.daysUntilDue, + isCrisis: plan.isCrisis, + })); + + const savingsTargets = cats + .filter((cat) => cat.isSavings && (cat.savingsTargetCents ?? 0n) > 0n) + .map((cat) => { + const target = Number(cat.savingsTargetCents ?? 0n); + const current = Number(cat.balanceCents ?? 0n); + const percent = target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0; + return { + id: cat.id, + name: cat.name, + balanceCents: current, + targetCents: target, + percent, + }; + }); + + const crisisAlerts = fixedPlans + .filter((plan) => plan.isCrisis && plan.remainingCents > 0) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue; + return a.name.localeCompare(b.name); + }) + .map((plan) => ({ + id: plan.id, + name: plan.name, + remainingCents: plan.remainingCents, + daysUntilDue: plan.daysUntilDue, + percentFunded: plan.percentFunded, + })); + + // Simplified fixed funding detection using tracking flags + function shouldFundFixedPlans(userType: string, incomeFrequency: string, fixedPlans: any[], crisisActive: boolean) { + // 1. Crisis mode = always fund fixed + if (crisisActive) return true; + + // 2. Irregular users = always fund until fully funded + if (userType === "irregular") { + return fixedPlans.some(plan => { + const remaining = Number(plan.remainingCents ?? 0); + return remaining > 0; + }); + } + + // 3. Regular users = use simple flag-based detection + // Plans needing funding will have needsFundingThisPeriod = true + return fixedPlans.some(plan => { + const remaining = Number(plan.remainingCents ?? 0); + if (remaining <= 0) return false; // Already fully funded + + // Simple check: does this plan need funding this period? + return plan.needsFundingThisPeriod === true; + }); + } + + const needsFixedFunding = shouldFundFixedPlans( + user?.incomeType ?? "regular", + user?.incomeFrequency ?? "biweekly", + fixedPlans, + crisisAlerts.length > 0 + ); + + const hasBudgetSetup = cats.length > 0 && percentTotal === 100; + + return { + totals, + variableCategories: cats, + fixedPlans: fixedPlans.map((plan) => ({ + ...plan, + dueOn: getUserMidnightFromDateOnly(userTimezone, plan.dueOn).toISOString(), + lastFundingDate: plan.lastFundingDate ? new Date(plan.lastFundingDate).toISOString() : null, + })), + recentTransactions: recentTxs, + percentTotal, + hasBudgetSetup, + user: { + id: userId, + email: user?.email ?? null, + displayName: user?.displayName ?? null, + incomeFrequency: user?.incomeFrequency ?? "biweekly", + incomeType: user?.incomeType ?? "regular", + timezone: user?.timezone ?? "America/New_York", + firstIncomeDate: user?.firstIncomeDate + ? getUserMidnightFromDateOnly(userTimezone, user.firstIncomeDate).toISOString() + : null, + fixedExpensePercentage: user?.fixedExpensePercentage ?? 40, + }, + monthlyTrend, + upcomingPlans, + savingsTargets, + crisis: { + active: crisisAlerts.length > 0, + plans: crisisAlerts, + }, + needsFixedFunding, + }; +}); + +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 userNow = getUserMidnight(userTimezone, now); + + const plans = await app.prisma.fixedPlan.findMany({ + where: { userId }, + orderBy: [{ priority: "asc" }, { dueOn: "asc" }], + select: { + id: true, + name: true, + totalCents: true, + fundedCents: true, + currentFundedCents: true, + dueOn: true, + priority: true, + }, + }); + + const crisisPlans = plans + .map((plan) => { + const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const total = Number(plan.totalCents ?? 0n); + const remainingCents = Math.max(0, total - funded); + const userDueDate = getUserMidnightFromDateOnly(userTimezone, plan.dueOn); + const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / (24 * 60 * 60 * 1000))); + const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0; + const isCrisis = remainingCents > 0 && daysUntilDue <= 7; + + return { + id: plan.id, + name: plan.name, + remainingCents, + daysUntilDue, + percentFunded, + priority: plan.priority, + isCrisis, + }; + }) + .filter((plan) => plan.isCrisis) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue; + return a.name.localeCompare(b.name); + }); + + return { + active: crisisPlans.length > 0, + plans: crisisPlans, + }; +}); + +// ----- Income allocation ----- +app.post("/income", mutationRateLimit, async (req, reply) => { + const Body = z.object({ + amountCents: z.number().int().nonnegative(), + overrides: z.array(AllocationOverrideSchema).optional(), + occurredAtISO: z.string().datetime().optional(), + note: z.string().trim().max(500).optional(), + isScheduledIncome: z.boolean().optional(), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid amount" }); + } + + const userId = req.userId; + const amountCentsNum = Math.max(0, Math.floor(parsed.data.amountCents | 0)); + const overrides = (parsed.data.overrides ?? []).filter((o) => o.amountCents > 0); + const note = parsed.data.note?.trim() ? parsed.data.note.trim() : null; + const isScheduledIncome = parsed.data.isScheduledIncome ?? false; + const postedAt = parsed.data.occurredAtISO ? new Date(parsed.data.occurredAtISO) : new Date(); + const postedAtISO = postedAt.toISOString(); + const incomeId = randomUUID(); + + if (overrides.length > 0) { + const manual = await allocateIncomeManual( + app.prisma, + userId, + amountCentsNum, + postedAtISO, + incomeId, + overrides, + note + ); + return manual; + } + + const result = await allocateIncome(app.prisma, userId, amountCentsNum, postedAtISO, incomeId, note, isScheduledIncome); return result; }); -// ───────────── Transactions: create (strict overdraft) ───────────── -app.post("/transactions", async (req, reply) => { - const Body = z.object({ - kind: z.enum(["variable_spend", "fixed_payment"]), - amountCents: z.number().int().positive(), - occurredAtISO: z.string().datetime(), - categoryId: z.string().optional(), - planId: z.string().optional(), - }); +// ----- Transactions: create ----- +app.post("/transactions", mutationRateLimit, async (req, reply) => { + const Body = z + .object({ + kind: z.enum(["variable_spend", "fixed_payment"]), + amountCents: z.number().int().positive(), + occurredAtISO: z.string().datetime(), + categoryId: z.string().uuid().optional(), + planId: z.string().uuid().optional(), + note: z.string().trim().max(500).optional(), + receiptUrl: z + .string() + .trim() + .url() + .max(2048) + .optional(), + isReconciled: z.boolean().optional(), + allowOverdraft: z.boolean().optional(), // Allow spending more than balance + useAvailableBudget: z.boolean().optional(), // Spend from total available budget + }) + .superRefine((data, ctx) => { + if (data.kind === "variable_spend") { + if (!data.categoryId && !data.useAvailableBudget) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "categoryId required for variable_spend", + path: ["categoryId"], + }); + } + if (data.planId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "planId not allowed for variable_spend", + path: ["planId"], + }); + } + } + if (data.kind === "fixed_payment") { + if (!data.planId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "planId required for fixed_payment", + path: ["planId"], + }); + } + if (data.categoryId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "categoryId not allowed for fixed_payment", + path: ["categoryId"], + }); + } + } + }); const parsed = Body.safeParse(req.body); - if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" }); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid payload" }); + } - const { kind, amountCents, occurredAtISO, categoryId, planId } = parsed.data; + const { kind, amountCents, occurredAtISO, categoryId, planId, note, receiptUrl, isReconciled, allowOverdraft, useAvailableBudget } = parsed.data; const userId = req.userId; const amt = toBig(amountCents); return await app.prisma.$transaction(async (tx) => { + let deletePlanAfterPayment = false; + let paidAmount = amountCents; + // Track updated next due date if we modify a fixed plan + let updatedDueOn: Date | undefined; if (kind === "variable_spend") { - if (!categoryId) return reply.code(400).send({ message: "categoryId required" }); - const cat = await tx.variableCategory.findFirst({ where: { id: categoryId, userId } }); - if (!cat) return reply.code(404).send({ message: "Category not found" }); + if (useAvailableBudget) { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + const availableBudget = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + if (amountCents > availableBudget && !allowOverdraft) { + const overdraftAmount = amountCents - availableBudget; + return reply.code(400).send({ + ok: false, + code: "OVERDRAFT_CONFIRMATION", + message: `This will overdraft your available budget by $${(overdraftAmount / 100).toFixed(2)}. The negative balance will be recovered from your next income.`, + overdraftAmount, + categoryName: "available budget", + currentBalance: availableBudget, + }); + } - const bal = cat.balanceCents ?? 0n; - if (amt > bal) { - const err: any = new Error("Insufficient category balance"); - err.statusCode = 400; err.code = "OVERDRAFT_CATEGORY"; - throw err; + const shareResult = allowOverdraft + ? computeOverdraftShares(categories, amountCents) + : computeWithdrawShares(categories, amountCents); + + if (!shareResult.ok) { + const err: any = new Error( + shareResult.reason === "no_percent" + ? "No category percentages available." + : "Insufficient category balances to cover this spend." + ); + err.statusCode = 400; + err.code = + shareResult.reason === "no_percent" + ? "NO_CATEGORY_PERCENT" + : "INSUFFICIENT_CATEGORY_BALANCES"; + throw err; + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + } else { + if (!categoryId) { + return reply.code(400).send({ message: "categoryId required" }); + } + const cat = await tx.variableCategory.findFirst({ + where: { id: categoryId, userId }, + }); + if (!cat) return reply.code(404).send({ message: "Category not found" }); + + const bal = cat.balanceCents ?? 0n; + if (amt > bal && !allowOverdraft) { + // Ask for confirmation before allowing overdraft + const overdraftAmount = Number(amt - bal); + return reply.code(400).send({ + ok: false, + code: "OVERDRAFT_CONFIRMATION", + message: `This will overdraft ${cat.name} by $${(overdraftAmount / 100).toFixed(2)}. The negative balance will be recovered from your next income.`, + overdraftAmount, + categoryName: cat.name, + currentBalance: Number(bal), + }); + } + const updated = await tx.variableCategory.updateMany({ + where: { id: cat.id, userId }, + data: { balanceCents: bal - amt }, // Can go negative + }); + if (updated.count === 0) { + return reply.code(404).send({ message: "Category not found" }); + } } - await tx.variableCategory.update({ where: { id: cat.id }, data: { balanceCents: bal - amt } }); } else { - if (!planId) return reply.code(400).send({ message: "planId required" }); - const plan = await tx.fixedPlan.findFirst({ where: { id: planId, userId } }); - if (!plan) return reply.code(404).send({ message: "Plan not found" }); - - const funded = plan.fundedCents ?? 0n; - if (amt > funded) { - const err: any = new Error("Insufficient plan funds"); - err.statusCode = 400; err.code = "OVERDRAFT_PLAN"; - throw err; + // fixed_payment: Either a funding contribution (default) or a reconciliation payment + if (!planId) { + return reply.code(400).send({ message: "planId required" }); + } + const plan = await tx.fixedPlan.findFirst({ + where: { id: planId, userId }, + }); + if (!plan) return reply.code(404).send({ message: "Plan not found" }); + const userTimezone = + (await tx.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + "America/New_York"; + + const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const totalAmount = Number(plan.totalCents ?? 0n); + const isOneTime = !plan.frequency || plan.frequency === "one-time"; + const isReconciledPayment = !!isReconciled; + + if (!isReconciledPayment) { + const remainingNeeded = Math.max(0, totalAmount - fundedAmount); + const amountToFund = Math.min(amountCents, remainingNeeded); + + if (amountToFund <= 0) { + return reply.code(400).send({ message: "Plan is already fully funded." }); + } + + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + const availableBudget = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + + if (availableBudget < amountToFund) { + const err: any = new Error("Insufficient available budget to fund this amount."); + err.statusCode = 400; + err.code = "INSUFFICIENT_AVAILABLE_BUDGET"; + err.availableBudget = availableBudget; + err.shortage = amountToFund; + throw err; + } + + const shareResult = computeWithdrawShares(categories, amountToFund); + if (!shareResult.ok) { + const err: any = new Error( + shareResult.reason === "no_percent" + ? "No category percentages available." + : "Insufficient category balances to fund this amount." + ); + err.statusCode = 400; + err.code = + shareResult.reason === "no_percent" + ? "NO_CATEGORY_PERCENT" + : "INSUFFICIENT_CATEGORY_BALANCES"; + throw err; + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(amountToFund), + incomeId: null, + }, + }); + + const newFunded = fundedAmount + amountToFund; + await tx.fixedPlan.update({ + where: { id: plan.id }, + data: { + fundedCents: BigInt(newFunded), + currentFundedCents: BigInt(newFunded), + lastFundingDate: new Date(), + lastFundedPayPeriod: new Date(), + needsFundingThisPeriod: newFunded < totalAmount, + }, + }); + + paidAmount = amountToFund; + + if (!isOneTime && newFunded >= totalAmount) { + if (plan.frequency && plan.frequency !== "one-time") { + updatedDueOn = calculateNextDueDate(plan.dueOn, plan.frequency, userTimezone); + } else { + updatedDueOn = plan.dueOn ?? undefined; + } + } + } else { + // Reconciliation: confirm a real payment + const normalizedPaid = Math.min(amountCents, totalAmount); + const shortage = Math.max(0, normalizedPaid - fundedAmount); + const effectiveFunded = fundedAmount + shortage; + + if (shortage > 0) { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + const availableBudget = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + + if (availableBudget < shortage) { + const err: any = new Error("Insufficient available budget to cover this payment."); + err.statusCode = 400; + err.code = "INSUFFICIENT_AVAILABLE_BUDGET"; + err.availableBudget = availableBudget; + err.shortage = shortage; + throw err; + } + + const shareResult = computeWithdrawShares(categories, shortage); + if (!shareResult.ok) { + const err: any = new Error( + shareResult.reason === "no_percent" + ? "No category percentages available." + : "Insufficient category balances to cover this payment." + ); + err.statusCode = 400; + err.code = + shareResult.reason === "no_percent" + ? "NO_CATEGORY_PERCENT" + : "INSUFFICIENT_CATEGORY_BALANCES"; + throw err; + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(shortage), + incomeId: null, + }, + }); + } + + paidAmount = normalizedPaid; + + // Reconciliation logic based on payment amount vs funded amount + if (paidAmount >= totalAmount) { + if (isOneTime) { + deletePlanAfterPayment = true; + } else { + let frequency = plan.frequency; + if (!frequency && plan.paymentSchedule) { + const schedule = plan.paymentSchedule as any; + frequency = schedule.frequency; + } + if (frequency && frequency !== "one-time") { + updatedDueOn = calculateNextDueDate(plan.dueOn, frequency, userTimezone); + } else { + updatedDueOn = plan.dueOn ?? undefined; + } + + const updateData: any = { + fundedCents: 0n, + currentFundedCents: 0n, + isOverdue: false, + overdueAmount: 0n, + overdueSince: null, + needsFundingThisPeriod: plan.paymentSchedule ? true : false, + }; + if (updatedDueOn) { + updateData.dueOn = updatedDueOn; + updateData.nextPaymentDate = plan.autoPayEnabled + ? updatedDueOn + : null; + } + + await tx.fixedPlan.update({ + where: { id: plan.id }, + data: updateData, + }); + } + + } else if (paidAmount > 0 && paidAmount < totalAmount) { + const refundAmount = Math.max(0, effectiveFunded - paidAmount); + + if (refundAmount > 0) { + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(-refundAmount), + incomeId: null, + }, + }); + } + + const remainingBalance = totalAmount - paidAmount; + const updatedPlan = await tx.fixedPlan.update({ + where: { id: plan.id }, + data: { + fundedCents: 0n, + currentFundedCents: 0n, + isOverdue: true, + overdueAmount: BigInt(remainingBalance), + overdueSince: plan.overdueSince ?? new Date(), + needsFundingThisPeriod: true, + }, + select: { id: true, dueOn: true }, + }); + updatedDueOn = updatedPlan.dueOn ?? undefined; + + } else { + await tx.fixedPlan.update({ + where: { id: plan.id }, + data: { + isOverdue: true, + overdueAmount: BigInt(totalAmount - fundedAmount), + overdueSince: plan.overdueSince ?? new Date(), + needsFundingThisPeriod: true, + }, + }); + } } - await tx.fixedPlan.update({ where: { id: plan.id }, data: { fundedCents: funded - amt } }); } const row = await tx.transaction.create({ @@ -239,18 +1487,630 @@ app.post("/transactions", async (req, reply) => { userId, occurredAt: new Date(occurredAtISO), kind, - amountCents: amt, + amountCents: toBig(paidAmount), categoryId: kind === "variable_spend" ? categoryId ?? null : null, - planId: kind === "fixed_payment" ? planId ?? null : null, + planId: kind === "fixed_payment" ? planId ?? null : null, + note: note?.trim() ? note.trim() : null, + receiptUrl: receiptUrl ?? null, + isReconciled: isReconciled ?? false, + isAutoPayment: false, }, select: { id: true, kind: true, amountCents: true, occurredAt: true }, }); + // If this was a fixed payment, include next due date info for UI toast + if (kind === "fixed_payment") { + if (deletePlanAfterPayment) { + await tx.fixedPlan.deleteMany({ where: { id: planId, userId } }); + } + return { + ...row, + planId, + nextDueOn: updatedDueOn || undefined, + } as any; + } + return row; }); }); -// ───────────── Transactions: list ───────────── +// ----- Fixed Plans: Enable Early Funding ----- +app.patch("/fixed-plans/:id/early-funding", mutationRateLimit, async (req, reply) => { + const userId = req.userId; + const params = z.object({ id: z.string().min(1) }).safeParse(req.params); + if (!params.success) { + return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues }); + } + const planId = params.data.id; + const Body = z.object({ + enableEarlyFunding: z.boolean(), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid request", issues: parsed.error.issues }); + } + + const plan = await app.prisma.fixedPlan.findFirst({ + where: { id: planId, userId }, + }); + + 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"; + + await app.prisma.fixedPlan.update({ + where: { id: planId }, + data: parsed.data.enableEarlyFunding + ? (() => { + let nextDue = plan.dueOn; + let frequency = plan.frequency; + if (!frequency && plan.paymentSchedule) { + const schedule = plan.paymentSchedule as any; + frequency = schedule.frequency; + } + if (frequency && frequency !== "one-time") { + nextDue = calculateNextDueDate(plan.dueOn, frequency as any, userTimezone); + } + return { + fundedCents: 0n, + currentFundedCents: 0n, + needsFundingThisPeriod: true, + cycleStart: getUserMidnight(userTimezone, new Date()), + dueOn: nextDue, + lastRollover: new Date(), + }; + })() + : { + needsFundingThisPeriod: false, + }, + }); + + return reply.send({ + ok: true, + planId, + needsFundingThisPeriod: parsed.data.enableEarlyFunding, + }); +}); + +// ----- Fixed Plans: Attempt Final Funding (called when payment modal opens) ----- +app.post("/fixed-plans/:id/attempt-final-funding", mutationRateLimit, async (req, reply) => { + const userId = req.userId; + const params = z.object({ id: z.string().min(1) }).safeParse(req.params); + if (!params.success) { + return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues }); + } + const planId = params.data.id; + + return await app.prisma.$transaction(async (tx) => { + const plan = await tx.fixedPlan.findFirst({ + where: { id: planId, userId }, + }); + + if (!plan) { + return reply.code(404).send({ message: "Plan not found" }); + } + + const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const totalAmount = Number(plan.totalCents ?? 0n); + const remainingNeeded = totalAmount - fundedAmount; + + // Already fully funded - no action needed + if (remainingNeeded <= 0) { + return { + ok: true, + planId, + status: "fully_funded", + fundedCents: fundedAmount, + totalCents: totalAmount, + isOverdue: false, + }; + } + + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + const availableBudget = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + + // Can we fully fund from available budget? + if (availableBudget >= remainingNeeded) { + const shareResult = computeWithdrawShares(categories, remainingNeeded); + if (!shareResult.ok) { + return reply.code(400).send({ message: "Insufficient category balances" }); + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(remainingNeeded), + }, + }); + + await tx.fixedPlan.update({ + where: { id: planId }, + data: { + currentFundedCents: BigInt(totalAmount), + fundedCents: BigInt(totalAmount), + }, + }); + + return { + ok: true, + planId, + status: "fully_funded", + fundedCents: totalAmount, + totalCents: totalAmount, + isOverdue: false, + message: `Topped off with $${(remainingNeeded / 100).toFixed(2)} from available budget`, + }; + } else if (availableBudget > 0) { + const shareResult = computeWithdrawShares(categories, availableBudget); + if (!shareResult.ok) { + return reply.code(400).send({ message: "Insufficient category balances" }); + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(availableBudget), + }, + }); + + const newFundedAmount = fundedAmount + availableBudget; + const overdueAmount = totalAmount - newFundedAmount; + + await tx.fixedPlan.update({ + where: { id: planId }, + data: { + currentFundedCents: BigInt(newFundedAmount), + fundedCents: BigInt(newFundedAmount), + isOverdue: true, + overdueAmount: BigInt(overdueAmount), + overdueSince: plan.overdueSince ?? new Date(), + needsFundingThisPeriod: true, + }, + }); + + return { + ok: true, + planId, + status: "overdue", + fundedCents: newFundedAmount, + totalCents: totalAmount, + isOverdue: true, + overdueAmount, + message: `Used all available budget ($${(availableBudget / 100).toFixed(2)}). Remaining $${(overdueAmount / 100).toFixed(2)} marked overdue.`, + }; + } else { + // No available budget - mark overdue with full remaining balance + await tx.fixedPlan.update({ + where: { id: planId }, + data: { + isOverdue: true, + overdueAmount: BigInt(remainingNeeded), + overdueSince: plan.overdueSince ?? new Date(), + needsFundingThisPeriod: true, + }, + }); + + return { + ok: true, + planId, + status: "overdue", + fundedCents: fundedAmount, + totalCents: totalAmount, + isOverdue: true, + overdueAmount: remainingNeeded, + message: `No available budget. $${(remainingNeeded / 100).toFixed(2)} marked overdue.`, + }; + } + }); +}); + +// ----- Fixed Plans: Mark as Overdue (Not Paid) ----- +app.patch("/fixed-plans/:id/mark-unpaid", mutationRateLimit, async (req, reply) => { + const userId = req.userId; + const params = z.object({ id: z.string().min(1) }).safeParse(req.params); + if (!params.success) { + return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues }); + } + const planId = params.data.id; + + const plan = await app.prisma.fixedPlan.findFirst({ + where: { id: planId, userId }, + }); + + if (!plan) { + return reply.code(404).send({ message: "Plan not found" }); + } + + const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const totalAmount = Number(plan.totalCents ?? 0n); + const remainingBalance = totalAmount - fundedAmount; + + await app.prisma.fixedPlan.update({ + where: { id: planId }, + data: { + isOverdue: true, + overdueAmount: BigInt(Math.max(0, remainingBalance)), + overdueSince: plan.overdueSince ?? new Date(), + needsFundingThisPeriod: true, // Will be prioritized in next income allocation + }, + }); + + return reply.send({ + ok: true, + planId, + isOverdue: true, + overdueAmount: Math.max(0, remainingBalance), + }); +}); + +// ----- Fixed Plans: Fund from available budget (all-or-nothing) ----- +app.post("/fixed-plans/:id/fund-from-available", mutationRateLimit, async (req, reply) => { + const userId = req.userId; + const params = z.object({ id: z.string().min(1) }).safeParse(req.params); + if (!params.success) { + return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues }); + } + const planId = params.data.id; + + return await app.prisma.$transaction(async (tx) => { + const plan = await tx.fixedPlan.findFirst({ + where: { id: planId, userId }, + }); + + if (!plan) { + return reply.code(404).send({ message: "Plan not found" }); + } + + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { incomeType: true, incomeFrequency: true, firstIncomeDate: true, timezone: true }, + }); + + const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const totalAmount = Number(plan.totalCents ?? 0n); + const remainingNeeded = Math.max(0, totalAmount - fundedAmount); + + if (remainingNeeded <= 0) { + return { + ok: true, + planId, + funded: true, + fundedAmountCents: 0, + fundedCents: fundedAmount, + totalCents: totalAmount, + message: "Already fully funded", + }; + } + + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + const availableBudget = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + + let amountToFund = remainingNeeded; + const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined; + if (user?.incomeType === "regular" && hasPaymentSchedule) { + const timezone = user?.timezone || "America/New_York"; + const now = new Date(); + const userNow = getUserMidnight(timezone, now); + const userDueDate = getUserMidnightFromDateOnly(timezone, plan.dueOn); + let cyclesLeft = 1; + if (user?.firstIncomeDate && user?.incomeFrequency) { + cyclesLeft = countPayPeriodsBetween( + userNow, + userDueDate, + user.firstIncomeDate, + user.incomeFrequency, + timezone + ); + } else if (user?.incomeFrequency) { + const freqDays = + user.incomeFrequency === "weekly" + ? 7 + : user.incomeFrequency === "biweekly" + ? 14 + : 30; + const daysUntilDue = Math.max( + 0, + Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS) + ); + cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays)); + } + amountToFund = Math.min(remainingNeeded, Math.ceil(remainingNeeded / cyclesLeft)); + } + + if (availableBudget < amountToFund) { + return { + ok: true, + planId, + funded: false, + fundedAmountCents: 0, + fundedCents: fundedAmount, + totalCents: totalAmount, + availableBudget, + message: "Insufficient available budget", + }; + } + + const shareResult = computeWithdrawShares(categories, amountToFund); + if (!shareResult.ok) { + return { + ok: true, + planId, + funded: false, + fundedAmountCents: 0, + fundedCents: fundedAmount, + totalCents: totalAmount, + availableBudget, + message: + shareResult.reason === "no_percent" + ? "No category percentages available" + : "Insufficient category balances", + }; + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(amountToFund), + }, + }); + + const newFunded = fundedAmount + amountToFund; + const stillNeedsFunding = newFunded < totalAmount; + await tx.fixedPlan.update({ + where: { id: planId }, + data: { + fundedCents: BigInt(newFunded), + currentFundedCents: BigInt(newFunded), + lastFundingDate: new Date(), + needsFundingThisPeriod: stillNeedsFunding, + }, + }); + + return { + ok: true, + planId, + funded: true, + fundedAmountCents: amountToFund, + fundedCents: newFunded, + totalCents: totalAmount, + availableBudget, + message: "Funded from available budget", + }; + }); +}); + +// ----- Fixed Plans: Catch up funding based on payment plan progress ----- +app.post("/fixed-plans/:id/catch-up-funding", mutationRateLimit, async (req, reply) => { + const userId = req.userId; + const params = z.object({ id: z.string().min(1) }).safeParse(req.params); + if (!params.success) { + return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues }); + } + const planId = params.data.id; + + return await app.prisma.$transaction(async (tx) => { + const plan = await tx.fixedPlan.findFirst({ + where: { id: planId, userId }, + }); + + if (!plan) { + return reply.code(404).send({ message: "Plan not found" }); + } + + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { incomeType: true, incomeFrequency: true, firstIncomeDate: true, timezone: true }, + }); + + const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined; + if (!hasPaymentSchedule || user?.incomeType !== "regular") { + return { + ok: true, + planId, + funded: false, + fundedAmountCents: 0, + fundedCents: Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n), + totalCents: Number(plan.totalCents ?? 0n), + message: "No payment plan to catch up", + }; + } + + const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const totalAmount = Number(plan.totalCents ?? 0n); + if (totalAmount <= 0) { + return { + ok: true, + planId, + funded: false, + fundedAmountCents: 0, + fundedCents: fundedAmount, + totalCents: totalAmount, + message: "No amount to fund", + }; + } + + const timezone = user?.timezone || "America/New_York"; + const now = new Date(); + let cycleStart = getUserMidnightFromDateOnly(timezone, plan.cycleStart); + const dueDate = getUserMidnightFromDateOnly(timezone, plan.dueOn); + const userNow = getUserMidnight(timezone, now); + + if (cycleStart >= dueDate || cycleStart > userNow) { + cycleStart = userNow; + } + + let totalPeriods = 1; + let elapsedPeriods = 1; + if (user?.firstIncomeDate && user?.incomeFrequency) { + totalPeriods = countPayPeriodsBetween( + cycleStart, + dueDate, + user.firstIncomeDate, + user.incomeFrequency, + timezone + ); + elapsedPeriods = countPayPeriodsBetween( + cycleStart, + userNow, + user.firstIncomeDate, + user.incomeFrequency, + timezone + ); + } + + totalPeriods = Math.max(1, totalPeriods); + elapsedPeriods = Math.max(1, Math.min(elapsedPeriods, totalPeriods)); + + const targetFunded = Math.min( + totalAmount, + Math.ceil((totalAmount * elapsedPeriods) / totalPeriods) + ); + const needed = Math.max(0, targetFunded - fundedAmount); + + if (needed === 0) { + return { + ok: true, + planId, + funded: false, + fundedAmountCents: 0, + fundedCents: fundedAmount, + totalCents: totalAmount, + message: "No catch-up needed", + }; + } + + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + const availableBudget = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + + if (availableBudget < needed) { + return { + ok: true, + planId, + funded: false, + fundedAmountCents: 0, + fundedCents: fundedAmount, + totalCents: totalAmount, + availableBudget, + message: "Insufficient available budget", + }; + } + + const shareResult = computeWithdrawShares(categories, needed); + if (!shareResult.ok) { + return { + ok: true, + planId, + funded: false, + fundedAmountCents: 0, + fundedCents: fundedAmount, + totalCents: totalAmount, + availableBudget, + message: + shareResult.reason === "no_percent" + ? "No category percentages available" + : "Insufficient category balances", + }; + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(needed), + }, + }); + + const newFunded = fundedAmount + needed; + await tx.fixedPlan.update({ + where: { id: planId }, + data: { + fundedCents: BigInt(newFunded), + currentFundedCents: BigInt(newFunded), + lastFundingDate: new Date(), + needsFundingThisPeriod: newFunded < totalAmount, + }, + }); + + return { + ok: true, + planId, + funded: true, + fundedAmountCents: needed, + fundedCents: newFunded, + totalCents: totalAmount, + availableBudget, + message: "Catch-up funded from available budget", + }; + }); +}); + +// ----- Transactions: list ----- app.get("/transactions", async (req, reply) => { const Query = z.object({ from: z.string().refine(isDate, "YYYY-MM-DD").optional(), @@ -259,55 +2119,306 @@ app.get("/transactions", async (req, reply) => { q: z.string().trim().optional(), page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(20), + bucketId: z.string().min(1).optional(), + categoryId: z.string().min(1).optional(), + sort: z.enum(["date", "amount", "kind", "bucket"]).optional(), + direction: z.enum(["asc", "desc"]).optional(), }); const parsed = Query.safeParse(req.query); if (!parsed.success) { - return reply.code(400).send({ message: "Invalid query", issues: parsed.error.issues }); + return reply + .code(400) + .send({ message: "Invalid query", issues: parsed.error.issues }); } - const { from, to, kind, q, page, limit } = parsed.data; + const { + from, + to, + kind, + q, + bucketId: rawBucketId, + categoryId, + sort = "date", + direction = "desc", + page, + limit, + } = parsed.data; + const bucketId = rawBucketId ?? categoryId; const userId = req.userId; + const userTimezone = + (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + "America/New_York"; - const where: any = { userId }; + const where: Record = { userId }; if (from || to) { - where.occurredAt = {}; - if (from) where.occurredAt.gte = isoStart(from); - if (to) where.occurredAt.lte = isoEnd(to); + where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, from, to); } - if (kind) where.kind = kind; - - // 💡 Only add OR if we actually have predicates - if (typeof q === "string" && q.trim() !== "") { - const ors: any[] = []; - const asNumber = Number(q); - if (Number.isFinite(asNumber)) { - ors.push({ amountCents: toBig(asNumber) }); - } - // (When you add text fields later, push them here too) - if (ors.length > 0) { - where.OR = ors; - } + if (kind) { + where.kind = kind; + } else { + where.kind = { in: ["variable_spend", "fixed_payment"] }; } - const skip = (page - 1) * limit; + const flexibleOr: any[] = []; + if (typeof q === "string" && q.trim() !== "") { + const qTrim = q.trim(); + const asCents = parseCurrencyToCents(qTrim); + if (asCents > 0) { + flexibleOr.push({ amountCents: toBig(asCents) }); + } + flexibleOr.push({ note: { contains: qTrim, mode: "insensitive" } }); + flexibleOr.push({ category: { name: { contains: qTrim, mode: "insensitive" } } }); + flexibleOr.push({ plan: { name: { contains: qTrim, mode: "insensitive" } } }); + } + if (bucketId) { + if (!kind || kind === "variable_spend") { + flexibleOr.push({ categoryId: bucketId }); + } + if (!kind || kind === "fixed_payment") { + flexibleOr.push({ planId: bucketId }); + } + } + if (flexibleOr.length > 0) { + const existing = Array.isArray((where as any).OR) ? (where as any).OR : []; + (where as any).OR = [...existing, ...flexibleOr]; + } - const [total, items] = await Promise.all([ - app.prisma.transaction.count({ where }), - app.prisma.transaction.findMany({ - where, - orderBy: { occurredAt: "desc" }, - skip, - take: limit, - select: { id: true, kind: true, amountCents: true, occurredAt: true }, - }), - ]); + const skip = (page - 1) * limit; + const orderDirection = direction === "asc" ? "asc" : "desc"; + const orderBy = + sort === "amount" + ? [ + { amountCents: orderDirection as Prisma.SortOrder }, + { occurredAt: "desc" as Prisma.SortOrder }, + ] + : sort === "kind" + ? [ + { kind: orderDirection as Prisma.SortOrder }, + { occurredAt: "desc" as Prisma.SortOrder }, + ] + : sort === "bucket" + ? [ + { category: { name: orderDirection as Prisma.SortOrder } }, + { plan: { name: orderDirection as Prisma.SortOrder } }, + { occurredAt: "desc" as Prisma.SortOrder }, + ] + : [{ occurredAt: orderDirection as Prisma.SortOrder }]; - return { items, page, limit, total }; + const txInclude = Prisma.validator()({ + category: { select: { name: true } }, + plan: { select: { name: true } }, + }); + type TxWithRelations = Prisma.TransactionGetPayload<{ + include: typeof txInclude; + }>; + + const [total, itemsRaw] = await Promise.all([ + app.prisma.transaction.count({ where }), + app.prisma.transaction.findMany({ + where, + orderBy, + skip, + take: limit, + include: txInclude, + }) as Promise, + ]); + + const items = itemsRaw.map((tx) => ({ + id: tx.id, + kind: tx.kind, + amountCents: tx.amountCents, + occurredAt: tx.occurredAt, + categoryId: tx.categoryId, + categoryName: + tx.category?.name ?? + (tx.kind === "variable_spend" && !tx.categoryId ? "Other" : null), + planId: tx.planId, + planName: tx.plan?.name ?? null, + note: tx.note ?? null, + receiptUrl: tx.receiptUrl ?? null, + isReconciled: !!tx.isReconciled, + isAutoPayment: !!tx.isAutoPayment, + })); + + return { items, page, limit, total }; }); -// ───────────── Variable Categories CRUD (sum=100 guard) ───────────── +app.patch("/transactions/:id", mutationRateLimit, async (req, reply) => { + const Params = z.object({ id: z.string().min(1) }); + const Body = z.object({ + note: z + .string() + .trim() + .max(500) + .or(z.literal("")) + .optional(), + receiptUrl: z + .string() + .trim() + .max(2048) + .url() + .or(z.literal("")) + .optional(), + isReconciled: z.boolean().optional(), + }); + const params = Params.safeParse(req.params); + const parsed = Body.safeParse(req.body); + if (!params.success || !parsed.success) { + return reply.code(400).send({ message: "Invalid payload" }); + } + const userId = req.userId; + const id = params.data.id; + + if ( + parsed.data.note === undefined && + parsed.data.receiptUrl === undefined && + parsed.data.isReconciled === undefined + ) { + return reply.code(400).send({ message: "No fields to update" }); + } + + const existing = await app.prisma.transaction.findFirst({ where: { id, userId } }); + if (!existing) return reply.code(404).send({ message: "Transaction not found" }); + + const data: Prisma.TransactionUpdateInput = {}; + if (parsed.data.note !== undefined) { + const value = parsed.data.note.trim(); + data.note = value.length > 0 ? value : null; + } + if (parsed.data.receiptUrl !== undefined) { + const url = parsed.data.receiptUrl.trim(); + data.receiptUrl = url.length > 0 ? url : null; + } + if (parsed.data.isReconciled !== undefined) { + data.isReconciled = parsed.data.isReconciled; + } + + const updated = await app.prisma.transaction.updateMany({ + where: { id, userId }, + data, + }); + if (updated.count === 0) return reply.code(404).send({ message: "Transaction not found" }); + + const refreshed = await app.prisma.transaction.findFirst({ + where: { id, userId }, + select: { + id: true, + note: true, + receiptUrl: true, + isReconciled: true, + }, + }); + + return refreshed; +}); + +app.delete("/transactions/:id", mutationRateLimit, async (req, reply) => { + const Params = z.object({ id: z.string().min(1) }); + const params = Params.safeParse(req.params); + if (!params.success) { + return reply.code(400).send({ message: "Invalid transaction id" }); + } + + const userId = req.userId; + const id = params.data.id; + + return await app.prisma.$transaction(async (tx) => { + const existing = await tx.transaction.findFirst({ + where: { id, userId }, + }); + if (!existing) return reply.code(404).send({ message: "Transaction not found" }); + + const amountCents = Number(existing.amountCents ?? 0n); + if (existing.kind === "variable_spend") { + if (!existing.categoryId) { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + const shareResult = computeDepositShares(categories, amountCents); + if (!shareResult.ok) { + return reply.code(400).send({ message: "No category percentages available." }); + } + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { increment: BigInt(s.share) } }, + }); + } + } else { + const updated = await tx.variableCategory.updateMany({ + where: { id: existing.categoryId, userId }, + data: { balanceCents: { increment: BigInt(amountCents) } }, + }); + if (updated.count === 0) { + return reply.code(404).send({ message: "Category not found" }); + } + } + } else if (existing.kind === "fixed_payment") { + if (!existing.planId) { + return reply.code(400).send({ message: "Transaction missing planId" }); + } + const plan = await tx.fixedPlan.findFirst({ + where: { id: existing.planId, userId }, + }); + if (!plan) { + return reply.code(404).send({ message: "Fixed plan not found" }); + } + + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + const shareResult = computeDepositShares(categories, amountCents); + if (!shareResult.ok) { + return reply.code(400).send({ message: "No category percentages available." }); + } + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { increment: BigInt(s.share) } }, + }); + } + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: existing.planId, + amountCents: BigInt(-amountCents), + incomeId: null, + }, + }); + + const fundedBefore = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const total = Number(plan.totalCents ?? 0n); + const newFunded = Math.max(0, fundedBefore - amountCents); + const updatedPlan = await tx.fixedPlan.updateMany({ + where: { id: plan.id, userId }, + data: { + fundedCents: BigInt(newFunded), + currentFundedCents: BigInt(newFunded), + needsFundingThisPeriod: newFunded < total, + }, + }); + if (updatedPlan.count === 0) { + return reply.code(404).send({ message: "Fixed plan not found" }); + } + } + + const deleted = await tx.transaction.deleteMany({ where: { id, userId } }); + if (deleted.count === 0) return reply.code(404).send({ message: "Transaction not found" }); + + return { ok: true, id }; + }); +}); + +// ----- Variable categories ----- const CatBody = z.object({ name: z.string().trim().min(1), percent: z.number().int().min(0).max(100), @@ -315,52 +2426,168 @@ const CatBody = z.object({ priority: z.number().int().min(0), }); -app.post("/variable-categories", async (req, reply) => { +async function assertPercentTotal( + tx: PrismaClient | Prisma.TransactionClient, + userId: string +) { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { percent: true, isSavings: true }, + }); + const sum = categories.reduce((total, cat) => total + (cat.percent ?? 0), 0); + const savingsSum = categories.reduce( + (total, cat) => total + (cat.isSavings ? cat.percent ?? 0 : 0), + 0 + ); + + // Allow partial states during onboarding (< 100%), but enforce exact 100% when sum would be >= 100% + if (sum > 100) { + const err: any = new Error("Percents must sum to 100"); + err.statusCode = 400; + err.code = "PERCENT_TOTAL_OVER_100"; + throw err; + } + if (sum >= 100 && savingsSum < 20) { + const err: any = new Error( + `Savings must total at least 20% (currently ${savingsSum}%)` + ); + err.statusCode = 400; + err.code = "SAVINGS_MINIMUM"; + throw err; + } +} + +app.post("/variable-categories", mutationRateLimit, async (req, reply) => { const parsed = CatBody.safeParse(req.body); - if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" }); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid payload" }); + } const userId = req.userId; + const userTimezone = + (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + "America/New_York"; + const normalizedName = parsed.data.name.trim().toLowerCase(); return await app.prisma.$transaction(async (tx) => { - await tx.variableCategory.create({ data: { userId, balanceCents: 0n, ...parsed.data } }); - const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } }); - if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" }); - return { ok: true }; + try { + const created = await tx.variableCategory.create({ + data: { userId, balanceCents: 0n, ...parsed.data, name: normalizedName }, + select: { id: true }, + }); + + await assertPercentTotal(tx, userId); + return reply.status(201).send(created); + } catch (error: any) { + if (error.code === 'P2002') { + return reply.status(400).send({ error: 'DUPLICATE_NAME', message: `Category name '${parsed.data.name}' already exists` }); + } + throw error; + } }); }); -app.patch("/variable-categories/:id", async (req, reply) => { +app.patch("/variable-categories/:id", mutationRateLimit, async (req, reply) => { const patch = CatBody.partial().safeParse(req.body); - if (!patch.success) return reply.code(400).send({ message: "Invalid payload" }); + if (!patch.success) { + return reply.code(400).send({ message: "Invalid payload" }); + } const id = String((req.params as any).id); const userId = req.userId; + const userTimezone = + (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + "America/New_York"; + const updateData = { + ...patch.data, + ...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}), + }; return await app.prisma.$transaction(async (tx) => { - const exists = await tx.variableCategory.findFirst({ where: { id, userId } }); + const exists = await tx.variableCategory.findFirst({ + where: { id, userId }, + }); if (!exists) return reply.code(404).send({ message: "Not found" }); - await tx.variableCategory.update({ where: { id }, data: patch.data }); - const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } }); - if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" }); + const updated = await tx.variableCategory.updateMany({ + where: { id, userId }, + data: updateData, + }); + if (updated.count === 0) return reply.code(404).send({ message: "Not found" }); + + await assertPercentTotal(tx, userId); return { ok: true }; }); }); -app.delete("/variable-categories/:id", async (req, reply) => { +app.delete("/variable-categories/:id", mutationRateLimit, async (req, reply) => { const id = String((req.params as any).id); const userId = req.userId; - return await app.prisma.$transaction(async (tx) => { - const exists = await tx.variableCategory.findFirst({ where: { id, userId } }); - if (!exists) return reply.code(404).send({ message: "Not found" }); - - await tx.variableCategory.delete({ where: { id } }); - const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } }); - if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" }); - return { ok: true }; + const exists = await app.prisma.variableCategory.findFirst({ + where: { id, userId }, }); + if (!exists) return reply.code(404).send({ message: "Not found" }); + + const deleted = await app.prisma.variableCategory.deleteMany({ + where: { id, userId }, + }); + if (deleted.count === 0) return reply.code(404).send({ message: "Not found" }); + await assertPercentTotal(app.prisma, userId); + return { ok: true }; }); -// ───────────── Fixed Plans CRUD (funded ≤ total) ───────────── +app.post("/variable-categories/rebalance", mutationRateLimit, async (req, reply) => { + const userId = req.userId; + const categories = await app.prisma.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + if (categories.length === 0) { + return { ok: true, applied: false }; + } + + const hasNegative = categories.some( + (c) => Number(c.balanceCents ?? 0n) < 0 + ); + if (hasNegative) { + return reply.code(400).send({ + ok: false, + code: "NEGATIVE_BALANCE", + message: "Cannot rebalance while a category has a negative balance.", + }); + } + + const totalBalance = categories.reduce( + (sum, c) => sum + Number(c.balanceCents ?? 0n), + 0 + ); + if (totalBalance <= 0) { + return { ok: true, applied: false }; + } + + const shareResult = computeDepositShares(categories, totalBalance); + if (!shareResult.ok) { + return reply.code(400).send({ + ok: false, + code: "NO_PERCENT", + message: "No percent totals available to rebalance.", + }); + } + + await app.prisma.$transaction( + shareResult.shares.map((s) => + app.prisma.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: BigInt(s.share) }, + }) + ) + ); + + return { ok: true, applied: true, totalBalance }; +}); + +// ----- Fixed plans ----- const PlanBody = z.object({ name: z.string().trim().min(1), totalCents: z.number().int().min(0), @@ -368,144 +2595,933 @@ const PlanBody = z.object({ priority: z.number().int().min(0), dueOn: z.string().datetime(), cycleStart: z.string().datetime().optional(), + frequency: z.enum(["one-time", "weekly", "biweekly", "monthly"]).optional(), + autoPayEnabled: z.boolean().optional(), + paymentSchedule: z + .object({ + frequency: z.enum(["daily", "weekly", "biweekly", "monthly", "custom"]), + dayOfMonth: z.number().int().min(1).max(31).optional(), + dayOfWeek: z.number().int().min(0).max(6).optional(), + everyNDays: z.number().int().min(1).max(365).optional(), + minFundingPercent: z.number().min(0).max(100).default(100), + }) + .partial({ dayOfMonth: true, dayOfWeek: true, everyNDays: true }) + .optional(), + nextPaymentDate: z.string().datetime().optional(), + maxRetryAttempts: z.number().int().min(0).max(10).optional(), }); -app.post("/fixed-plans", async (req, reply) => { +app.post("/fixed-plans", mutationRateLimit, async (req, reply) => { const parsed = PlanBody.safeParse(req.body); - if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" }); + if (!parsed.success) { + 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 totalBig = toBig(parsed.data.totalCents); const fundedBig = toBig(parsed.data.fundedCents ?? 0); - if (fundedBig > totalBig) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" }); - - await app.prisma.fixedPlan.create({ - data: { - userId, - name: parsed.data.name, - totalCents: totalBig, - fundedCents: fundedBig, - priority: parsed.data.priority, - dueOn: new Date(parsed.data.dueOn), - cycleStart: new Date(parsed.data.cycleStart ?? parsed.data.dueOn), - fundingMode: "auto-on-deposit", - }, - }); - return { ok: true }; + if (fundedBig > totalBig) { + return reply + .code(400) + .send({ message: "fundedCents cannot exceed totalCents" }); + } + const autoPayEnabled = !!parsed.data.autoPayEnabled && !!parsed.data.paymentSchedule; + const paymentSchedule = parsed.data.paymentSchedule + ? { ...parsed.data.paymentSchedule, minFundingPercent: parsed.data.paymentSchedule.minFundingPercent ?? 100 } + : null; + const nextPaymentDate = + parsed.data.nextPaymentDate && autoPayEnabled + ? getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.nextPaymentDate)) + : autoPayEnabled && parsed.data.dueOn + ? getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn)) + : null; + // Extract frequency from explicit field or paymentSchedule + let frequency = parsed.data.frequency; + if (!frequency && paymentSchedule?.frequency) { + // Map paymentSchedule frequency to plan frequency + const scheduleFreq = paymentSchedule.frequency; + if (scheduleFreq === "monthly" || scheduleFreq === "weekly" || scheduleFreq === "biweekly") { + frequency = scheduleFreq; + } + } + + try { + const created = await app.prisma.fixedPlan.create({ + data: { + userId, + name: parsed.data.name, + totalCents: totalBig, + fundedCents: fundedBig, + currentFundedCents: fundedBig, + priority: parsed.data.priority, + dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn)), + cycleStart: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.cycleStart ?? parsed.data.dueOn)), + frequency: frequency || null, + fundingMode: "auto-on-deposit", + autoPayEnabled, + paymentSchedule: paymentSchedule ?? Prisma.DbNull, + nextPaymentDate: autoPayEnabled ? nextPaymentDate : null, + maxRetryAttempts: parsed.data.maxRetryAttempts ?? 3, + lastFundingDate: fundedBig > 0n ? new Date() : null, + }, + select: { id: true }, + }); + return reply.code(201).send(created); + } catch (error: any) { + if (error.code === 'P2002') { + return reply.code(400).send({ error: 'DUPLICATE_NAME', message: `Fixed plan name '${parsed.data.name}' already exists` }); + } + throw error; + } }); -app.patch("/fixed-plans/:id", async (req, reply) => { +app.patch("/fixed-plans/:id", mutationRateLimit, async (req, reply) => { const patch = PlanBody.partial().safeParse(req.body); - if (!patch.success) return reply.code(400).send({ message: "Invalid payload" }); + if (!patch.success) { + return reply.code(400).send({ message: "Invalid payload" }); + } 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 plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } }); + const plan = await app.prisma.fixedPlan.findFirst({ + where: { id, userId }, + }); if (!plan) return reply.code(404).send({ message: "Not found" }); - const total = "totalCents" in patch.data ? toBig(patch.data.totalCents as number) : (plan.totalCents ?? 0n); - const funded = "fundedCents" in patch.data ? toBig(patch.data.fundedCents as number) : (plan.fundedCents ?? 0n); - if (funded > total) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" }); + const total = + "totalCents" in patch.data + ? toBig(patch.data.totalCents as number) + : plan.totalCents ?? 0n; + const funded = + "fundedCents" in patch.data + ? toBig(patch.data.fundedCents as number) + : plan.fundedCents ?? 0n; + if (funded > total) { + return reply + .code(400) + .send({ message: "fundedCents cannot exceed totalCents" }); + } - await app.prisma.fixedPlan.update({ - where: { id }, + const hasScheduleInPatch = "paymentSchedule" in patch.data; + const paymentSchedule = + hasScheduleInPatch && patch.data.paymentSchedule + ? { ...patch.data.paymentSchedule, minFundingPercent: patch.data.paymentSchedule.minFundingPercent ?? 100 } + : hasScheduleInPatch + ? null + : undefined; + const autoPayEnabled = + "autoPayEnabled" in patch.data + ? !!patch.data.autoPayEnabled && paymentSchedule !== null && (paymentSchedule !== undefined ? true : !!plan.paymentSchedule) + : paymentSchedule === null + ? false + : plan.autoPayEnabled; + const nextPaymentDate = + "nextPaymentDate" in patch.data + ? patch.data.nextPaymentDate + ? getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.nextPaymentDate)) + : null + : undefined; + + const updated = await app.prisma.fixedPlan.updateMany({ + where: { id, userId }, data: { ...patch.data, - ...(patch.data.totalCents !== undefined ? { totalCents: total } : {}), - ...(patch.data.fundedCents !== undefined ? { fundedCents: funded } : {}), - ...(patch.data.dueOn ? { dueOn: new Date(patch.data.dueOn) } : {}), - ...(patch.data.cycleStart ? { cycleStart: new Date(patch.data.cycleStart) } : {}), + ...(patch.data.totalCents !== undefined ? { totalCents: total } : {}), + ...(patch.data.fundedCents !== undefined + ? { fundedCents: funded, currentFundedCents: funded, lastFundingDate: new Date() } + : {}), + ...(patch.data.dueOn ? { dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.dueOn)) } : {}), + ...(patch.data.cycleStart + ? { cycleStart: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.cycleStart)) } + : {}), + ...(paymentSchedule !== undefined + ? { paymentSchedule: paymentSchedule ?? Prisma.DbNull } + : {}), + ...(autoPayEnabled !== undefined ? { autoPayEnabled } : {}), + ...(nextPaymentDate !== undefined + ? { nextPaymentDate: autoPayEnabled ? nextPaymentDate : null } + : {}), + ...(patch.data.maxRetryAttempts !== undefined + ? { maxRetryAttempts: patch.data.maxRetryAttempts } + : {}), }, }); + if (updated.count === 0) return reply.code(404).send({ message: "Not found" }); return { ok: true }; }); -app.delete("/fixed-plans/:id", async (req, reply) => { +app.delete("/fixed-plans/:id", mutationRateLimit, async (req, reply) => { const id = String((req.params as any).id); const userId = req.userId; - const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } }); + const plan = await app.prisma.fixedPlan.findFirst({ + where: { id, userId }, + select: { id: true, fundedCents: true, currentFundedCents: true }, + }); if (!plan) return reply.code(404).send({ message: "Not found" }); - await app.prisma.fixedPlan.delete({ where: { id } }); - return { ok: true }; + + return await app.prisma.$transaction(async (tx) => { + + const refundCents = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + if (refundCents > 0) { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + const shareResult = computeDepositShares(categories, refundCents); + if (shareResult.ok) { + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { increment: BigInt(s.share) } }, + }); + } + } + + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: plan.id, + amountCents: BigInt(-refundCents), + incomeId: null, + }, + }); + } + + await tx.fixedPlan.deleteMany({ where: { id, userId } }); + return { ok: true, refundedCents: refundCents }; + }); }); -// ───────────── Income Preview (server-side; mirrors FE preview) ───────────── -app.post("/income/preview", async (req, reply) => { - const Body = z.object({ amountCents: z.number().int().nonnegative() }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" }); +// ----- Fixed plans: due list ----- +app.get("/fixed-plans/due", async (req, reply) => { + const Query = z.object({ + asOf: z.string().datetime().optional(), + daysAhead: z.coerce.number().int().min(0).max(60).default(0), + }); + const parsed = Query.safeParse(req.query); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid query" }); + } const userId = req.userId; - let remaining = Math.max(0, parsed.data.amountCents | 0); + const now = new Date(); + const asOfDate = parsed.data.asOf ? new Date(parsed.data.asOf) : now; + const user = await app.prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + const userTimezone = user?.timezone || "America/New_York"; + const todayUser = getUserMidnight(userTimezone, asOfDate); + const cutoff = new Date(todayUser.getTime() + parsed.data.daysAhead * DAY_MS); - const [plans, cats] = await Promise.all([ - app.prisma.fixedPlan.findMany({ - where: { userId }, orderBy: [{ priority: "asc" }, { dueOn: "asc" }], - select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true }, + const plans = await app.prisma.fixedPlan.findMany({ + where: { userId }, + orderBy: [{ priority: "asc" }, { dueOn: "asc" }], + select: { + id: true, + name: true, + totalCents: true, + fundedCents: true, + currentFundedCents: true, + dueOn: true, + priority: true, + }, + }); + + const items = plans + .map((p) => { + const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0n); + const total = Number(p.totalCents ?? 0n); + const remaining = Math.max(0, total - funded); + const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0; + const dueDate = new Date(p.dueOn); + const dueUser = getUserMidnightFromDateOnly(userTimezone, dueDate); + return { + id: p.id, + name: p.name, + dueOn: dueUser.toISOString(), + remainingCents: remaining, + percentFunded, + isDue: dueUser.getTime() <= todayUser.getTime(), + isOverdue: dueUser.getTime() < todayUser.getTime(), + }; + }) + // Include all items due by cutoff, even if fully funded (remaining 0). + .filter((p) => { + const dueDate = new Date(p.dueOn); + return ( + getUserMidnightFromDateOnly(userTimezone, dueDate).getTime() <= + cutoff.getTime() + ); + }); + + return { items, asOfISO: cutoff.toISOString() }; +}); + +// ----- Fixed plans: pay now wrapper ----- +app.post("/fixed-plans/:id/pay-now", mutationRateLimit, async (req, reply) => { + const Params = z.object({ id: z.string().min(1) }); + const Body = z.object({ + occurredAtISO: z.string().datetime().optional(), + overrideDueOnISO: z.string().datetime().optional(), + fundingSource: z.enum(["funded", "savings", "deficit"]).optional(), + savingsCategoryId: z.string().optional(), + note: z.string().trim().max(500).optional(), + }); + const params = Params.safeParse(req.params); + const parsed = Body.safeParse(req.body); + if (!params.success || !parsed.success) { + return reply.code(400).send({ message: "Invalid payload" }); + } + + const userId = req.userId; + const id = params.data.id; + const { occurredAtISO, overrideDueOnISO, fundingSource, savingsCategoryId, note } = parsed.data; + + return await app.prisma.$transaction(async (tx) => { + const plan = await tx.fixedPlan.findFirst({ + where: { id, userId }, + select: { + id: true, + name: true, + totalCents: true, + fundedCents: true, + currentFundedCents: true, + dueOn: true, + frequency: true, + autoPayEnabled: true, + nextPaymentDate: true, + paymentSchedule: true, + }, + }); + if (!plan) { + const err: any = new Error("Plan not found"); + err.statusCode = 404; + throw err; + } + + const total = Number(plan.totalCents ?? 0n); + const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const shortage = Math.max(0, total - funded); + const isOneTime = !plan.frequency || plan.frequency === "one-time"; + + let savingsUsed = false; + let deficitCovered = false; + + // Decide funding source automatically if fully funded + const source = funded >= total ? (fundingSource ?? "funded") : fundingSource; + + if (shortage > 0) { + if (!source) { + const err: any = new Error("Insufficient funds: specify fundingSource (savings or deficit)"); + err.statusCode = 400; + err.code = "INSUFFICIENT_FUNDS"; + throw err; + } + if (source === "savings") { + if (!savingsCategoryId) { + const err: any = new Error("savingsCategoryId required when fundingSource is savings"); + err.statusCode = 400; + err.code = "SAVINGS_CATEGORY_REQUIRED"; + throw err; + } + const cat = await tx.variableCategory.findFirst({ + where: { id: savingsCategoryId, userId }, + select: { id: true, name: true, isSavings: true, balanceCents: true }, + }); + if (!cat) { + const err: any = new Error("Savings category not found"); + err.statusCode = 404; + err.code = "SAVINGS_NOT_FOUND"; + throw err; + } + if (!cat.isSavings) { + const err: any = new Error("Selected category is not savings"); + err.statusCode = 400; + err.code = "NOT_SAVINGS_CATEGORY"; + throw err; + } + const bal = Number(cat.balanceCents ?? 0n); + if (shortage > bal) { + const err: any = new Error("Savings balance insufficient to cover shortage"); + err.statusCode = 400; + err.code = "OVERDRAFT_SAVINGS"; + throw err; + } + // Deduct from savings balance + await tx.variableCategory.update({ + where: { id: cat.id }, + data: { balanceCents: toBig(bal - shortage) }, + }); + // Record a variable_spend transaction to reflect covering shortage + await tx.transaction.create({ + data: { + userId, + occurredAt: occurredAtISO ? new Date(occurredAtISO) : new Date(), + kind: "variable_spend", + amountCents: toBig(shortage), + categoryId: cat.id, + planId: null, + note: `Covered shortage for ${plan.name}`, + receiptUrl: null, + isReconciled: false, + }, + }); + savingsUsed = true; + } else if (source === "deficit") { + // Allow proceeding without additional funding. Tracking of deficit can be expanded later. + deficitCovered = true; + } + } + + // Fetch user to check incomeType for conditional logic + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { incomeType: true, timezone: true }, + }); + if (!user) { + const err: any = new Error("User not found"); + err.statusCode = 404; + throw err; + } + const userTimezone = user.timezone ?? "America/New_York"; + + // Update plan: reset funded to 0 and set new due date + const updateData: any = { + fundedCents: 0n, + currentFundedCents: 0n, + }; + + // For REGULAR users with payment plans, resume funding after payment + const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined; + if (user.incomeType === "regular" && hasPaymentSchedule) { + updateData.needsFundingThisPeriod = true; + } + + let nextDue = plan.dueOn; + if (overrideDueOnISO) { + nextDue = getUserMidnightFromDateOnly(userTimezone, new Date(overrideDueOnISO)); + updateData.dueOn = nextDue; + } else { + // Try plan.frequency first, fallback to paymentSchedule.frequency + let frequency = plan.frequency; + if (!frequency && plan.paymentSchedule) { + const schedule = plan.paymentSchedule as any; + frequency = schedule.frequency; + } + + if (frequency && frequency !== "one-time") { + nextDue = calculateNextDueDate(plan.dueOn, frequency as any, userTimezone); + updateData.dueOn = nextDue; + } + } + if (plan.autoPayEnabled) { + updateData.nextPaymentDate = nextDue; + } + + const updatedPlan = isOneTime + ? null + : await tx.fixedPlan.update({ + where: { id: plan.id }, + data: updateData, + select: { id: true, dueOn: true }, + }); + + // Create the fixed payment transaction for full bill amount + const paymentTx = await tx.transaction.create({ + data: { + userId, + occurredAt: occurredAtISO ? new Date(occurredAtISO) : new Date(), + kind: "fixed_payment", + amountCents: toBig(total), + categoryId: null, + planId: plan.id, + note: note?.trim() ? note.trim() : null, + receiptUrl: null, + isReconciled: false, + }, + select: { id: true, occurredAt: true }, + }); + + if (isOneTime) { + await tx.fixedPlan.deleteMany({ where: { id: plan.id, userId } }); + } + + return { + ok: true, + planId: plan.id, + transactionId: paymentTx.id, + nextDueOn: updatedPlan?.dueOn?.toISOString() ?? null, + savingsUsed, + deficitCovered, + shortageCents: shortage, + }; + }); +}); + +app.get("/income/history", async (req) => { + const userId = req.userId; + const events = await app.prisma.incomeEvent.findMany({ + where: { userId }, + orderBy: { postedAt: "desc" }, + take: 5, + select: { id: true, postedAt: true, amountCents: true }, + }); + if (events.length === 0) return []; + const allocations = await app.prisma.allocation.findMany({ + where: { userId, incomeId: { in: events.map((e) => e.id) } }, + select: { incomeId: true, kind: true, amountCents: true }, + }); + const sums = new Map< + string, + { fixed: number; variable: number } + >(); + for (const alloc of allocations) { + if (!alloc.incomeId) continue; + const entry = sums.get(alloc.incomeId) ?? { fixed: 0, variable: 0 }; + const value = Number(alloc.amountCents ?? 0n); + if (alloc.kind === "fixed") entry.fixed += value; + else entry.variable += value; + sums.set(alloc.incomeId, entry); + } + return events.map((event) => { + const totals = sums.get(event.id) ?? { fixed: 0, variable: 0 }; + return { + id: event.id, + postedAt: event.postedAt, + amountCents: Number(event.amountCents ?? 0n), + fixedTotal: totals.fixed, + variableTotal: totals.variable, + }; + }); +}); + +// ----- Income preview ----- +app.post("/income/preview", async (req, reply) => { + const Body = z.object({ + amountCents: z.number().int().nonnegative(), + occurredAtISO: z.string().datetime().optional(), + isScheduledIncome: z.boolean().optional(), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid amount" }); + } + + const userId = req.userId; + const result = await previewAllocation( + app.prisma, + userId, + parsed.data.amountCents, + parsed.data.occurredAtISO, + parsed.data.isScheduledIncome ?? false + ); + + return result; +}); + +// ----- Payday Management ----- +app.get("/payday/status", async (req, reply) => { + const userId = req.userId; + const Query = z.object({ + debugNow: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + }); + const query = Query.safeParse(req.query); + + logDebug(app, "Payday status check started", { userId }); + + const [user, paymentPlansCount] = await Promise.all([ + app.prisma.user.findUnique({ + where: { id: userId }, + select: { + incomeType: true, + incomeFrequency: true, + firstIncomeDate: true, + pendingScheduledIncome: true, + timezone: true, + }, }), - app.prisma.variableCategory.findMany({ - where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }], - select: { id: true, name: true, percent: true, isSavings: true, priority: true }, + app.prisma.fixedPlan.count({ + where: { + userId, + paymentSchedule: { not: Prisma.DbNull }, + }, }), ]); - // Fixed pass - const fixed: Array<{ id: string; name: string; amountCents: number }> = []; - for (const p of plans) { - if (remaining <= 0) break; - const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n); - const need = Number(needBig > 0n ? needBig : 0n); - if (need <= 0) continue; - const give = Math.min(need, remaining); - fixed.push({ id: p.id, name: p.name, amountCents: give }); - remaining -= give; + if (!user) { + if (!isProd) { + app.log.warn({ userId }, "User not found"); + } + return reply.code(404).send({ message: "User not found" }); } - // Variable pass (largest remainder w/ savings-first tie) - const variable: Array<{ id: string; name: string; amountCents: number }> = []; - if (remaining > 0 && cats.length > 0) { - const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0); - const normCats = totalPercent === 100 - ? cats - : cats.map(c => ({ ...c, percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0 })); + logDebug(app, "Payday user data retrieved", { + userId, + incomeType: user.incomeType, + incomeFrequency: user.incomeFrequency, + firstIncomeDate: user.firstIncomeDate?.toISOString(), + pendingScheduledIncome: user.pendingScheduledIncome, + paymentPlansCount, + }); - const base: number[] = new Array(normCats.length).fill(0); - const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = []; - let sumBase = 0; - - normCats.forEach((c, idx) => { - const exact = (remaining * c.percent) / 100; - const floor = Math.floor(exact); - base[idx] = floor; sumBase += floor; - tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name }); + // Only relevant for regular income users with payment plans + const hasPaymentPlans = paymentPlansCount > 0; + const isRegularUser = user.incomeType === "regular"; + + if (!isRegularUser || !hasPaymentPlans || !user.firstIncomeDate) { + logDebug(app, "Payday check skipped - not applicable", { + userId, + isRegularUser, + hasPaymentPlans, + hasFirstIncomeDate: !!user.firstIncomeDate, }); - - let leftovers = remaining - sumBase; - tie.sort((a, b) => { - if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1; - if (a.remainder !== b.remainder) return b.remainder - a.remainder; - if (a.priority !== b.priority) return a.priority - b.priority; - return a.name.localeCompare(b.name); - }); - for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1; - - normCats.forEach((c, idx) => { - const give = base[idx] || 0; - if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give }); - }); - - remaining = leftovers; + return { + shouldShowOverlay: false, + pendingScheduledIncome: false, + nextPayday: null, + }; } - return { fixed, variable, unallocatedCents: Math.max(0, remaining) }; + // Calculate next expected payday using the imported function with user's timezone + const { calculateNextPayday, isWithinPaydayWindow } = await import("./allocator.js"); + const userTimezone = user.timezone || "America/New_York"; + const debugNow = query.success ? query.data.debugNow : undefined; + const now = debugNow + ? fromZonedTime(new Date(`${debugNow}T00:00:00`), userTimezone) + : new Date(); + const nextPayday = calculateNextPayday(user.firstIncomeDate, user.incomeFrequency, now, userTimezone); + const isPayday = isWithinPaydayWindow(now, nextPayday, 0, userTimezone); + const dayStart = getUserMidnight(userTimezone, now); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000 - 1); + const scheduledIncomeToday = await app.prisma.incomeEvent.findFirst({ + where: { + userId, + isScheduledIncome: true, + postedAt: { + gte: dayStart, + lte: dayEnd, + }, + }, + select: { id: true }, + }); + + logDebug(app, "Payday calculation complete", { + userId, + now: now.toISOString(), + firstIncomeDate: user.firstIncomeDate.toISOString(), + nextPayday: nextPayday.toISOString(), + isPayday, + pendingScheduledIncome: user.pendingScheduledIncome, + scheduledIncomeToday: !!scheduledIncomeToday, + shouldShowOverlay: isPayday && !scheduledIncomeToday, + }); + + return { + shouldShowOverlay: isPayday && !scheduledIncomeToday, + pendingScheduledIncome: !scheduledIncomeToday, + nextPayday: nextPayday.toISOString(), + }; }); -// ───────────── Start ───────────── +app.post("/payday/dismiss", mutationRateLimit, async (req, reply) => { + return { ok: true }; +}); + +// ----- Budget allocation (for irregular income) ----- +const BudgetBody = z.object({ + newIncomeCents: z.number().int().nonnegative(), + fixedExpensePercentage: z.number().min(0).max(100).default(30), + postedAtISO: z.string().datetime().optional(), +}); + +app.post("/budget/allocate", mutationRateLimit, async (req, reply) => { + const parsed = BudgetBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid budget data" }); + } + + const userId = req.userId; + + try { + const result = await allocateBudget( + app.prisma, + userId, + parsed.data.newIncomeCents, + parsed.data.fixedExpensePercentage, + parsed.data.postedAtISO + ); + return result; + } catch (error: any) { + app.log.error( + { error, userId, body: isProd ? undefined : parsed.data }, + "Budget allocation failed" + ); + return reply.code(500).send({ message: "Budget allocation failed" }); + } +}); + +// Endpoint for irregular income onboarding - actually funds accounts +app.post("/budget/fund", mutationRateLimit, async (req, reply) => { + const parsed = BudgetBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid budget data" }); + } + + const userId = req.userId; + const incomeId = `onboarding-${userId}-${Date.now()}`; + + try { + const result = await applyIrregularIncome( + app.prisma, + userId, + parsed.data.newIncomeCents, + parsed.data.fixedExpensePercentage, + parsed.data.postedAtISO || new Date().toISOString(), + incomeId, + "Initial budget setup" + ); + return result; + } catch (error: any) { + app.log.error( + { error, userId, body: isProd ? undefined : parsed.data }, + "Budget funding failed" + ); + return reply.code(500).send({ message: "Budget funding failed" }); + } +}); + +const ReconcileBody = z.object({ + bankTotalCents: z.number().int().nonnegative(), +}); + +app.post("/budget/reconcile", mutationRateLimit, async (req, reply) => { + const parsed = ReconcileBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid reconciliation data" }); + } + + const userId = req.userId; + const desiredTotal = parsed.data.bankTotalCents; + + return await app.prisma.$transaction(async (tx) => { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + }); + if (categories.length === 0) { + return reply.code(400).send({ + ok: false, + code: "NO_CATEGORIES", + message: "Create at least one expense category before reconciling.", + }); + } + + const plans = await tx.fixedPlan.findMany({ + where: { userId }, + select: { fundedCents: true, currentFundedCents: true }, + }); + const fixedFundedCents = plans.reduce( + (sum, plan) => + sum + Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n), + 0 + ); + const variableTotal = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + const currentTotal = variableTotal + fixedFundedCents; + const delta = desiredTotal - currentTotal; + + if (delta === 0) { + return { + ok: true, + deltaCents: 0, + currentTotalCents: currentTotal, + newTotalCents: currentTotal, + }; + } + + if (desiredTotal < fixedFundedCents) { + return reply.code(400).send({ + ok: false, + code: "BELOW_FIXED_FUNDED", + message: `Bank total cannot be below funded fixed expenses (${fixedFundedCents} cents).`, + }); + } + + if (delta > 0) { + const shareResult = computeDepositShares(categories, delta); + if (!shareResult.ok) { + return reply.code(400).send({ + ok: false, + code: "NO_PERCENT", + message: "No category percentages available.", + }); + } + for (const share of shareResult.shares) { + if (share.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: share.id }, + data: { balanceCents: { increment: BigInt(share.share) } }, + }); + } + } else { + const amountToRemove = Math.abs(delta); + if (amountToRemove > variableTotal) { + return reply.code(400).send({ + ok: false, + code: "INSUFFICIENT_BALANCE", + message: "Available budget is lower than the adjustment amount.", + }); + } + const shareResult = computeWithdrawShares(categories, amountToRemove); + if (!shareResult.ok) { + return reply.code(400).send({ + ok: false, + code: "INSUFFICIENT_BALANCE", + message: "Available budget is lower than the adjustment amount.", + }); + } + for (const share of shareResult.shares) { + if (share.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: share.id }, + data: { balanceCents: { decrement: BigInt(share.share) } }, + }); + } + } + + await tx.transaction.create({ + data: { + userId, + occurredAt: new Date(), + kind: "balance_adjustment", + amountCents: BigInt(Math.abs(delta)), + note: + delta > 0 + ? "Balance reconciliation: increase" + : "Balance reconciliation: decrease", + isReconciled: true, + }, + }); + + return { + ok: true, + deltaCents: delta, + currentTotalCents: currentTotal, + newTotalCents: desiredTotal, + }; + }); +}); + +const UserConfigBody = z.object({ + incomeType: z.enum(["regular", "irregular"]).optional(), + totalBudgetCents: z.number().int().nonnegative().optional(), + budgetPeriod: z.enum(["weekly", "biweekly", "monthly"]).optional(), + incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]).optional(), + firstIncomeDate: z + .union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/)]) + .nullable() + .optional(), + timezone: z.string().refine((value) => { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); + return true; + } catch { + return false; + } + }, "Invalid timezone").optional(), // IANA timezone identifier + fixedExpensePercentage: z.number().int().min(0).max(100).optional(), +}); + +app.patch("/user/config", async (req, reply) => { + const parsed = UserConfigBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid user config data" }); + } + + const userId = req.userId; + const updateData: any = {}; + const scheduleChange = + parsed.data.incomeFrequency !== undefined || + parsed.data.firstIncomeDate !== undefined; + const wantsFirstIncomeDate = parsed.data.firstIncomeDate !== undefined; + + if (parsed.data.incomeFrequency) updateData.incomeFrequency = parsed.data.incomeFrequency; + if (parsed.data.totalBudgetCents !== undefined) updateData.totalBudgetCents = BigInt(parsed.data.totalBudgetCents); + if (parsed.data.budgetPeriod) updateData.budgetPeriod = parsed.data.budgetPeriod; + if (parsed.data.incomeType) updateData.incomeType = parsed.data.incomeType; + if (parsed.data.timezone) updateData.timezone = parsed.data.timezone; + if (parsed.data.fixedExpensePercentage !== undefined) { + updateData.fixedExpensePercentage = parsed.data.fixedExpensePercentage; + } + + const updated = await app.prisma.$transaction(async (tx) => { + const existing = await tx.user.findUnique({ + where: { id: userId }, + select: { incomeType: true, timezone: true }, + }); + const effectiveTimezone = parsed.data.timezone ?? existing?.timezone ?? "America/New_York"; + if (wantsFirstIncomeDate) { + updateData.firstIncomeDate = parsed.data.firstIncomeDate + ? getUserMidnightFromDateOnly(effectiveTimezone, new Date(parsed.data.firstIncomeDate)) + : null; + } + const updatedUser = await tx.user.update({ + where: { id: userId }, + data: updateData, + select: { + incomeFrequency: true, + incomeType: true, + totalBudgetCents: true, + budgetPeriod: true, + firstIncomeDate: true, + timezone: true, + fixedExpensePercentage: true, + }, + }); + const finalIncomeType = updateData.incomeType ?? existing?.incomeType ?? "regular"; + if (scheduleChange && finalIncomeType === "regular") { + await tx.fixedPlan.updateMany({ + where: { userId, paymentSchedule: { not: Prisma.DbNull } }, + data: { needsFundingThisPeriod: true }, + }); + } + return updatedUser; + }); + + return { + incomeFrequency: updated.incomeFrequency, + incomeType: updated.incomeType || "regular", + totalBudgetCents: updated.totalBudgetCents ? Number(updated.totalBudgetCents) : null, + budgetPeriod: updated.budgetPeriod, + firstIncomeDate: updated.firstIncomeDate + ? getUserMidnightFromDateOnly(updated.timezone ?? "America/New_York", updated.firstIncomeDate).toISOString() + : null, + timezone: updated.timezone, + fixedExpensePercentage: updated.fixedExpensePercentage ?? 40, + }; +}); + + return app; +} + const PORT = env.PORT; const HOST = process.env.HOST || "0.0.0.0"; -export default app; // <-- add this - +const app = await buildApp(); +export default app; if (process.env.NODE_ENV !== "test") { app.listen({ port: PORT, host: HOST }).catch((err) => { @@ -513,4 +3529,3 @@ if (process.env.NODE_ENV !== "test") { process.exit(1); }); } - diff --git a/api/src/worker/auto-payments.ts b/api/src/worker/auto-payments.ts new file mode 100644 index 0000000..8153c52 --- /dev/null +++ b/api/src/worker/auto-payments.ts @@ -0,0 +1,46 @@ +import cron from "node-cron"; +import { PrismaClient } from "@prisma/client"; +import { processAutoPayments } from "../jobs/auto-payments.js"; + +const prisma = new PrismaClient(); + +// Default: run every 15 minutes to catch users at their local 9 AM across all timezones +// The job itself filters to only process users where local time >= 9 AM +const schedule = + process.env.AUTO_PAYMENT_SCHEDULE_CRON && typeof process.env.AUTO_PAYMENT_SCHEDULE_CRON === "string" + ? process.env.AUTO_PAYMENT_SCHEDULE_CRON + : "*/15 * * * *"; + +async function runOnce() { + const asOf = new Date(); + try { + const reports = await processAutoPayments(prisma, asOf, { dryRun: false }); + console.log( + `[auto-payments] ${asOf.toISOString()} processed=${reports.length} successful=${reports.filter(r => r.success).length}` + ); + + // Log any failures + const failures = reports.filter(r => !r.success); + if (failures.length > 0) { + if (process.env.NODE_ENV === "production") { + console.warn(`[auto-payments] ${failures.length} failed payments.`); + } else { + console.warn( + `[auto-payments] ${failures.length} failed payments:`, + failures.map(f => `${f.name}: ${f.error}`).join(", ") + ); + } + } + } catch (err) { + console.error("[auto-payments] job failed", err); + } +} + +console.log(`[auto-payments-worker] starting cron ${schedule}`); +cron.schedule(schedule, () => { + runOnce().catch((err) => console.error("[auto-payments] schedule error", err)); +}); + +if (process.env.RUN_ONCE === "1") { + runOnce().finally(() => process.exit(0)); +} diff --git a/api/src/worker/rollover.ts b/api/src/worker/rollover.ts new file mode 100644 index 0000000..0e07a49 --- /dev/null +++ b/api/src/worker/rollover.ts @@ -0,0 +1,39 @@ +import cron from "node-cron"; +import { PrismaClient } from "@prisma/client"; +import { rolloverFixedPlans } from "../jobs/rollover.js"; + +const prisma = new PrismaClient(); + +// Default: run every 15 minutes to catch users at their local 6 AM across all timezones +// The job itself filters to only process users where local time >= 6 AM +const schedule = + process.env.ROLLOVER_SCHEDULE_CRON && typeof process.env.ROLLOVER_SCHEDULE_CRON === "string" + ? process.env.ROLLOVER_SCHEDULE_CRON + : "*/15 * * * *"; + +async function runOnce() { + const asOf = new Date(); + try { + const results = await rolloverFixedPlans(prisma, asOf, { dryRun: false }); + if (process.env.NODE_ENV === "production") { + console.log(`[rollover] ${asOf.toISOString()} processed=${results.length}`); + } else { + console.log( + `[rollover] ${asOf.toISOString()} processed=${results.length} ids=${results + .map((r) => r.planId) + .join(",")}` + ); + } + } catch (err) { + console.error("[rollover] job failed", err); + } +} + +console.log(`[rollover-worker] starting cron ${schedule}`); +cron.schedule(schedule, () => { + runOnce().catch((err) => console.error("[rollover] schedule error", err)); +}); + +if (process.env.RUN_ONCE === "1") { + runOnce().finally(() => process.exit(0)); +} diff --git a/api/test-income-overdue.sh b/api/test-income-overdue.sh new file mode 100644 index 0000000..f3ecfe7 --- /dev/null +++ b/api/test-income-overdue.sh @@ -0,0 +1,25 @@ +#!/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 new file mode 100644 index 0000000..d621f12 --- /dev/null +++ b/api/test-monthly-income.cjs @@ -0,0 +1,228 @@ +/** + * Test script for monthly income payday calculations with TIMEZONE awareness + * Run with: node test-monthly-income.cjs + * + * This replicates the actual allocator.ts logic including timezone handling + */ + +// Simulating date-fns-tz behavior (simplified for testing) +function toZonedTime(date, timezone) { + // For testing, we'll use a simple offset approach + // In real code, this uses proper timezone rules + const utc = date.getTime(); + const tzOffset = getTimezoneOffset(timezone, date); + return new Date(utc + tzOffset); +} + +function fromZonedTime(date, timezone) { + const tzOffset = getTimezoneOffset(timezone, date); + return new Date(date.getTime() - tzOffset); +} + +// Simplified timezone offset (real implementation uses IANA database) +function getTimezoneOffset(timezone, date) { + const offsets = { + 'UTC': 0, + 'America/New_York': -5 * 60 * 60 * 1000, // EST (ignoring DST for simplicity) + 'America/Los_Angeles': -8 * 60 * 60 * 1000, // PST + 'Asia/Tokyo': 9 * 60 * 60 * 1000, // JST + }; + return offsets[timezone] || 0; +} + +function getUserMidnight(timezone, date = new Date()) { + const zonedDate = toZonedTime(date, timezone); + zonedDate.setHours(0, 0, 0, 0); + return fromZonedTime(zonedDate, timezone); +} + +const frequencyDays = { + weekly: 7, + biweekly: 14, + monthly: 30, // Not used for monthly anymore +}; + +function calculateNextPayday(firstIncomeDate, frequency, fromDate = new Date(), timezone = 'UTC') { + const normalizedFrom = getUserMidnight(timezone, fromDate); + const nextPayDate = getUserMidnight(timezone, firstIncomeDate); + + // Get the target day in the USER'S timezone + const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone); + const targetDay = zonedFirstIncome.getDate(); + + let iterations = 0; + while (nextPayDate < normalizedFrom) { + if (frequency === 'monthly') { + // Work in user's timezone for month advancement + const zonedPayDate = toZonedTime(nextPayDate, timezone); + zonedPayDate.setMonth(zonedPayDate.getMonth() + 1); + const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate(); + zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth)); + zonedPayDate.setHours(0, 0, 0, 0); + const newPayDate = fromZonedTime(zonedPayDate, timezone); + nextPayDate.setTime(newPayDate.getTime()); + } else { + nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]); + } + iterations++; + } + + return { nextPayDate, iterations, targetDay }; +} + +function countPayPeriodsBetween(startDate, endDate, firstIncomeDate, frequency, timezone = 'UTC') { + let count = 0; + const nextPayDate = getUserMidnight(timezone, firstIncomeDate); + const normalizedStart = getUserMidnight(timezone, startDate); + const normalizedEnd = getUserMidnight(timezone, endDate); + + const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone); + const targetDay = zonedFirstIncome.getDate(); + + const advanceByPeriod = () => { + if (frequency === 'monthly') { + const zonedPayDate = toZonedTime(nextPayDate, timezone); + zonedPayDate.setMonth(zonedPayDate.getMonth() + 1); + const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate(); + zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth)); + zonedPayDate.setHours(0, 0, 0, 0); + const newPayDate = fromZonedTime(zonedPayDate, timezone); + nextPayDate.setTime(newPayDate.getTime()); + } else { + nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]); + } + }; + + while (nextPayDate < normalizedStart) { + advanceByPeriod(); + } + + while (nextPayDate < normalizedEnd) { + count++; + advanceByPeriod(); + } + + return Math.max(1, count); +} + +// Helper to format dates +const fmt = (d) => d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); +const fmtISO = (d) => d.toISOString().split('T')[0]; + +console.log('═══════════════════════════════════════════════════════════════'); +console.log(' MONTHLY INCOME PAYDAY CALCULATION TESTS (Timezone-Aware)'); +console.log('═══════════════════════════════════════════════════════════════\n'); + +// Test 1: Monthly payday on the 15th - America/New_York +console.log('TEST 1: Monthly payday on the 15th (America/New_York)'); +console.log('─────────────────────────────────────'); +const firstPayday15 = new Date('2025-01-15T05:00:00.000Z'); // Midnight EST = 5am UTC +const today = new Date('2025-12-20T05:00:00.000Z'); + +const result1 = calculateNextPayday(firstPayday15, 'monthly', today, 'America/New_York'); +console.log(`First income (UTC): ${firstPayday15.toISOString()}`); +console.log(`Today (UTC): ${today.toISOString()}`); +console.log(`Target day: ${result1.targetDay}th of month`); +console.log(`Next payday (UTC): ${result1.nextPayDate.toISOString()}`); +console.log(`Iterations: ${result1.iterations}`); +console.log(`✓ Should be Jan 15, 2026 in EST\n`); + +// Test 2: Edge case - payday stored as UTC midnight crossing timezone boundary +console.log('TEST 2: Timezone boundary edge case'); +console.log('─────────────────────────────────────'); +// If user in LA set payday to "15th", it might be stored as 2025-01-15T08:00:00Z (midnight PST) +const firstPaydayLA = new Date('2025-01-15T08:00:00.000Z'); +const todayLA = new Date('2025-12-20T08:00:00.000Z'); + +const resultLA = calculateNextPayday(firstPaydayLA, 'monthly', todayLA, 'America/Los_Angeles'); +console.log(`Timezone: America/Los_Angeles`); +console.log(`First income (UTC): ${firstPaydayLA.toISOString()}`); +console.log(`Target day: ${resultLA.targetDay}th of month`); +console.log(`Next payday (UTC): ${resultLA.nextPayDate.toISOString()}`); +console.log(`✓ Target day should be 15, not 14 or 16\n`); + +// Test 3: Compare UTC vs timezone-aware for same "15th" payday +console.log('TEST 3: UTC vs Timezone-aware comparison'); +console.log('─────────────────────────────────────'); +const sameDate = new Date('2025-01-15T00:00:00.000Z'); // Midnight UTC +const fromDate = new Date('2025-06-01T00:00:00.000Z'); + +const resultUTC = calculateNextPayday(sameDate, 'monthly', fromDate, 'UTC'); +const resultEST = calculateNextPayday(sameDate, 'monthly', fromDate, 'America/New_York'); +const resultTokyo = calculateNextPayday(sameDate, 'monthly', fromDate, 'Asia/Tokyo'); + +console.log(`Date stored: ${sameDate.toISOString()}`); +console.log(`From date: ${fromDate.toISOString()}`); +console.log(`UTC target day: ${resultUTC.targetDay} → Next: ${fmtISO(resultUTC.nextPayDate)}`); +console.log(`EST target day: ${resultEST.targetDay} → Next: ${fmtISO(resultEST.nextPayDate)}`); +console.log(`JST target day: ${resultTokyo.targetDay} → Next: ${fmtISO(resultTokyo.nextPayDate)}`); +console.log(`⚠️ Same UTC date shows different "day of month" in different timezones!\n`); + +// Test 4: Monthly payday on 31st with day clamping +console.log('TEST 4: Monthly payday on 31st (day clamping)'); +console.log('─────────────────────────────────────'); +const firstPayday31 = new Date('2025-01-31T05:00:00.000Z'); +console.log(`First payday: Jan 31, 2025`); + +let tempDate = getUserMidnight('America/New_York', firstPayday31); +console.log(`\nPayday progression:`); +for (let i = 0; i < 6; i++) { + const zoned = toZonedTime(tempDate, 'America/New_York'); + console.log(` ${zoned.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`); + + // Advance by month + zoned.setMonth(zoned.getMonth() + 1); + const maxDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate(); + zoned.setDate(Math.min(31, maxDay)); + zoned.setHours(0, 0, 0, 0); + tempDate = fromZonedTime(zoned, 'America/New_York'); +} +console.log(`✓ Feb shows 28th (clamped), other months show 31st or 30th\n`); + +// Test 5: Count pay periods with timezone +console.log('TEST 5: Count pay periods (timezone-aware)'); +console.log('─────────────────────────────────────'); +const firstIncome = new Date('2025-01-15T05:00:00.000Z'); +const nowDate = new Date('2025-12-20T05:00:00.000Z'); +const billDue = new Date('2026-03-01T05:00:00.000Z'); + +const periodsEST = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'America/New_York'); +const periodsUTC = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'UTC'); + +console.log(`Now: Dec 20, 2025`); +console.log(`Bill due: Mar 1, 2026`); +console.log(`First income: Jan 15, 2025`); +console.log(`Periods (EST): ${periodsEST}`); +console.log(`Periods (UTC): ${periodsUTC}`); +console.log(`✓ Should be 2-3 periods (Jan 15, Feb 15)\n`); + +// Test 6: OLD vs NEW comparison (with timezone) +console.log('TEST 6: OLD (30 days) vs NEW (actual month) - with timezone'); +console.log('─────────────────────────────────────'); +const startDate = new Date('2025-01-15T05:00:00.000Z'); +let oldDate = new Date(startDate); +let newResult = calculateNextPayday(startDate, 'monthly', startDate, 'America/New_York'); +let newDate = new Date(newResult.nextPayDate); + +console.log('Month | OLD (30 days) | NEW (timezone) | Drift'); +console.log('──────┼────────────────┼─────────────────┼───────'); + +for (let i = 0; i < 12; i++) { + oldDate.setDate(oldDate.getDate() + 30); + + // For new method, advance one month from previous + const nextFrom = new Date(newDate.getTime() + 24 * 60 * 60 * 1000); // +1 day to get next + newResult = calculateNextPayday(startDate, 'monthly', nextFrom, 'America/New_York'); + newDate = newResult.nextPayDate; + + const drift = Math.round((oldDate - newDate) / (24 * 60 * 60 * 1000)); + console.log(` ${String(i + 1).padStart(2)} | ${fmtISO(oldDate)} | ${fmtISO(newDate)} | ${drift > 0 ? '+' : ''}${drift} days`); +} + +console.log('\n✓ NEW method stays on the 15th (in user\'s timezone)!'); +console.log('✓ OLD method drifts 5-6 days early after 12 months\n'); + +console.log('═══════════════════════════════════════════════════════════════'); +console.log(' ALL TESTS COMPLETE - Timezone handling verified'); +console.log('═══════════════════════════════════════════════════════════════'); + diff --git a/api/test-overdue-api.sh b/api/test-overdue-api.sh new file mode 100644 index 0000000..0ba76e4 --- /dev/null +++ b/api/test-overdue-api.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Test overdue payment via API endpoint + +# Login to get token +echo "🔐 Logging in..." +LOGIN_RESPONSE=$(curl -s -X POST http://localhost:8080/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@skymoney.com","password":"password123"}') + +TOKEN=$(echo $LOGIN_RESPONSE | grep -o '"token":"[^"]*' | cut -d'"' -f4) + +if [ -z "$TOKEN" ]; then + echo "❌ Login failed" + exit 1 +fi + +echo "✅ Logged in successfully" + +# Check current state +echo -e "\n📋 Checking current plans..." +curl -s -X GET http://localhost:8080/api/fixed-plans \ + -H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}' + +# Post $500 income - should pay $500 to overdue (was $1000, now $500 remaining) +echo -e "\n💰 Posting $500 income..." +INCOME_RESPONSE=$(curl -s -X POST http://localhost:8080/api/income \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "amountCents": 50000, + "postedAt": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'", + "note": "Test income for overdue" + }') + +echo $INCOME_RESPONSE | jq '.' + +# Check state after income +echo -e "\n📋 Checking plans after income..." +curl -s -X GET http://localhost:8080/api/fixed-plans \ + -H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}' diff --git a/api/test-overdue-payment.cjs b/api/test-overdue-payment.cjs new file mode 100644 index 0000000..3622461 --- /dev/null +++ b/api/test-overdue-payment.cjs @@ -0,0 +1,133 @@ +// Script to post test income and verify overdue payment +const { PrismaClient } = require('@prisma/client'); +const { randomUUID } = require('crypto'); + +async function main() { + const prisma = new PrismaClient(); + + try { + const user = await prisma.user.findUnique({ + where: { email: 'test@skymoney.com' } + }); + + if (!user) { + console.log('❌ Test user not found. Run create-test-user.cjs first.'); + return; + } + + console.log('✅ Found test user:', user.email); + + // Check overdue status BEFORE posting income + const plansBefore = await prisma.fixedPlan.findMany({ + where: { userId: user.id }, + select: { + id: true, + name: true, + totalCents: true, + fundedCents: true, + isOverdue: true, + overdueAmount: true, + }, + }); + + console.log('\n📋 Plans BEFORE income:'); + for (const plan of plansBefore) { + console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`); + } + + // Post $1000 income - should pay $500 to overdue first, then allocate $500 normally + const incomeAmount = 100000; // $1000 in cents + console.log(`\n💰 Posting income: $${incomeAmount / 100}`); + + const incomeId = randomUUID(); + const now = new Date().toISOString(); + + // Simulate what the allocateIncome function does + const result = await prisma.$transaction(async (tx) => { + await tx.incomeEvent.create({ + data: { + id: incomeId, + userId: user.id, + postedAt: now, + amountCents: BigInt(incomeAmount), + note: 'Test income for overdue payment', + }, + }); + + // Find overdue plans + const overduePlans = await tx.fixedPlan.findMany({ + where: { + userId: user.id, + isOverdue: true, + overdueAmount: { gt: 0 }, + }, + orderBy: { overdueSince: 'asc' }, + }); + + console.log(`\n🔍 Found ${overduePlans.length} overdue plan(s)`); + + let remaining = incomeAmount; + for (const plan of overduePlans) { + if (remaining <= 0) break; + + const overdueAmount = Number(plan.overdueAmount); + const amountToPay = Math.min(overdueAmount, remaining); + + console.log(` Paying $${amountToPay / 100} to ${plan.name} (was $${overdueAmount / 100} overdue)`); + + // Create allocation + await tx.allocation.create({ + data: { + userId: user.id, + kind: 'fixed', + toId: plan.id, + amountCents: BigInt(amountToPay), + incomeId, + }, + }); + + // Update plan + const newOverdueAmount = overdueAmount - amountToPay; + await tx.fixedPlan.update({ + where: { id: plan.id }, + data: { + fundedCents: (plan.fundedCents ?? 0n) + BigInt(amountToPay), + currentFundedCents: (plan.currentFundedCents ?? plan.fundedCents ?? 0n) + BigInt(amountToPay), + overdueAmount: newOverdueAmount, + isOverdue: newOverdueAmount > 0, + lastFundingDate: new Date(now), + }, + }); + + remaining -= amountToPay; + } + + return { remaining }; + }); + + console.log(`\n💵 Remaining after overdue payments: $${result.remaining / 100}`); + + // Check overdue status AFTER posting income + const plansAfter = await prisma.fixedPlan.findMany({ + where: { userId: user.id }, + select: { + id: true, + name: true, + totalCents: true, + fundedCents: true, + isOverdue: true, + overdueAmount: true, + }, + }); + + console.log('\n📋 Plans AFTER income:'); + for (const plan of plansAfter) { + console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`); + } + + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/api/test-simple.sh b/api/test-simple.sh new file mode 100644 index 0000000..0c7da77 --- /dev/null +++ b/api/test-simple.sh @@ -0,0 +1,19 @@ +#!/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/allocator.test.ts b/api/tests/allocator.test.ts index 829906e..d87daba 100644 --- a/api/tests/allocator.test.ts +++ b/api/tests/allocator.test.ts @@ -1,65 +1,232 @@ // tests/allocator.test.ts import { describe, it, expect, beforeEach, afterAll } from "vitest"; import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers"; -import { allocateIncome } from "../src/allocator"; +import { allocateIncome, buildPlanStates } from "../src/allocator"; -describe("allocator — core behaviors", () => { +describe("allocator — new funding system", () => { beforeEach(async () => { await resetUser(U); await ensureUser(U); + + // Update user with income frequency + await prisma.user.update({ + where: { id: U }, + data: { + incomeFrequency: "biweekly", + }, + }); }); afterAll(async () => { await closePrisma(); }); - it("distributes remainder to variables by largest remainder with savings-first tie", async () => { - const c1 = cid("c1"); - const c2 = cid("c2"); // make this savings to test the tiebreaker - await prisma.variableCategory.createMany({ - data: [ - { id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n }, - { id: c2, userId: U, name: "Saver", percent: 40, priority: 1, isSavings: true, balanceCents: 0n }, - ], + describe("buildPlanStates", () => { + it("calculates funding needs based on strategy and time remaining", async () => { + const p1 = pid("rent"); + await prisma.fixedPlan.create({ + data: { + id: p1, + userId: U, + name: "Rent", + cycleStart: new Date(Date.now() - 86400000).toISOString(), // started yesterday + dueOn: new Date(Date.now() + 14 * 86400000).toISOString(), // due in 2 weeks + totalCents: 100000n, // $1000 + fundedCents: 0n, + currentFundedCents: 0n, + lastFundingDate: null, + priority: 1, + fundingMode: "auto-on-deposit", + }, + }); + + const user = await prisma.user.findUniqueOrThrow({ where: { id: U } }); + const plans = await prisma.fixedPlan.findMany({ where: { userId: U } }); + const states = buildPlanStates(plans, + { incomeFrequency: user.incomeFrequency! }, + new Date()); + + expect(states).toHaveLength(1); + const rentState = states[0]; + expect(rentState.id).toBe(p1); + expect(rentState.desiredThisIncome).toBeGreaterThan(0); + expect(rentState.desiredThisIncome).toBeLessThanOrEqual(100000); + expect(rentState.remainingCents).toBeGreaterThan(0); + expect(rentState.remainingCents).toBeLessThanOrEqual(100000); }); - const p1 = pid("rent"); - await prisma.fixedPlan.create({ - data: { - id: p1, - userId: U, - name: "Rent", - cycleStart: new Date().toISOString(), - dueOn: new Date(Date.now() + 7 * 864e5).toISOString(), - totalCents: 10000n, - fundedCents: 0n, - priority: 1, - fundingMode: "auto-on-deposit", - }, + it("detects crisis mode when payment is due soon", async () => { + const p1 = pid("urgent"); + await prisma.fixedPlan.create({ + data: { + id: p1, + userId: U, + name: "Urgent Payment", + cycleStart: new Date(Date.now() - 86400000).toISOString(), + dueOn: new Date(Date.now() + 5 * 86400000).toISOString(), // due in 5 days + totalCents: 50000n, // $500 + fundedCents: 0n, + currentFundedCents: 10000n, // only $100 funded + lastFundingDate: null, + priority: 1, + fundingMode: "auto-on-deposit", + }, + }); + + const user = await prisma.user.findUniqueOrThrow({ where: { id: U } }); + const plans = await prisma.fixedPlan.findMany({ where: { userId: U } }); + const states = buildPlanStates(plans, + { incomeFrequency: user.incomeFrequency! }, + new Date()); + + const urgentState = states[0]; + expect(urgentState.isCrisis).toBe(true); }); - // $100 income - const result = await allocateIncome(prisma as any, U, 10000, new Date().toISOString(), "inc1"); - expect(result).toBeDefined(); - // rent should be funded first up to need - const fixed = result.fixedAllocations ?? []; - const variable = result.variableAllocations ?? []; - // sanity - expect(Array.isArray(fixed)).toBe(true); - expect(Array.isArray(variable)).toBe(true); }); - it("handles zeros and single bucket", async () => { - const cOnly = cid("only"); - await prisma.variableCategory.create({ - data: { id: cOnly, userId: U, name: "Only", percent: 100, priority: 1, isSavings: false, balanceCents: 0n }, + describe("allocateIncome", () => { + it("distributes income with new percentage-based funding", async () => { + const c1 = cid("c1"); + const c2 = cid("c2"); + await prisma.variableCategory.createMany({ + data: [ + { id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n }, + { id: c2, userId: U, name: "Saver", percent: 40, priority: 1, isSavings: true, balanceCents: 0n }, + ], + }); + + const p1 = pid("rent"); + await prisma.fixedPlan.create({ + data: { + id: p1, + userId: U, + name: "Rent", + cycleStart: new Date(Date.now() - 86400000).toISOString(), + dueOn: new Date(Date.now() + 14 * 86400000).toISOString(), + totalCents: 30000n, // $300 - much smaller so there's leftover + fundedCents: 0n, + currentFundedCents: 0n, + lastFundingDate: null, + priority: 1, + fundingMode: "auto-on-deposit", + }, + }); + + // Allocate $1000 income (much more than needed for small rent) + const result = await allocateIncome(prisma as any, U, 100000, new Date().toISOString(), "inc1"); + expect(result).toBeDefined(); + + const fixed = result.fixedAllocations ?? []; + const variable = result.variableAllocations ?? []; + + console.log('DEBUG: result =', JSON.stringify(result, null, 2)); + + expect(Array.isArray(fixed)).toBe(true); + expect(Array.isArray(variable)).toBe(true); + + // Should have allocations for both fixed and variable + expect(fixed.length).toBeGreaterThan(0); + expect(variable.length).toBeGreaterThan(0); + + // Fixed allocation should be based on desired amount, not full income + const rentAllocation = fixed.find(f => f.fixedPlanId === p1); + expect(rentAllocation).toBeDefined(); + expect(rentAllocation!.amountCents).toBeGreaterThan(0); + expect(rentAllocation!.amountCents).toBeLessThanOrEqual(30000); // Should not exceed plan's desired amount }); - const result = await allocateIncome(prisma as any, U, 0, new Date().toISOString(), "inc2"); - expect(result).toBeDefined(); - const variable = result.variableAllocations ?? []; - const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0); - expect(sum).toBe(0); + it("handles crisis mode by prioritizing underfunded expenses", async () => { + const c1 = cid("c1"); + await prisma.variableCategory.create({ + data: { id: c1, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 0n }, + }); + + const p1 = pid("urgent"); + const p2 = pid("normal"); + + await prisma.fixedPlan.createMany({ + data: [ + { + id: p1, + userId: U, + name: "Urgent Payment", + cycleStart: new Date(Date.now() - 86400000).toISOString(), + dueOn: new Date(Date.now() + 5 * 86400000).toISOString(), // due soon (crisis) + totalCents: 50000n, + fundedCents: 0n, + currentFundedCents: 10000n, // underfunded + lastFundingDate: null, + priority: 1, + fundingMode: "auto-on-deposit", + }, + { + id: p2, + userId: U, + name: "Normal Payment", + cycleStart: new Date(Date.now() - 86400000).toISOString(), + dueOn: new Date(Date.now() + 20 * 86400000).toISOString(), // due later + totalCents: 30000n, + fundedCents: 0n, + currentFundedCents: 0n, + lastFundingDate: null, + priority: 2, + fundingMode: "auto-on-deposit", + }, + ], + }); + + // Small income that should prioritize crisis + const result = await allocateIncome(prisma as any, U, 20000, new Date().toISOString(), "inc1"); + + const fixed = result.fixedAllocations ?? []; + const urgentAllocation = fixed.find(f => f.fixedPlanId === p1); + const normalAllocation = fixed.find(f => f.fixedPlanId === p2); + + // Urgent should get more funding due to crisis mode + expect(urgentAllocation).toBeDefined(); + if (normalAllocation) { + expect(urgentAllocation!.amountCents).toBeGreaterThan(normalAllocation.amountCents); + } + }); + + it("updates currentFundedCents correctly", async () => { + const p1 = pid("rent"); + await prisma.fixedPlan.create({ + data: { + id: p1, + userId: U, + name: "Rent", + cycleStart: new Date(Date.now() - 86400000).toISOString(), + dueOn: new Date(Date.now() + 14 * 86400000).toISOString(), + totalCents: 100000n, + fundedCents: 0n, + currentFundedCents: 20000n, // already $200 funded + lastFundingDate: null, + priority: 1, + fundingMode: "auto-on-deposit", + }, + }); + + await allocateIncome(prisma as any, U, 30000, new Date().toISOString(), "inc1"); + + const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: p1 } }); + expect(updatedPlan.currentFundedCents).toBeGreaterThan(20000n); // Should have increased + expect(updatedPlan.lastFundingDate).not.toBeNull(); // Should be updated + }); + + it("handles zeros and empty allocation", async () => { + const cOnly = cid("only"); + await prisma.variableCategory.create({ + data: { id: cOnly, userId: U, name: "Only", percent: 100, priority: 1, isSavings: false, balanceCents: 0n }, + }); + + const result = await allocateIncome(prisma as any, U, 0, new Date().toISOString(), "inc2"); + expect(result).toBeDefined(); + const variable = result.variableAllocations ?? []; + const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0); + expect(sum).toBe(0); + }); }); }); diff --git a/api/tests/auth.routes.test.ts b/api/tests/auth.routes.test.ts new file mode 100644 index 0000000..fb6ff9f --- /dev/null +++ b/api/tests/auth.routes.test.ts @@ -0,0 +1,83 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import type { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { buildApp } from "../src/server"; + +const prisma = new PrismaClient(); +let app: FastifyInstance; + +beforeAll(async () => { + app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: true }); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); + await prisma.$disconnect(); +}); + +describe("Auth routes", () => { + it("rejects protected routes without a session", async () => { + const res = await request(app.server).get("/dashboard"); + expect(res.status).toBe(401); + expect(res.body.code).toBe("UNAUTHENTICATED"); + }); + + it("registers a user and grants access via cookie session", async () => { + const agent = request.agent(app.server); + const email = `reg-${Date.now()}@test.dev`; + const password = "SupersAFE123!"; + + const register = await agent.post("/auth/register").send({ email, password }); + expect(register.status).toBe(200); + + const dash = await agent.get("/dashboard"); + expect(dash.status).toBe(200); + + const created = await prisma.user.findUniqueOrThrow({ where: { email } }); + const [catCount, planCount] = await Promise.all([ + prisma.variableCategory.count({ where: { userId: created.id } }), + prisma.fixedPlan.count({ where: { userId: created.id } }), + ]); + expect(catCount).toBeGreaterThan(0); + expect(planCount).toBeGreaterThan(0); + + await prisma.user.deleteMany({ where: { email } }); + }); + + it("logs in existing user and accesses dashboard", async () => { + const agent = request.agent(app.server); + const email = `login-${Date.now()}@test.dev`; + const password = "SupersAFE123!"; + + await agent.post("/auth/register").send({ email, password }); + await agent.post("/auth/logout"); + + const login = await agent.post("/auth/login").send({ email, password }); + expect(login.status).toBe(200); + + const dash = await agent.get("/dashboard"); + expect(dash.status).toBe(200); + + await prisma.user.deleteMany({ where: { email } }); + }); + + it("reports session info and handles logout", async () => { + const agent = request.agent(app.server); + const email = `session-${Date.now()}@test.dev`; + const password = "SupersAFE123!"; + + await agent.post("/auth/register").send({ email, password }); + + const session = await agent.get("/auth/session"); + expect(session.status).toBe(200); + expect(session.body.userId).toBeDefined(); + + await agent.post("/auth/logout"); + const afterLogout = await agent.get("/dashboard"); + expect(afterLogout.status).toBe(401); + + await prisma.user.deleteMany({ where: { email } }); + }); +}); diff --git a/api/tests/auto-payments.test.ts b/api/tests/auto-payments.test.ts new file mode 100644 index 0000000..18a72fb --- /dev/null +++ b/api/tests/auto-payments.test.ts @@ -0,0 +1,338 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers"; +import { processAutoPayments, type PaymentSchedule } from "../src/jobs/auto-payments"; + +describe("processAutoPayments", () => { + beforeEach(async () => { + await resetUser(U); + await ensureUser(U); + }); + + afterAll(async () => { + await closePrisma(); + }); + + it("processes auto-payment for fully funded monthly plan", async () => { + const paymentSchedule: PaymentSchedule = { + frequency: "monthly", + dayOfMonth: 1, + minFundingPercent: 100, + }; + + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Rent Auto-Pay", + totalCents: 120000n, // $1,200 + fundedCents: 120000n, // Fully funded + currentFundedCents: 120000n, // Set current funding + priority: 10, + cycleStart: new Date("2025-01-01T00:00:00Z"), + dueOn: new Date("2025-01-01T00:00:00Z"), + periodDays: 30, + autoPayEnabled: true, + paymentSchedule, + nextPaymentDate: new Date("2025-01-01T09:00:00Z"), // Payment due + }, + select: { id: true }, + }); + + // Process payments as of payment date + const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false }); + + expect(reports).toHaveLength(1); + expect(reports[0].success).toBe(true); + expect(reports[0].paymentAmountCents).toBe(120000); + expect(reports[0].planId).toBe(plan.id); + + // Verify payment transaction was created + const transaction = await prisma.transaction.findFirst({ + where: { planId: plan.id, kind: "fixed_payment" }, + }); + + expect(transaction).toBeTruthy(); + expect(Number(transaction?.amountCents)).toBe(120000); + expect(transaction?.note).toBe("Auto-payment (monthly)"); + + // Verify plan funding was reduced + const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({ + where: { id: plan.id }, + }); + + expect(Number(updatedPlan.fundedCents)).toBe(0); + expect(updatedPlan.lastAutoPayment).toBeTruthy(); + expect(updatedPlan.nextPaymentDate).toBeTruthy(); + + // Next payment should be February 1st + const nextPayment = updatedPlan.nextPaymentDate!; + expect(nextPayment.getMonth()).toBe(1); // February (0-indexed) + expect(nextPayment.getDate()).toBe(1); + }); + + it("skips payment when funding is below minimum threshold", async () => { + const paymentSchedule: PaymentSchedule = { + frequency: "monthly", + dayOfMonth: 1, + minFundingPercent: 100, + }; + + await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Under-funded Plan", + totalCents: 100000n, // $1,000 + fundedCents: 50000n, // Only 50% funded + currentFundedCents: 50000n, // Set current funding + priority: 10, + cycleStart: new Date("2025-01-01T00:00:00Z"), + dueOn: new Date("2025-01-01T00:00:00Z"), + periodDays: 30, + autoPayEnabled: true, + paymentSchedule, + nextPaymentDate: new Date("2025-01-01T09:00:00Z"), + }, + }); + + const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false }); + + expect(reports).toHaveLength(1); + expect(reports[0].success).toBe(false); + expect(reports[0].error).toContain("Insufficient funding: 50.0% < 100%"); + expect(reports[0].paymentAmountCents).toBe(0); + + // Verify no transaction was created + const transaction = await prisma.transaction.findFirst({ + where: { kind: "fixed_payment" }, + }); + + expect(transaction).toBeNull(); + }); + + it("processes weekly auto-payment", async () => { + const paymentSchedule: PaymentSchedule = { + frequency: "weekly", + dayOfWeek: 1, // Monday + minFundingPercent: 50, + }; + + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Weekly Payment", + totalCents: 50000n, // $500 + fundedCents: 30000n, // 60% funded (above 50% minimum) + currentFundedCents: 30000n, // Set current funding + priority: 10, + cycleStart: new Date("2025-01-06T00:00:00Z"), // Monday + dueOn: new Date("2025-01-06T00:00:00Z"), + periodDays: 7, + autoPayEnabled: true, + paymentSchedule, + nextPaymentDate: new Date("2025-01-06T09:00:00Z"), // Monday 9 AM + }, + select: { id: true }, + }); + + const reports = await processAutoPayments(prisma, "2025-01-06T10:00:00Z", { dryRun: false }); + + expect(reports).toHaveLength(1); + expect(reports[0].success).toBe(true); + expect(reports[0].paymentAmountCents).toBe(30000); // Full funded amount + + // Verify next payment is scheduled for next Monday + const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({ + where: { id: plan.id }, + }); + + const nextPayment = updatedPlan.nextPaymentDate!; + expect(nextPayment.getDay()).toBe(1); // Monday + expect(nextPayment.getDate()).toBe(13); // Next Monday (Jan 13) + }); + + it("processes daily auto-payment", async () => { + const paymentSchedule: PaymentSchedule = { + frequency: "daily", + minFundingPercent: 25, + }; + + await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Daily Payment", + totalCents: 10000n, // $100 + fundedCents: 3000n, // 30% funded (above 25% minimum) + currentFundedCents: 3000n, // Set current funding + priority: 10, + cycleStart: new Date("2025-01-01T00:00:00Z"), + dueOn: new Date("2025-01-01T00:00:00Z"), + periodDays: 1, + autoPayEnabled: true, + paymentSchedule, + nextPaymentDate: new Date("2025-01-01T09:00:00Z"), + }, + }); + + const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false }); + + expect(reports).toHaveLength(1); + expect(reports[0].success).toBe(true); + expect(reports[0].paymentAmountCents).toBe(3000); + }); + + it("handles multiple plans with different schedules", async () => { + // Plan 1: Ready for payment + await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Ready Plan", + totalCents: 50000n, + fundedCents: 50000n, + currentFundedCents: 50000n, + priority: 10, + cycleStart: new Date("2025-01-01T00:00:00Z"), + dueOn: new Date("2025-01-01T00:00:00Z"), + periodDays: 30, + autoPayEnabled: true, + paymentSchedule: { frequency: "monthly", minFundingPercent: 100 }, + nextPaymentDate: new Date("2025-01-01T09:00:00Z"), + }, + }); + + // Plan 2: Not ready (insufficient funding) + await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Not Ready Plan", + totalCents: 100000n, + fundedCents: 30000n, // Only 30% funded + currentFundedCents: 30000n, + priority: 20, + cycleStart: new Date("2025-01-01T00:00:00Z"), + dueOn: new Date("2025-01-01T00:00:00Z"), + periodDays: 30, + autoPayEnabled: true, + paymentSchedule: { frequency: "monthly", minFundingPercent: 100 }, + nextPaymentDate: new Date("2025-01-01T09:00:00Z"), + }, + }); + + // Plan 3: Auto-pay disabled + await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Disabled Plan", + totalCents: 75000n, + fundedCents: 75000n, + currentFundedCents: 75000n, + priority: 30, + cycleStart: new Date("2025-01-01T00:00:00Z"), + dueOn: new Date("2025-01-01T00:00:00Z"), + periodDays: 30, + autoPayEnabled: false, // Disabled + nextPaymentDate: new Date("2025-01-01T09:00:00Z"), + }, + }); + + const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: false }); + + // Should only process the first two plans (third is disabled) + expect(reports).toHaveLength(2); + + const successfulPayments = reports.filter(r => r.success); + const failedPayments = reports.filter(r => !r.success); + + expect(successfulPayments).toHaveLength(1); + expect(failedPayments).toHaveLength(1); + + expect(successfulPayments[0].name).toBe("Ready Plan"); + expect(failedPayments[0].name).toBe("Not Ready Plan"); + + // Verify only one transaction was created + const transactions = await prisma.transaction.findMany({ + where: { kind: "fixed_payment" }, + }); + + expect(transactions).toHaveLength(1); + }); + + it("handles dry run mode", async () => { + await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Dry Run Plan", + totalCents: 100000n, + fundedCents: 100000n, + currentFundedCents: 100000n, + priority: 10, + cycleStart: new Date("2025-01-01T00:00:00Z"), + dueOn: new Date("2025-01-01T00:00:00Z"), + periodDays: 30, + autoPayEnabled: true, + paymentSchedule: { frequency: "monthly", minFundingPercent: 100 }, + nextPaymentDate: new Date("2025-01-01T09:00:00Z"), + }, + }); + + // Run in dry-run mode + const reports = await processAutoPayments(prisma, "2025-01-01T10:00:00Z", { dryRun: true }); + + expect(reports).toHaveLength(1); + expect(reports[0].success).toBe(true); + expect(reports[0].paymentAmountCents).toBe(100000); + + // Verify no transaction was created in dry-run mode + const transaction = await prisma.transaction.findFirst({ + where: { kind: "fixed_payment" }, + }); + + expect(transaction).toBeNull(); + + // Verify plan was not modified in dry-run mode + const plan = await prisma.fixedPlan.findFirst({ + where: { name: "Dry Run Plan" }, + }); + + expect(Number(plan?.fundedCents)).toBe(100000); // Still fully funded + expect(plan?.lastAutoPayment).toBeNull(); // Not updated + }); + + it("calculates next payment dates correctly for end-of-month scenarios", async () => { + const paymentSchedule: PaymentSchedule = { + frequency: "monthly", + dayOfMonth: 31, // End of month + minFundingPercent: 100, + }; + + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "End of Month Plan", + totalCents: 100000n, + fundedCents: 100000n, + currentFundedCents: 100000n, + priority: 10, + cycleStart: new Date("2025-01-31T00:00:00Z"), // January 31st + dueOn: new Date("2025-01-31T00:00:00Z"), + periodDays: 30, + autoPayEnabled: true, + paymentSchedule, + nextPaymentDate: new Date("2025-01-31T09:00:00Z"), + }, + select: { id: true }, + }); + + const reports = await processAutoPayments(prisma, "2025-01-31T10:00:00Z", { dryRun: false }); + + expect(reports).toHaveLength(1); + expect(reports[0].success).toBe(true); + + // Verify next payment is February 28th (since Feb doesn't have 31 days) + const updatedPlan = await prisma.fixedPlan.findUniqueOrThrow({ + where: { id: plan.id }, + }); + + const nextPayment = updatedPlan.nextPaymentDate!; + expect(nextPayment.getMonth()).toBe(1); // February + expect(nextPayment.getDate()).toBe(28); // February 28th (not 31st) + }); +}); \ No newline at end of file diff --git a/api/tests/budget-allocation.test.ts b/api/tests/budget-allocation.test.ts new file mode 100644 index 0000000..a38b9d0 --- /dev/null +++ b/api/tests/budget-allocation.test.ts @@ -0,0 +1,132 @@ +// tests/budget-allocation.test.ts +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers"; +import { allocateBudget } from "../src/allocator"; + +describe("Budget Allocation for Irregular Income", () => { + beforeEach(async () => { + await resetUser(U); + await ensureUser(U); + + // Update user to irregular income type + await prisma.user.update({ + where: { id: U }, + data: { + incomeType: "irregular", + totalBudgetCents: 300000n, // $3000 monthly budget + budgetPeriod: "monthly", + }, + }); + }); + + afterAll(async () => { + await closePrisma(); + }); + + describe("allocateBudget", () => { + it("allocates budget between fixed plans and variable categories", async () => { + const c1 = cid("groceries"); + const c2 = cid("savings"); + await prisma.variableCategory.createMany({ + data: [ + { id: c1, userId: U, name: "Groceries", percent: 60, priority: 2, isSavings: false, balanceCents: 0n }, + { id: c2, userId: U, name: "Savings", percent: 40, priority: 1, isSavings: true, balanceCents: 0n }, + ], + }); + + const p1 = pid("rent"); + await prisma.fixedPlan.create({ + data: { + id: p1, + userId: U, + name: "Rent", + cycleStart: new Date().toISOString(), + dueOn: new Date(Date.now() + 21 * 86400000).toISOString(), // due in 3 weeks + totalCents: 120000n, // $1200 rent + fundedCents: 0n, + currentFundedCents: 0n, + priority: 1, + fundingMode: "auto-on-deposit", + }, + }); + + // Allocate $3000 budget with 30% to fixed expenses + const result = await allocateBudget(prisma as any, U, 300000, 30); + + expect(result).toBeDefined(); + expect(result.totalBudgetCents).toBe(300000); + + // Should have both fixed and variable allocations + expect(result.fixedAllocations.length).toBeGreaterThan(0); + expect(result.variableAllocations.length).toBeGreaterThan(0); + + // Total allocation should not exceed budget + const totalAllocated = result.fundedBudgetCents + result.availableBudgetCents; + expect(totalAllocated).toBeLessThanOrEqual(300000); + + // Rent should get some funding + const rentAllocation = result.fixedAllocations.find(a => a.fixedPlanId === p1); + expect(rentAllocation).toBeDefined(); + expect(rentAllocation!.amountCents).toBeGreaterThan(0); + }); + + it("handles crisis mode with longer window for irregular income", async () => { + const c1 = cid("emergency"); + await prisma.variableCategory.create({ + data: { id: c1, userId: U, name: "Emergency", percent: 100, priority: 1, isSavings: true, balanceCents: 0n }, + }); + + const p1 = pid("urgent"); + await prisma.fixedPlan.create({ + data: { + id: p1, + userId: U, + name: "Urgent Bill", + cycleStart: new Date().toISOString(), + dueOn: new Date(Date.now() + 10 * 86400000).toISOString(), // due in 10 days + totalCents: 80000n, // $800 - more than the 50% allocation ($500) + fundedCents: 0n, + currentFundedCents: 0n, + priority: 1, + fundingMode: "auto-on-deposit", + }, + }); + + const result = await allocateBudget(prisma as any, U, 100000, 50); // 50% to fixed for crisis testing + + // Crisis should be detected (10 days < 14 day window for irregular income) + expect(result.crisis.active).toBe(true); + expect(result.crisis.plans.length).toBeGreaterThan(0); + + const urgentPlan = result.crisis.plans.find(p => p.id === p1); + expect(urgentPlan).toBeDefined(); + }); + + it("creates and updates budget session", async () => { + const c1 = cid("test"); + await prisma.variableCategory.create({ + data: { id: c1, userId: U, name: "Test", percent: 100, priority: 1, isSavings: false, balanceCents: 0n }, + }); + + const result = await allocateBudget(prisma as any, U, 200000, 40); // 40% to fixed expenses + + expect(result.totalBudgetCents).toBe(200000); + expect(result.availableBudgetCents).toBeGreaterThan(0); + }); + + it("handles zero budget gracefully", async () => { + const c1 = cid("test"); + await prisma.variableCategory.create({ + data: { id: c1, userId: U, name: "Test", percent: 100, priority: 1, isSavings: false, balanceCents: 0n }, + }); + + const result = await allocateBudget(prisma as any, U, 0, 30); // 30% to fixed expenses + + expect(result.totalBudgetCents).toBe(0); + expect(result.fundedBudgetCents).toBe(0); + expect(result.availableBudgetCents).toBe(0); + expect(result.fixedAllocations).toHaveLength(0); + expect(result.variableAllocations).toHaveLength(0); + }); + }); +}); \ No newline at end of file diff --git a/api/tests/irregular-income-simple.test.ts b/api/tests/irregular-income-simple.test.ts new file mode 100644 index 0000000..eda80d9 --- /dev/null +++ b/api/tests/irregular-income-simple.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { allocateBudget, applyIrregularIncome, previewAllocation } from '../src/allocator.ts'; +import { prisma, U, resetUser, ensureUser, cid, pid } from './helpers.ts'; + +describe('Irregular Income Allocation', () => { + const userId = U; + + beforeEach(async () => { + await resetUser(userId); + await ensureUser(userId); + }); + + async function createFixedPlan(totalCents: number, name: string, priority: number, dueInDays: number) { + const id = pid(); + const now = new Date(); + await prisma.fixedPlan.create({ + data: { + id, + userId, + name, + totalCents: BigInt(totalCents), + priority, + dueOn: new Date(Date.now() + dueInDays * 24 * 60 * 60 * 1000), + cycleStart: now, // Required field + }, + }); + return id; + } + + async function createVariableCategory(name: string, percent: number, priority: number, isSavings = false) { + const id = cid(); + await prisma.variableCategory.create({ + data: { + id, + userId, + name, + percent, + priority, + isSavings, + }, + }); + return id; + } + + async function createIncomeEvent(amountCents: number, daysAgo = 0) { + const id = `income_${Date.now()}_${Math.random()}`; + await prisma.incomeEvent.create({ + data: { + id, + userId, + amountCents: BigInt(amountCents), + postedAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000), + }, + }); + return id; + } + + describe('Basic Allocation Logic', () => { + it('should allocate percentage of new income to fixed expenses', async () => { + // Setup: Create fixed plans and variable categories + const rentId = await createFixedPlan(50000, 'Rent', 1, 10); // $500, due in 10 days (reduced so both can get funding) + const carId = await createFixedPlan(40000, 'Car Payment', 2, 5); // $400, due in 5 days + + await createVariableCategory('Groceries', 50, 1); + await createVariableCategory('Entertainment', 30, 2); + await createVariableCategory('Savings', 20, 3, true); + + // Add some available budget + await createIncomeEvent(50000, 1); // $500 from 1 day ago + + // Test: Allocate $2,000 with 30% to fixed expenses + const result = await allocateBudget(prisma, userId, 200000, 30); + + expect(result.totalBudgetCents).toBe(200000); // $2,000 new income + + // Fixed expenses should get 30% of new income = $600 + const totalFixedAllocated = result.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0); + expect(totalFixedAllocated).toBe(60000); // $600 + + // Car payment (5 days, crisis for irregular) should get some allocation + const carAllocation = result.fixedAllocations.find(a => a.fixedPlanId === carId); + expect(carAllocation).toBeDefined(); + expect(carAllocation!.amountCents).toBeGreaterThan(0); + + // Variables should get available budget + remaining new income + const totalVariableAllocated = result.variableAllocations.reduce((sum, a) => sum + a.amountCents, 0); + expect(totalVariableAllocated).toBeGreaterThan(0); + + + }); + + it('should treat plans due within 14 days as crisis', async () => { + // Setup: Create fixed plan due in 12 days + const urgentBillId = await createFixedPlan(80000, 'Urgent Bill', 1, 12); // $800, due in 12 days + + const result = await allocateBudget(prisma, userId, 100000, 50); // $1,000 with 50% to fixed + + expect(result.crisis.active).toBe(true); + expect(result.crisis.plans).toHaveLength(1); + expect(result.crisis.plans[0].id).toBe(urgentBillId); + expect(result.crisis.plans[0].daysUntilDue).toBe(12); + }); + + it('should work with different fixed expense percentages', async () => { + await createFixedPlan(100000, 'Rent', 1, 30); // $1,000 + await createVariableCategory('Everything', 100, 1); + + // Test 10% to fixed expenses + const result10 = await allocateBudget(prisma, userId, 200000, 10); + const fixed10 = result10.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0); + + // Test 50% to fixed expenses + const result50 = await allocateBudget(prisma, userId, 200000, 50); + const fixed50 = result50.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0); + + expect(fixed50).toBeGreaterThan(fixed10); + console.log('10% allocation to fixed:', fixed10); + console.log('50% allocation to fixed:', fixed50); + }); + }); + + describe('Comparison with Regular Income', () => { + it('should show meaningful differences from regular income allocation', async () => { + // Setup same scenario for both + await createFixedPlan(120000, 'Rent', 1, 10); // $1,200, due in 10 days + await createFixedPlan(40000, 'Car Payment', 2, 5); // $400, due in 5 days + + await createVariableCategory('Groceries', 60, 1); + await createVariableCategory('Savings', 40, 2, true); + + // Add available budget + await createIncomeEvent(75000, 1); // $750 available + + // Set user to biweekly frequency for regular income test + await prisma.user.update({ + where: { id: userId }, + data: { incomeFrequency: 'biweekly' } + }); + + // Test regular income allocation + const regularResult = await previewAllocation(prisma, userId, 200000); // $2,000 + + // Test irregular income allocation + const irregularResult = await allocateBudget(prisma, userId, 200000, 30); // $2,000 with 30% fixed + + // Compare results + const regularFixedTotal = regularResult.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0); + const irregularFixedTotal = irregularResult.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0); + + // The strategies should be different + expect(regularFixedTotal).not.toBe(irregularFixedTotal); + + console.log('Regular Income Fixed Allocation:', regularFixedTotal); + console.log('Irregular Income Fixed Allocation:', irregularFixedTotal); + console.log('Irregular should be capped at 30% = $600:', irregularFixedTotal <= 60000); + }); + }); + + describe('Edge Cases', () => { + it('should handle 0% fixed expense allocation', async () => { + await createFixedPlan(100000, 'Rent', 1, 30); + await createVariableCategory('Everything', 100, 1); + + const result = await allocateBudget(prisma, userId, 200000, 0); // 0% to fixed + + const fixedTotal = result.fixedAllocations.reduce((sum, a) => sum + a.amountCents, 0); + expect(fixedTotal).toBe(0); + }); + + it('should handle no fixed expenses', async () => { + await createVariableCategory('Everything', 100, 1); + + const result = await allocateBudget(prisma, userId, 200000, 30); + + expect(result.fixedAllocations).toHaveLength(0); + const variableTotal = result.variableAllocations.reduce((sum, a) => sum + a.amountCents, 0); + expect(variableTotal).toBe(200000); // All income to variables + }); + }); +}); \ No newline at end of file diff --git a/api/tests/payment-rollover.test.ts b/api/tests/payment-rollover.test.ts new file mode 100644 index 0000000..349b179 --- /dev/null +++ b/api/tests/payment-rollover.test.ts @@ -0,0 +1,230 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import request from "supertest"; +import appFactory from "./appFactory"; +import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers"; +import type { FastifyInstance } from "fastify"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await appFactory(); +}); + +beforeEach(async () => { + await resetUser(U); + await ensureUser(U); +}); + +afterAll(async () => { + await app.close(); + await closePrisma(); +}); + +describe("Payment-Triggered Rollover", () => { + it("advances due date for weekly frequency on payment", async () => { + // Create a fixed plan with weekly frequency + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Weekly Subscription", + totalCents: 1000n, + fundedCents: 1000n, + currentFundedCents: 1000n, + priority: 10, + cycleStart: new Date("2025-11-01T00:00:00Z"), + dueOn: new Date("2025-12-01T00:00:00Z"), + frequency: "weekly", + }, + }); + + // Make payment + const txRes = await request(app.server) + .post("/transactions") + .set("x-user-id", U) + .send({ + occurredAtISO: "2025-11-27T12:00:00Z", + kind: "fixed_payment", + amountCents: 1000, + planId: plan.id, + }); + + if (txRes.status !== 200) { + console.log("Response status:", txRes.status); + console.log("Response body:", txRes.body); + } + expect(txRes.status).toBe(200); + + // Check plan was updated with next due date (7 days later) + const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); + expect(updated?.fundedCents).toBe(0n); + expect(updated?.currentFundedCents).toBe(0n); + expect(updated?.dueOn.toISOString()).toBe("2025-12-08T00:00:00.000Z"); + }); + + it("advances due date for biweekly frequency on payment", async () => { + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Biweekly Bill", + totalCents: 5000n, + fundedCents: 5000n, + currentFundedCents: 5000n, + priority: 10, + cycleStart: new Date("2025-11-01T00:00:00Z"), + dueOn: new Date("2025-12-01T00:00:00Z"), + frequency: "biweekly", + }, + }); + + const txRes = await request(app.server) + .post("/transactions") + .set("x-user-id", U) + .send({ + occurredAtISO: "2025-11-27T12:00:00Z", + kind: "fixed_payment", + amountCents: 5000, + planId: plan.id, + }); + + expect(txRes.status).toBe(200); + + const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); + expect(updated?.fundedCents).toBe(0n); + expect(updated?.dueOn.toISOString()).toBe("2025-12-15T00:00:00.000Z"); + }); + + it("advances due date for monthly frequency on payment", async () => { + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Monthly Rent", + totalCents: 100000n, + fundedCents: 100000n, + currentFundedCents: 100000n, + priority: 10, + cycleStart: new Date("2025-11-01T00:00:00Z"), + dueOn: new Date("2025-12-01T00:00:00Z"), + frequency: "monthly", + }, + }); + + const txRes = await request(app.server) + .post("/transactions") + .set("x-user-id", U) + .send({ + occurredAtISO: "2025-11-27T12:00:00Z", + kind: "fixed_payment", + amountCents: 100000, + planId: plan.id, + }); + + expect(txRes.status).toBe(200); + + const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); + expect(updated?.fundedCents).toBe(0n); + expect(updated?.dueOn.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("does not advance due date for one-time frequency", async () => { + const originalDueDate = new Date("2025-12-01T00:00:00Z"); + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "One-time Expense", + totalCents: 2000n, + fundedCents: 2000n, + currentFundedCents: 2000n, + priority: 10, + cycleStart: new Date("2025-11-01T00:00:00Z"), + dueOn: originalDueDate, + frequency: "one-time", + }, + }); + + const txRes = await request(app.server) + .post("/transactions") + .set("x-user-id", U) + .send({ + occurredAtISO: "2025-11-27T12:00:00Z", + kind: "fixed_payment", + amountCents: 2000, + planId: plan.id, + }); + + expect(txRes.status).toBe(200); + + const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); + expect(updated?.fundedCents).toBe(0n); + // Due date should remain unchanged for one-time expenses + expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString()); + }); + + it("does not advance due date when no frequency is set", async () => { + const originalDueDate = new Date("2025-12-01T00:00:00Z"); + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Manual Bill", + totalCents: 3000n, + fundedCents: 3000n, + currentFundedCents: 3000n, + priority: 10, + cycleStart: new Date("2025-11-01T00:00:00Z"), + dueOn: originalDueDate, + frequency: null, + }, + }); + + const txRes = await request(app.server) + .post("/transactions") + .set("x-user-id", U) + .send({ + occurredAtISO: "2025-11-27T12:00:00Z", + kind: "fixed_payment", + amountCents: 3000, + planId: plan.id, + }); + + expect(txRes.status).toBe(200); + + const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); + expect(updated?.fundedCents).toBe(0n); + // Due date should remain unchanged when no frequency + expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString()); + }); + + it("prevents payment when insufficient funded amount", async () => { + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Underfunded Bill", + totalCents: 10000n, + fundedCents: 5000n, + currentFundedCents: 5000n, + priority: 10, + cycleStart: new Date("2025-11-01T00:00:00Z"), + dueOn: new Date("2025-12-01T00:00:00Z"), + frequency: "monthly", + }, + }); + + // Try to pay more than funded amount + const txRes = await request(app.server) + .post("/transactions") + .set("x-user-id", U) + .send({ + occurredAtISO: "2025-11-27T12:00:00Z", + kind: "fixed_payment", + amountCents: 10000, + planId: plan.id, + }); + + expect(txRes.status).toBe(400); + expect(txRes.body.code).toBe("OVERDRAFT_PLAN"); + + // Plan should remain unchanged + const unchanged = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); + expect(unchanged?.fundedCents).toBe(5000n); + expect(unchanged?.dueOn.toISOString()).toBe("2025-12-01T00:00:00.000Z"); + }); +}); diff --git a/api/tests/rollover.test.ts b/api/tests/rollover.test.ts new file mode 100644 index 0000000..e76da10 --- /dev/null +++ b/api/tests/rollover.test.ts @@ -0,0 +1,63 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers"; +import { rolloverFixedPlans } from "../src/jobs/rollover"; + +describe("rolloverFixedPlans", () => { + beforeEach(async () => { + await resetUser(U); + await ensureUser(U); + }); + + it("advances overdue plans and resets funding", async () => { + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Test Plan", + totalCents: 10000n, + fundedCents: 6000n, + priority: 10, + cycleStart: new Date("2025-01-01T00:00:00Z"), + dueOn: new Date("2025-01-05T00:00:00Z"), + periodDays: 30, + }, + select: { id: true }, + }); + + const results = await rolloverFixedPlans(prisma, "2025-01-10T00:00:00Z"); + const match = results.find((r) => r.planId === plan.id); + expect(match?.cyclesAdvanced).toBe(1); + expect(match?.deficitCents).toBe(4000); + + const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } }); + expect(updated.fundedCents).toBe(0n); + expect(updated.dueOn.toISOString()).toBe("2025-02-04T00:00:00.000Z"); + }); + + it("handles multiple missed cycles and carries surplus", async () => { + const plan = await prisma.fixedPlan.create({ + data: { + userId: U, + name: "Surplus Plan", + totalCents: 5000n, + fundedCents: 12000n, + priority: 5, + cycleStart: new Date("2025-01-01T00:00:00Z"), + dueOn: new Date("2025-01-10T00:00:00Z"), + periodDays: 15, + }, + select: { id: true }, + }); + + const results = await rolloverFixedPlans(prisma, "2025-02-05T00:00:00Z"); + const match = results.find((r) => r.planId === plan.id); + expect(match?.cyclesAdvanced).toBe(2); + expect(match?.carryForwardCents).toBe(2000); + + const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } }); + expect(updated.fundedCents).toBe(2000n); + }); +}); + +afterAll(async () => { + await closePrisma(); +}); diff --git a/api/tests/setup.ts b/api/tests/setup.ts index 9efb13a..8f9d876 100644 --- a/api/tests/setup.ts +++ b/api/tests/setup.ts @@ -9,6 +9,8 @@ process.env.DATABASE_URL = process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port process.env.HOST ??= "127.0.0.1"; process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || ""; +process.env.AUTH_DISABLED = process.env.AUTH_DISABLED || "1"; +process.env.SEED_DEFAULT_BUDGET = process.env.SEED_DEFAULT_BUDGET || "1"; export const prisma = new PrismaClient(); @@ -25,8 +27,18 @@ export async function resetUser(userId: string) { beforeAll(async () => { // make sure the schema is applied before running tests execSync("npx prisma migrate deploy", { stdio: "inherit" }); + + // Ensure a clean slate: wipe all tables to avoid cross-file leakage + await prisma.$transaction([ + prisma.allocation.deleteMany({}), + prisma.transaction.deleteMany({}), + prisma.incomeEvent.deleteMany({}), + prisma.fixedPlan.deleteMany({}), + prisma.variableCategory.deleteMany({}), + prisma.user.deleteMany({}), + ]); }); afterAll(async () => { await prisma.$disconnect(); -}); \ No newline at end of file +}); diff --git a/api/tests/transactions.test.ts b/api/tests/transactions.test.ts index 0604acd..09b2e99 100644 --- a/api/tests/transactions.test.ts +++ b/api/tests/transactions.test.ts @@ -1,7 +1,7 @@ import request from "supertest"; import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest"; import appFactory from "./appFactory"; -import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers"; +import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers"; import type { FastifyInstance } from "fastify"; let app: FastifyInstance; @@ -16,16 +16,34 @@ afterAll(async () => { }); describe("GET /transactions", () => { + let catId: string; + let planId: string; + beforeEach(async () => { await resetUser(U); await ensureUser(U); - const c = cid("c"); + catId = cid("c"); + planId = pid("p"); + await prisma.variableCategory.create({ - data: { id: c, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n }, + data: { id: catId, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n }, + }); + + await prisma.fixedPlan.create({ + data: { + id: planId, + userId: U, + name: "Rent", + totalCents: 10000n, + fundedCents: 2000n, + priority: 1, + cycleStart: new Date().toISOString(), + dueOn: new Date(Date.now() + 864e5).toISOString(), + fundingMode: "auto-on-deposit", + }, }); - // seed some transactions of different kinds/dates await prisma.transaction.createMany({ data: [ { @@ -33,7 +51,7 @@ describe("GET /transactions", () => { userId: U, occurredAt: new Date("2025-01-03T12:00:00.000Z"), kind: "variable_spend", - categoryId: c, + categoryId: catId, amountCents: 1000n, }, { @@ -41,17 +59,13 @@ describe("GET /transactions", () => { userId: U, occurredAt: new Date("2025-01-10T12:00:00.000Z"), kind: "fixed_payment", - planId: null, + planId, amountCents: 2000n, }, ], }); }); - afterAll(async () => { - await closePrisma(); - }); - it("paginates + filters by kind/date", async () => { const res = await request(app.server) .get("/transactions?from=2025-01-02&to=2025-01-06&kind=variable_spend&page=1&limit=10") @@ -63,4 +77,129 @@ describe("GET /transactions", () => { expect(body.items.length).toBe(1); expect(body.items[0].kind).toBe("variable_spend"); }); + + it("filters by bucket id for either category or plan", async () => { + const byCategory = await request(app.server) + .get(`/transactions?bucketId=${catId}`) + .set("x-user-id", U); + + expect(byCategory.status).toBe(200); + expect(byCategory.body.items.every((t: any) => t.categoryId === catId)).toBe(true); + + const byPlan = await request(app.server) + .get(`/transactions?bucketId=${planId}`) + .set("x-user-id", U); + + expect(byPlan.status).toBe(200); + expect(byPlan.body.items.every((t: any) => t.planId === planId)).toBe(true); + }); +}); + +describe("POST /transactions", () => { + const dateISO = new Date().toISOString(); + let catId: string; + let planId: string; + + beforeEach(async () => { + await resetUser(U); + await ensureUser(U); + + catId = cid("cat"); + planId = pid("plan"); + + await prisma.variableCategory.create({ + data: { + id: catId, + userId: U, + name: "Dining", + percent: 100, + priority: 1, + isSavings: false, + balanceCents: 5000n, + }, + }); + + await prisma.fixedPlan.create({ + data: { + id: planId, + userId: U, + name: "Loan", + totalCents: 10000n, + fundedCents: 3000n, + priority: 1, + cycleStart: new Date().toISOString(), + dueOn: new Date(Date.now() + 864e5).toISOString(), + fundingMode: "auto-on-deposit", + }, + }); + }); + + it("spends from a variable category and updates balance", async () => { + const res = await request(app.server) + .post("/transactions") + .set("x-user-id", U) + .send({ + kind: "variable_spend", + amountCents: 2000, + occurredAtISO: dateISO, + categoryId: catId, + note: "Groceries run", + receiptUrl: "https://example.com/receipt", + isReconciled: true, + }); + + expect(res.status).toBe(200); + const category = await prisma.variableCategory.findUniqueOrThrow({ where: { id: catId } }); + expect(Number(category.balanceCents)).toBe(3000); + const tx = await prisma.transaction.findFirstOrThrow({ where: { userId: U, categoryId: catId } }); + expect(tx.note).toBe("Groceries run"); + expect(tx.receiptUrl).toBe("https://example.com/receipt"); + expect(tx.isReconciled).toBe(true); + }); + + it("prevents overdrawing fixed plans", async () => { + const res = await request(app.server) + .post("/transactions") + .set("x-user-id", U) + .send({ + kind: "fixed_payment", + amountCents: 400000, // exceeds funded + occurredAtISO: dateISO, + planId, + }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe("OVERDRAFT_PLAN"); + }); + + it("updates note/receipt and reconciliation via patch", async () => { + const created = await request(app.server) + .post("/transactions") + .set("x-user-id", U) + .send({ + kind: "variable_spend", + amountCents: 1000, + occurredAtISO: dateISO, + categoryId: catId, + }); + expect(created.status).toBe(200); + const txId = created.body.id; + + const res = await request(app.server) + .patch(`/transactions/${txId}`) + .set("x-user-id", U) + .send({ + note: "Cleared", + isReconciled: true, + receiptUrl: "https://example.com/r.pdf", + }); + + expect(res.status).toBe(200); + expect(res.body.isReconciled).toBe(true); + expect(res.body.note).toBe("Cleared"); + expect(res.body.receiptUrl).toBe("https://example.com/r.pdf"); + + const tx = await prisma.transaction.findUniqueOrThrow({ where: { id: txId } }); + expect(tx.isReconciled).toBe(true); + }); }); diff --git a/api/vitest.config.ts b/api/vitest.config.ts index ce9a6ec..67546a1 100644 --- a/api/vitest.config.ts +++ b/api/vitest.config.ts @@ -8,7 +8,12 @@ export default defineConfig({ pool: "threads", poolOptions: { threads: { singleThread: true } }, testTimeout: 30_000, - env: { NODE_ENV: "test" }, + env: { + NODE_ENV: "test", + AUTH_DISABLED: "1", + DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney", + SEED_DEFAULT_BUDGET: "1" + }, setupFiles: ['tests/setup.ts'], }, }); \ No newline at end of file diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..8418b9e --- /dev/null +++ b/cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJiOGEzN2U1Ny01M2QyLTQyMGYtOWYzZi1lYTdiNmEyM2JmNjgiLCJpYXQiOjE3NjM4NzEwMjl9.2H5loEHfxq33U1D5kS_Jt43CIoq_kKwKeHUTjjXQFzI diff --git a/cookies2.txt b/cookies2.txt new file mode 100644 index 0000000..8f0a3c9 --- /dev/null +++ b/cookies2.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmODE5NzlmYS1mZTQwLTQxOTUtOGMwNC0xZWE2OWQ1NjRmZDQiLCJpYXQiOjE3NjM4NzEwOTl9.Jj4PzUGZuW2Eg-8vt1uJkLIcdDe5ghWFDCcvGYRHjSY diff --git a/cookies_debug.txt b/cookies_debug.txt new file mode 100644 index 0000000..aef419c --- /dev/null +++ b/cookies_debug.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / TRUE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2ZWQzMTNhMS05MTNiLTRmYzEtYTFiZC0wODhmOWFlY2FhNGUiLCJpYXQiOjE3NjM4NzE0MjN9.hMpW1tyRd-f5P6WuIm-56-PbdVo_7D9reYYYtcSgVn0 diff --git a/cookies_fixed.txt b/cookies_fixed.txt new file mode 100644 index 0000000..0ad602b --- /dev/null +++ b/cookies_fixed.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhNjEzMDZhZi05Mzg3LTQ3MzYtODYxYS0zODA5M2RhYzYwZmYiLCJpYXQiOjE3NjM4NzE2Mzl9.g_RurcGYjyJitJRLaPiVM5vAk5Nc94u05k5Rc83lV5k diff --git a/cookies_immediate.txt b/cookies_immediate.txt new file mode 100644 index 0000000..588e0aa --- /dev/null +++ b/cookies_immediate.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3MWQxZjc1My1hYTI3LTQxOTEtYWIxNS05NjNjYWZlNjE2MGEiLCJpYXQiOjE3NjM4NzE1MDJ9.djJEIX3xz_typgayo_FcT8lvpWGXOjv8FLalmaPojQg diff --git a/cookies_login.txt b/cookies_login.txt new file mode 100644 index 0000000..95aa001 --- /dev/null +++ b/cookies_login.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhNjEzMDZhZi05Mzg3LTQ3MzYtODYxYS0zODA5M2RhYzYwZmYiLCJpYXQiOjE3NjM4NzE2NTV9.K5pjWe9iUDawB7yrLNoQywvAmjgv33D2LTa6T50FKCY diff --git a/cookies_test.txt b/cookies_test.txt new file mode 100644 index 0000000..ec7ac90 --- /dev/null +++ b/cookies_test.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyYmFhNzUwMS1iMzVkLTQyN2EtYTRlMy0wNTg0ZDczN2Y4MmYiLCJpYXQiOjE3NjM4NzE0NzN9.LpnakXIqy6brb_I59fFBTPhzyeZ6NoFpCiSvRQla-BE diff --git a/deploy/nginx/skymoneybudget.com.conf b/deploy/nginx/skymoneybudget.com.conf new file mode 100644 index 0000000..f8e393d --- /dev/null +++ b/deploy/nginx/skymoneybudget.com.conf @@ -0,0 +1,39 @@ +server { + listen 80; + server_name skymoneybudget.com; + + # Redirect all HTTP to HTTPS + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name skymoneybudget.com; + + # TLS certs (adjust paths if using certbot or another ACME client) + ssl_certificate /etc/letsencrypt/live/skymoneybudget.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/skymoneybudget.com/privkey.pem; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Static web app + root /var/www/skymoney/dist; + index index.html; + + location / { + try_files $uri /index.html; + } + + # API reverse proxy + location /api/ { + proxy_pass http://127.0.0.1:8081/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index c5b2586..1959eb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,18 @@ -version: "3.9" services: postgres: image: postgres:15 environment: - POSTGRES_DB: skymoney - POSTGRES_USER: app - POSTGRES_PASSWORD: app + POSTGRES_DB: ${POSTGRES_DB:-skymoney} + POSTGRES_USER: ${POSTGRES_USER:-app} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app} volumes: - pgdata:/var/lib/postgresql/data ports: - "5432:5432" restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -U app -d skymoney"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-skymoney}"] interval: 5s timeout: 3s retries: 10 @@ -23,10 +22,14 @@ services: context: ./api dockerfile: Dockerfile environment: - NODE_ENV: production - PORT: "8080" - DATABASE_URL: postgres://app:app@postgres:5432/skymoney - CORS_ORIGIN: http://localhost:5173 + NODE_ENV: ${NODE_ENV:-development} + PORT: ${PORT:-8080} + DATABASE_URL: ${DATABASE_URL:-postgres://app:app@postgres:5432/skymoney} + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173} + SEED_DEFAULT_BUDGET: ${SEED_DEFAULT_BUDGET:-0} + AUTH_DISABLED: ${AUTH_DISABLED:-false} + JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me} + COOKIE_SECRET: ${COOKIE_SECRET:-dev-cookie-secret-change-me} depends_on: - postgres @@ -57,5 +60,23 @@ services: timeout: 3s retries: 10 + scheduler: + build: + context: ./api + dockerfile: Dockerfile + command: ["node", "dist/worker/rollover.js"] + environment: + NODE_ENV: ${NODE_ENV:-production} + DATABASE_URL: ${DATABASE_URL:-postgres://app:app@postgres:5432/skymoney} + ROLLOVER_SCHEDULE_CRON: "${ROLLOVER_SCHEDULE_CRON:-0 6 * * *}" + restart: unless-stopped + depends_on: + - postgres + healthcheck: + test: ["CMD-SHELL", "node -e \"process.exit(0)\""] + interval: 60s + timeout: 5s + retries: 3 + volumes: pgdata: diff --git a/exporting b/exporting new file mode 100644 index 0000000..e69de29 diff --git a/irregular_cookies.txt b/irregular_cookies.txt new file mode 100644 index 0000000..1db519a --- /dev/null +++ b/irregular_cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlNzkzNTNiNS0zNTY1LTQ5YTQtYjE2NC1jNzU4NWVkNmU2MTgiLCJpYXQiOjE3NjM4NzE5ODV9.uoibcsSuH15ZS2bjtssHtt2aOcJF2RdDAHK_ynIeIco diff --git a/regular_cookies.txt b/regular_cookies.txt new file mode 100644 index 0000000..c82ff17 --- /dev/null +++ b/regular_cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 0 session eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MzUyZjRhMy1hNjM5LTQyMWEtODI2Ny0yMGM0NTdhMDk3MmUiLCJpYXQiOjE3NjM4NzE5NzN9.ZslzE0_K4Mxsmgg37PsFKUP-lfSdYZaQfUqTf3o5m5Y diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..a828b67 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${DATABASE_URL:-}" && -z "${BACKUP_DATABASE_URL:-}" ]]; then + echo "DATABASE_URL or BACKUP_DATABASE_URL is required." + exit 1 +fi + +ENV_FILE="${ENV_FILE:-./.env}" +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck source=/dev/null + . "$ENV_FILE" + set +a +fi + +OUT_DIR="${BACKUP_DIR:-./backups}" +mkdir -p "$OUT_DIR" + +STAMP="$(date +%F_%H%M%S)" +OUT_FILE="${OUT_DIR}/skymoney_${STAMP}.dump" + +pg_dump "${BACKUP_DATABASE_URL:-$DATABASE_URL}" -Fc -f "$OUT_FILE" + +echo "Backup written to: $OUT_FILE" diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100644 index 0000000..9b8588d --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${ENV_FILE:-./.env}" +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck source=/dev/null + . "$ENV_FILE" + set +a +fi + +if [[ -z "${BACKUP_FILE:-}" ]]; then + echo "BACKUP_FILE is required." + exit 1 +fi + +if [[ -z "${DATABASE_URL:-}" ]]; then + echo "DATABASE_URL is required." + exit 1 +fi + +RESTORE_DB="${RESTORE_DB:-skymoney_restore_test}" +RESTORE_URL="${RESTORE_DATABASE_URL:-}" +ADMIN_URL="${ADMIN_DATABASE_URL:-$DATABASE_URL}" + +if [[ -z "$RESTORE_URL" ]]; then + echo "RESTORE_DATABASE_URL is required (example: postgresql://user:pass@host:5432/${RESTORE_DB})." + exit 1 +fi + +echo "Creating restore database: ${RESTORE_DB}" +psql "$ADMIN_URL" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${RESTORE_DB}\";" >/dev/null +psql "$ADMIN_URL" -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"${RESTORE_DB}\";" >/dev/null + +pg_restore --no-owner --no-privileges --dbname="$RESTORE_URL" "$BACKUP_FILE" + +echo "Restore completed into: ${RESTORE_DB}" diff --git a/web/index.html b/web/index.html index af88f03..47ed02c 100644 --- a/web/index.html +++ b/web/index.html @@ -5,6 +5,36 @@ web +
diff --git a/web/package-lock.json b/web/package-lock.json index b73aad9..a86e035 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,11 +8,14 @@ "name": "web", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query": "^5.90.9", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.9.6", + "react-router-dom": "^7.12.0", "recharts": "^3.4.1", "tailwindcss": "^4.1.17", "zod": "^4.1.12" @@ -316,6 +319,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2479,12 +2535,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -4026,9 +4086,9 @@ } }, "node_modules/react-router": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", - "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4048,12 +4108,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", - "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", "license": "MIT", "dependencies": { - "react-router": "7.9.6" + "react-router": "7.12.0" }, "engines": { "node": ">=20.0.0" @@ -4441,6 +4501,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/web/package.json b/web/package.json index 159a9c8..9f14c2d 100644 --- a/web/package.json +++ b/web/package.json @@ -12,11 +12,14 @@ "test:watch": "vitest" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query": "^5.90.9", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.9.6", + "react-router-dom": "^7.12.0", "recharts": "^3.4.1", "tailwindcss": "^4.1.17", "zod": "^4.1.12" diff --git a/web/src/App.tsx b/web/src/App.tsx index a883d58..7ce864e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,63 +1,18 @@ -import { Link, NavLink, Route, Routes } from "react-router-dom"; -import DashboardPage from "./pages/DashboardPage"; -import IncomePage from "./pages/IncomePage"; -import TransactionsPage from "./pages/TransactionsPage"; +import { Suspense } from "react"; +import { Outlet } from "react-router-dom"; +import { SessionTimeoutWarning } from "./components/SessionTimeoutWarning"; +import NavBar from "./components/NavBar"; export default function App() { return ( -
- -
- - } /> - } /> - } /> - } /> - + <> + + +
+ Loading…
}> + + -
- SkyMoney • {new Date().getFullYear()} -
- - ); -} - -function TopNav() { - return ( -
-
- - SkyMoney - - - - -
demo user
-
-
- ); -} - -function Nav({ to, children }: { to: string; children: React.ReactNode }) { - return ( - - "px-3 py-1 rounded-xl hover:bg-[--color-ink]/60 " + - (isActive ? "bg-[--color-ink]" : "") - } - end - > - {children} - - ); -} - -function NotFound() { - return ( -
-

404

-

This page got lost in the budget cuts.

-
+ ); } diff --git a/web/src/api/budget.ts b/web/src/api/budget.ts new file mode 100644 index 0000000..1e7813a --- /dev/null +++ b/web/src/api/budget.ts @@ -0,0 +1,77 @@ +import { http } from "./http"; + +export interface BudgetAllocationRequest { + newIncomeCents: number; + fixedExpensePercentage: number; + postedAtISO?: string; +} + +export interface BudgetAllocationResponse { + fixedAllocations: Array<{ + fixedPlanId: string; + amountCents: number; + source: string; + }>; + variableAllocations: Array<{ + variableCategoryId: string; + amountCents: number; + }>; + totalBudgetCents: number; + fundedBudgetCents: number; + availableBudgetCents: number; + remainingBudgetCents: number; + crisis: { + active: boolean; + plans: Array<{ + id: string; + name: string; + remainingCents: number; + daysUntilDue: number; + priority: number; + allocatedCents: number; + }>; + }; + planStatesAfter: Array<{ + id: string; + name: string; + totalCents: number; + fundedCents: number; + remainingCents: number; + daysUntilDue: number; + isCrisis: boolean; + }>; +} + +export interface BudgetReconcileRequest { + bankTotalCents: number; +} + +export interface BudgetReconcileResponse { + ok: boolean; + deltaCents: number; + currentTotalCents: number; + newTotalCents: number; +} + +export const budgetApi = { + async allocate(data: BudgetAllocationRequest): Promise { + return http("/budget/allocate", { + method: "POST", + body: data, + }); + }, + + async fund(data: BudgetAllocationRequest): Promise { + return http("/budget/fund", { + method: "POST", + body: data, + }); + }, + + async reconcile(data: BudgetReconcileRequest): Promise { + return http("/budget/reconcile", { + method: "POST", + body: data, + }); + }, +}; diff --git a/web/src/api/categories.ts b/web/src/api/categories.ts index 5031a64..8ffbe6d 100644 --- a/web/src/api/categories.ts +++ b/web/src/api/categories.ts @@ -1,4 +1,4 @@ -import { request } from "./client"; +import { apiDelete, apiPatch, apiPost } from "./http"; export type NewCategory = { name: string; @@ -9,22 +9,8 @@ export type NewCategory = { export type UpdateCategory = Partial; export const categoriesApi = { - create: (body: NewCategory) => - request<{ id: number }>("/variable-categories", { - method: "POST", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" } - }), - - update: (id: number, body: UpdateCategory) => - request(`/variable-categories/${id}`, { - method: "PATCH", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" } - }), - - delete: (id: number) => - request(`/variable-categories/${id}`, { - method: "DELETE" - }) -}; \ No newline at end of file + create: (body: NewCategory) => apiPost<{ id: string }>("/variable-categories", body), + update: (id: string, body: UpdateCategory) => apiPatch(`/variable-categories/${id}`, body), + delete: (id: string) => apiDelete(`/variable-categories/${id}`), + rebalance: () => apiPost<{ ok: boolean; applied?: boolean; totalBalance?: number }>("/variable-categories/rebalance", {}), +}; diff --git a/web/src/api/client.ts b/web/src/api/client.ts deleted file mode 100644 index f2ef43e..0000000 --- a/web/src/api/client.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type ApiError = { status: number; message: string }; - -const base = "/api"; -const KEY = "skymoney:userId"; - -export function getUserId(): string { - let id = localStorage.getItem(KEY); - if (!id) { id = "1"; localStorage.setItem(KEY, id); } - return id; -} -export function setUserId(id: string) { - localStorage.setItem(KEY, String(id || "1")); -} - -export async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(`${base}${path}`, { - headers: { "Content-Type": "application/json", "x-user-id": getUserId(), ...(init?.headers || {}) }, - ...init - }); - const text = await res.text(); - const data = text ? JSON.parse(text) : null; - if (!res.ok) throw { status: res.status, message: data?.message || res.statusText } as ApiError; - return data as T; -} - -export const api = { - get: (path: string) => request(path), - post: (path: string, body: unknown) => - request(path, { method: "POST", body: JSON.stringify(body) }), -}; \ No newline at end of file diff --git a/web/src/api/fixedPlans.ts b/web/src/api/fixedPlans.ts index 1854884..5e5036b 100644 --- a/web/src/api/fixedPlans.ts +++ b/web/src/api/fixedPlans.ts @@ -1,4 +1,11 @@ -import { request } from "./client"; +import { apiDelete, apiGet, apiPatch, apiPost } from "./http"; + +export type PaymentScheduleInput = { + dayOfMonth?: number; + dayOfWeek?: number; + everyNDays?: number; + minFundingPercent?: number; +}; export type NewPlan = { name: string; @@ -6,22 +13,81 @@ export type NewPlan = { fundedCents?: number; // optional, default 0 priority: number; // int dueOn: string; // ISO date + frequency?: "one-time" | "weekly" | "biweekly" | "monthly"; + autoPayEnabled?: boolean; + paymentSchedule?: PaymentScheduleInput | null; + nextPaymentDate?: string | null; + maxRetryAttempts?: number; }; export type UpdatePlan = Partial; export const fixedPlansApi = { - create: (body: NewPlan) => - request<{ id: number }>("/fixed-plans", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - update: (id: number, body: UpdatePlan) => - request(`/fixed-plans/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - delete: (id: number) => - request(`/fixed-plans/${id}`, { method: "DELETE" }), -}; \ No newline at end of file + create: (body: NewPlan) => apiPost<{ id: string }>("/fixed-plans", body), + update: (id: string, body: UpdatePlan) => + apiPatch(`/fixed-plans/${id}`, body), + delete: (id: string) => apiDelete(`/fixed-plans/${id}`), + due: (query?: { asOf?: string; daysAhead?: number }) => + apiGet<{ items: Array<{ id: string; name: string; dueOn: string; remainingCents: number; percentFunded: number; isDue: boolean; isOverdue: boolean }>; asOfISO: string }>( + "/fixed-plans/due", + query as any + ), + attemptFinalFunding: (id: string) => + apiPost<{ + ok: boolean; + planId: string; + status: "fully_funded" | "overdue"; + fundedCents: number; + totalCents: number; + isOverdue: boolean; + overdueAmount?: number; + message: string; + }>(`/fixed-plans/${id}/attempt-final-funding`, {}), + payNow: ( + id: string, + body: { + occurredAtISO?: string; + overrideDueOnISO?: string; + fundingSource?: "funded" | "savings" | "deficit"; + savingsCategoryId?: string; + note?: string; + } + ) => + apiPost<{ + ok: boolean; + planId: string; + transactionId: string; + nextDueOn: string | null; + savingsUsed: boolean; + deficitCovered: boolean; + shortageCents: number; + }>(`/fixed-plans/${id}/pay-now`, body), + markUnpaid: (id: string) => + apiPatch<{ + ok: boolean; + planId: string; + isOverdue: boolean; + overdueAmount: number; + }>(`/fixed-plans/${id}/mark-unpaid`, {}), + fundFromAvailable: (id: string) => + apiPost<{ + ok: boolean; + planId: string; + funded: boolean; + fundedAmountCents: number; + fundedCents: number; + totalCents: number; + availableBudget?: number; + message: string; + }>(`/fixed-plans/${id}/fund-from-available`, {}), + catchUpFunding: (id: string) => + apiPost<{ + ok: boolean; + planId: string; + funded: boolean; + fundedAmountCents: number; + fundedCents: number; + totalCents: number; + availableBudget?: number; + message: string; + }>(`/fixed-plans/${id}/catch-up-funding`, {}), +}; diff --git a/web/src/api/http.ts b/web/src/api/http.ts index 56e1e5d..3c84448 100644 --- a/web/src/api/http.ts +++ b/web/src/api/http.ts @@ -2,13 +2,14 @@ const BASE = (typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_URL) || - ""; // e.g. "http://localhost:8080" or proxy + "/api"; // default to proxy prefix when no explicit API URL type FetchOpts = { method?: "GET" | "POST" | "PATCH" | "DELETE"; headers?: Record; body?: any; query?: Record; + skipAuthRedirect?: boolean; }; function toQS(q?: FetchOpts["query"]) { @@ -22,19 +23,48 @@ function toQS(q?: FetchOpts["query"]) { return s ? `?${s}` : ""; } -async function http(path: string, opts: FetchOpts = {}): Promise { - const { method = "GET", headers = {}, body, query } = opts; +function getCookie(name: string): string | undefined { + if (typeof document === "undefined") return undefined; + const cookies = document.cookie.split(";").map((cookie) => cookie.trim()); + for (const entry of cookies) { + if (!entry) continue; + const [key, ...rest] = entry.split("="); + if (key === name) { + return decodeURIComponent(rest.join("=")); + } + } + return undefined; +} + +export async function http(path: string, opts: FetchOpts = {}): Promise { + const { + method = "GET", + headers = {}, + body, + query, + skipAuthRedirect = false, + } = opts; const url = `${BASE}${path}${toQS(query)}`; + const hasBody = body !== undefined; + const requestHeaders: Record = { ...headers }; + + // Only set Content-Type header if we have a body to send + if (hasBody) { + requestHeaders["Content-Type"] = "application/json"; + } + if (!["GET", "HEAD", "OPTIONS"].includes(method)) { + const csrfToken = getCookie("csrf"); + if (csrfToken && !requestHeaders["x-csrf-token"]) { + requestHeaders["x-csrf-token"] = csrfToken; + } + } + const res = await fetch(url, { method, - headers: { - "Content-Type": "application/json", - // The API defaults x-user-id if missing; add your own if you want: - // "x-user-id": localStorage.getItem("userId") ?? "demo-user-1", - ...headers, - }, - body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: "include", + headers: requestHeaders, + body: hasBody ? JSON.stringify(body) : undefined, }); // Try to parse JSON either way @@ -42,11 +72,44 @@ async function http(path: string, opts: FetchOpts = {}): Promise { const json = text ? JSON.parse(text) : null; if (!res.ok) { + if ( + res.status === 401 && + !skipAuthRedirect && + typeof window !== "undefined" + ) { + const next = `${window.location.pathname}${window.location.search}`; + const encoded = encodeURIComponent(next); + const dest = + encoded && encoded !== "%2F" ? `/login?next=${encoded}` : "/login"; + window.location.assign(dest); + } + const msg = (json && (json.message || json.error)) || `${res.status} ${res.statusText}` || "Request failed"; - throw new Error(msg); + const err = new Error(msg) as Error & { + status?: number; + code?: string; + data?: any; + overdraftAmount?: number; + categoryName?: string; + currentBalance?: number; + availableBudget?: number; + shortage?: number; + }; + err.status = res.status; + if (json && typeof json === "object") { + const payload = json as any; + if (payload.code) err.code = payload.code; + err.data = payload; + if (payload.overdraftAmount !== undefined) err.overdraftAmount = payload.overdraftAmount; + if (payload.categoryName !== undefined) err.categoryName = payload.categoryName; + if (payload.currentBalance !== undefined) err.currentBalance = payload.currentBalance; + if (payload.availableBudget !== undefined) err.availableBudget = payload.availableBudget; + if (payload.shortage !== undefined) err.shortage = payload.shortage; + } + throw err; } return json as T; } diff --git a/web/src/api/transactions.ts b/web/src/api/transactions.ts index ada225f..78a487c 100644 --- a/web/src/api/transactions.ts +++ b/web/src/api/transactions.ts @@ -1,4 +1,4 @@ -import { request } from "./client"; +import { apiGet, apiPost } from "./http"; import { TransactionsList, type TransactionsListT } from "./schemas"; export type TxQuery = { @@ -10,11 +10,26 @@ export type TxQuery = { limit?: number; // default 20 }; -export async function listTransactions(params: TxQuery): Promise { - const u = new URL("/api/transactions", location.origin); - for (const [k, v] of Object.entries(params)) { - if (v !== undefined && v !== "") u.searchParams.set(k, String(v)); - } - const data = await request(u.pathname + "?" + u.searchParams.toString()); +export type CreateTransactionPayload = { + kind: "variable_spend" | "fixed_payment"; + amountCents: number; + occurredAtISO: string; + categoryId?: string; + planId?: string; + note?: string; + receiptUrl?: string; + isReconciled?: boolean; +}; + +export async function listTransactions( + params: TxQuery +): Promise { + const data = await apiGet("/transactions", params); return TransactionsList.parse(data); -} \ No newline at end of file +} + +export async function createTransaction( + payload: CreateTransactionPayload +): Promise<{ id: string; nextDueOn?: string }> { + return await apiPost("/transactions", payload); +} diff --git a/web/src/assets/SkyMoneyLogo.png b/web/src/assets/SkyMoneyLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..da23fcccfd99b2076409b76bd7eb2908df6f427d GIT binary patch literal 101240 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 literal 0 HcmV?d00001 diff --git a/web/src/components/CurrencyInput.tsx b/web/src/components/CurrencyInput.tsx index df984cf..f7fd5d9 100644 --- a/web/src/components/CurrencyInput.tsx +++ b/web/src/components/CurrencyInput.tsx @@ -1,31 +1,75 @@ import React from "react"; -export default function CurrencyInput({ - value, - onValue, - placeholder = "0.00", -}: { +type BaseProps = Omit< + React.InputHTMLAttributes, + "value" | "onChange" +> & { + className?: string; +}; + +type StringProps = BaseProps & { value: string; onValue: (v: string) => void; - placeholder?: string; -}) { - function onChange(e: React.ChangeEvent) { - const raw = e.target.value.replace(/[^0-9.]/g, ""); - // Keep only first dot, max 2 decimals - const parts = raw.split("."); - const cleaned = - parts.length === 1 - ? parts[0] - : `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`; - onValue(cleaned); + valueCents?: never; + onChange?: never; +}; + +type CentsProps = BaseProps & { + valueCents: number; + onChange: (cents: number) => void; + value?: never; + onValue?: never; +}; + +type Props = StringProps | CentsProps; + +export default function CurrencyInput({ + className, + placeholder = "0.00", + ...rest +}: Props) { + const mergedClass = ["input", className].filter(Boolean).join(" "); + const formatString = (raw: string) => { + const cleanedRaw = raw.replace(/[^0-9.]/g, ""); + const parts = cleanedRaw.split("."); + return parts.length === 1 + ? parts[0] + : `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`; + }; + + if ("valueCents" in rest) { + const { valueCents, onChange, ...inputProps } = rest as CentsProps; + const handleChange = (e: React.ChangeEvent) => { + const formatted = formatString(e.target.value); + const parsed = Number.parseFloat(formatted || "0"); + onChange(Number.isFinite(parsed) ? Math.round(parsed * 100) : 0); + }; + const displayValue = (valueCents ?? 0) / 100; + const value = Number.isFinite(displayValue) ? displayValue.toString() : ""; + return ( + + ); } + + const { value, onValue, ...inputProps } = rest as StringProps; + const handleChange = (e: React.ChangeEvent) => { + onValue(formatString(e.target.value)); + }; return ( ); } diff --git a/web/src/components/EarlyFundingModal.tsx b/web/src/components/EarlyFundingModal.tsx new file mode 100644 index 0000000..953695d --- /dev/null +++ b/web/src/components/EarlyFundingModal.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { apiPatch } from "../api/http"; +import { formatDateInTimezone } from "../utils/timezone"; + +interface EarlyFundingModalProps { + planId: string; + planName: string; + nextDueDate?: string; + timezone: string; + onClose: () => void; +} + +export default function EarlyFundingModal({ planId, planName, nextDueDate, timezone, onClose }: EarlyFundingModalProps) { + const [loading, setLoading] = useState(false); + + const handleResponse = async (enableEarlyFunding: boolean) => { + setLoading(true); + try { + await apiPatch(`/fixed-plans/${planId}/early-funding`, { enableEarlyFunding }); + onClose(); + } catch (error) { + console.error("Failed to update early funding:", error); + // Still close the modal even if it fails + onClose(); + } finally { + setLoading(false); + } + }; + + const nextDueLabel = nextDueDate + ? formatDateInTimezone(nextDueDate, timezone, { + month: 'long', + day: 'numeric', + year: 'numeric' + }) + : "next billing cycle"; + + return ( +

+
+

+ Start Funding Early? +

+ +

+ You've paid {planName} which is due on {nextDueLabel}. +

+ +

+ Would you like to start funding for the next payment now, or wait until closer to the due date? +

+ +
+ + + +
+ +

+ Start Now: Your next income will begin funding this bill.
+ Wait: Funding will resume automatically on {nextDueLabel}. +

+
+
+ ); +} diff --git a/web/src/components/EarlyPaymentPromptModal.tsx b/web/src/components/EarlyPaymentPromptModal.tsx new file mode 100644 index 0000000..c2559d3 --- /dev/null +++ b/web/src/components/EarlyPaymentPromptModal.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { apiPatch } from "../api/http"; +import { formatDateInTimezone } from "../utils/timezone"; + +type EarlyPaymentPromptModalProps = { + planId: string; + planName: string; + dueOn: string; + timezone: string; + onClose: () => void; + onConfirmed: () => void; +}; + +export default function EarlyPaymentPromptModal({ + planId, + planName, + dueOn, + timezone, + onClose, + onConfirmed, +}: EarlyPaymentPromptModalProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const dueLabel = formatDateInTimezone(dueOn, timezone, { + month: "long", + day: "numeric", + year: "numeric", + }); + + const handleConfirm = async (paidEarly: boolean) => { + setIsSubmitting(true); + try { + if (paidEarly) { + await apiPatch(`/fixed-plans/${planId}/early-funding`, { + enableEarlyFunding: true, + }); + onConfirmed(); + } + } catch (error) { + console.error("Failed to mark paid early:", error); + } finally { + setIsSubmitting(false); + onClose(); + } + }; + + return ( +
+
+
+

Paid early?

+

+ {planName} is fully funded for the due date on {dueLabel}. +

+
+ +
+ Did you actually pay this expense early in real life? If yes, we will + move it to the next cycle so your budget stays accurate. +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/components/FundingConfirmationModal.tsx b/web/src/components/FundingConfirmationModal.tsx new file mode 100644 index 0000000..9d4208c --- /dev/null +++ b/web/src/components/FundingConfirmationModal.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { fixedPlansApi } from "../api/fixedPlans"; +import { Money } from "./ui"; + +type FundingConfirmationModalProps = { + planId: string; + planName: string; + totalCents: number; + fundedCents: number; + availableBudget: number; + onClose: () => void; + onFundingComplete: (result: { + totalCents: number; + fundedCents: number; + isOverdue?: boolean; + overdueAmount?: number; + message?: string; + }) => void; +}; + +export default function FundingConfirmationModal({ + planId, + planName, + totalCents, + fundedCents, + availableBudget, + onClose, + onFundingComplete, +}: FundingConfirmationModalProps) { + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(""); + + const shortfall = totalCents - fundedCents; + const canFund = availableBudget >= shortfall; + + const handleRemindLater = () => { + // Store dismissal timestamp in localStorage (4 hours from now) + const dismissedUntil = Date.now() + (4 * 60 * 60 * 1000); // 4 hours + localStorage.setItem(`overdue-dismissed-${planId}`, dismissedUntil.toString()); + onClose(); + }; + + const handlePullFromBudget = async () => { + setError(""); + setIsProcessing(true); + + try { + const result = await fixedPlansApi.attemptFinalFunding(planId); + onFundingComplete(result); + } catch (err: any) { + setError(err?.message || "Failed to fund from available budget"); + setIsProcessing(false); + } + }; + + return ( +
+
+ {/* Header */} +
+

{planName} is due today

+

+ This bill is not fully funded yet. +

+
+ + {/* Funding Details */} +
+
+ Total amount: + +
+
+ Currently funded: + +
+
+
+ Still needed: + +
+
+
+ + {/* Available Budget */} +
+
+ Available budget: + +
+
+ + {/* Message */} + {canFund ? ( +
+ Would you like to pull from your available budget to fully fund this payment? +
+ ) : ( +
+ Insufficient available budget. You need more to fully fund this payment. +
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + + {canFund && ( + + )} +
+
+
+ ); +} diff --git a/web/src/components/NavBar.tsx b/web/src/components/NavBar.tsx new file mode 100644 index 0000000..6663c5b --- /dev/null +++ b/web/src/components/NavBar.tsx @@ -0,0 +1,102 @@ +import { NavLink, useLocation, useNavigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import ThemeToggle from "./ThemeToggle"; + +export default function NavBar({ + hideOn = ["/onboarding", "/login", "/register"], +}: { + hideOn?: string[]; +}) { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const [menuOpen, setMenuOpen] = useState(false); + const linkClass = ({ isActive }: { isActive: boolean }) => + "nav-link " + (isActive ? "nav-link-active" : ""); + const mobileLinkClass = ({ isActive }: { isActive: boolean }) => + "nav-link text-[--color-text] " + + (isActive ? "nav-link-active" : "hover:bg-[--color-ink]/20"); + + useEffect(() => { + setMenuOpen(false); + }, [pathname]); + + if (hideOn.some((p) => pathname.startsWith(p))) return null; + + return ( +
+
+ {/* Brand */} + + + {/* Links */} + + + {/* Actions */} +
+
+ +
+ + {/* Mobile menu */} +
+ + +
+
+
+
+ ); +} diff --git a/web/src/components/OnboardingTracker.tsx b/web/src/components/OnboardingTracker.tsx new file mode 100644 index 0000000..d52ad11 --- /dev/null +++ b/web/src/components/OnboardingTracker.tsx @@ -0,0 +1,398 @@ +import { useMemo } from "react"; +import { previewAllocation } from "../utils/allocatorPreview"; +import { dateStringToUTCMidnight, getBrowserTimezone, isoToDateString } from "../utils/timezone"; + +type VariableCat = { + id: string; + name: string; + percent: number; + priority: number; + isSavings?: boolean; +}; + +type FixedItem = { + id: string; + name: string; + amountCents: number; + priority: number; + dueOn: string; + autoPayEnabled?: boolean; +}; + +type OnboardingTrackerProps = { + step: number; + budgetCents: number; + vars: VariableCat[]; + fixeds: FixedItem[]; + incomeType: "regular" | "irregular"; + budgetPeriod?: "weekly" | "biweekly" | "monthly"; + conservatismPercent?: number; // For irregular income: percentage to allocate to fixed expenses + firstIncomeDate?: Date | string; // For accurate pay period calculation + userTimezone?: string; +}; + +const fmtMoney = (cents: number) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format((cents ?? 0) / 100); + +// Calculate expected funding per paycheck for regular income users +// Count actual pay periods between two dates, matching API logic +function countPayPeriodsBetween( + startDate: Date, + endDate: Date, + firstIncomeDate: Date, + frequency: "weekly" | "biweekly" | "monthly", + timezone: string +): number { + let count = 0; + let nextPayDate = new Date(firstIncomeDate); + + const targetDay = Number(isoToDateString(firstIncomeDate.toISOString(), timezone).split("-")[2] || "1"); + const advanceByPeriod = () => { + if (frequency === "monthly") { + const year = nextPayDate.getUTCFullYear(); + const month = nextPayDate.getUTCMonth() + 1; + const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth))); + } else { + const days = frequency === "biweekly" ? 14 : 7; + nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000); + } + }; + + // Advance to the first pay date on or after startDate + while (nextPayDate < startDate) { + advanceByPeriod(); + } + + // Count all pay dates up to (but not including) the end date + while (nextPayDate < endDate) { + count++; + advanceByPeriod(); + } + + // Ensure at least 1 period to avoid division by zero + return Math.max(1, count); +} + +function calculateExpectedFunding( + totalCents: number, + dueDate: string, + incomeFrequency: "weekly" | "biweekly" | "monthly", + firstIncomeDate?: Date | string, + now = new Date(), + timezone = getBrowserTimezone() +): number { + const daysPerPaycheck = incomeFrequency === "weekly" ? 7 : incomeFrequency === "biweekly" ? 14 : 30; + const todayIso = dateStringToUTCMidnight(isoToDateString(now.toISOString(), timezone), timezone); + const dueIso = dateStringToUTCMidnight(dueDate, timezone); + + const due = new Date(dueIso); + const userNow = new Date(todayIso); + + let totalPaychecks: number; + + if (firstIncomeDate) { + // Use the same logic as the API: count actual pay dates + const firstIncomeIso = + typeof firstIncomeDate === "string" + ? firstIncomeDate.includes("T") + ? dateStringToUTCMidnight(isoToDateString(firstIncomeDate, timezone), timezone) + : dateStringToUTCMidnight(firstIncomeDate, timezone) + : dateStringToUTCMidnight(isoToDateString(firstIncomeDate.toISOString(), timezone), timezone); + const firstIncome = new Date(firstIncomeIso); + totalPaychecks = countPayPeriodsBetween(userNow, due, firstIncome, incomeFrequency, timezone); + } else { + // Fallback to simple calculation if firstIncomeDate not provided + const DAY_MS = 24 * 60 * 60 * 1000; + const daysUntilDue = Math.max(0, Math.ceil((due.getTime() - userNow.getTime()) / DAY_MS)); + totalPaychecks = Math.max(1, Math.ceil(daysUntilDue / daysPerPaycheck)); + } + + // Amount to fund per paycheck - use ceil to match API + const perPaycheck = Math.ceil(totalCents / totalPaychecks); + + return perPaycheck; +} + +export default function OnboardingTracker({ + step, + budgetCents, + vars, + fixeds, + incomeType, + budgetPeriod = "monthly", + conservatismPercent = 40, + firstIncomeDate, + userTimezone, +}: OnboardingTrackerProps) { + const timezone = userTimezone || getBrowserTimezone(); + // Only show tracker on steps 4, 5, and 6 (categories, fixed plans, review) + const shouldShow = step >= 4; + + // Calculate totals + const eligibleFixeds = useMemo( + () => fixeds.filter((f) => f.autoPayEnabled), + [fixeds] + ); + + const fixedTotal = useMemo( + () => eligibleFixeds.reduce((sum, f) => sum + (f.amountCents || 0), 0), + [eligibleFixeds] + ); + + const varsTotal = useMemo( + () => vars.reduce((sum, v) => sum + (v.percent || 0), 0), + [vars] + ); + + // Preview allocation if we have a budget + const preview = useMemo(() => { + if (budgetCents <= 0) return null; + + // Convert onboarding data to the format expected by previewAllocation + const fixedPlans = eligibleFixeds.map(f => { + let totalCents = f.amountCents; + let fundedCents = 0; + + if (incomeType === "regular" && f.autoPayEnabled) { + // Regular income: calculate per-paycheck amount based on due date + totalCents = calculateExpectedFunding(f.amountCents, f.dueOn, budgetPeriod, firstIncomeDate, new Date(), timezone); + } + // For irregular income, we pass the full amount as the need + // The conservatism will be applied by limiting the total budget passed to previewAllocation + + return { + id: f.id, + name: f.name, + totalCents, + fundedCents, + dueOn: f.dueOn, + priority: f.priority, + cycleStart: f.dueOn, + }; + }); + + const variableCategories = vars.map(v => ({ + id: v.id, + name: v.name, + percent: v.percent, + balanceCents: 0, + isSavings: v.isSavings || false, + priority: v.priority, + })); + + // For irregular income, apply conservatism to split budget between fixed and variable + if (incomeType === "irregular") { + const fixedPercentage = conservatismPercent / 100; + const fixedBudget = Math.floor(budgetCents * fixedPercentage); + const variableBudget = budgetCents - fixedBudget; + + // Allocate fixed budget to fixed plans (by priority) + const fixedResult = previewAllocation(fixedBudget, fixedPlans, []); + + // Allocate remaining budget to variables + const variableResult = previewAllocation(variableBudget, [], variableCategories); + + return { + fixed: fixedResult.fixed, + variable: variableResult.variable, + unallocatedCents: fixedResult.unallocatedCents + variableResult.unallocatedCents, + }; + } + + // For regular income, use standard allocation (fixed first, then variable) + return previewAllocation(budgetCents, fixedPlans, variableCategories); + }, [ + budgetCents, + eligibleFixeds, + vars, + incomeType, + budgetPeriod, + conservatismPercent, + firstIncomeDate, + timezone, + ]); + + // Calculate actual fixed allocation amount from preview + const fixedAllocated = preview + ? preview.fixed.reduce((sum, f) => sum + f.amountCents, 0) + : 0; + + const variableAmount = preview + ? preview.variable.reduce((sum, v) => sum + v.amountCents, 0) + : budgetCents - fixedTotal; + + const isValid = varsTotal === 100 && budgetCents > 0; + + if (!shouldShow || budgetCents <= 0) return null; + + return ( +
+
+
+

Live Budget Tracker

+
+ + {/* Total Budget */} +
+
Total Budget
+
{fmtMoney(budgetCents)}
+
+ Available to allocate +
+
+ + {/* Fixed Expenses */} +
+
+
Fixed Expenses
+
{eligibleFixeds.length} items
+
+
{fmtMoney(fixedTotal)}
+
+
+
+
+ {budgetCents > 0 + ? `${Math.round((fixedAllocated / budgetCents) * 100)}% allocated` + : "0% of budget"} +
+ + {/* Individual fixed expenses breakdown */} + {eligibleFixeds.length > 0 && ( +
+
Breakdown:
+ {eligibleFixeds.map((f) => { + const previewItem = preview?.fixed.find(item => item.id === f.id); + const totalAmount = f.amountCents; + const fundedAmount = previewItem?.amountCents || 0; + + const fundingPercentage = totalAmount > 0 + ? Math.round((fundedAmount / totalAmount) * 100) + : 0; + + return ( +
+
+ + {f.name || "Unnamed"} + + + {fmtMoney(totalAmount)} + +
+ {f.autoPayEnabled && fundedAmount > 0 && ( +
+ {incomeType === "regular" ? "Auto" : "Plan"}: {fmtMoney(fundedAmount)} ({fundingPercentage}%) +
+ )} + {f.autoPayEnabled && fundedAmount === 0 && ( +
+ Plan enabled (no funds yet) +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Variable Categories */} +
+
+
Variable Budget
+
{vars.length} categories
+
+
{fmtMoney(variableAmount)}
+
+
+
+
+ {budgetCents > 0 + ? `${Math.round((variableAmount / budgetCents) * 100)}% of budget` + : "0% of budget"} +
+ + {/* Individual variable categories breakdown */} + {vars.length > 0 && preview && ( +
+
Breakdown:
+ {preview.variable.map((item) => { + const originalVar = vars.find(v => v.id === item.id); + const percentage = originalVar?.percent || 0; + + return ( +
+ + {item.name || "Unnamed"} + {originalVar?.isSavings && ( + (Savings) + )} + + + {fmtMoney(item.amountCents)} ({percentage}%) + +
+ ); + })} +
+ )} + + {/* Percentage validation */} +
+
+ Total percentage: + + {varsTotal}% + +
+ {varsTotal !== 100 && ( +
+ Must equal 100% +
+ )} +
+
+ + {/* Remaining/Unallocated */} + {preview && preview.unallocatedCents > 0 && ( +
+
Unallocated
+
+ {fmtMoney(preview.unallocatedCents)} +
+
+ )} + + {/* Status indicator */} +
+ {isValid ? "Ready to continue" : "Complete setup to continue"} +
+
+ ); +} diff --git a/web/src/components/Pagination.tsx b/web/src/components/Pagination.tsx index de75735..f74077b 100644 --- a/web/src/components/Pagination.tsx +++ b/web/src/components/Pagination.tsx @@ -1,5 +1,3 @@ -import React from "react"; - export default function Pagination({ page, limit, diff --git a/web/src/components/PaydayOverlay.tsx b/web/src/components/PaydayOverlay.tsx new file mode 100644 index 0000000..2245d55 --- /dev/null +++ b/web/src/components/PaydayOverlay.tsx @@ -0,0 +1,214 @@ +import { useEffect, useState } from "react"; +import { http } from "../api/http"; +import { useAuthSession } from "../hooks/useAuthSession"; +import { useDashboard } from "../hooks/useDashboard"; +import { useQueryClient } from "@tanstack/react-query"; +import { dateStringToUTCMidnight, formatDateInTimezone, getBrowserTimezone } from "../utils/timezone"; +import CurrencyInput from "./CurrencyInput"; + +type PaydayStatus = { + shouldShowOverlay: boolean; + pendingScheduledIncome: boolean; + nextPayday: string | null; +}; + +export default function PaydayOverlay() { + const session = useAuthSession(); + const { data: dashboard } = useDashboard(); + const queryClient = useQueryClient(); + const [paydayStatus, setPaydayStatus] = useState(null); + const [showPopup, setShowPopup] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [incomeCents, setIncomeCents] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const userTimezone = dashboard?.user?.timezone || getBrowserTimezone(); + const debugNow = new URLSearchParams(window.location.search).get("debugNow"); + const debugNowISO = debugNow ? dateStringToUTCMidnight(debugNow, userTimezone) : null; + const debugNowDate = debugNowISO ? new Date(debugNowISO) : null; + + useEffect(() => { + if (!session.data?.userId) return; + + const checkPaydayStatus = async () => { + try { + const status = await http("/payday/status", { + query: debugNow ? { debugNow } : undefined, + }); + setPaydayStatus(status); + if (status.shouldShowOverlay) { + setShowPopup(true); + } + } catch (error) { + console.error("Failed to check payday status:", error); + } finally { + setIsLoading(false); + } + }; + + checkPaydayStatus(); + }, [session.data?.userId]); + + const handleDismiss = async () => { + try { + await http("/payday/dismiss", { method: "POST" }); + setShowPopup(false); + } catch (error) { + console.error("Failed to dismiss payday overlay:", error); + } + }; + + const handleAddIncome = async () => { + if (!incomeCents || incomeCents <= 0) { + setError("Please enter a valid amount"); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + await http("/income", { + method: "POST", + body: { + amountCents: incomeCents, + isScheduledIncome: true, + occurredAtISO: debugNowISO ?? new Date().toISOString(), + }, + }); + + // Refresh dashboard data + await queryClient.invalidateQueries({ queryKey: ["dashboard"] }); + + // Dismiss overlay + await http("/payday/dismiss", { method: "POST" }); + setShowPopup(false); + } catch (err: any) { + console.error("Failed to add income:", err); + setError(err.message || "Failed to add income. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading || !showPopup || !paydayStatus) { + return null; + } + + return ( + <> + {/* Backdrop */} +
+ + {/* Popup */} +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

+ It's Payday! +

+

+ {formatDateInTimezone((debugNowDate ?? new Date()).toISOString(), userTimezone, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + })} +

+
+
+ + {/* Message */} +
+

+ Ready to add your paycheck? Recording your scheduled income helps us: +

+
    +
  • + OK + Fund your payment plans automatically +
  • +
  • + OK + Track your regular income vs bonuses +
  • +
  • + OK + Keep your budget on schedule +
  • +
+
+ + {/* Income Input */} +
+ +
+ + $ + + { + setIncomeCents(cents); + setError(null); + }} + placeholder="0.00" + className="w-full !pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent" + disabled={isSubmitting} + autoFocus + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + {/* Actions */} +
+ + +
+ + {/* Next payday info */} + {paydayStatus.nextPayday && ( +
+

+ Next expected payday:{" "} + {formatDateInTimezone(paydayStatus.nextPayday, userTimezone, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+ )} +
+
+ + ); +} diff --git a/web/src/components/PaymentConfirmationModal.tsx b/web/src/components/PaymentConfirmationModal.tsx new file mode 100644 index 0000000..54bbb1d --- /dev/null +++ b/web/src/components/PaymentConfirmationModal.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; + +interface PaymentConfirmationModalProps { + message: string; + onConfirm: () => void; + onCancel: () => void; +} + +export default function PaymentConfirmationModal({ + message, + onConfirm, + onCancel, +}: PaymentConfirmationModalProps) { + const dialogRef = useRef(null); + + useEffect(() => { + dialogRef.current?.showModal(); + }, []); + + return ( + +
+

Confirm Payment

+

{message}

+ +
+ + +
+
+
+ ); +} diff --git a/web/src/components/PaymentReconciliationModal.tsx b/web/src/components/PaymentReconciliationModal.tsx new file mode 100644 index 0000000..14eb27e --- /dev/null +++ b/web/src/components/PaymentReconciliationModal.tsx @@ -0,0 +1,239 @@ +import { useState } from "react"; +import { fixedPlansApi } from "../api/fixedPlans"; +import { createTransaction } from "../api/transactions"; + +type PaymentReconciliationModalProps = { + planId: string; + planName: string; + totalCents: number; + fundedCents: number; + isOverdue: boolean; + overdueAmount?: number; + message: string; + nextDueDate: string; + onClose: () => void; + onSuccess: () => void; +}; + +export default function PaymentReconciliationModal({ + planId, + planName, + totalCents, + fundedCents, + isOverdue, + overdueAmount, + message, + nextDueDate, + onClose, + onSuccess, +}: PaymentReconciliationModalProps) { + const [paymentType, setPaymentType] = useState<"full" | "partial" | "none">("full"); + const [partialAmount, setPartialAmount] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + + const formatMoney = (cents: number) => { + return `$${(cents / 100).toFixed(2)}`; + }; + + const handleRemindLater = () => { + // Store dismissal timestamp in localStorage (4 hours from now) + const dismissedUntil = Date.now() + (4 * 60 * 60 * 1000); // 4 hours + localStorage.setItem(`overdue-dismissed-${planId}`, dismissedUntil.toString()); + onClose(); + }; + + const handleSubmit = async () => { + setError(""); + setIsSubmitting(true); + + try { + if (paymentType === "full") { + // Full payment + await createTransaction({ + kind: "fixed_payment", + amountCents: totalCents, + planId: planId, + occurredAtISO: new Date().toISOString(), + note: `Payment for ${planName}`, + isReconciled: true, + }); + onSuccess(); + } else if (paymentType === "partial") { + // Partial payment + const partialCents = Math.round(parseFloat(partialAmount) * 100); + if (isNaN(partialCents) || partialCents <= 0) { + setError("Please enter a valid amount"); + setIsSubmitting(false); + return; + } + if (partialCents >= totalCents) { + setError("Partial amount must be less than total"); + setIsSubmitting(false); + return; + } + + await createTransaction({ + kind: "fixed_payment", + amountCents: partialCents, + planId: planId, + occurredAtISO: new Date().toISOString(), + note: `Partial payment for ${planName}`, + isReconciled: true, + }); + onSuccess(); + } else { + // No payment - mark as unpaid + await fixedPlansApi.markUnpaid(planId); + onSuccess(); + } + } catch (err: any) { + setError(err?.message || "Failed to process payment"); + setIsSubmitting(false); + } + }; + + return ( +
+
+ {/* Header */} +
+

{planName} is due

+

+ {isOverdue + ? `Warning: ${message}` + : "Was the full amount paid?"} +

+
+ + {/* Plan Details */} +
+
+ Total amount: + {formatMoney(totalCents)} +
+
+ Currently funded: + {formatMoney(fundedCents)} +
+ {isOverdue && overdueAmount && overdueAmount > 0 && ( +
+ Overdue amount: + {formatMoney(overdueAmount)} +
+ )} +
+ Next due date: + {nextDueDate} +
+
+ + {/* Payment Options */} +
+
How much was paid?
+ + {/* Full Payment */} + + + {/* Partial Payment */} +
+ + {paymentType === "partial" && ( +
+ setPartialAmount(e.target.value)} + disabled={isSubmitting} + /> +
+ )} +
+ + {/* No Payment */} + +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ {isOverdue && ( + + )} + + +
+
+
+ ); +} diff --git a/web/src/components/PercentGuard.tsx b/web/src/components/PercentGuard.tsx index a31170b..c5635ae 100644 --- a/web/src/components/PercentGuard.tsx +++ b/web/src/components/PercentGuard.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { useDashboard } from "../hooks/useDashboard"; export default function PercentGuard() { @@ -10,7 +9,7 @@ export default function PercentGuard() { return (
- Variable category percents must sum to 100% (currently {total}%). + Expense category percents must sum to 100% (currently {total}%). Adjust them before recording income.
); diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx new file mode 100644 index 0000000..3257816 --- /dev/null +++ b/web/src/components/RequireAuth.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; +import { Navigate, useLocation } from "react-router-dom"; +import { useAuthSession } from "../hooks/useAuthSession"; + +type Props = { children: ReactNode }; + +export function RequireAuth({ children }: Props) { + const location = useLocation(); + const session = useAuthSession(); + + if (session.isLoading) { + return ( +
+ Checking session… +
+ ); + } + + if (session.isError) { + const status = (session.error as any)?.status ?? 0; + if (status === 401) { + const next = encodeURIComponent( + `${location.pathname}${location.search}`.replace(/^$/, "/") + ); + return ; + } + return ( +
+ Unable to verify session. Try refreshing. +
+ ); + } + + return <>{children}; +} diff --git a/web/src/components/SessionTimeoutWarning.tsx b/web/src/components/SessionTimeoutWarning.tsx new file mode 100644 index 0000000..83313f1 --- /dev/null +++ b/web/src/components/SessionTimeoutWarning.tsx @@ -0,0 +1,44 @@ +import { useSessionTimeout } from "../hooks/useSessionTimeout"; + +export function SessionTimeoutWarning() { + const { state, timeRemaining, extendSession, logout } = useSessionTimeout(); + + if (state !== "warning") { + return null; + } + + return ( +
+
+
+
+

Session Expiring Soon

+
+ +

+ Your session will expire in {timeRemaining} minute{timeRemaining !== 1 ? "s" : ""} due to inactivity. +

+ +

+ Would you like to stay logged in? +

+ +
+ + +
+
+
+
+ ); +} diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..5b99bf5 --- /dev/null +++ b/web/src/components/ThemeToggle.tsx @@ -0,0 +1,30 @@ +import { useTheme } from "../theme/useTheme"; + +export default function ThemeToggle({ size = "md" }: { size?: "sm" | "md" }) { + const { theme, setTheme } = useTheme(); + const opts: Array<"dark" | "light" | "system"> = ["dark", "light", "system"]; + const base = + size === "sm" + ? "text-[11px] px-2 py-1 rounded-lg" + : "text-xs px-2 py-1 rounded-xl"; + + return ( +
+ {opts.map((opt) => ( + + ))} +
+ ); +} diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx index 29e9755..3527b38 100644 --- a/web/src/components/Toast.tsx +++ b/web/src/components/Toast.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react"; +import { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react"; type Toast = { id: string; kind: "ok" | "err"; message: string }; type Ctx = { push: (kind: Toast["kind"], message: string) => void }; diff --git a/web/src/components/UserSwitcher.tsx b/web/src/components/UserSwitcher.tsx deleted file mode 100644 index 5ab8bb4..0000000 --- a/web/src/components/UserSwitcher.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { setUserId, getUserId } from "../api/client"; -import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; - -export default function UserSwitcher() { - const qc = useQueryClient(); - const [val, setVal] = useState(getUserId()); - const apply = () => { - setUserId(val); - qc.invalidateQueries(); // why: reload all data for new tenant - }; - return ( -
- setVal(e.target.value)} - title="Dev User Id" - /> - -
- ); -} \ No newline at end of file diff --git a/web/src/components/charts/FixedFundingBars.tsx b/web/src/components/charts/FixedFundingBars.tsx index 544f9eb..ca46427 100644 --- a/web/src/components/charts/FixedFundingBars.tsx +++ b/web/src/components/charts/FixedFundingBars.tsx @@ -1,6 +1,16 @@ -import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts"; +import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from "recharts"; +import { fmtMoney } from "../../utils/money"; +import { useInView } from "../../hooks/useInView"; -export type FixedItem = { name: string; funded: number; remaining: number }; +export type FixedItem = { + name: string; + funded: number; + remaining: number; + fundedCents: number; + remainingCents: number; + aheadCents?: number; + isOverdue?: boolean; +}; export default function FixedFundingBars({ data }: { data: FixedItem[] }) { if (!data.length) { @@ -11,22 +21,89 @@ export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
); } + + const { ref, isInView } = useInView(); + const colorPalette = [ + "#3B82F6", + "#F59E0B", + "#EF4444", + "#8B5CF6", + "#06B6D4", + "#F97316", + "#EC4899", + "#84CC16", + ]; + const toRgba = (hex: string, alpha: number) => { + const cleaned = hex.replace("#", ""); + const bigint = parseInt(cleaned, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + }; return (

Fixed Plan Funding

-
- - - - - `${Math.round(Number(v) * 100)}%`} stroke="#94a3b8" /> - `${Math.round(Number(v) * 100)}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} /> - - - - - +
+ {isInView ? ( + + + + + `${v}%`} stroke="#94a3b8" /> + { + if (!active || !payload || payload.length === 0) return null; + const row = payload[0]?.payload as FixedItem | undefined; + if (!row) return null; + const fundedLabel = `${Math.round(row.funded)}% (${fmtMoney(row.fundedCents)})`; + const remainingLabel = `${Math.round(row.remaining)}% (${fmtMoney(row.remainingCents)})`; + return ( +
+
{row.name}
+
Funded: {fundedLabel}
+
Remaining: {remainingLabel}
+ {row.isOverdue && ( +
Overdue
+ )} + {!row.isOverdue && row.aheadCents && row.aheadCents > 0 && ( +
+ Ahead {fmtMoney(row.aheadCents)} +
+ )} +
+ ); + }} + /> + + {data.map((entry, index) => ( + + ))} + + + {data.map((entry, index) => { + const base = colorPalette[index % colorPalette.length]; + return ( + + ); + })} + +
+
+ ) : ( +
+ )}
); -} \ No newline at end of file +} diff --git a/web/src/components/charts/MonthlyTrendChart.tsx b/web/src/components/charts/MonthlyTrendChart.tsx new file mode 100644 index 0000000..c0044aa --- /dev/null +++ b/web/src/components/charts/MonthlyTrendChart.tsx @@ -0,0 +1,58 @@ +import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts"; +import { useInView } from "../../hooks/useInView"; + +export type TrendPoint = { monthKey: string; label: string; incomeCents: number; spendCents: number }; + +const currency = (value: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value); + +export default function MonthlyTrendChart({ data }: { data: TrendPoint[] }) { + if (!data.length) { + return ( +
+

Monthly Trend

+
No data yet. Add income or spending to see history.
+
+ ); + } + + const normalized = data.map((point) => ({ + label: point.label, + income: point.incomeCents / 100, + spend: point.spendCents / 100, + })); + const { ref, isInView } = useInView(); + + return ( +
+

Monthly Trend

+
+ {isInView ? ( + + + + + currency(v)} width={90} /> + currency(value)} + contentStyle={{ + background: "#1F2937", + border: "1px solid #374151", + borderRadius: 8, + color: "#F9FAFB", + fontSize: "14px", + fontWeight: "500" + }} + /> + + + + + + ) : ( +
+ )} +
+
+ ); +} diff --git a/web/src/components/charts/VariableAllocationDonut.tsx b/web/src/components/charts/VariableAllocationDonut.tsx index 1f75388..3b16fb4 100644 --- a/web/src/components/charts/VariableAllocationDonut.tsx +++ b/web/src/components/charts/VariableAllocationDonut.tsx @@ -1,10 +1,14 @@ // web/src/components/charts/VariableAllocationDonut.tsx import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"; +import { useInView } from "../../hooks/useInView"; export type VariableSlice = { name: string; value: number; isSavings: boolean }; export default function VariableAllocationDonut({ data }: { data: VariableSlice[] }) { const total = data.reduce((s, d) => s + d.value, 0); + const savingsTotal = data.filter((d) => d.isSavings).reduce((s, d) => s + d.value, 0); + const savingsPercent = total > 0 ? Math.round((savingsTotal / total) * 100) : 0; + const { ref, isInView } = useInView(); if (!data.length || total === 0) { return (
@@ -14,21 +18,80 @@ export default function VariableAllocationDonut({ data }: { data: VariableSlice[ ); } - const fillFor = (s: boolean) => (s ? "#165F46" : "#374151"); + // Color palette for variable categories with savings highlighting + const colorPalette = [ + "#3B82F6", // bright blue + "#F59E0B", // amber/gold + "#EF4444", // red + "#8B5CF6", // purple + "#06B6D4", // cyan + "#F97316", // orange + "#EC4899", // pink + "#84CC16", // lime green + ]; + + const savingsColors = { + primary: "#10B981", // emerald-500 (brighter) + accent: "#059669", // emerald-600 (darker alternate) + }; + + const getColor = (index: number, isSavings: boolean) => { + if (isSavings) { + return index % 2 === 0 ? savingsColors.primary : savingsColors.accent; + } + return colorPalette[index % colorPalette.length]; + }; return (

Variable Allocation

-
- - - - {data.map((d, i) => )} - - `${v}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} /> - - - +
+ {isInView ? ( + + + + {data.map((d, i) => )} + + + {Math.round(total)}% + + + Savings {savingsPercent}% + + { + if (active && payload && payload.length) { + const data = payload[0]; + return ( +
+

+ {data.name}: {data.value}% +

+
+ ); + } + return null; + }} + /> + +
+
+ ) : ( +
+ )}
); diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx index 975d889..a136e0e 100644 --- a/web/src/components/ui.tsx +++ b/web/src/components/ui.tsx @@ -1,4 +1,4 @@ -import React, { type PropsWithChildren } from "react"; +import { type PropsWithChildren } from "react"; import { fmtMoney } from "../utils/money"; export function Money({ cents }: { cents: number }) { diff --git a/web/src/hooks/useAuthSession.ts b/web/src/hooks/useAuthSession.ts new file mode 100644 index 0000000..2535003 --- /dev/null +++ b/web/src/hooks/useAuthSession.ts @@ -0,0 +1,16 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { http } from "../api/http"; + +type SessionResponse = { ok: true; userId: string; email: string | null; displayName: string | null }; + +type Options = Omit, "queryKey" | "queryFn">; + +export function useAuthSession(options?: Options) { + return useQuery({ + queryKey: ["auth", "session"], + queryFn: async () => + http("/auth/session", { skipAuthRedirect: true }), + retry: false, + ...options, + }); +} diff --git a/web/src/hooks/useCategories.ts b/web/src/hooks/useCategories.ts index 16b03dc..d1797d6 100644 --- a/web/src/hooks/useCategories.ts +++ b/web/src/hooks/useCategories.ts @@ -21,7 +21,11 @@ export function useCreateCategory() { ...prev, variableCategories: [ ...prev.variableCategories, - { id: -Math.floor(Math.random() * 1e9), balanceCents: 0, ...vars } + { + id: `temp-${Math.random().toString(36).slice(2)}`, + balanceCents: 0, + ...vars, + }, ] }; qc.setQueryData(DASHBOARD_KEY, optimistic); @@ -36,7 +40,8 @@ export function useCreateCategory() { export function useUpdateCategory() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, body }: { id: number; body: UpdateCategory }) => categoriesApi.update(id, body), + mutationFn: ({ id, body }: { id: string; body: UpdateCategory }) => + categoriesApi.update(id, body), onMutate: async ({ id, body }) => { await qc.cancelQueries({ queryKey: DASHBOARD_KEY }); const prev = qc.getQueryData(DASHBOARD_KEY); @@ -59,7 +64,7 @@ export function useUpdateCategory() { export function useDeleteCategory() { const qc = useQueryClient(); return useMutation({ - mutationFn: (id: number) => categoriesApi.delete(id), + mutationFn: (id: string) => categoriesApi.delete(id), onMutate: async (id) => { await qc.cancelQueries({ queryKey: DASHBOARD_KEY }); const prev = qc.getQueryData(DASHBOARD_KEY); @@ -75,4 +80,4 @@ export function useDeleteCategory() { onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev), onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }) }); -} \ No newline at end of file +} diff --git a/web/src/hooks/useDashboard.ts b/web/src/hooks/useDashboard.ts index 63d131b..bf3f34d 100644 --- a/web/src/hooks/useDashboard.ts +++ b/web/src/hooks/useDashboard.ts @@ -8,15 +8,28 @@ export type VariableCategory = { priority: number; isSavings: boolean; balanceCents?: number; + savingsTargetCents?: number | null; }; export type FixedPlan = { id: string; name: string; priority: number; - totalCents?: number; - fundedCents?: number; + totalCents: number; + fundedCents: number; dueOn: string; + cycleStart: string; + periodDays?: number; + autoRollover?: boolean; + lastRollover?: string | null; + autoPayEnabled?: boolean; + paymentSchedule?: { + frequency: string; + dayOfMonth?: number; + customDays?: number; + minFundingPercent?: number; + } | null; + nextPaymentDate?: string | null; }; export type Tx = { @@ -36,12 +49,41 @@ export type DashboardResponse = { variableCategories: VariableCategory[]; fixedPlans: FixedPlan[]; recentTransactions: Tx[]; + monthlyTrend: Array<{ + monthKey: string; + label: string; + incomeCents: number; + spendCents: number; + }>; + upcomingPlans: Array<{ id: string; name: string; dueOn: string; remainingCents: number }>; + savingsTargets: Array<{ + id: string; + name: string; + balanceCents: number; + targetCents: number; + percent: number; + }>; + hasBudgetSetup: boolean; + user: { + id: string; + email: string | null; + displayName: string | null; + incomeType?: "regular" | "irregular"; + incomeFrequency?: "weekly" | "biweekly" | "monthly"; + timezone?: string; // IANA timezone identifier + firstIncomeDate?: string | null; + fixedExpensePercentage?: number; + }; + crisis?: { + active: boolean; + }; }; -export function useDashboard() { +export function useDashboard(enabled = true) { return useQuery({ queryKey: ["dashboard"], queryFn: () => apiGet("/dashboard"), staleTime: 10_000, + enabled, }); } diff --git a/web/src/hooks/useFixedPlans.ts b/web/src/hooks/useFixedPlans.ts index c838e38..e3f2eb8 100644 --- a/web/src/hooks/useFixedPlans.ts +++ b/web/src/hooks/useFixedPlans.ts @@ -16,7 +16,7 @@ export function useCreatePlan() { fixedPlans: [ ...prev.fixedPlans, { - id: -Math.floor(Math.random() * 1e9), + id: `temp-${Math.random().toString(36).slice(2)}`, fundedCents: Math.min(vars.fundedCents ?? 0, vars.totalCents), ...vars, }, @@ -34,7 +34,7 @@ export function useCreatePlan() { export function useUpdatePlan() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, body }: { id: number; body: UpdatePlan }) => + mutationFn: ({ id, body }: { id: string; body: UpdatePlan }) => fixedPlansApi.update(id, body), onMutate: async ({ id, body }) => { await qc.cancelQueries({ queryKey: DASH }); @@ -67,7 +67,7 @@ export function useUpdatePlan() { export function useDeletePlan() { const qc = useQueryClient(); return useMutation({ - mutationFn: (id: number) => fixedPlansApi.delete(id), + mutationFn: (id: string) => fixedPlansApi.delete(id), onMutate: async (id) => { await qc.cancelQueries({ queryKey: DASH }); const prev = qc.getQueryData(DASH); @@ -83,4 +83,4 @@ export function useDeletePlan() { onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev), onSuccess: () => qc.invalidateQueries({ queryKey: DASH }), }); -} \ No newline at end of file +} diff --git a/web/src/hooks/useInView.ts b/web/src/hooks/useInView.ts new file mode 100644 index 0000000..7c27ab8 --- /dev/null +++ b/web/src/hooks/useInView.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef, useState } from "react"; + +export function useInView(rootMargin = "200px") { + const ref = useRef(null); + const [isInView, setIsInView] = useState(false); + + useEffect(() => { + const node = ref.current; + if (!node || isInView) return; + if (typeof IntersectionObserver === "undefined") { + setIsInView(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setIsInView(true); + observer.disconnect(); + } + }, + { rootMargin } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [isInView, rootMargin]); + + return { ref, isInView }; +} diff --git a/web/src/hooks/useIncome.ts b/web/src/hooks/useIncome.ts index 581773b..2a58eec 100644 --- a/web/src/hooks/useIncome.ts +++ b/web/src/hooks/useIncome.ts @@ -1,4 +1,4 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiPost } from "../api/http"; export type IncomeResult = { @@ -7,9 +7,27 @@ export type IncomeResult = { remainingUnallocatedCents: number; }; +export type AllocationOverrideInput = { + type: "fixed" | "variable"; + id: string; + amountCents: number; +}; + +export type CreateIncomeInput = { + amountCents: number; + overrides?: AllocationOverrideInput[]; + occurredAtISO?: string; + note?: string; +}; + export function useCreateIncome() { + const queryClient = useQueryClient(); + return useMutation({ - mutationFn: (body: { amountCents: number }) => - apiPost("/income", body), + mutationFn: (body: CreateIncomeInput) => apiPost("/income", body), + onSuccess: () => { + // Invalidate dashboard to refresh plan states after income posting + queryClient.invalidateQueries({ queryKey: ["dashboard"] }); + }, }); } diff --git a/web/src/hooks/useIncomeHistory.ts b/web/src/hooks/useIncomeHistory.ts new file mode 100644 index 0000000..a488d43 --- /dev/null +++ b/web/src/hooks/useIncomeHistory.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "../api/http"; + +export type IncomeHistoryEntry = { + id: string; + postedAt: string; + amountCents: number; + fixedTotal: number; + variableTotal: number; +}; + +export function useIncomeHistory() { + return useQuery({ + queryKey: ["income", "history"], + queryFn: () => apiGet("/income/history"), + staleTime: 60_000, + }); +} diff --git a/web/src/hooks/useSessionTimeout.ts b/web/src/hooks/useSessionTimeout.ts new file mode 100644 index 0000000..465daba --- /dev/null +++ b/web/src/hooks/useSessionTimeout.ts @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { apiPost } from "../api/http"; + +const TIMEOUT_MINUTES = 30; // Should match backend SESSION_TIMEOUT_MINUTES +const WARNING_MINUTES = 5; // Show warning 5 minutes before timeout +const ACTIVITY_THROTTLE_MS = 60_000; // Only refresh once per minute max + +type SessionState = "active" | "warning" | "expired"; + +export function useSessionTimeout() { + const [state, setState] = useState("active"); + const [timeRemaining, setTimeRemaining] = useState(TIMEOUT_MINUTES); + const lastActivityRef = useRef(Date.now()); + const lastRefreshRef = useRef(Date.now()); + const timerRef = useRef(null); + + const refreshSession = useCallback(async () => { + const now = Date.now(); + + // Throttle refresh calls + if (now - lastRefreshRef.current < ACTIVITY_THROTTLE_MS) { + return; + } + + try { + await apiPost("/auth/refresh"); + lastRefreshRef.current = now; + lastActivityRef.current = now; + setState("active"); + setTimeRemaining(TIMEOUT_MINUTES); + } catch (error) { + console.error("Failed to refresh session:", error); + setState("expired"); + } + }, []); + + const handleActivity = useCallback(() => { + const now = Date.now(); + lastActivityRef.current = now; + + // If in warning state and user is active, refresh the session + if (state === "warning") { + refreshSession(); + } + }, [state, refreshSession]); + + const extendSession = useCallback(() => { + refreshSession(); + }, [refreshSession]); + + const logout = useCallback(async () => { + try { + await apiPost("/auth/logout"); + } finally { + window.location.href = "/login"; + } + }, []); + + useEffect(() => { + // Track user activity + const events = ["mousedown", "keydown", "scroll", "touchstart"]; + events.forEach((event) => { + window.addEventListener(event, handleActivity, { passive: true }); + }); + + // Check session status every 30 seconds + const checkSession = () => { + const now = Date.now(); + const elapsed = now - lastActivityRef.current; + const elapsedMinutes = Math.floor(elapsed / 60_000); + const remaining = TIMEOUT_MINUTES - elapsedMinutes; + + setTimeRemaining(remaining); + + if (remaining <= 0) { + setState("expired"); + logout(); + } else if (remaining <= WARNING_MINUTES) { + setState("warning"); + } else { + setState("active"); + } + }; + + timerRef.current = setInterval(checkSession, 30_000); + checkSession(); // Run immediately + + return () => { + events.forEach((event) => { + window.removeEventListener(event, handleActivity); + }); + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + }, [handleActivity, logout]); + + return { + state, + timeRemaining, + extendSession, + logout, + }; +} diff --git a/web/src/hooks/useTransactions.ts b/web/src/hooks/useTransactions.ts index 4500eb8..14d3ee5 100644 --- a/web/src/hooks/useTransactions.ts +++ b/web/src/hooks/useTransactions.ts @@ -1,28 +1,81 @@ // web/src/hooks/useTransaction.ts import { useMutation, useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; -import { api } from "../api/client"; +import { apiPost, apiPatch, apiDelete } from "../api/http"; -const Tx = z.object({ - kind: z.enum(["variable_spend", "fixed_payment"]), - amountCents: z.number().int().positive(), - occurredAtISO: z.string().datetime(), - variableCategoryId: z.number().int().optional(), - fixedPlanId: z.number().int().optional() -}).superRefine((v, ctx) => { - const isVar = v.kind === "variable_spend"; - if (isVar && !v.variableCategoryId) ctx.addIssue({ code: "custom", message: "variableCategoryId required" }); - if (!isVar && !v.fixedPlanId) ctx.addIssue({ code: "custom", message: "fixedPlanId required" }); -}); +const Tx = z + .object({ + kind: z.enum(["variable_spend", "fixed_payment"]), + amountCents: z.number().int().positive(), + occurredAtISO: z.string().datetime(), + categoryId: z.string().min(1).optional(), + planId: z.string().min(1).optional(), + note: z.string().trim().max(500).optional(), + receiptUrl: z + .union([z.string().trim().url().max(2048), z.literal("")]) + .optional(), + isReconciled: z.boolean().optional(), + allowOverdraft: z.boolean().optional(), + useAvailableBudget: z.boolean().optional(), + }) + .superRefine((v, ctx) => { + const isVar = v.kind === "variable_spend"; + if (isVar && !v.categoryId && !v.useAvailableBudget) { + ctx.addIssue({ code: "custom", message: "categoryId required" }); + } + if (!isVar && !v.planId) ctx.addIssue({ code: "custom", message: "planId required" }); + }); export type TxInput = z.infer; export function useCreateTransaction() { const qc = useQueryClient(); return useMutation({ - mutationFn: async (input: TxInput) => api.post("/transactions", Tx.parse(input)), + mutationFn: async (input: TxInput) => + apiPost("/transactions", Tx.parse(input)), onSuccess: () => { qc.invalidateQueries({ queryKey: ["dashboard"] }); qc.invalidateQueries({ queryKey: ["transactions"] }); // ensure list refreshes if open - } + }, + }); +} + +const TxPatch = z + .object({ + id: z.string().min(1), + note: z.string().trim().max(500).optional(), + receiptUrl: z.string().trim().url().max(2048).optional(), + isReconciled: z.boolean().optional(), + }) + .refine( + (payload) => + payload.note !== undefined || + payload.receiptUrl !== undefined || + payload.isReconciled !== undefined, + { message: "No fields to update" } + ); +export type TxPatchInput = z.infer; + +export function useUpdateTransaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (input: TxPatchInput) => { + const { id, ...data } = TxPatch.parse(input); + return apiPatch(`/transactions/${id}`, data); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + +export function useDeleteTransaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => apiDelete(`/transactions/${id}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, }); } diff --git a/web/src/hooks/useTransactionsQuery.tsx b/web/src/hooks/useTransactionsQuery.tsx index 4c65851..1b50048 100644 --- a/web/src/hooks/useTransactionsQuery.tsx +++ b/web/src/hooks/useTransactionsQuery.tsx @@ -6,6 +6,14 @@ export type TxItem = { kind: "variable_spend" | "fixed_payment"; amountCents: number; occurredAt: string; + categoryId?: string | null; + categoryName?: string | null; + planId?: string | null; + planName?: string | null; + note?: string | null; + receiptUrl?: string | null; + isReconciled: boolean; + isAutoPayment?: boolean; }; export type TxListResponse = { @@ -22,6 +30,9 @@ export type TxQueryParams = { from?: string; to?: string; kind?: "variable_spend" | "fixed_payment"; + bucketId?: string; + sort?: "date" | "amount" | "kind" | "bucket"; + direction?: "asc" | "desc"; }; export function useTransactionsQuery(params: TxQueryParams) { @@ -31,3 +42,7 @@ export function useTransactionsQuery(params: TxQueryParams) { placeholderData: (previousData) => previousData, }); } + +export function fetchTransactions(params: TxQueryParams) { + return apiGet("/transactions", params); +} diff --git a/web/src/index.css b/web/src/index.css deleted file mode 100644 index f3a7f34..0000000 --- a/web/src/index.css +++ /dev/null @@ -1,128 +0,0 @@ -@import "tailwindcss"; - - -@theme { - /* Colors */ - --color-midnight: #0B1020; /* app bg */ - --color-ink: #111827; /* surfaces/lines */ - --color-pine: #165F46; /* primary: discipline/growth */ - --color-sage: #B7CAB6; /* muted text */ - --color-sand: #E7E3D7; /* body text */ - --color-amber: #E0B04E; /* positive */ - --color-rose: #CC4B4B; /* alerts */ - - - --radius-lg: 0.75rem; - --radius-xl: 0.9rem; - --radius-2xl: 1.2rem; - --shadow-soft: 0 6px 24px rgba(0,0,0,.25); - - - --container-w: 72rem; - -} - - -html, body, #root { height: 100%; } -body { - background: var(--color-midnight); - color: var(--color-sand); - font-family: var(--font-sans); -} - -/* Accessible focus ring */ -:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-pine) 80%, white 20%); outline-offset: 2px; } - -/* Motion sensitivity */ -@media (prefers-reduced-motion: reduce) { - * { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; } -} - -/* Utility tweaks */ -.mono { font-family: var(--font-mono); } -.muted { color: var(--color-sage); } - -/* ==== App Shell ==== */ -.app { @apply min-h-full grid grid-rows-[auto,1fr]; } - -/* Top nav */ -.nav { - @apply sticky top-0 z-10 backdrop-blur border-b; - background: color-mix(in oklab, var(--color-ink) 85%, transparent); - border-color: var(--color-ink); -} -.container { @apply mx-auto p-4 md:p-6; max-width: var(--container-w); } - -/* Links in the header */ -.link { @apply px-3 py-2 rounded-[--radius-xl] transition; } -.link:hover { background: var(--color-ink); } -.link-active { background: var(--color-ink); } - -/* ==== Cards / Sections ==== */ -.card { - @apply p-4 border rounded-[--radius-2xl]; - background: var(--color-ink); - border-color: var(--color-ink); - box-shadow: var(--shadow-soft); -} -.section-title { @apply text-lg font-semibold mb-3; } - -/* KPI tile */ -.kpi { @apply flex flex-col gap-1; } /* no 'card' here */ -.kpi h3 { @apply text-sm; color: var(--color-sage); } -.kpi .val { @apply text-2xl font-semibold; } -/* ==== Buttons ==== */ -.btn { - @apply inline-flex items-center gap-2 px-4 py-2 font-semibold border rounded-[--radius-xl] active:scale-[.99]; - background: var(--color-pine); - color: var(--color-sand); - border-color: var(--color-ink); -} -.btn:disabled { @apply opacity-60 cursor-not-allowed; } - -/* ==== Inputs ==== */ -.input, select, textarea { - @apply w-full px-3 py-2 rounded-[--radius-xl] border outline-none; - background: var(--color-midnight); - border-color: var(--color-ink); -} -label.field { @apply grid gap-1 mb-3; } -label.field > span { @apply text-sm; color: var(--color-sage); } - -/* ==== Table ==== */ -.table { @apply w-full border-separate; border-spacing: 0 0.5rem; } -.table thead th { @apply text-left text-sm pb-2; color: var(--color-sage); } -.table tbody tr { background: var(--color-ink); } -.table td, .table th { @apply px-3 py-2; } -.table tbody tr > td:first-child { @apply rounded-l-[--radius-xl]; } -.table tbody tr > td:last-child { @apply rounded-r-[--radius-xl]; } - -/* ==== Badges / Chips ==== */ -.badge { - @apply inline-flex items-center px-2 py-0.5 text-xs rounded-full border; - background: var(--color-ink); - border-color: var(--color-ink); -} - -/* ==== Simple Toasts ==== */ -.toast-ok { - @apply rounded-[--radius-xl] px-3 py-2 border; - background: #0a2917; /* dark green */ - color: #bbf7d0; - border-color: var(--color-pine); -} -.toast-err { - @apply rounded-[--radius-xl] px-3 py-2 border; - background: #3f1515; - color: #fecaca; - border-color: #7f1d1d; -} - -/* ==== Layout helpers ==== */ -.grid-auto { @apply grid gap-4 sm:grid-cols-2 lg:grid-cols-3; } -.stack { @apply flex flex-col gap-3; } -.row { @apply flex items-center gap-3; } - -/* ==== Chart wrappers (consistent sizing) ==== */ -.chart-md { @apply h-64; } -.chart-lg { @apply h-72; } \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx index 073681a..6c7ff4a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,12 +1,39 @@ -import React from "react"; +import React, { lazy } from "react"; import ReactDOM from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; +import { + RouterProvider, + createBrowserRouter, + createRoutesFromElements, + Route, + Navigate, +} from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ToastProvider } from "./components/Toast"; +import { RequireAuth } from "./components/RequireAuth"; import App from "./App"; import "./styles.css"; +// Initialize theme before React renders +const theme = (localStorage.getItem("theme") as "dark" | "light" | "system") || "system"; +const actualTheme = theme === "system" + ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") + : theme; +const colorScheme = (localStorage.getItem("colorScheme") as "blue" | "green" | "purple" | "orange") || "blue"; + +if (actualTheme === "dark") { + document.documentElement.setAttribute("data-theme", "dark"); + document.documentElement.classList.add("dark"); + document.body.classList.add("dark"); + document.body.setAttribute("data-theme", "dark"); +} else { + document.documentElement.setAttribute("data-theme", "light"); + document.documentElement.classList.remove("dark"); + document.body.classList.remove("dark"); + document.body.setAttribute("data-theme", "light"); +} +document.documentElement.classList.remove("scheme-blue", "scheme-green", "scheme-purple", "scheme-orange"); +document.documentElement.classList.add(`scheme-${colorScheme}`); + const client = new QueryClient({ defaultOptions: { queries: { @@ -16,15 +43,135 @@ const client = new QueryClient({ }, }); +const DashboardPage = lazy(() => import("./pages/DashboardPage")); +const SpendPage = lazy(() => import("./pages/SpendPage")); +const IncomePage = lazy(() => import("./pages/IncomePage")); +const TransactionsPage = lazy(() => import("./pages/TransactionsPage")); +const SettingsPage = lazy(() => import("./pages/settings/SettingsPage")); +const HealthPage = lazy(() => import("./pages/HealthPage")); +const OnboardingPage = lazy(() => import("./pages/OnboardingPage")); +const LoginPage = lazy(() => import("./pages/LoginPage")); +const RegisterPage = lazy(() => import("./pages/RegisterPage")); + +const router = createBrowserRouter( + createRoutesFromElements( + }> + {/* Public */} + } /> + } /> + + {/* Protected onboarding */} + + + + } + /> + + {/* Authenticated app */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Fallback */} + } /> + + ) +); + ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - - + + + ); diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 4e0c16c..d0a610a 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -1,148 +1,1351 @@ -import { useEffect, useState } from "react"; -import { apiGet } from "../api/http"; -import { fmtMoney } from "../utils/money"; - -type VariableCategory = { - id: string; name: string; percent: number; priority: number; - isSavings: boolean; balanceCents?: number; -}; - -type FixedPlan = { - id: string; name: string; priority: number; - totalCents?: number; fundedCents?: number; dueOn: string; -}; - -type Tx = { id: string; kind: "variable_spend"|"fixed_payment"; amountCents: number; occurredAt: string }; - -type DashboardResponse = { - totals: { - incomeCents: number; - variableBalanceCents: number; - fixedRemainingCents: number; - }; - percentTotal: number; - variableCategories: VariableCategory[]; - fixedPlans: FixedPlan[]; - recentTransactions: Tx[]; -}; +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useDashboard, type DashboardResponse } from "../hooks/useDashboard"; +import { Money } from "../components/ui"; +import PercentGuard from "../components/PercentGuard"; +import MonthlyTrendChart from "../components/charts/MonthlyTrendChart"; +import FixedFundingBars, { + type FixedItem, +} from "../components/charts/FixedFundingBars"; +import VariableAllocationDonut, { + type VariableSlice, +} from "../components/charts/VariableAllocationDonut"; +import { useAuthSession } from "../hooks/useAuthSession"; +import { fixedPlansApi } from "../api/fixedPlans"; +import { useToast } from "../components/Toast"; +import PaydayOverlay from "../components/PaydayOverlay"; +import PaymentReconciliationModal from "../components/PaymentReconciliationModal"; +import FundingConfirmationModal from "../components/FundingConfirmationModal"; +import EarlyPaymentPromptModal from "../components/EarlyPaymentPromptModal"; +import { isoToDateString, dateStringToUTCMidnight, getTodayInTimezone, getBrowserTimezone, formatDateInTimezone } from "../utils/timezone"; export default function DashboardPage() { - const [data, setData] = useState(null); - const [err, setErr] = useState(null); - const [loading, setLoading] = useState(true); + const session = useAuthSession(); + const shouldLoadDashboard = !!session.data?.userId; + const { data, isLoading, isError, error, refetch } = useDashboard( + shouldLoadDashboard, + ); + const navigate = useNavigate(); + const { push } = useToast(); - async function load() { - setLoading(true); - setErr(null); - try { - const d = await apiGet("/dashboard"); - setData(d); - } catch (e: any) { - setErr(e.message || "Failed to load"); - } finally { - setLoading(false); + const [activeTab, setActiveTab] = useState< + "overview" | "variable" | "fixed" | "trend" + >("overview"); + const [isDesktop, setIsDesktop] = useState(() => { + if (typeof window === "undefined") return true; + return window.matchMedia("(min-width: 1024px)").matches; + }); + + // Get user timezone from dashboard data + const userTimezone = data?.user?.timezone || getBrowserTimezone(); + const incomeType = data?.user?.incomeType ?? "regular"; + const incomeFrequency = data?.user?.incomeFrequency; + const firstIncomeDate = data?.user?.firstIncomeDate ?? null; + + const variableSlices: VariableSlice[] = useMemo(() => { + if (!data) return []; + return data.variableCategories.map((c) => ({ + name: c.name, + value: c.percent, + isSavings: !!c.isSavings, + })); + }, [data]); + + const fixedChartData: FixedItem[] = useMemo(() => { + if (!data) return []; + return data.fixedPlans.map((plan) => { + const total = plan.totalCents ?? 0; + const funded = Math.min(plan.fundedCents ?? 0, total); + const remaining = Math.max(total - funded, 0); + const aheadCents = getFundingAhead(plan) ?? 0; + + // Calculate percentages for the chart (0-100 scale) + const fundedPercent = total > 0 ? (funded / total) * 100 : 0; + const remainingPercent = total > 0 ? (remaining / total) * 100 : 0; + + return { + name: plan.name, + funded: fundedPercent, + remaining: remainingPercent, + fundedCents: funded, + remainingCents: remaining, + aheadCents, + isOverdue: (plan as { isOverdue?: boolean }).isOverdue ?? false, + dueOn: plan.dueOn, + }; + }); + }, [data]); + + // Due-today overlay state + const [dueTodayPlan, setDueTodayPlan] = useState< + | { id: string; name: string; dueOn: string; remainingCents: number } + | null + >(null); + const [overrideDueISO, setOverrideDueISO] = useState(""); + + useEffect(() => { + if (typeof window === "undefined") return; + const media = window.matchMedia("(min-width: 1024px)"); + const update = () => setIsDesktop(media.matches); + update(); + media.addEventListener("change", update); + return () => { + media.removeEventListener("change", update); + }; + }, []); + useEffect(() => { + if (!isDesktop && activeTab === "overview") { + setActiveTab("variable"); } + }, [isDesktop, activeTab]); + const [showDueOverlay, setShowDueOverlay] = useState(false); + const [isSubmittingDue, setIsSubmittingDue] = useState(false); + const [dueQueue, setDueQueue] = useState< + Array<{ id: string; name: string; dueOn: string; remainingCents: number }> + >([]); + const [queueIndex, setQueueIndex] = useState(0); + const savingsCategories = useMemo( + () => (data?.variableCategories || []).filter((c) => c.isSavings), + [data] + ); + const savingsReserveById = useMemo(() => { + if (!data) return new Map(); + const reserveTotal = data.fixedPlans.reduce( + (sum, plan) => sum + (plan.fundedCents || 0), + 0 + ); + const savingsTotalPercent = savingsCategories.reduce( + (sum, c) => sum + (c.percent || 0), + 0 + ); + const shares = savingsCategories.map((c) => { + const raw = savingsTotalPercent + ? (reserveTotal * (c.percent || 0)) / savingsTotalPercent + : 0; + const floored = Math.floor(raw); + return { id: c.id, share: floored, frac: raw - floored }; + }); + + let remainder = + reserveTotal - shares.reduce((sum, item) => sum + item.share, 0); + shares + .slice() + .sort((a, b) => b.frac - a.frac) + .forEach((item) => { + if (remainder > 0) { + item.share += 1; + remainder -= 1; + } + }); + + return new Map(shares.map((item) => [item.id, item.share])); + }, [data, savingsCategories]); + const [fundingSource, setFundingSource] = useState<"savings" | "deficit">( + "savings" + ); + const [savingsCategoryId, setSavingsCategoryId] = useState( + null + ); + const [hasCheckedDueToday, setHasCheckedDueToday] = useState(false); + + // Funding confirmation modal state + const [showFundingConfirmation, setShowFundingConfirmation] = useState(false); + const [fundingData, setFundingData] = useState<{ + planId: string; + planName: string; + totalCents: number; + fundedCents: number; + availableBudget: number; + } | null>(null); + + // Payment reconciliation modal state + const [showPaymentReconciliation, setShowPaymentReconciliation] = useState(false); + const [reconciliationData, setReconciliationData] = useState<{ + planId: string; + planName: string; + totalCents: number; + fundedCents: number; + isOverdue: boolean; + overdueAmount?: number; + message: string; + nextDueDate: string; + } | null>(null); + + const [earlyPaymentPrompt, setEarlyPaymentPrompt] = useState<{ + planId: string; + planName: string; + dueOn: string; + } | null>(null); + + const openDueItem = async (item: { id: string; name: string; dueOn: string }) => { + const planDetail = data?.fixedPlans.find((p) => p.id === item.id); + const totalCents = planDetail?.totalCents ?? 0; + const fundedCents = planDetail?.fundedCents ?? 0; + const isFullyFunded = fundedCents >= totalCents; + + if (!isFullyFunded) { + const availableBudget = data?.totals.variableBalanceCents ?? 0; + setFundingData({ + planId: item.id, + planName: item.name, + totalCents, + fundedCents, + availableBudget, + }); + setShowFundingConfirmation(true); + return; + } + + try { + const fundingResult = await fixedPlansApi.attemptFinalFunding(item.id); + const freq = (planDetail as any)?.frequency; + const nextDueISO = computeNextDueDateISO(item.dueOn, freq); + const nextDueDate = nextDueISO + ? isoToDateString(nextDueISO, userTimezone) + : item.dueOn.slice(0, 10); + + setReconciliationData({ + planId: item.id, + planName: item.name, + totalCents: fundingResult.totalCents, + fundedCents: fundingResult.fundedCents, + isOverdue: !!fundingResult.isOverdue, + overdueAmount: fundingResult.overdueAmount, + message: fundingResult.message ?? "", + nextDueDate: nextDueDate ?? getTodayInTimezone(userTimezone), + }); + setShowPaymentReconciliation(true); + } catch (fundingError) { + console.error("[DueOverlay] attempt-final-funding failed", fundingError); + } + }; + + const advanceDueQueue = async () => { + const nextIdx = queueIndex + 1; + if (nextIdx < dueQueue.length) { + const nextItem = dueQueue[nextIdx]; + setQueueIndex(nextIdx); + setDueTodayPlan(nextItem); + setOverrideDueISO(nextItem.dueOn.slice(0, 10)); + const initialSavings = savingsCategories[0]?.id || null; + setSavingsCategoryId(initialSavings); + setFundingSource("savings"); + await openDueItem(nextItem); + } else { + setDueTodayPlan(null); + setDueQueue([]); + setQueueIndex(0); + } + }; + + function toUserMidnight(iso: string, timezone: string) { + const dateStr = isoToDateString(iso, timezone); + return new Date(dateStringToUTCMidnight(dateStr, timezone)); } - useEffect(() => { load(); }, []); + function countPayPeriodsBetween( + startIso: string, + endIso: string, + firstIncomeIso: string, + frequency: NonNullable, + timezone: string + ) { + let count = 0; + let nextPayDate = toUserMidnight(firstIncomeIso, timezone); + const normalizedStart = toUserMidnight(startIso, timezone); + const normalizedEnd = toUserMidnight(endIso, timezone); - if (loading) return
Loading dashboard…
; - if (err) return ( -
- {err} -
- ); - if (!data) return null; + const targetDay = Number(isoToDateString(firstIncomeIso, timezone).split("-")[2] || "1"); + const advanceByPeriod = () => { + if (frequency === "monthly") { + const year = nextPayDate.getUTCFullYear(); + const month = nextPayDate.getUTCMonth() + 1; + const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth))); + } else { + const days = frequency === "biweekly" ? 14 : 7; + nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000); + } + }; + + while (nextPayDate < normalizedStart) { + advanceByPeriod(); + } + while (nextPayDate < normalizedEnd) { + count++; + advanceByPeriod(); + } + return Math.max(1, count); + } + + function getFundingAhead(plan: any) { + if ( + incomeType !== "regular" || + !incomeFrequency || + !firstIncomeDate || + !plan.cycleStart || + !plan.dueOn + ) { + return null; + } + + const nowOverride = new URLSearchParams(window.location.search).get("debugNow"); + const now = nowOverride ? new Date(`${nowOverride}T00:00:00.000Z`).toISOString() : new Date().toISOString(); + let cycleStart = plan.cycleStart as string; + const dueOn = plan.dueOn as string; + + let cycleStartDate: Date; + let dueDate: Date; + let nowDate: Date; + try { + cycleStartDate = toUserMidnight(cycleStart, userTimezone); + dueDate = toUserMidnight(dueOn, userTimezone); + nowDate = toUserMidnight(now, userTimezone); + } catch { + return null; + } + + if (cycleStartDate >= dueDate || cycleStartDate > nowDate) { + cycleStart = now; + } + + const totalPeriods = countPayPeriodsBetween( + cycleStart, + dueOn, + firstIncomeDate, + incomeFrequency, + userTimezone + ); + const elapsedPeriods = countPayPeriodsBetween( + cycleStart, + now, + firstIncomeDate, + incomeFrequency, + userTimezone + ); + const targetFunded = Math.min( + plan.totalCents ?? 0, + Math.ceil(((plan.totalCents ?? 0) * elapsedPeriods) / totalPeriods) + ); + const funded = plan.fundedCents ?? 0; + const aheadBy = Math.max(0, funded - targetFunded); + return aheadBy > 0 ? aheadBy : null; + } + + // Helper: compute next due date from current due using user's timezone + function computeNextDueDateISO(dateISO: string, frequency?: string): string | null { + if (!frequency || frequency === "one-time") return null; + + // Convert ISO to date string in user's timezone + const dateStr = isoToDateString(dateISO, userTimezone); + const [year, month, day] = dateStr.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + + if (frequency === "weekly") { + date.setUTCDate(date.getUTCDate() + 7); + } else if (frequency === "biweekly") { + date.setUTCDate(date.getUTCDate() + 14); + } else if (frequency === "monthly") { + date.setUTCMonth(date.getUTCMonth() + 1); + } + + // Convert back to ISO with timezone handling + const nextDateStr = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`; + return dateStringToUTCMidnight(nextDateStr, userTimezone); + } + + // Detect if any fixed plan is due today; show overlay to confirm due date and pay-now + useEffect(() => { + let cancelled = false; + async function checkDueToday() { + if (!data || !shouldLoadDashboard || hasCheckedDueToday) return; + try { + const res = await fixedPlansApi.due({ daysAhead: 0 }); + const items = (res.items || []).filter((x) => x.isDue && !x.isOverdue); + if (!cancelled && items.length > 0) { + setHasCheckedDueToday(true); + const first = items[0]; + + // Check if user has dismissed this overdue prompt recently + const dismissedUntilStr = localStorage.getItem(`overdue-dismissed-${first.id}`); + if (dismissedUntilStr) { + const dismissedUntil = parseInt(dismissedUntilStr, 10); + if (Date.now() < dismissedUntil) { + // Still within the "remind me later" window - skip this prompt + return; + } else { + // Expired - remove it + localStorage.removeItem(`overdue-dismissed-${first.id}`); + } + } + + // Get plan details to check if fully funded + const planDetail = data.fixedPlans.find(p => p.id === first.id); + const totalCents = planDetail?.totalCents ?? 0; + const fundedCents = planDetail?.fundedCents ?? 0; + const isFullyFunded = fundedCents >= totalCents; + + await openDueItem(first); + + // Store queue for potential future bills + setDueQueue(items.map((i) => ({ + id: i.id, + name: i.name, + dueOn: i.dueOn, + remainingCents: i.remainingCents, + }))); + setQueueIndex(0); + } + } catch (e: any) { + // Non-blocking: just ignore failures + try { console.warn("[DueOverlay] due check failed", e); } catch {} + } + } + checkDueToday(); + return () => { + cancelled = true; + }; + }, [data, shouldLoadDashboard, hasCheckedDueToday, savingsCategories, userTimezone]); + + useEffect(() => { + if ( + !data || + showDueOverlay || + showFundingConfirmation || + showPaymentReconciliation || + earlyPaymentPrompt + ) { + return; + } + + const eligible = data.fixedPlans.find((plan: any) => { + const total = plan.totalCents ?? 0; + const funded = plan.fundedCents ?? 0; + if (total <= 0 || funded < total) return false; + if (plan.isOverdue) return false; + if (plan.needsFundingThisPeriod !== false) return false; + const ahead = getFundingAhead(plan); + if (!ahead || ahead <= 0) return false; + const key = `early-paid-prompt-${plan.id}-${plan.dueOn}`; + if (localStorage.getItem(key)) return false; + return true; + }); + + if (eligible) { + setEarlyPaymentPrompt({ + planId: eligible.id, + planName: eligible.name, + dueOn: eligible.dueOn, + }); + } + }, [ + data, + showDueOverlay, + showFundingConfirmation, + showPaymentReconciliation, + earlyPaymentPrompt, + ]); + + useEffect(() => { + if (data && !data.hasBudgetSetup) { + navigate("/onboarding", { replace: true }); + } + }, [data?.hasBudgetSetup, navigate]); + + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+ ))} +
+
+ ); + } + + if (isError || !data) { + return ( +
+

{(error as any)?.message || "Failed to load dashboard"}

+ +
+ ); + } + + const currency = (cents: number) => (cents / 100).toFixed(2); + + // CSV export removed per UI simplification request. + + const hasCategories = data.variableCategories.length > 0; + const hasPlans = data.fixedPlans.length > 0; + const hasTx = data.recentTransactions.length > 0; + + const greetingName = + data.user.displayName || + session.data?.displayName || + session.data?.email?.split("@")[0] || + "friend"; + + // Chart data for analytics section return ( -
-

Dashboard

- -
- - - -
- -
-
-

Variable Categories (sum {data.percentTotal}%)

-
- {data.variableCategories.map(c => ( -
-
-
{c.name}
-
- {c.isSavings ? "Savings • " : ""}Priority {c.priority} -
-
-
-
{fmtMoney(c.balanceCents ?? 0)}
-
{c.percent}%
-
-
- ))} -
-
- -
-

Fixed Plans

-
- {data.fixedPlans.map(p => { - const total = p.totalCents ?? 0; - const funded = p.fundedCents ?? 0; - const remaining = Math.max(total - funded, 0); +
+ {/* Payday Overlay */} + + + {/* Due Today Overlay */} + {showDueOverlay && dueTodayPlan && ( +
+
+
+

{dueTodayPlan.name} is due today

+

+ Confirm the due date and record the payment. We'll roll the plan forward automatically. +

+
+ {(() => { + const planFull = data?.fixedPlans?.find((p) => p.id === dueTodayPlan.id); + // Extract frequency from plan.frequency or paymentSchedule.frequency + let freq = (planFull as any)?.frequency; + if (!freq && (planFull as any)?.paymentSchedule?.frequency) { + freq = (planFull as any).paymentSchedule.frequency; + } + const suggestedISO = planFull ? computeNextDueDateISO(dueTodayPlan.dueOn, freq) : null; + const suggestedDateOnly = suggestedISO ? suggestedISO.slice(0, 10) : ""; return ( -
-
-
{p.name}
-
- Due {new Date(p.dueOn).toLocaleDateString()} • Priority {p.priority} -
+
+
Calculated next due date
+
+ {suggestedDateOnly || "n/a"}
-
-
{fmtMoney(remaining)}
-
remaining
+
+ If this looks correct, press Confirm. Or adjust below.
+
); - })} + })()} +
+
Remaining
+
+ +
+
{queueIndex + 1} of {dueQueue.length}
+
+ {dueTodayPlan.remainingCents > 0 && ( +
+
Funding source
+
+ + +
+ {fundingSource === "savings" && ( + + )} +
+ )} +
+ + + +
+ )} + + {/* Funding Confirmation Modal */} + {showFundingConfirmation && fundingData && ( + { + setShowFundingConfirmation(false); + setFundingData(null); + }} + onFundingComplete={async (fundingResult) => { + setShowFundingConfirmation(false); + try { + const planDetail = data?.fixedPlans.find(p => p.id === fundingData.planId); + const freq = (planDetail as any)?.frequency; + const fallbackDueISO = dateStringToUTCMidnight(getTodayInTimezone(userTimezone), userTimezone); + const nextDueISO = computeNextDueDateISO(planDetail?.dueOn ?? fallbackDueISO, freq); + const nextDueDate = nextDueISO ? isoToDateString(nextDueISO, userTimezone) : getTodayInTimezone(userTimezone); + + setReconciliationData({ + planId: fundingData.planId, + planName: fundingData.planName, + totalCents: fundingResult.totalCents, + fundedCents: fundingResult.fundedCents, + isOverdue: !!fundingResult.isOverdue, + overdueAmount: fundingResult.overdueAmount, + message: fundingResult.message ?? "", + nextDueDate: nextDueDate ?? getTodayInTimezone(userTimezone), + }); + setFundingData(null); + setShowPaymentReconciliation(true); + await refetch(); + } catch (err) { + console.error("Failed to proceed to payment reconciliation", err); + push("err", "Failed to proceed with payment"); + setFundingData(null); + } + }} + /> + )} + + {/* Payment Reconciliation Modal */} + {showPaymentReconciliation && reconciliationData && ( + { + setShowPaymentReconciliation(false); + setReconciliationData(null); + }} + onSuccess={async () => { + setShowPaymentReconciliation(false); + setReconciliationData(null); + // Clear any "remind me later" dismissal since they've handled it + if (reconciliationData?.planId) { + localStorage.removeItem(`overdue-dismissed-${reconciliationData.planId}`); + } + push("ok", "Payment recorded successfully"); + await refetch(); + if (dueQueue.length > 0) { + await advanceDueQueue(); + } + }} + /> + )} + + {earlyPaymentPrompt && ( + { + const key = `early-paid-prompt-${earlyPaymentPrompt.planId}-${earlyPaymentPrompt.dueOn}`; + localStorage.setItem(key, "dismissed"); + setEarlyPaymentPrompt(null); + }} + onConfirmed={() => { + const key = `early-paid-prompt-${earlyPaymentPrompt.planId}-${earlyPaymentPrompt.dueOn}`; + localStorage.setItem(key, "confirmed"); + refetch(); + }} + /> + )} + + {/* HEADER */} +
+

Hello, {greetingName}!

+

+ Snapshot of balances, plans, and recent activity. +

+
+ + {/* OVERDUE BILLS WARNING BANNER */} + {(() => { + const overduePlans = data.fixedPlans.filter((p: any) => p.isOverdue && (p.overdueAmount ?? 0) > 0); + if (overduePlans.length === 0) return null; + const totalOverdue = overduePlans.reduce((sum: number, p: any) => sum + (p.overdueAmount ?? 0), 0); + return ( +
+
+ ! +
+
+ {overduePlans.length === 1 + ? `${overduePlans[0].name} is overdue` + : `${overduePlans.length} bills are overdue`} +
+
+ Total outstanding: · Post income to auto-fund +
+
+ + + Add Income + +
+ {overduePlans.length > 1 && ( +
+ Priority order: {overduePlans + .sort((a: any, b: any) => { + const aTime = a.overdueSince ? new Date(a.overdueSince).getTime() : 0; + const bTime = b.overdueSince ? new Date(b.overdueSince).getTime() : 0; + return aTime - bTime; + }) + .map((p: any) => `${p.name} ($${((p.overdueAmount ?? 0) / 100).toFixed(0)})`) + .join(" → ")} +
+ )} +
+ ); + })()} + + + + {/* BUDGET OVERVIEW – key metrics */} +
+ + + + + + + + sum + (plan.fundedCents || 0), 0)} /> +
-
-

Recent Transactions

-
- - - - - - - - - - {data.recentTransactions.map(tx => ( - - - - - - ))} - -
DateKindAmount
{new Date(tx.occurredAt).toLocaleString()}{tx.kind.replace("_", " ")}{fmtMoney(tx.amountCents)}
+ {/* ANALYTICS SECTION - Desktop: Show early with tabs */} + {isDesktop && ( +
+
+

Analytics

+
+ setActiveTab("overview")} + /> + setActiveTab("variable")} + /> + setActiveTab("fixed")} + /> + setActiveTab("trend")} + /> +
+
+ + {activeTab === "overview" && ( +
+ + +
+ )} + + {activeTab === "variable" && ( + + )} + + {activeTab === "fixed" && ( + + )} + + {activeTab === "trend" && ( +
+ + +
+ )} +
+ )} + + {/* UPCOMING PLAN ALERTS */} + {data.upcomingPlans.length > 0 && ( + + )} + + {/* VARIABLE / FIXED LISTS */} +
+
+
+

Variable Categories

+ + {data.percentTotal}% + +
+ {!hasCategories ? ( + + ) : ( +
+ {data.variableCategories.map((c) => { + return ( +
+
+
+
{c.name}
+
+ {c.isSavings ? "Savings • " : ""} + Priority {c.priority} +
+ {c.isSavings && + (c.savingsTargetCents ?? 0) > 0 && ( +
+ Goal ${currency(c.savingsTargetCents ?? 0)} +
+ )} +
+
+ {c.isSavings ? ( + <> + +
+ Available · + Reserved{" "} + +
+
{c.percent}%
+ + ) : ( + <> + +
{c.percent}%
+ + )} +
+
+ +
+ ); + })} +
+ )} +
+ +
+

Fixed plans

+ {!hasPlans ? ( + + ) : ( +
+ {data.fixedPlans.map((p) => { + const total = p.totalCents ?? 0; + const funded = Math.min(p.fundedCents ?? 0, total); + const remaining = Math.max(total - funded, 0); + const pct = total + ? Math.round((funded / total) * 100) + : 0; + const isOverdue = (p as any).isOverdue; + const overdueAmount = (p as any).overdueAmount ?? 0; + const aheadCents = getFundingAhead(p); + return ( +
+
+
+
+
{p.name}
+ {isOverdue && ( + + OVERDUE + + )} + {aheadCents !== null && ( + + Ahead {new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format((aheadCents ?? 0) / 100)} + + )} +
+
+ Due{" "} + {formatDateInTimezone(p.dueOn, userTimezone)} · + Priority {p.priority} +
+ {isOverdue && overdueAmount > 0 && ( +
+ Outstanding: +
+ )} +
+
+ Remaining +
+
+ +
+ ); + })} +
+ )}
+ + {/* SAVINGS GOALS */} + {data.savingsTargets.length > 0 && ( + + )} + + {/* Mobile / tablet: tabbed analytics */} +
+
+

Analytics

+
+ setActiveTab("variable")} + /> + setActiveTab("fixed")} + /> + setActiveTab("trend")} + /> +
+
+ {activeTab === "variable" && ( + + )} + {activeTab === "fixed" && ( + + )} + {activeTab === "trend" && ( +
+ + +
+ )} +
); } -function Card({ label, value }: { label: string; value: string }) { +/* ─────────────────────────── + * Small components + * ───────────────────────────*/ + +function AnalyticsTabButton({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { return ( -
-
{label}
-
{value}
+ + ); +} + +function UpcomingPlanAlerts({ + plans, + userTimezone, +}: { + plans: { id: string; name: string; dueOn: string; remainingCents: number }[]; + userTimezone: string; +}) { + if (plans.length === 0) return null; + return ( +
+
+ Upcoming plan alerts (next 14 days) +
+
    + {plans.map((plan) => ( +
  • +
    +
    {plan.name}
    +
    + Due{" "} + {formatDateInTimezone(plan.dueOn, userTimezone, { + month: "short", + day: "numeric", + })} +
    +
    +
    +
    Remaining
    + +
    +
  • + ))} +
+
+ ); +} + +function SavingsGoalsPanel({ + goals, +}: { + goals: Array<{ + id: string; + name: string; + balanceCents: number; + targetCents: number; + percent: number; + }>; +}) { + return ( +
+

Savings goals

+
    + {goals.map((goal) => ( +
  • +
    + {goal.name} + {goal.percent}% +
    +
    +
    +
    +
    + + saved + + + Goal + +
    +
  • + ))} +
+
+ ); +} + +function RecentTransactionsPanel({ + transactions, + hasData, +}: { + transactions: DashboardResponse["recentTransactions"]; + hasData: boolean; +}) { + if (!hasData) { + return ( +
+

Recent transactions

+ +
+ ); + } + return ( +
+

Recent transactions

+ + {/* Desktop table view */} +
+ + + + + + + + + + {transactions.map((tx) => ( + + + + + + ))} + +
DateKindAmount
+ {new Date(tx.occurredAt).toLocaleString()} + + {tx.kind.replace("_", " ")} + + +
+
+ + {/* Mobile card view */} +
+ {transactions.map((tx) => ( +
+
+
+
+ {tx.kind.replace("_", " ")} +
+
+ {new Date(tx.occurredAt).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + })} +
+
+
+ +
+
+
+ ))} +
+
+ ); +} + +// Modernized KPI card +function Card({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function ProgressBar({ + value, + max, + label, + variant = "default", +}: { + value: number; + max: number; + label?: string; + variant?: "default" | "danger"; +}) { + const pct = Math.min(100, Math.round((value / Math.max(max, 1)) * 100)); + const barColor = variant === "danger" ? "bg-red-500" : "bg-[--color-ink]"; + return ( +
+ {label &&
{label}
} +
+
+
+
+ ); +} + +function EmptyState({ + message, + actionLabel, + actionTo, +}: { + message: string; + actionLabel: string; + actionTo: string; +}) { + return ( +
+

{message}

+ + {actionLabel} +
); } diff --git a/web/src/pages/HealthPage.tsx b/web/src/pages/HealthPage.tsx index 76c1f0d..6a1ec3b 100644 --- a/web/src/pages/HealthPage.tsx +++ b/web/src/pages/HealthPage.tsx @@ -1,18 +1,218 @@ import { useQuery } from "@tanstack/react-query"; -import { api } from "../api/client"; +import { http } from "../api/http"; + +type AppHealth = { ok: true }; +type DbHealth = { ok: true; nowISO: string; latencyMs: number }; export default function HealthPage() { - const app = useQuery({ queryKey: ["health"], queryFn: () => api.get<{ok:true}>("/health") }); - const db = useQuery({ queryKey: ["health","db"], queryFn: () => api.get<{ok:true; nowISO:string; latencyMs:number}>("/health/db") }); + const app = useQuery({ + queryKey: ["health"], + queryFn: () => http("/health"), + }); + + const db = useQuery({ + queryKey: ["health", "db"], + queryFn: () => http("/health/db"), + }); + + const appStatus = getStatus(app.isLoading, !!app.data?.ok, !!app.error); + const dbStatus = getStatus(db.isLoading, !!db.data?.ok, !!db.error); return ( -
-

Health

-
    -
  • API: {app.isLoading ? "…" : app.data?.ok ? "OK" : "Down"}
  • -
  • DB: {db.isLoading ? "…" : db.data?.ok ? `OK (${db.data.latencyMs} ms)` : "Down"}
  • -
  • Server Time: {db.data?.nowISO ? new Date(db.data.nowISO).toLocaleString() : "…"}
  • -
+
+
+
+

System Health

+

+ Quick status for the SkyMoney API and database. Use this when debugging issues or latency. +

+
+ + {/* Status overview */} +
+ + + {app.error && ( + + )} + + } + onRetry={!app.isLoading ? () => app.refetch() : undefined} + /> + + + + + + {db.error && ( + + )} + + } + onRetry={!db.isLoading ? () => db.refetch() : undefined} + /> +
+ + {/* Raw data (tiny, for debugging) */} +
+
+ + Debug + + + Useful for logs / screenshots + +
+
+            API: {app.isLoading ? "Loading…" : JSON.stringify(app.data ?? { ok: false })}{"\n"}
+            DB: {db.isLoading ? "Loading…" : JSON.stringify(db.data ?? { ok: false })}
+          
+
+
); -} \ No newline at end of file +} + +type Status = "checking" | "up" | "down"; + +function getStatus( + isLoading: boolean, + ok: boolean, + hasError: boolean, +): Status { + if (isLoading) return "checking"; + if (ok && !hasError) return "up"; + return "down"; +} + +function labelForStatus(status: Status) { + if (status === "checking") return "Checking…"; + if (status === "up") return "Operational"; + return "Unavailable"; +} + +function HealthCard({ + label, + status, + description, + details, + onRetry, +}: { + label: string; + status: Status; + description: string; + details: React.ReactNode; + onRetry?: () => void; +}) { + return ( +
+
+
+
{label}
+

{description}

+
+ +
+ +
{details}
+ + {onRetry && ( +
+ +
+ )} +
+ ); +} + +function StatusPill({ + status, + className = "", +}: { + status: Status; + className?: string; +}) { + let text = ""; + let tone = ""; + + switch (status) { + case "checking": + text = "Checking…"; + tone = "bg-amber-500/10 text-amber-100 border border-amber-500/40"; + break; + case "up": + text = "OK"; + tone = "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"; + break; + case "down": + text = "Down"; + tone = "bg-red-500/10 text-red-100 border border-red-500/40"; + break; + } + + return ( + + + {text} + + ); +} + +function StatusLine({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/src/pages/IncomePage.tsx b/web/src/pages/IncomePage.tsx index a70dcda..e16aa69 100644 --- a/web/src/pages/IncomePage.tsx +++ b/web/src/pages/IncomePage.tsx @@ -1,223 +1,596 @@ import { useMemo, useState, type FormEvent } from "react"; -import { useCreateIncome } from "../hooks/useIncome"; -import { useDashboard } from "../hooks/useDashboard"; -import { Money, Field, Button } from "../components/ui"; import CurrencyInput from "../components/CurrencyInput"; -import { previewAllocation } from "../utils/allocatorPreview"; -import PercentGuard from "../components/PercentGuard"; +import { useDashboard } from "../hooks/useDashboard"; +import { computeNeedsFixedFunding } from "../utils/funding"; +import { useCreateIncome, type AllocationOverrideInput, type CreateIncomeInput } from "../hooks/useIncome"; import { useToast } from "../components/Toast"; -import { useIncomePreview } from "../hooks/useIncomePreview"; +import EarlyFundingModal from "../components/EarlyFundingModal"; +import { dateStringToUTCMidnight, getBrowserTimezone, isoToDateString } from "../utils/timezone"; -function dollarsToCents(input: string): number { - const n = Number.parseFloat(input || "0"); - if (!Number.isFinite(n)) return 0; - return Math.round(n * 100); +function fmt(cents: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }).format(cents / 100); } -type Alloc = { id: number | string; amountCents: number; name: string }; - -export default function IncomePage() { - const [amountStr, setAmountStr] = useState(""); - const { push } = useToast(); - const m = useCreateIncome(); - const dash = useDashboard(); - - const cents = dollarsToCents(amountStr); - const canSubmit = (dash.data?.percentTotal ?? 0) === 100; - - // Server preview (preferred) with client fallback - const srvPreview = useIncomePreview(cents); - const preview = useMemo(() => { - if (!dash.data || cents <= 0) return null; - if (srvPreview.data) return srvPreview.data; - // fallback: local simulation - return previewAllocation(cents, dash.data.fixedPlans, dash.data.variableCategories); - }, [cents, dash.data, srvPreview.data]); - - const submit = (e: FormEvent) => { - e.preventDefault(); - if (cents <= 0 || !canSubmit) return; - m.mutate( - { amountCents: cents }, - { - onSuccess: (res) => { - const fixed = (res.fixedAllocations ?? []).reduce( - (s: number, a: any) => s + (a.amountCents ?? 0), - 0 - ); - const variable = (res.variableAllocations ?? []).reduce( - (s: number, a: any) => s + (a.amountCents ?? 0), - 0 - ); - const unalloc = res.remainingUnallocatedCents ?? 0; - push( - "ok", - `Allocated: Fixed ${(fixed / 100).toFixed(2)} + Variable ${(variable / 100).toFixed( - 2 - )}. Unallocated ${(unalloc / 100).toFixed(2)}.` - ); - setAmountStr(""); - }, - onError: (err: any) => push("err", err?.message ?? "Income failed"), - } - ); - }; - - const variableAllocations: Alloc[] = useMemo(() => { - if (!m.data) return []; - const nameById = new Map( - (dash.data?.variableCategories ?? []).map((c) => [c.id as string | number, c.name] as const) - ); - const grouped = new Map(); - for (const a of m.data.variableAllocations ?? []) { - const id = (a as any).variableCategoryId ?? (a as any).id ?? -1; - grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents); - } - return [...grouped.entries()] - .map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Category #${id}` })) - .sort((a, b) => b.amountCents - a.amountCents); - }, [m.data, dash.data]); - - const fixedAllocations: Alloc[] = useMemo(() => { - if (!m.data) return []; - const nameById = new Map( - (dash.data?.fixedPlans ?? []).map((p) => [p.id as string | number, p.name] as const) - ); - const grouped = new Map(); - for (const a of m.data.fixedAllocations ?? []) { - const id = (a as any).fixedPlanId ?? (a as any).id ?? -1; - grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents); - } - return [...grouped.entries()] - .map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Plan #${id}` })) - .sort((a, b) => b.amountCents - a.amountCents); - }, [m.data, dash.data]); - - const hasResult = !!m.data; +function parseCurrencyToCents(value: string) { + const cleaned = value.replace(/[^0-9.]/g, ""); + const [whole, fraction = ""] = cleaned.split("."); + const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole; + const parsed = Number.parseFloat(normalized || "0"); + return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; +} +function ManualRow({ + label, + amountCents, + onChange, +}: { + label: string; + amountCents: number; + onChange: (cents: number) => void; +}) { return ( -
- - -
-

Record Income

- - - - - - - {/* Live Preview */} - {!hasResult && preview && ( -
-
-

Preview (not yet applied)

- - Unallocated: - -
- -
-
-

Fixed Plans

- - s + x.amountCents, 0)} /> - -
- {preview.fixed.length === 0 ? ( -
No fixed allocations.
- ) : ( -
    - {preview.fixed.map((a) => ( -
  • - {a.name} - - - -
  • - ))} -
- )} -
- -
-
-

Variable Categories

- - s + x.amountCents, 0)} /> - -
- {preview.variable.length === 0 ? ( -
No variable allocations.
- ) : ( -
    - {preview.variable.map((a) => ( -
  • - {a.name} - - - -
  • - ))} -
- )} -
-
- )} - - {/* Actual Result */} - {m.error &&
⚠️ {(m.error as any).message}
} - {hasResult && ( -
-
- Unallocated - - - -
- -
-
-

Fixed Plans (Applied)

- - s + x.amountCents, 0)} /> - -
-
    - {fixedAllocations.map((a) => ( -
  • - {a.name} - - - -
  • - ))} -
-
- -
-
-

Variable Categories (Applied)

- - s + x.amountCents, 0)} /> - -
-
    - {variableAllocations.map((a) => ( -
  • - {a.name} - - - -
  • - ))} -
-
-
- )} -
+
+ {label} + +
+ ); +} + +const DAY_MS = 86_400_000; + +function allocateIrregularFixed( + plans: Array<{ + id: string; + name: string; + totalCents?: number; + fundedCents?: number; + dueOn: string; + priority: number; + }>, + fixedPool: number, + now: Date, + timezone: string +) { + const userNowIso = dateStringToUTCMidnight(isoToDateString(now.toISOString(), timezone), timezone); + const userNow = new Date(userNowIso); + + const planStates = plans.map((plan) => { + const total = plan.totalCents ?? 0; + const funded = plan.fundedCents ?? 0; + const remainingCents = Math.max(0, total - funded); + const dueIso = dateStringToUTCMidnight(isoToDateString(plan.dueOn, timezone), timezone); + const dueDate = new Date(dueIso); + const daysUntilDue = Math.max(0, Math.ceil((dueDate.getTime() - userNow.getTime()) / DAY_MS)); + const isCrisis = remainingCents > 0 && daysUntilDue <= 14; + return { ...plan, remainingCents, daysUntilDue, isCrisis }; + }); + + const fixedAlloc: Record = {}; + const fixedNeed = planStates.reduce((sum, plan) => sum + plan.remainingCents, 0); + let remainingPool = fixedPool; + + const crisisPlans = planStates + .filter((p) => p.isCrisis && p.remainingCents > 0) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue; + return a.name.localeCompare(b.name); + }); + + for (const plan of crisisPlans) { + if (remainingPool <= 0) break; + const allocation = Math.min(remainingPool, plan.remainingCents); + if (allocation > 0) { + fixedAlloc[plan.id] = allocation; + remainingPool -= allocation; + plan.remainingCents -= allocation; + } + } + + const regularPlans = planStates.filter((p) => !p.isCrisis && p.remainingCents > 0); + const totalRegularNeeded = regularPlans.reduce((sum, p) => sum + p.remainingCents, 0); + + if (remainingPool > 0 && totalRegularNeeded > 0) { + for (const plan of regularPlans) { + const proportion = plan.remainingCents / totalRegularNeeded; + const allocation = Math.floor(remainingPool * proportion); + if (allocation > 0) { + fixedAlloc[plan.id] = (fixedAlloc[plan.id] ?? 0) + allocation; + } + } + } + + const allocatedTotal = Object.values(fixedAlloc).reduce((sum, val) => sum + val, 0); + + return { fixedAlloc, allocatedTotal, fixedNeed }; +} + + + +export default function IncomePage() { + const { data: dashboard } = useDashboard(); + const toast = useToast(); + const userTimezone = dashboard?.user?.timezone || getBrowserTimezone(); + const debugNow = new URLSearchParams(window.location.search).get("debugNow"); + const debugNowISO = debugNow ? dateStringToUTCMidnight(debugNow, userTimezone) : null; + const debugNowDate = debugNowISO ? new Date(debugNowISO) : null; + const [amountInput, setAmountInput] = useState(""); + const amountCents = useMemo(() => parseCurrencyToCents(amountInput), [amountInput]); + const [notes, setNotes] = useState(""); + const [manualMode, setManualMode] = useState(false); + const [manualFixed, setManualFixed] = useState>({}); + const [manualVariable, setManualVariable] = useState>({}); + const [earlyFundingModals, setEarlyFundingModals] = useState>([]); + + const allocation = useMemo(() => { + if (!dashboard || amountCents <= 0) return null; + const fixedPlans = dashboard.fixedPlans ?? []; + const variableCats = dashboard.variableCategories ?? []; + if (fixedPlans.length === 0 && variableCats.length === 0) return null; + + const fixedAlloc: Record = {}; + const varAlloc: Record = {}; + const isIrregular = dashboard?.user?.incomeType === "irregular"; + const fixedExpensePercentage = dashboard?.user?.fixedExpensePercentage ?? 40; + const eligibleFixedPlans = fixedPlans.filter((plan) => plan.autoPayEnabled); + + let remainingAmount = amountCents; + let shouldFundFixed = false; + let fixedNeed = 0; + + if (isIrregular) { + const fixedPool = Math.floor((amountCents * fixedExpensePercentage) / 100); + const rawFixedNeed = eligibleFixedPlans.reduce((sum, plan) => { + const total = plan.totalCents ?? 0; + const funded = plan.fundedCents ?? 0; + return sum + Math.max(total - funded, 0); + }, 0); + const fixedAllocationPool = Math.min(fixedPool, rawFixedNeed); + const irregularFunding = allocateIrregularFixed( + eligibleFixedPlans, + fixedAllocationPool, + debugNowDate ?? new Date(), + userTimezone + ); + fixedNeed = rawFixedNeed; + if (fixedAllocationPool > 0 && rawFixedNeed > 0) { + Object.assign(fixedAlloc, irregularFunding.fixedAlloc); + remainingAmount = Math.max(0, amountCents - fixedAllocationPool); + shouldFundFixed = true; + } + } else { + // Use client-side smart fixed funding detection (preferred) + shouldFundFixed = computeNeedsFixedFunding( + dashboard?.user?.incomeType ?? "regular", + dashboard?.user?.incomeFrequency ?? "biweekly", + fixedPlans, + debugNowDate ?? new Date(), + dashboard?.crisis?.active ?? false, + 100 + ); + fixedNeed = shouldFundFixed + ? eligibleFixedPlans.reduce((sum, plan) => { + const total = plan.totalCents ?? 0; + const funded = plan.fundedCents ?? 0; + return sum + Math.max(total - funded, 0); + }, 0) + : 0; + } + + // Calculate fixed plan allocation only if needed (regular income path) + if (!isIrregular && shouldFundFixed) { + // STEP 1: Pay overdue bills first (oldest first) + const overduePlans = fixedPlans + .filter(p => (p as any).isOverdue && ((p as any).overdueAmount ?? 0) > 0) + .sort((a, b) => { + const aTime = (a as any).overdueSince ? new Date((a as any).overdueSince).getTime() : 0; + const bTime = (b as any).overdueSince ? new Date((b as any).overdueSince).getTime() : 0; + return aTime - bTime; // oldest first + }); + + overduePlans.forEach((plan) => { + if (remainingAmount <= 0) return; + const overdueAmount = (plan as any).overdueAmount ?? 0; + const payment = Math.min(overdueAmount, remainingAmount); + if (payment > 0) { + fixedAlloc[plan.id] = payment; + remainingAmount -= payment; + } + }); + + // STEP 2: Allocate remaining to non-overdue plans (proportional) + if (remainingAmount > 0) { + const nonOverduePlans = eligibleFixedPlans.filter( + (p) => !(p as any).isOverdue || ((p as any).overdueAmount ?? 0) === 0 + ); + const fixedNeed = nonOverduePlans.reduce((sum, plan) => { + const total = plan.totalCents ?? 0; + const funded = plan.fundedCents ?? 0; + const alreadyAllocated = fixedAlloc[plan.id] ?? 0; + return sum + Math.max(total - funded - alreadyAllocated, 0); + }, 0); + + if (fixedNeed > 0) { + nonOverduePlans.forEach((plan) => { + const total = plan.totalCents ?? 0; + const funded = plan.fundedCents ?? 0; + const alreadyAllocated = fixedAlloc[plan.id] ?? 0; + const need = Math.max(total - funded - alreadyAllocated, 0); + const allocation = Math.floor((need / fixedNeed) * remainingAmount); + fixedAlloc[plan.id] = (fixedAlloc[plan.id] ?? 0) + Math.min(allocation, need); + remainingAmount -= Math.min(allocation, need); + }); + } + } + } + + // Smart variable allocation with deficit recovery + const totalPercent = variableCats.reduce((sum, cat) => sum + (cat.percent || 0), 0); + + if (totalPercent > 0 && remainingAmount > 0) { + // Step 1: Handle negative balances first + let poolAfterDeficits = remainingAmount; + + variableCats.forEach((cat) => { + const currentBalance = cat.balanceCents ?? 0; + if (currentBalance < 0 && poolAfterDeficits > 0) { + const deficitAmount = Math.min(Math.abs(currentBalance), poolAfterDeficits); + varAlloc[cat.id] = (varAlloc[cat.id] || 0) + deficitAmount; + poolAfterDeficits -= deficitAmount; + } + }); + + // Step 2: Distribute remaining by percentages + if (poolAfterDeficits > 0) { + variableCats.forEach((cat) => { + const percentageAmount = Math.floor(((cat.percent || 0) / totalPercent) * poolAfterDeficits); + varAlloc[cat.id] = (varAlloc[cat.id] || 0) + percentageAmount; + }); + } + } + + return { + fixedAlloc, + varAlloc, + shouldFundFixed, + fixedNeed + }; + }, [dashboard, amountCents]); + + const manualTotal = useMemo(() => { + const sumFixed = Object.values(manualFixed).reduce((a, b) => a + b, 0); + const sumVar = Object.values(manualVariable).reduce((a, b) => a + b, 0); + return sumFixed + sumVar; + }, [manualFixed, manualVariable]); + + const manualOver = manualTotal > amountCents; + + const createIncome = useCreateIncome(); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (amountCents <= 0) { + toast.push("err", "Enter an amount before recording income."); + return; + } + if (manualMode && manualOver) { + toast.push("err", "Total exceeds deposit amount."); + return; + } + + const payload: CreateIncomeInput = { + amountCents, + }; + if (debugNowISO) { + payload.occurredAtISO = debugNowISO; + } + + const trimmedNote = notes.trim(); + if (trimmedNote) { + payload.note = trimmedNote; + } + + if (manualMode) { + const overrides: AllocationOverrideInput[] = []; + Object.entries(manualFixed).forEach(([id, cents]) => { + if (cents > 0) overrides.push({ type: "fixed", id, amountCents: cents }); + }); + Object.entries(manualVariable).forEach(([id, cents]) => { + if (cents > 0) overrides.push({ type: "variable", id, amountCents: cents }); + }); + if (overrides.length > 0) { + payload.overrides = overrides; + } + } + + createIncome.mutate(payload, { + onSuccess: (result: any) => { + // Check if overdue bills were paid first + if (result?.overduePaid?.totalAmount > 0) { + const totalPaid = fmt(result.overduePaid.totalAmount); + const plans = result.overduePaid.plans; + + if (plans.length === 1) { + // Single overdue bill + toast.push("ok", `Paid ${fmt(plans[0].amountPaid)} to overdue bill: ${plans[0].name}`); + } else { + // Multiple overdue bills - show priority order + const plansList = plans + .map((p: any) => `${p.name} (${fmt(p.amountPaid)})`) + .join(", "); + toast.push("ok", `Paid ${totalPaid} to ${plans.length} overdue bills (oldest first): ${plansList}`); + } + } else { + toast.push("ok", "Income recorded."); + } + + // Check if any bills were fully funded + if (result?.fullyFundedPlans && result.fullyFundedPlans.length > 0) { + const modals = result.fullyFundedPlans.map((plan: any) => ({ + planId: plan.id, + planName: plan.name, + nextDueDate: plan.dueOn, + })); + setEarlyFundingModals(modals); + } + + setAmountInput(""); + setManualMode(false); + setManualFixed({}); + setManualVariable({}); + setNotes(""); + }, + onError: (err: any) => toast.push("err", err?.message ?? "Failed."), + }); + }; + + if (!dashboard) { + return
Loading…
; + } + + const fixed = dashboard.fixedPlans ?? []; + const variable = dashboard.variableCategories ?? []; + const isIrregularUser = dashboard?.user?.incomeType === "irregular"; + const autoFundFixed = isIrregularUser ? fixed.filter((plan) => plan.autoPayEnabled) : fixed; + const pageTimezone = dashboard?.user?.timezone || getBrowserTimezone(); + const monthLabel = new Date().toLocaleString("en-US", { + month: "long", + year: "numeric", + timeZone: pageTimezone, + }); + + return ( +
+
+

Record Income

+ {monthLabel} +
+ +
+
+ + + {/* Date field removed: income uses current time automatically */} + + +
+ + {amountCents > 0 && allocation && !manualMode && ( +
+

Automatic Allocation

+ + {allocation.shouldFundFixed && allocation.fixedNeed > 0 && ( +
+
+ + {fmt(Object.values(allocation.fixedAlloc).reduce((sum, val) => sum + val, 0))} will go to fixed expenses + + + {fmt(amountCents - Object.values(allocation.fixedAlloc).reduce((sum, val) => sum + val, 0))} remains for categories + +
+
+ )} + + {allocation.shouldFundFixed && allocation.fixedNeed > 0 && ( +
+
+ ! +
+ Fixed Expenses Need Funding + + {dashboard?.crisis?.active ? "Crisis mode - prioritizing fixed expenses" : + dashboard?.user?.incomeType === "irregular" ? "Irregular income - funding available plans" : + "Behind schedule - catching up on fixed plans"} + +
+
+
+ )} + + {allocation.shouldFundFixed && ( +
+

Fixed Expenses (deducted from income)

+ {autoFundFixed.map((plan) => { + const currentFunded = plan.fundedCents ?? 0; + const newAllocation = allocation.fixedAlloc[plan.id] ?? 0; + const newFunded = currentFunded + newAllocation; + const total = plan.totalCents ?? 0; + return ( +
+
+ {plan.name} + + +{fmt(newAllocation)} + +
+
+ Funded: {fmt(currentFunded)} / {fmt(total)} + New: {fmt(newFunded)} / {fmt(total)} +
+
+ ); + })} +
+ )} + +
+

Variable Categories

+ {variable.map((cat) => { + const currentBalance = cat.balanceCents ?? 0; + const newAllocation = allocation.varAlloc[cat.id] ?? 0; + const newTotal = currentBalance + newAllocation; + return ( +
+
+ {cat.name} + + +{fmt(newAllocation)} + +
+
+ Current: {fmt(currentBalance)} + New Total: {fmt(newTotal)} +
+
+ ); + })} +
+ + +
+ )} + + {manualMode && ( +
+

Manual Allocation

+
+

Fixed Expenses

+ {fixed.map((plan) => ( + + setManualFixed((prev) => ({ + ...prev, + [plan.id]: cents, + })) + } + /> + ))} +
+ +
+

Expenses

+ {variable.map((cat) => ( + + setManualVariable((prev) => ({ + ...prev, + [cat.id]: cents, + })) + } + /> + ))} +
+ +
+ Total Allocated + + {fmt(manualTotal)} / {fmt(amountCents)} + +
+
+ )} + +
+ {manualMode && ( + + )} + {!manualMode && allocation && ( + + )} + +
+
+ +
+

History

+ {/* History content can live here when ready */} +
+ + {earlyFundingModals.map((modal) => ( + { + setEarlyFundingModals((prev) => + prev.filter((m) => m.planId !== modal.planId) + ); + }} + /> + ))}
); } diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..80c0e6f --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,136 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { http } from "../api/http"; +import { useAuthSession } from "../hooks/useAuthSession"; + +function useNextPath() { + const location = useLocation(); + const params = new URLSearchParams(location.search); + return params.get("next") || "/"; +} + +export default function LoginPage() { + const navigate = useNavigate(); + const next = useNextPath(); + const qc = useQueryClient(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [touched, setTouched] = useState({ email: false, password: false }); + const [submitted, setSubmitted] = useState(false); + const session = useAuthSession({ retry: false }); + + const isEmailValid = (value: string) => /\S+@\S+\.\S+/.test(value); + const emailError = + (touched.email || submitted) && !email.trim() + ? "Email is required." + : (touched.email || submitted) && !isEmailValid(email) + ? "Enter a valid email address." + : ""; + const passwordError = + (touched.password || submitted) && !password + ? "Password is required." + : (touched.password || submitted) && password.length < 8 + ? "Password must be at least 8 characters." + : ""; + + useEffect(() => { + if (session.data?.userId) { + navigate(next || "/", { replace: true }); + } + }, [session.data, navigate, next]); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setPending(true); + setSubmitted(true); + if (emailError || passwordError) { + setPending(false); + return; + } + try { + await http<{ ok: true }>("/auth/login", { + method: "POST", + body: { email, password }, + skipAuthRedirect: true, + }); + qc.clear(); + navigate(next || "/", { replace: true }); + } catch (err) { + const status = (err as { status?: number })?.status; + const message = + status === 401 + ? "Email or password is incorrect." + : status === 400 + ? "Enter a valid email and password." + : err instanceof Error + ? err.message + : "Unable to login. Try again."; + setError(message); + } finally { + setPending(false); + } + } + + return ( +
+
+

Login

+

Sign in to continue budgeting.

+ {error &&
{error}
} + {/* Session errors are expected on login page, so don't show them */} +
+ + + +
+

+ Need an account?{" "} + + Register + +

+
+
+ ); +} diff --git a/web/src/pages/OnboardingPage.tsx b/web/src/pages/OnboardingPage.tsx new file mode 100644 index 0000000..4d6f1c3 --- /dev/null +++ b/web/src/pages/OnboardingPage.tsx @@ -0,0 +1,1423 @@ +import { useEffect, useMemo, useRef, useState, } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import CurrencyInput from "../components/CurrencyInput"; +import { apiPost, http } from "../api/http"; +import { categoriesApi } from "../api/categories"; +import { fixedPlansApi } from "../api/fixedPlans"; +import { budgetApi } from "../api/budget"; +import { useToast } from "../components/Toast"; +import ThemeToggle from "../components/ThemeToggle"; +import OnboardingTracker from "../components/OnboardingTracker"; +import type { DashboardResponse } from "../hooks/useDashboard"; +import { getTodayInTimezone, dateStringToUTCMidnight, getBrowserTimezone, addDaysToDate, formatDateInTimezone } from "../utils/timezone"; + +type Step = 1 | 2 | 3 | 4 | 5 | 6; + +type IncomeType = "regular" | "irregular"; +type BudgetConservatism = "tight" | "moderate" | "relaxed" | "custom"; + +type VariableCat = { + id: string; + name: string; + percent: number; // percent 0..100 + priority: number; // display / allocation order + isSavings?: boolean; +}; + +type AutoPaySchedule = { + frequency: "weekly" | "biweekly" | "monthly" | "daily" | "custom"; + dayOfWeek?: number; + dayOfMonth?: number; + everyNDays?: number; + minFundingPercent: number; +}; + +type FixedItem = { + id: string; + name: string; + amountCents: number; // absolute amount + priority: number; + dueOn: string; // yyyy-mm-dd + frequency?: "one-time" | "weekly" | "biweekly" | "monthly"; + autoPayEnabled?: boolean; + schedule?: AutoPaySchedule; + nextPaymentDate?: string; +}; + +const LS_KEY = "onboarding.v2"; // bump to avoid clashing with prior data +const fmtMoney = (cents: number) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format((cents ?? 0) / 100); +const formatCentsForInput = (cents: number) => { + if (!Number.isFinite(cents) || cents <= 0) return ""; + const value = (cents / 100).toFixed(2); + return value.replace(/\.00$/, ""); +}; +const parseCurrencyToCents = (value: string) => { + const cleaned = value.replace(/[^0-9.]/g, ""); + const [whole, fraction = ""] = cleaned.split("."); + const normalized = + fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole; + const parsed = Number.parseFloat(normalized || "0"); + return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; +}; +const defaultSchedule = (): AutoPaySchedule => ({ + frequency: "monthly", + minFundingPercent: 100, +}); +const DEFAULT_SAVINGS_PERCENT = 20; +const MIN_SAVINGS_PERCENT = DEFAULT_SAVINGS_PERCENT; +const normalizeCategoryName = (value: string) => value.trim().toLowerCase(); +const createDefaultSavingsCategory = (): VariableCat => ({ + id: crypto.randomUUID(), + name: "savings", + percent: DEFAULT_SAVINGS_PERCENT, + priority: 1, + isSavings: true, +}); + +export default function OnboardingPage() { + const navigate = useNavigate(); + const qc = useQueryClient(); + const { push } = useToast(); + + // Detect user's timezone + const userTimezone = getBrowserTimezone(); + + // recovery / existing data detection + const [dashboardSnapshot, setDashboardSnapshot] = useState(null); + const [recovering, setRecovering] = useState(false); + const [resetting, setResetting] = useState(false); + + // steps + const [step, setStep] = useState(1); + + // basics + const [displayName, setDisplayName] = useState(""); + const [incomeType, setIncomeType] = useState("regular"); + const [budgetCents, setBudgetCents] = useState(0); + const [budgetInput, setBudgetInput] = useState(""); + const [budgetPeriod, setBudgetPeriod] = useState<"weekly" | "biweekly" | "monthly">("monthly"); + const [budgetConservatism, setBudgetConservatism] = useState("moderate"); + const [customFixedPercentage, setCustomFixedPercentage] = useState(50); + const [nextIncomeDate, setNextIncomeDate] = useState(""); // For regular income users + + // variable (percent) + const [vars, setVars] = useState(() => [createDefaultSavingsCategory()]); + + // fixed (amount) + const [fixeds, setFixeds] = useState([]); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // ── restore + useEffect(() => { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return; + const s = JSON.parse(raw); + if (s.step) setStep(s.step); + if (s.displayName) setDisplayName(s.displayName); + if (s.incomeType) setIncomeType(s.incomeType); + if (Number.isFinite(s.budgetCents)) { + setBudgetCents(s.budgetCents); + setBudgetInput(formatCentsForInput(s.budgetCents)); + } + if (s.nextIncomeDate) setNextIncomeDate(s.nextIncomeDate); + if (s.budgetPeriod) setBudgetPeriod(s.budgetPeriod); + if (s.budgetConservatism) setBudgetConservatism(s.budgetConservatism); + if (Number.isFinite(s.customFixedPercentage)) setCustomFixedPercentage(s.customFixedPercentage); + if (Array.isArray(s.vars) && s.vars.length > 0) { + setVars(s.vars.map((v: VariableCat) => ({ ...v, name: normalizeCategoryName(v.name) }))); + } + if (Array.isArray(s.fixeds)) + setFixeds( + s.fixeds.map((f: FixedItem) => ({ + ...f, + schedule: f.schedule ?? defaultSchedule(), + autoPayEnabled: !!f.autoPayEnabled, + })) + ); + } catch {} + }, []); + + // attempt to load current dashboard to detect partial or completed setup + useEffect(() => { + let cancelled = false; + (async () => { + try { + const snap = await http("/dashboard"); + if (cancelled) return; + setDashboardSnapshot(snap); + // If fully set up already AND no local in-progress (step < 5) then redirect. + if (snap.hasBudgetSetup) { + // If user refreshed after success we should not force them to redo. + push("ok", "Budget already set up – redirecting to dashboard."); + navigate("/", { replace: true }); + } + } catch (e: any) { + // silently ignore (likely not authenticated yet) + } + })(); + return () => { cancelled = true; }; + }, [navigate, push]); + + // ── persist + useEffect(() => { + localStorage.setItem( + LS_KEY, + JSON.stringify({ step, displayName, incomeType, budgetCents, budgetPeriod, budgetConservatism, customFixedPercentage, nextIncomeDate, vars, fixeds }) + ); + }, [step, displayName, incomeType, budgetCents, budgetPeriod, budgetConservatism, customFixedPercentage, nextIncomeDate, vars, fixeds]); + + // ── computed + const varsTotal = useMemo( + () => vars.reduce((s, v) => s + (v.percent || 0), 0), + [vars] + ); + const savingsTotal = useMemo( + () => vars.reduce((s, v) => s + (v.isSavings ? v.percent || 0 : 0), 0), + [vars] + ); + const varsRemaining = Math.max(0, 100 - varsTotal); + const duplicateVarNames = useMemo(() => { + const counts = new Map(); + vars.forEach((v) => { + const key = normalizeCategoryName(v.name); + if (!key) return; + counts.set(key, (counts.get(key) ?? 0) + 1); + }); + return Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([name]) => name); + }, [vars]); + const fixedTotal = useMemo( + () => fixeds.reduce((s, f) => s + (f.amountCents || 0), 0), + [fixeds] + ); + + // ── guards + const canNext1 = displayName.trim().length >= 2; + const canNext2 = !!incomeType; + + // Validation helper for next income date + const validateNextIncomeDate = (): string | null => { + if (incomeType !== "regular" || !nextIncomeDate) return null; + + // Compare date strings directly to avoid timezone issues + const todayStr = getTodayInTimezone(userTimezone); + + // Must be today or in the future + if (nextIncomeDate < todayStr) { + return "Next income date must be today or in the future"; + } + + // Calculate expected date range based on frequency + const maxDays = budgetPeriod === "weekly" ? 7 : budgetPeriod === "biweekly" ? 14 : 31; + // Calculate max date by adding days to today + const maxDateStr = addDaysToDate(todayStr, maxDays, userTimezone); + + if (nextIncomeDate > maxDateStr) { + return `For ${budgetPeriod} income, next income should be within ${maxDays} days`; + } + + return null; + }; + + const canNext3 = + incomeType === "irregular" + ? budgetCents > 0 + : budgetCents > 0 && budgetPeriod && nextIncomeDate && !validateNextIncomeDate(); + + const canNext4 = + vars.length > 0 && + varsTotal === 100 && + vars.every((v) => v.name.trim()) && + vars.some((v) => v.isSavings) && + savingsTotal >= MIN_SAVINGS_PERCENT && + duplicateVarNames.length === 0; + const canNext5 = + fixeds.every((f) => f.name.trim()) && + fixeds.every((f) => f.amountCents >= 0); + + // ── nav + const next = () => setStep((s) => Math.min(6, s + 1) as Step); + const back = () => setStep((s) => Math.max(1, s - 1) as Step); + + // ── DnD (native) + const dragId = useRef(null); + const onDragStart = (e: React.DragEvent, id: string) => { + dragId.current = id; + e.dataTransfer.effectAllowed = "move"; + }; + const onDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + const reorder = (list: T[], overId: string) => { + const from = dragId.current; + if (!from || from === overId) return list; + const src = list.findIndex((x) => x.id === from); + const dst = list.findIndex((x) => x.id === overId); + if (src < 0 || dst < 0) return list; + const copy = [...list]; + const [moved] = copy.splice(src, 1); + copy.splice(dst, 0, moved); + return copy; + }; + + // ── variable ops + const addVar = () => + setVars((list) => [ + ...list, + { + id: crypto.randomUUID(), + name: "", + percent: 0, + priority: list.length + 1, + isSavings: false, + }, + ]); + const updateVar = (id: string, patch: Partial) => + setVars((list) => { + return list.map((v) => + v.id === id + ? { + ...v, + ...patch, + ...(patch.name !== undefined ? { name: normalizeCategoryName(patch.name) } : {}), + } + : v + ); + }); + const removeVar = (id: string) => + setVars((list) => + list.filter((v) => v.id !== id).map((v, i) => ({ ...v, priority: i + 1 })) + ); + const onDropVar = (e: React.DragEvent, overId: string) => { + e.preventDefault(); + const reordered = reorder(vars, overId); + if (reordered !== vars) { + setVars(reordered.map((v, i) => ({ ...v, priority: i + 1 }))); + } + dragId.current = null; + }; + + // ── fixed ops + const addFixed = () => + setFixeds((list) => [ + ...list, + { + id: crypto.randomUUID(), + name: "", + amountCents: 0, + priority: list.length + 1, + dueOn: getTodayInTimezone(userTimezone), + frequency: "monthly", + autoPayEnabled: false, + schedule: defaultSchedule(), + nextPaymentDate: getTodayInTimezone(userTimezone), + }, + ]); + const updateFixed = (id: string, patch: Partial) => + setFixeds((list) => + list.map((v) => (v.id === id ? { ...v, ...patch } : v)) + ); + const removeFixed = (id: string) => + setFixeds((list) => + list.filter((v) => v.id !== id).map((v, i) => ({ ...v, priority: i + 1 })) + ); + const onDropFixed = (e: React.DragEvent, overId: string) => { + e.preventDefault(); + const reordered = reorder(fixeds, overId); + if (reordered !== fixeds) { + setFixeds(reordered.map((v, i) => ({ ...v, priority: i + 1 }))); + } + dragId.current = null; + }; + + // ── finish + const getFixedExpensePercentage = (conservatism: BudgetConservatism): number => { + switch (conservatism) { + case "tight": return 60; + case "moderate": return 40; + case "relaxed": return 20; + case "custom": return customFixedPercentage; + default: return 40; + } + }; + + async function handleFinish() { + if (!canNext5) { + setError("Please complete all steps before finishing."); + return; + } + setSaving(true); + setError(null); + try { + // 1) name and income config + const trimmed = displayName.trim(); + if (trimmed) + await http("/me", { method: "PATCH", body: { displayName: trimmed } }); + + // Set user income configuration (including detected timezone) + const userConfigPayload = { + incomeType, + incomeFrequency: incomeType === "regular" ? budgetPeriod : undefined, + totalBudgetCents: budgetCents, + firstIncomeDate: incomeType === "regular" && nextIncomeDate ? dateStringToUTCMidnight(nextIncomeDate, userTimezone) : null, + timezone: userTimezone, + ...(incomeType === "irregular" + ? { fixedExpensePercentage: getFixedExpensePercentage(budgetConservatism) } + : {}), + }; + console.log("Sending user config with timezone:", userConfigPayload); + await http("/user/config", { + method: "PATCH", + body: userConfigPayload + }); + + // 2) variable categories + await seedVariableCategories(vars); + + // 3) fixed plans (cleanup then create to avoid duplicates on re-run) + try { + const snapshot = await http("/dashboard"); + const existingPlans = snapshot?.fixedPlans ?? []; + for (const plan of existingPlans) { + try { + await fixedPlansApi.delete(plan.id); + console.log(`Deleted existing fixed plan ${plan.id} (${plan.name})`); + } catch (e) { + console.warn(`Failed deleting fixed plan ${plan.id}:`, e); + } + } + } catch (e) { + console.warn("Failed to fetch existing fixed plans for cleanup", e); + } + for (const [i, f] of fixeds.entries()) { + const name = f.name.trim(); + if (!name) continue; // skip empties just in case + try { + const schedule = f.autoPayEnabled + ? { + frequency: "monthly" as const, + minFundingPercent: 100, + } + : undefined; + // Convert date to ISO with proper timezone handling + const dueDate = f.dueOn || getTodayInTimezone(userTimezone); + const dueOnISO = dateStringToUTCMidnight(dueDate, userTimezone); + const nextPaymentDate = f.autoPayEnabled && f.dueOn + ? dueOnISO + : undefined; + + const created = await fixedPlansApi.create({ + name, + totalCents: f.amountCents, + fundedCents: 0, + priority: i + 1, + dueOn: dueOnISO, + frequency: f.frequency, + autoPayEnabled: !!f.autoPayEnabled && !!schedule, + paymentSchedule: schedule, + nextPaymentDate, + }); + console.log(`Created fixed plan '${name}' id=${created.id}`); + } catch (err: any) { + if (err?.message?.includes("already exists")) { + throw new Error(`Fixed plan name '${name}' already exists. Use a different name.`); + } + throw err; + } + } + + // 4) optional: seed budget as income or budget allocation + if (budgetCents > 0) { + if (incomeType === "irregular") { + // Use budget funding for irregular income with conservatism-based percentage + await budgetApi.fund({ + newIncomeCents: budgetCents, + fixedExpensePercentage: getFixedExpensePercentage(budgetConservatism), + }); + } else { + // Use regular income allocation for regular income + await apiPost("/income", { amountCents: budgetCents }); + } + } + + localStorage.removeItem(LS_KEY); + await qc.invalidateQueries({ queryKey: ["dashboard"] }); + push("ok", "Budget setup complete!"); + navigate("/"); + } catch (err: any) { + setError(err?.message ?? "Failed to finish onboarding"); + } finally { + setSaving(false); + } + } + + // ----- recovery helpers ----- + const hasPartialServerState = useMemo(() => { + if (!dashboardSnapshot) return false; + const vc = dashboardSnapshot.variableCategories.length; + const fp = dashboardSnapshot.fixedPlans.length; + // Partial if either exists but setup flag false + return (vc > 0 || fp > 0) && !dashboardSnapshot.hasBudgetSetup; + }, [dashboardSnapshot]); + + async function resetServerBudget() { + if (!dashboardSnapshot) return; + setResetting(true); + try { + // delete variable categories + for (const cat of dashboardSnapshot.variableCategories) { + try { await categoriesApi.delete(cat.id); } catch {} + } + // delete fixed plans + for (const plan of dashboardSnapshot.fixedPlans) { + try { await fixedPlansApi.delete(plan.id); } catch {} + } + await qc.invalidateQueries({ queryKey: ["dashboard"] }); + setDashboardSnapshot(null); + // clear local progress, preserve name + budget if user wants + setVars([createDefaultSavingsCategory()]); + setFixeds([]); + setStep(4); // jump to variable category step for redo + localStorage.setItem(LS_KEY, JSON.stringify({ + step: 4, displayName, incomeType, budgetCents, budgetPeriod, budgetConservatism, + vars: [createDefaultSavingsCategory()], fixeds: [] + })); + push("ok", "Previous partial setup cleared. Start fresh."); + } catch (e: any) { + push("err", e?.message ?? "Failed to reset budget"); + } finally { + setResetting(false); + } + } + + function resumeServerState() { + if (!dashboardSnapshot) return; + setRecovering(true); + try { + // Map server categories/plans into local state so user can adjust + setVars( + dashboardSnapshot.variableCategories.map((c, i) => ({ + id: c.id, + name: normalizeCategoryName(c.name), + percent: c.percent, + priority: i + 1, + isSavings: !!c.isSavings, + })) + ); + setFixeds( + dashboardSnapshot.fixedPlans.map((p, i) => ({ + id: p.id, + name: p.name, + amountCents: p.totalCents, + priority: i + 1, + dueOn: p.dueOn, + autoPayEnabled: false, + schedule: defaultSchedule(), + nextPaymentDate: p.dueOn?.slice(0, 10), + })) + ); + setStep(4); // let user adjust categories first + push("ok", "Loaded existing partial setup. Adjust and finish."); + } finally { + setRecovering(false); + } + } + + // ── UI + return ( +
+ {/* Hero Header */} +
+
+
+ +
+
+

Welcome to SkyMoney

+

Let's set up your budget in just a few steps

+
+
+
+ +
+ {/* Progress Steps */} +
+ +
+ + {/* Recovery Banner */} + {hasPartialServerState && ( +
+
+
+ Partial setup detected + Previous categories or fixed plans exist but totals are incomplete. Resume or reset to start fresh. +
+
+ + +
+
+
+ )} +
+ + {/* Step 1: name */} + {step === 1 && ( +
+
+

What should we call you?

+

This will help personalize your experience

+
+ + +
+
+
+ )} + + {/* Step 2: Income Type */} + {step === 2 && ( +
+
+
+

How do you receive income?

+

This helps us optimize your budget allocation

+
+ +
+ + + +
+ + +
+
+ )} + + {/* Step 3: Budget */} + {step === 3 && ( +
+
+
+

What's your total budget?

+

+ This is the total amount you plan to allocate across all your expenses and savings +

+ + {incomeType === "regular" && ( +
+ + + + +

+ This helps us calculate the correct number of pay periods for your fixed expenses +

+
+ )} + + {incomeType === "irregular" && ( +
+ + + {budgetConservatism === "custom" && ( + + )} + +

+ {budgetConservatism === "custom" + ? `${customFixedPercentage}% of each income will go toward fixed expenses, ${100 - customFixedPercentage}% to variable spending` + : "This affects how much of each income goes toward fixed expenses vs variable spending"} +

+
+ )} + +
+ { + setBudgetInput(nextValue); + setBudgetCents(parseCurrencyToCents(nextValue)); + }} + /> + + +
+ +
+
+
+
+
+ )} + + {/* Step 4: Variable (percent) */} + {step === 4 && ( +
+
+
+

Variable Categories

+

Allocate your budget by percentage (must total 100%)

+
+ +
+ Remaining: {varsRemaining}% + Total: {varsTotal}% + +
+ +
+ {vars.map((v) => ( +
onDragStart(e, v.id)} + onDragOver={onDragOver} + onDrop={(e) => onDropVar(e, v.id)} + > +
+ {v.priority} + updateVar(v.id, { name: e.target.value })} + /> +
+ +
+
+ { + const raw = Math.max( + 0, + Math.min(100, Math.floor(Number(e.target.value) || 0)) + ); + const diff = raw - (v.percent || 0); + if (diff > varsRemaining) return; + updateVar(v.id, { percent: raw }); + }} + /> + % +
+ + + + +
+
+ ))} +
+ + {vars.length === 0 && ( +
+
+ Add categories to allocate your budget by percentage +
+

+ Examples: Groceries (30%), Entertainment (10%), Savings (20%) +

+
+ )} + + {varsTotal !== 100 && varsTotal > 0 && ( +
+ Categories must total 100%. Current: {varsTotal}% +
+ )} + + {varsTotal === 100 && !vars.some(v => v.isSavings) && ( +
+ Please mark at least one category as Savings +
+ )} + + {varsTotal === 100 && + vars.some((v) => v.isSavings) && + savingsTotal < MIN_SAVINGS_PERCENT && ( +
+ Savings must total at least {MIN_SAVINGS_PERCENT}%{" "} + (currently {savingsTotal}%) +
+ )} + + {duplicateVarNames.length > 0 && ( +
+ Duplicate expense names: {duplicateVarNames.join(", ")} +
+ )} + + + +
+ +
+
+
+ )} + + {/* Step 5: Fixed (amount) */} + {step === 5 && ( +
+
+
+

Fixed Expenses

+

+ {incomeType === "regular" + ? "Set amounts for recurring bills and expenses" + : "Set amounts for recurring bills and choose payment planning options"} +

+
+ +
+ +
+ +
+ {fixeds.map((f) => ( +
onDragStart(e, f.id)} + onDragOver={onDragOver} + onDrop={(e) => onDropFixed(e, f.id)} + > +
+ {f.priority} + updateFixed(f.id, { name: e.target.value })} + /> + +
+
+ updateFixed(f.id, { amountCents: c })} + /> + updateFixed(f.id, { dueOn: e.target.value })} + /> + +
+ + + + {f.autoPayEnabled && ( +
+ {incomeType === "irregular" ? ( +
+

Payment Plan Enabled

+

This expense will be prioritized in budget allocation based on due date and priority.

+
+ ) : ( +
+

Auto-Fund Enabled

+

This expense will be automatically paid when fully funded and due.

+
+ )} +
+ )} +
+ ))} +
+ + {fixeds.length === 0 && ( +
+
+ Add your fixed monthly expenses +
+

+ Examples: Rent, Insurance, Internet, Phone Bill +

+
+ )} + + {fixedTotal > 0 && ( +
+ Total Fixed Expenses + {fmtMoney(fixedTotal)} +
+ )} + + + +
+ +
+
+
+ )} + + {/* Step 6: Review */} + {step === 6 && ( +
+
+
+

+ {displayName ? `${displayName}'s Budget Setup` : 'Your Budget Setup'} +

+

Everything looks good? Let's get started!

+
+ +
+
+

Income Type

+
+ {incomeType === "regular" ? "Regular" : "Irregular"} +
+
+
+

Budget

+
{fmtMoney(budgetCents)}
+
+
+

Fixed

+
{fmtMoney(fixedTotal)}
+
+
+ +
+
+

Variable Categories

+
+ {vars.map((v, i) => ( +
+ #{i + 1} + + {v.name || Unnamed} + + + {v.percent}% + +
+ ))} +
+ Total + {varsTotal}% +
+
+
+ +
+

Fixed Expenses

+ {fixeds.length === 0 ? ( +
+ No fixed expenses added +
+ ) : ( +
+ {fixeds.map((f, i) => ( +
+ #{i + 1} +
+ + {f.name || Unnamed} + + + Due {formatDateInTimezone(f.dueOn, userTimezone)} + + {f.autoPayEnabled && ( + + {incomeType === "irregular" ? "Payment plan" : "Auto-fund"} + + )} +
+ + {fmtMoney(f.amountCents)} + +
+ ))} +
+ )} +
+
+ +
+ +
+ +
+ + {error &&
{error}
} +

+ You can modify these settings anytime in the app. +

+
+
+
+ )} +
+ ); +} + +function Stepper({ step }: { step: Step }) { + const steps = [ + { n: 1, label: "Name" }, + { n: 2, label: "Income Type" }, + { n: 3, label: "Budget" }, + { n: 4, label: "Categories" }, + { n: 5, label: "Fixed Plans" }, + { n: 6, label: "Review" }, + ] as const; + return ( +
    + {steps.map((s) => { + const active = step === (s.n as Step); + const done = (step as number) > s.n; + return ( +
  1. + + {done ? "✓" : s.n} + + {s.label} +
  2. + ); + })} +
+ ); +} + +function Actions({ + back, + next, +}: { + back?: { label: string; onClick: () => void }; + next?: { label: string; onClick: () => void; disabled?: boolean }; +}) { + return ( +
+ {back && ( + + )} + +
+ ); +} + +async function seedVariableCategories(vars: VariableCat[]) { + if (vars.length === 0) return; + // Normalize percents client-side to guarantee total = 100 + const normalizePercents = (items: VariableCat[]): VariableCat[] => { + const cleaned = items.map((v) => ({ + ...v, + name: v.name.trim(), + percent: Math.max(0, Math.min(100, Math.round(v.percent || 0))), + })); + const sum = cleaned.reduce((s, v) => s + v.percent, 0); + if (sum === 100) return cleaned; + if (sum === 0) return cleaned; // avoid division by zero; UI already blocks + + // Scale to 100, then fix rounding drift by adjusting the last non-savings item if possible + const scaled = cleaned.map((v) => ({ + ...v, + percent: Math.max(0, Math.min(100, Math.round((v.percent * 100) / sum))), + })); + let drift = 100 - scaled.reduce((s, v) => s + v.percent, 0); + if (drift !== 0) { + // Prefer adjusting a non-savings category; else adjust the last item + const idxToAdjust = scaled.findIndex((v) => !v.isSavings) !== -1 + ? scaled.findIndex((v) => !v.isSavings) + : scaled.length - 1; + scaled[idxToAdjust] = { + ...scaled[idxToAdjust], + percent: Math.max(0, Math.min(100, scaled[idxToAdjust].percent + drift)), + }; + // Recompute drift once; guard if still off due to clamping + const finalSum = scaled.reduce((s, v) => s + v.percent, 0); + if (finalSum !== 100) { + // Adjust the last item regardless to force exact 100 within bounds + const j = scaled.length - 1; + const delta = 100 - finalSum; + scaled[j] = { ...scaled[j], percent: Math.max(0, Math.min(100, scaled[j].percent + delta)) }; + } + } + return scaled; + }; + + // Apply normalization and assign priorities + const normalized = normalizePercents(vars).map((v, idx) => ({ + ...v, + name: normalizeCategoryName(v.name), + priority: idx + 1, + })); + + // clear any previous categories so re-running onboarding is idempotent + try { + const snapshot = await http("/dashboard"); + const existing = snapshot?.variableCategories ?? []; + + console.log("Existing categories before cleanup:", existing); + + // Delete existing categories sequentially to avoid race conditions + for (const cat of existing) { + try { + console.log(`Deleting category ${cat.id} (${cat.name})`); + await categoriesApi.delete(cat.id); + console.log(`Successfully deleted category ${cat.id}`); + } catch (error) { + console.warn(`Failed to delete category ${cat.id} (${cat.name}):`, error); + // Continue with other deletions even if one fails + } + } + + console.log("Category cleanup completed"); + } catch (error) { + console.warn("Failed to fetch existing categories:", error); + // ignore – dashboard call requires auth and may fail before login + } + + // Ensure at least one savings category is selected. + const savingsIndices = normalized.map((v, i) => (v.isSavings ? i : -1)).filter((i) => i >= 0); + if (savingsIndices.length === 0) { + throw new Error("Please mark one category as Savings."); + } + const savingsPercentTotal = normalized.reduce( + (sum, v) => sum + (v.isSavings ? v.percent || 0 : 0), + 0 + ); + if (savingsPercentTotal < MIN_SAVINGS_PERCENT) { + throw new Error( + `Savings must total at least ${MIN_SAVINGS_PERCENT}% (current ${savingsPercentTotal}%).` + ); + } + + // Final guard: total must be 100 (after normalization it should be) + const total = normalized.reduce((s, v) => s + (v.percent || 0), 0); + if (total !== 100) { + throw new Error(`Variable category percents must total 100 (current ${total}).`); + } + const normalizedNames = normalized.map((v) => normalizeCategoryName(v.name)).filter(Boolean); + const dupes = normalizedNames.filter((n, i) => normalizedNames.indexOf(n) !== i); + if (dupes.length > 0) { + throw new Error(`Duplicate expense names: ${Array.from(new Set(dupes)).join(", ")}`); + } + + // Create categories exactly as specified; backend dashboard logic relies on exact sum. + for (const cat of normalized) { + try { + const result = await categoriesApi.create({ + name: cat.name, + percent: cat.percent, + isSavings: !!cat.isSavings, + priority: cat.priority, + }); + console.log(`Created category '${cat.name}' id=${result.id} percent=${cat.percent}`); + } catch (error: any) { + console.error(`Failed to create category '${cat.name}':`, error); + if (error?.message?.includes('already exists')) { + throw new Error(`Category "${cat.name}" already exists. Use a different name.`); + } + throw new Error(`Failed to create category "${cat.name}": ${error?.message || 'Unknown error'}`); + } + } +} diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..70a64e2 --- /dev/null +++ b/web/src/pages/RegisterPage.tsx @@ -0,0 +1,170 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { http } from "../api/http"; +import { useAuthSession } from "../hooks/useAuthSession"; + +function useNextPath() { + const location = useLocation(); + const params = new URLSearchParams(location.search); + return params.get("next") || "/"; +} + +export default function RegisterPage() { + const navigate = useNavigate(); + const next = useNextPath(); + const qc = useQueryClient(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [touched, setTouched] = useState({ + email: false, + password: false, + confirmPassword: false, + }); + const [submitted, setSubmitted] = useState(false); + const session = useAuthSession({ retry: false }); + + const isEmailValid = (value: string) => /\S+@\S+\.\S+/.test(value); + const emailError = + (touched.email || submitted) && !email.trim() + ? "Email is required." + : (touched.email || submitted) && !isEmailValid(email) + ? "Enter a valid email address." + : ""; + const passwordError = + (touched.password || submitted) && !password + ? "Password is required." + : (touched.password || submitted) && password.length < 8 + ? "Password must be at least 8 characters." + : ""; + const confirmError = + (touched.confirmPassword || submitted) && !confirmPassword + ? "Please confirm your password." + : (touched.confirmPassword || submitted) && + password && + confirmPassword !== password + ? "Passwords do not match." + : ""; + + useEffect(() => { + if (session.data?.userId) { + navigate(next || "/", { replace: true }); + } + }, [session.data, navigate, next]); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setPending(true); + setSubmitted(true); + if (emailError || passwordError || confirmError) { + setPending(false); + return; + } + try { + await http<{ ok: true }>("/auth/register", { + method: "POST", + body: { email, password }, + skipAuthRedirect: true, + }); + qc.clear(); + navigate(next || "/", { replace: true }); + } catch (err) { + const status = (err as { status?: number })?.status; + const message = + status === 409 + ? "That email is already registered. Try signing in." + : status === 400 + ? "Enter a valid email and password." + : err instanceof Error + ? err.message + : "Unable to register. Try again."; + setError(message); + } finally { + setPending(false); + } + } + + return ( +
+
+

Register

+

Create an account to track your money buckets.

+ {error &&
{error}
} + {/* Session errors are expected on registration page, so don't show them */} +
+ + + + +
+

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/web/src/pages/SpendPage.tsx b/web/src/pages/SpendPage.tsx index 62ddd63..58bcb27 100644 --- a/web/src/pages/SpendPage.tsx +++ b/web/src/pages/SpendPage.tsx @@ -1,168 +1,581 @@ -import { useMemo, useState, type FormEvent, type ChangeEvent } from "react"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useDashboard } from "../hooks/useDashboard"; -import { useCreateTransaction } from "../hooks/useTransactions"; -import { Money, Field, Button } from "../components/ui"; +import { useCreateTransaction, useDeleteTransaction } from "../hooks/useTransactions"; +import { useTransactionsQuery } from "../hooks/useTransactionsQuery"; +import { Money } from "../components/ui"; import CurrencyInput from "../components/CurrencyInput"; import { useToast } from "../components/Toast"; -import { nowLocalISOStringMinute } from "../utils/format"; +import { getCurrentTimestamp, getBrowserTimezone, formatDateInTimezone } from "../utils/timezone"; +import EarlyFundingModal from "../components/EarlyFundingModal"; +import PaymentConfirmationModal from "../components/PaymentConfirmationModal"; + type Kind = "variable_spend" | "fixed_payment"; +type Errors = Partial>; -function dollarsToCents(input: string): number { - const n = Number.parseFloat(input || "0"); - if (!Number.isFinite(n)) return 0; - return Math.round(n * 100); +const LS_KEY = "spend.lastKind"; +const OTHER_CATEGORY_ID = "__other__"; + +function parseCurrencyToCents(value: string) { + const cleaned = value.replace(/[^0-9.]/g, ""); + const [whole, fraction = ""] = cleaned.split("."); + const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole; + const parsed = Number.parseFloat(normalized || "0"); + return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; } export default function SpendPage() { const dash = useDashboard(); - const m = useCreateTransaction(); + const createTx = useCreateTransaction(); + const deleteTx = useDeleteTransaction(); const { push } = useToast(); - const [kind, setKind] = useState("variable_spend"); - const [amountStr, setAmountStr] = useState(""); - const [occurredAt, setOccurredAt] = useState(nowLocalISOStringMinute()); + // ---- form state + const [kind, setKind] = useState(() => (localStorage.getItem(LS_KEY) as Kind) || "variable_spend"); + const [amountInput, setAmountInput] = useState(""); + const amountCents = useMemo(() => parseCurrencyToCents(amountInput), [amountInput]); const [variableCategoryId, setVariableCategoryId] = useState(""); const [fixedPlanId, setFixedPlanId] = useState(""); + const [note, setNote] = useState(""); + const [showSavingsWarning, setShowSavingsWarning] = useState(false); + const [errors, setErrors] = useState({}); + const [earlyFundingModal, setEarlyFundingModal] = useState<{ + planId: string; + planName: string; + nextDueDate?: string; + } | null>(null); + const [confirmationModal, setConfirmationModal] = useState<{ + message: string; + payload: any; + } | null>(null); + const [overdraftConfirmation, setOverdraftConfirmation] = useState<{ + message: string; + overdraftAmount: number; + categoryName: string; + payload: any; + } | null>(null); + const [showMoreRecent, setShowMoreRecent] = useState(false); - const amountCents = dollarsToCents(amountStr); - - // Optional UX lock: block variable spend if chosen category has 0 balance. - const selectedCategory = useMemo(() => { - if (!dash.data) return null; - return dash.data.variableCategories.find(c => String(c.id) === variableCategoryId) ?? null; - }, [dash.data, variableCategoryId]); - - const disableIfZeroBalance = true; // flip to false to allow negatives - const categoryBlocked = - kind === "variable_spend" && - !!selectedCategory && - disableIfZeroBalance && - (selectedCategory.balanceCents ?? 0) <= 0; - - const canSubmit = useMemo(() => { - if (!dash.data) return false; - if (amountCents <= 0) return false; - if (kind === "variable_spend") return !!variableCategoryId && !categoryBlocked; - return !!fixedPlanId; // fixed_payment - }, [dash.data, amountCents, kind, variableCategoryId, fixedPlanId, categoryBlocked]); - - const onSubmit = (e: FormEvent) => { - e.preventDefault(); - if (!canSubmit) return; - - const payload = { - kind, - amountCents, - occurredAtISO: new Date(occurredAt).toISOString(), - variableCategoryId: kind === "variable_spend" ? Number(variableCategoryId) : undefined, - fixedPlanId: kind === "fixed_payment" ? Number(fixedPlanId) : undefined, - } as any; - - m.mutate(payload, { - onSuccess: () => { - push("ok", kind === "variable_spend" ? "Recorded spend." : "Recorded payment."); - setAmountStr(""); - // Keep date defaulting to “now” for quick entry - setOccurredAt(nowLocalISOStringMinute()); - }, - onError: (err: any) => push("err", err?.message ?? "Failed to record"), - }); - }; + // remember chosen kind + useEffect(() => { + localStorage.setItem(LS_KEY, kind); + }, [kind]); + // data const cats = dash.data?.variableCategories ?? []; const plans = dash.data?.fixedPlans ?? []; + const planOptions = useMemo(() => plans.map(p => ({ id: p.id, name: p.name })), [plans]); + const catOptions = useMemo(() => cats.map(c => ({ id: c.id, name: c.name, isSavings: c.isSavings })), [cats]); + + // Check if selected category is savings + const selectedCategory = useMemo(() => + variableCategoryId === OTHER_CATEGORY_ID ? undefined : cats.find(c => c.id === variableCategoryId), + [cats, variableCategoryId] + ); + const isSpendingFromSavings = kind === "variable_spend" && selectedCategory?.isSavings; + const isOtherSpend = kind === "variable_spend" && variableCategoryId === OTHER_CATEGORY_ID; + // quick stats + const remainingVariable = useMemo(() => { + const categories = dash.data?.variableCategories ?? []; + return categories.reduce((acc, c) => acc + (c.balanceCents ?? 0), 0); + }, [dash.data?.variableCategories]); + + const budgetDenominator = + (dash.data?.totals.variableBalanceCents ?? 0) + + (dash.data?.totals.fixedRemainingCents ?? 0) || + 1; + const lowBalance = + remainingVariable > 0 && remainingVariable / budgetDenominator < 0.1; + const userTimezone = dash.data?.user?.timezone || getBrowserTimezone(); + const monthLabel = useMemo( + () => + new Date().toLocaleString("en-US", { + month: "long", + year: "numeric", + timeZone: userTimezone, + }), + [userTimezone] + ); + + const recentQuery = useTransactionsQuery({ + page: 1, + limit: 10, + sort: "date", + direction: "desc", + }); + const recentTransactions = useMemo(() => { + const items = recentQuery.data?.items ?? []; + const filtered = items.filter((t) => { + if (kind === "variable_spend") return t.kind === "variable_spend"; + return t.kind === "fixed_payment" && !t.isAutoPayment; + }); + const limitRecent = showMoreRecent ? 10 : 3; + return filtered.slice(0, limitRecent); + }, [recentQuery.data, showMoreRecent, kind]); + + // ---- validation + function validate(): boolean { + const next: Errors = {}; + if (!amountCents || amountCents <= 0) next.amount = "Enter an amount greater than $0."; + if (kind === "variable_spend" && !variableCategoryId) next.kindSpecific = "Pick a category."; + if (kind === "fixed_payment" && !fixedPlanId) next.kindSpecific = "Pick a plan."; + + // Enhanced validation for savings withdrawals + if (isSpendingFromSavings) { + if (!note || note.trim().length < 10) { + next.kindSpecific = "Savings withdrawal requires detailed justification (min 10 characters)."; + } + } + + setErrors(next); + return Object.keys(next).length === 0; + } + + function onBlurField(key: keyof Errors) { + return () => { + // re-run partial validation + const next = { ...errors }; + if (key === "amount") { + if (!amountCents || amountCents <= 0) next.amount = "Enter an amount greater than $0."; + else delete next.amount; + } + if (key === "kindSpecific") { + if (kind === "variable_spend" && !variableCategoryId) next.kindSpecific = "Pick a category."; + else if (kind === "fixed_payment" && !fixedPlanId) next.kindSpecific = "Pick a plan."; + else delete next.kindSpecific; + } + setErrors(next); + }; + } + + // ---- submit + function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!validate()) return; + + // Show confirmation dialog for savings withdrawals + if (isSpendingFromSavings && !showSavingsWarning) { + setShowSavingsWarning(true); + return; + } + + const payload: any = { + kind, + amountCents, + occurredAtISO: getCurrentTimestamp(), // Use current time in UTC + note: note.trim() || undefined, + categoryId: kind === "variable_spend" && !isOtherSpend ? variableCategoryId : undefined, + useAvailableBudget: isOtherSpend ? true : undefined, + planId: kind === "fixed_payment" ? fixedPlanId : undefined, + }; + + createTx.mutate(payload, { + onSuccess: (res: any) => { + if (kind === "fixed_payment") { + const planName = planOptions.find(p => p.id === fixedPlanId)?.name || "Bill"; + const nextISO: string | undefined = res?.nextDueOn; + const nextLabel = nextISO ? formatDateInTimezone(nextISO, userTimezone) : undefined; + const msg = nextLabel + ? `Funded ${planName}. Fully funded. Next due: ${nextLabel}` + : `Funded ${planName}.`; + push("ok", msg); + + // Show early funding modal if this is a recurring bill + if (nextISO) { + setEarlyFundingModal({ + planId: fixedPlanId, + planName, + nextDueDate: nextISO, + }); + } + } else { + push("ok", "Recorded spend."); + } + // soft reset for quick entry + setAmountInput(""); + setNote(""); + if (kind === "variable_spend") setVariableCategoryId(""); + if (kind === "fixed_payment") setFixedPlanId(""); + setErrors({}); + setShowSavingsWarning(false); + setOverdraftConfirmation(null); + }, + onError: (err: any) => { + // Check for overdraft confirmation requirement + if (err?.code === "OVERDRAFT_CONFIRMATION") { + setOverdraftConfirmation({ + message: err.message, + overdraftAmount: err.overdraftAmount, + categoryName: err.categoryName, + payload: { ...payload, allowOverdraft: true }, + }); + } else if (err?.code === "CONFIRMATION_REQUIRED") { + setConfirmationModal({ + message: err.message, + payload: { ...payload, confirmVariableImpact: true }, + }); + } else { + push("err", err?.message ?? "Failed to record."); + } + }, + }); + } + + function confirmOverdraft() { + if (!overdraftConfirmation) return; + createTx.mutate(overdraftConfirmation.payload, { + onSuccess: () => { + push("ok", `Recorded spend. ${overdraftConfirmation.categoryName} is now in overdraft.`); + setAmountInput(""); + setNote(""); + setVariableCategoryId(""); + setErrors({}); + setOverdraftConfirmation(null); + }, + onError: (err: any) => { + push("err", err?.message ?? "Failed to record."); + setOverdraftConfirmation(null); + }, + }); + } + + function cancelOverdraft() { + setOverdraftConfirmation(null); + } + + function confirmSavingsWithdrawal() { + setShowSavingsWarning(false); + handleSubmit({ preventDefault: () => {} } as FormEvent); + } + + function cancelSavingsWithdrawal() { + setShowSavingsWarning(false); + } + + // ---- UI return ( -
-
-

Spend / Pay

+
+ {/* Header */} +
+

Record Spend

+ {monthLabel} +
+ {/* Summary cards */} +
+ } helper={lowBalance ? "Heads up: under 10% remaining." : undefined} /> + + +
+ + {/* Recent activity */} +
+
+
+

Recent activity

+
Quick undo for your latest transactions.
+
+ {(recentQuery.data?.items?.length ?? 0) > 3 && ( + + )} +
+ {recentQuery.isLoading ? ( +
Loading recent transactions…
+ ) : recentTransactions.length === 0 ? ( +
No recent transactions yet.
+ ) : ( +
+ {recentTransactions.map((t) => ( +
+
+ + {t.categoryName ?? t.planName ?? t.note ?? "–"} + + + {t.kind === "variable_spend" ? "Variable spend" : "Fixed payment"} ·{" "} + {formatDateInTimezone(t.occurredAt, userTimezone)} + +
+
+ + +
+
+ ))} +
+ )} +
+ + {/* Form */} + {/* Kind toggle */} -
- - +
+ { setKind("variable_spend"); setFixedPlanId(""); setErrors(e => ({ ...e, kindSpecific: undefined })); }} + > + Variable Spend + + { setKind("fixed_payment"); setVariableCategoryId(""); setErrors(e => ({ ...e, kindSpecific: undefined })); }} + > + Fixed Payment +
- {/* Pick target */} + {/* Amount */} + + + + + {/* Kind-specific inputs */} {kind === "variable_spend" ? ( - + + {errors.kindSpecific &&
{errors.kindSpecific}
} + ) : ( - + + {errors.kindSpecific &&
{errors.kindSpecific}
} + )} - {/* Amount + Date */} - - - - + {/* Optional note */} + + {isSpendingFromSavings && ( +
+ Withdrawing from savings - please explain why this is necessary +
+ )} + - {/* Guard + submit */} - {categoryBlocked && ( -
- Selected category has no available balance. Add income or pick another category. -
- )} - - {m.error &&
⚠️ {(m.error as any).message}
} + {/* Actions */} +
+ + +
+ + {/* Savings Withdrawal Warning Dialog */} + {showSavingsWarning && ( +
+
+
+ ! +

Savings Withdrawal Warning

+
+ +
+

You're about to withdraw from your {selectedCategory?.name} savings.

+

Reason: "{note}"

+

This will reduce your savings progress. Are you sure this is necessary?

+
+ +
+ + +
+
+
+ )} + + {/* Overdraft Confirmation Modal */} + {overdraftConfirmation && ( +
+
+
+ ! +

Overdraft Warning

+
+ +
+

{overdraftConfirmation.message}

+
+
+ {overdraftConfirmation.categoryName} will go negative by +
+
+ This deficit will be automatically recovered from your next income. +
+
+
+ +
+ + +
+
+
+ )} + + {/* Early Funding Modal */} + {earlyFundingModal && ( + setEarlyFundingModal(null)} + /> + )} + + {confirmationModal && ( + { + createTx.mutate(confirmationModal.payload, { + onSuccess: (res: any) => { + if (kind === "fixed_payment") { + const planName = planOptions.find(p => p.id === fixedPlanId)?.name || "Bill"; + const nextISO: string | undefined = res?.nextDueOn; + const nextLabel = nextISO ? formatDateInTimezone(nextISO, userTimezone) : undefined; + const msg = nextLabel + ? `Paid ${planName}. Next due: ${nextLabel}` + : `Paid ${planName}.`; + push("ok", msg); + + if (nextISO) { + setEarlyFundingModal({ + planId: fixedPlanId, + planName, + nextDueDate: nextISO, + }); + } + } else { + push("ok", "Recorded spend."); + } + setAmountInput(""); + setNote(""); + if (kind === "variable_spend") setVariableCategoryId(""); + if (kind === "fixed_payment") setFixedPlanId(""); + setErrors({}); + setShowSavingsWarning(false); + setConfirmationModal(null); + }, + onError: (err: any) => { + push("err", err?.message ?? "Failed to record."); + setConfirmationModal(null); + }, + }); + }} + onCancel={() => setConfirmationModal(null)} + /> + )}
); -} \ No newline at end of file +} + +function Toggle({ pressed, onClick, children }: { pressed: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +function InfoCard({ label, value, helper }: { label: string; value: React.ReactNode; helper?: string }) { + return ( +
+
{label}
+
{value}
+ {helper &&
{helper}
} +
+ ); +} diff --git a/web/src/pages/TransactionsPage.tsx b/web/src/pages/TransactionsPage.tsx index e770c20..2e4e7c4 100644 --- a/web/src/pages/TransactionsPage.tsx +++ b/web/src/pages/TransactionsPage.tsx @@ -1,183 +1,263 @@ -import { useEffect, useMemo, useState, type ChangeEvent } from "react"; -import { useSearchParams } from "react-router-dom"; -import { useTransactionsQuery } from "../hooks/useTransactionsQuery"; +import { useMemo, useState } from "react"; import { Money } from "../components/ui"; +import { Skeleton } from "../components/Skeleton"; import Pagination from "../components/Pagination"; - -type Kind = "all" | "variable_spend" | "fixed_payment"; - -function isoDateOnly(d: Date) { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const da = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${da}`; -} +import { useTransactionsQuery, type TxQueryParams } from "../hooks/useTransactionsQuery"; +import { useDashboard } from "../hooks/useDashboard"; +import { getTodayInTimezone, getBrowserTimezone, addDaysToDate } from "../utils/timezone"; export default function TransactionsPage() { - const today = isoDateOnly(new Date()); - const [sp, setSp] = useSearchParams(); + const { data: dashboard } = useDashboard(); + const userTimezone = dashboard?.user?.timezone || getBrowserTimezone(); + + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState<"all" | "variable" | "fixed">("all"); + const [catFilter, setCatFilter] = useState("all"); + const [dateFilter, setDateFilter] = useState<"month" | "30" | "all">("month"); + const [page, setPage] = useState(1); + const limit = 100; - // init from URL - const initKind = (sp.get("kind") as Kind) || "all"; - const initQ = sp.get("q") || ""; - const initFrom = sp.get("from") || ""; - const initTo = sp.get("to") || today; - const initPage = Math.max(1, Number(sp.get("page") || 1)); + // Get current date in user's timezone + const todayStr = getTodayInTimezone(userTimezone); + const [year, month] = todayStr.split('-').map(Number); + const firstOfMonthStr = `${year}-${String(month).padStart(2, '0')}-01`; + const thirtyAgoStr = addDaysToDate(todayStr, -30, userTimezone); - const [kind, setKind] = useState(initKind); - const [qRaw, setQRaw] = useState(initQ); - const [q, setQ] = useState(initQ.trim()); - const [from, setFrom] = useState(initFrom); - const [to, setTo] = useState(initTo); - const [page, setPage] = useState(initPage); - const limit = 20; - - // debounce search - useEffect(() => { - const id = setTimeout(() => { - setPage(1); - setQ(qRaw.trim()); - }, 250); - return () => clearTimeout(id); - }, [qRaw]); - - // write to URL on change - useEffect(() => { - const next = new URLSearchParams(); - if (kind !== "all") next.set("kind", kind); - if (q) next.set("q", q); - if (from) next.set("from", from); - if (to) next.set("to", to); - if (page !== 1) next.set("page", String(page)); - setSp(next, { replace: true }); - }, [kind, q, from, to, page, setSp]); - - const params = useMemo( - () => ({ + const params = useMemo(() => { + const qp: TxQueryParams = { page, limit, - q: q || undefined, - from: from || undefined, - to: to || undefined, - kind: kind === "all" ? undefined : kind, - }), - [page, limit, q, from, to, kind] - ); + sort: "date", + direction: "desc", + }; + const term = search.trim(); + if (term) qp.q = term; + if (typeFilter !== "all") { + qp.kind = typeFilter === "variable" ? "variable_spend" : "fixed_payment"; + } + if (catFilter !== "all") { + qp.bucketId = catFilter; + } + if (dateFilter === "month") { + qp.from = firstOfMonthStr; + } else if (dateFilter === "30") { + qp.from = thirtyAgoStr; + } else { + qp.from = undefined; + } + return qp; + }, [page, limit, search, typeFilter, catFilter, dateFilter, firstOfMonthStr, thirtyAgoStr]); - const { data, isLoading, error, refetch, isFetching } = useTransactionsQuery(params); + const txQuery = useTransactionsQuery(params); + const transactions = txQuery.data?.items ?? []; + const total = txQuery.data?.total ?? 0; + + const catOptions = useMemo(() => { + if (transactions.length === 0) return []; + const buckets = new Map(); + transactions.forEach((t) => { + if (t.categoryId && t.categoryName) buckets.set(t.categoryId, t.categoryName); + if (t.planId && t.planName) buckets.set(t.planId, t.planName); + }); + return Array.from(buckets.entries()).map(([id, name]) => ({ id, name })); + }, [transactions]); - const clear = () => { - setKind("all"); - setQRaw(""); - setFrom(""); - setTo(today); - setPage(1); - }; - const rows = data?.items ?? []; - const totalAmount = rows.reduce((s, r) => s + (r.amountCents ?? 0), 0); return ( -
-
-

Transactions

+
+
+

Records

+
+ +
+
+ { + setSearch(e.target.value); + setPage(1); + }} + /> + +
+ { + setTypeFilter("all"); + setPage(1); + }} + /> + { + setTypeFilter("variable"); + setPage(1); + }} + /> + { + setTypeFilter("fixed"); + setPage(1); + }} + /> +
- {/* Filters */} -
- setQRaw(e.target.value)} - /> - { - setFrom(e.target.value); + setDateFilter(e.target.value as typeof dateFilter); setPage(1); }} - /> - { - setTo(e.target.value); - setPage(1); - }} - /> - -
- {isFetching ? "Refreshing…" : `Showing ${rows.length}`} -
+ > + + + +
+
- {/* States */} - {isLoading &&
Loading…
} - {error && !isLoading && ( -
- Couldn’t load transactions.{" "} - -
- )} - - {/* Table */} - {!isLoading && rows.length === 0 ? ( -
No transactions match your filters.
- ) : ( - <> - + {txQuery.isLoading ? ( + + ) : ( + <> +
+
- - - - + + + + + - {rows.map((t) => ( - - - + + - + ))}
TypeAmountDate
DateTypeCategoryAmount
{t.kind} - + {transactions.map((t) => ( +
{formatDate(t.occurredAt, userTimezone)} + + + {prettyKind(t.kind)} + - {new Date(t.occurredAt).toLocaleString()} + {t.categoryName ?? t.planName ?? t.note ?? "–"} +
-
-
Page total
-
- -
-
- {data && ( - + {total > limit && ( + )} - - )} -
+
+ +
+ {transactions.map((t) => ( +
+
+
+ {t.categoryName ?? t.planName ?? t.note ?? "–"} + + + {prettyKind(t.kind)} · {formatDate(t.occurredAt, userTimezone)} + +
+
+ +
+
+ {t.note && ( +
+ {t.note} +
+ )} +
+ ))} + {total > limit && ( + + )} +
+ + )}
); } + +function FilterToggle({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + + + +function prettyKind(kind: string) { + if (kind === "variable_spend") return "Variable Spend"; + if (kind === "fixed_payment") return "Fixed Payment"; + return kind; +} + +function formatDate(iso: string, userTimezone: string) { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: userTimezone, + }).format(new Date(iso)); +} diff --git a/web/src/pages/settings/AccountSettings.tsx b/web/src/pages/settings/AccountSettings.tsx new file mode 100644 index 0000000..f789ae3 --- /dev/null +++ b/web/src/pages/settings/AccountSettings.tsx @@ -0,0 +1,515 @@ +// web/src/pages/settings/AccountSettings.tsx +import { useState, useEffect, useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useToast } from "../../components/Toast"; +import { useAuthSession } from "../../hooks/useAuthSession"; +import { useDashboard } from "../../hooks/useDashboard"; +import { http } from "../../api/http"; +import { + addDaysToDate, + dateStringToUTCMidnight, + getBrowserTimezone, + getTodayInTimezone, + isoToDateString, +} from "../../utils/timezone"; + +export default function AccountSettings() { + const qc = useQueryClient(); + const { data: session, refetch: refetchSession } = useAuthSession(); + const { data: dashboard } = useDashboard(); + const { push } = useToast(); + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isUpdating, setIsUpdating] = useState(false); + const [timezone, setTimezone] = useState(getBrowserTimezone()); + const [isUpdatingTimezone, setIsUpdatingTimezone] = useState(false); + const [incomeFrequency, setIncomeFrequency] = useState< + "weekly" | "biweekly" | "monthly" + >("weekly"); + const [nextPayDate, setNextPayDate] = useState(""); + const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false); + const [fixedExpensePercentage, setFixedExpensePercentage] = useState(40); + const [isUpdatingConservatism, setIsUpdatingConservatism] = useState(false); + + const browserTimezone = useMemo(() => getBrowserTimezone(), []); + const timezoneOptions = useMemo(() => { + const supported = + typeof Intl !== "undefined" && "supportedValuesOf" in Intl + ? (Intl as unknown as { supportedValuesOf: (k: string) => string[] }).supportedValuesOf("timeZone") + : []; + const list = supported.length ? supported : [browserTimezone]; + const withBrowser = list.includes(browserTimezone) ? list : [browserTimezone, ...list]; + return Array.from(new Set(withBrowser)).sort(); + }, [browserTimezone]); + + // Load session data when available + useEffect(() => { + if (session) { + setDisplayName(session.displayName || ""); + setEmail(session.email || ""); + } + }, [session]); + + useEffect(() => { + if (dashboard?.user?.timezone) { + setTimezone(dashboard.user.timezone); + } + }, [dashboard?.user?.timezone]); + + useEffect(() => { + if (dashboard?.user?.incomeType !== "regular") return; + if (dashboard.user.incomeFrequency) { + setIncomeFrequency(dashboard.user.incomeFrequency); + } + if (dashboard.user.firstIncomeDate && dashboard.user.timezone) { + setNextPayDate( + isoToDateString(dashboard.user.firstIncomeDate, dashboard.user.timezone) + ); + } else if (dashboard?.user?.timezone) { + setNextPayDate(getTodayInTimezone(dashboard.user.timezone)); + } + }, [ + dashboard?.user?.incomeType, + dashboard?.user?.incomeFrequency, + dashboard?.user?.firstIncomeDate, + dashboard?.user?.timezone, + ]); + + useEffect(() => { + if (dashboard?.user?.incomeType !== "irregular") return; + setFixedExpensePercentage(dashboard.user.fixedExpensePercentage ?? 40); + }, [dashboard?.user?.incomeType, dashboard?.user?.fixedExpensePercentage]); + + const scheduleError = useMemo(() => { + if (dashboard?.user?.incomeType !== "regular") return null; + if (!nextPayDate) return "Next payday is required."; + const todayStr = getTodayInTimezone(timezone); + if (nextPayDate < todayStr) { + return "Next payday must be today or in the future."; + } + const maxDays = + incomeFrequency === "weekly" + ? 7 + : incomeFrequency === "biweekly" + ? 14 + : 31; + const maxDateStr = addDaysToDate(todayStr, maxDays, timezone); + if (nextPayDate > maxDateStr) { + return `For ${incomeFrequency} income, next payday should be within ${maxDays} days.`; + } + return null; + }, [dashboard?.user?.incomeType, incomeFrequency, nextPayDate, timezone]); + + const handleUpdateProfile = async (e: React.FormEvent) => { + e.preventDefault(); + setIsUpdating(true); + + try { + await http("/me", { + method: "PATCH", + body: { + displayName: displayName.trim(), + }, + }); + + // Refetch session to update displayed data + await refetchSession(); + push("ok", "Profile updated successfully"); + } catch (error: any) { + push("err", error?.message ?? "Failed to update profile"); + } finally { + setIsUpdating(false); + } + }; + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + + if (newPassword !== confirmPassword) { + push("err", "New passwords don't match"); + return; + } + + if (newPassword.length < 8) { + push("err", "New password must be at least 8 characters"); + return; + } + + setIsUpdating(true); + + try { + await http("/me/password", { + method: "PATCH", + body: { + currentPassword, + newPassword, + }, + }); + + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + push("ok", "Password changed successfully"); + } catch (error: any) { + push("err", error?.message ?? "Failed to change password"); + } finally { + setIsUpdating(false); + } + }; + + const handleUpdateTimezone = async () => { + if (!timezone) return; + setIsUpdatingTimezone(true); + try { + await http("/user/config", { + method: "PATCH", + body: { timezone }, + }); + await qc.invalidateQueries({ queryKey: ["dashboard"] }); + push("ok", "Timezone updated"); + } catch (error: any) { + push("err", error?.message ?? "Failed to update timezone"); + } finally { + setIsUpdatingTimezone(false); + } + }; + + const handleUpdateSchedule = async () => { + if (dashboard?.user?.incomeType !== "regular") return; + if (scheduleError) { + push("err", scheduleError); + return; + } + if (!nextPayDate) { + push("err", "Next payday is required."); + return; + } + setIsUpdatingSchedule(true); + try { + await http("/user/config", { + method: "PATCH", + body: { + incomeFrequency, + budgetPeriod: incomeFrequency, + firstIncomeDate: dateStringToUTCMidnight(nextPayDate, timezone), + }, + }); + await qc.invalidateQueries({ queryKey: ["dashboard"] }); + push("ok", "Income schedule updated"); + } catch (error: any) { + push("err", error?.message ?? "Failed to update schedule"); + } finally { + setIsUpdatingSchedule(false); + } + }; + + const handleLogout = async () => { + try { + await http("/auth/logout", { + method: "POST", + body: {}, + skipAuthRedirect: true, + }); + } finally { + qc.clear(); + window.location.replace("/login"); + } + }; + + const handleUpdateConservatism = async () => { + if (dashboard?.user?.incomeType !== "irregular") return; + const clamped = Math.max(0, Math.min(100, Math.round(fixedExpensePercentage))); + setIsUpdatingConservatism(true); + try { + await http("/user/config", { + method: "PATCH", + body: { fixedExpensePercentage: clamped }, + }); + await qc.invalidateQueries({ queryKey: ["dashboard"] }); + push("ok", "Auto-fund percentage updated"); + } catch (error: any) { + push("err", error?.message ?? "Failed to update auto-fund percentage"); + } finally { + setIsUpdatingConservatism(false); + } + }; + + return ( +
+ {/* Profile Information */} +
+
+

Profile Information

+

Update your display name and view your account details.

+
+
+
+ + setDisplayName(e.target.value)} + placeholder="Your display name" + /> +
+ +
+ +
{email || "Not set"}
+

+ Email address cannot be changed at this time. +

+
+ + +
+
+ + {/* Timezone */} +
+
+

Timezone

+

Used for income dates and due dates. Update this if you travel or want a different reference timezone.

+
+
+
+ + +

Current: {dashboard?.user?.timezone ?? "Not set"}

+
+
+ + +
+
+
+ + {/* Income Schedule */} +
+
+

Income Schedule

+

+ {dashboard?.user?.incomeType !== "regular" + ? "Income schedule changes are available for regular income users only." + : "Update your pay frequency and next payday. This recalculates payment plan timelines without changing current funding."} +

+
+ {dashboard?.user?.incomeType === "regular" && ( +
+
+
+ + +
+
+ + setNextPayDate(e.target.value)} + /> +
+
+ {scheduleError && ( +
{scheduleError}
+ )} +
+ +
+
+ )} +
+ + {/* Irregular Income Auto-Fund */} + {dashboard?.user?.incomeType === "irregular" && ( +
+
+

Irregular Income Auto-Fund

+

Choose how much of each income deposit is reserved to auto-fund fixed expenses.

+
+
+
+ + { + const nextValue = Number(e.target.value); + setFixedExpensePercentage(Number.isFinite(nextValue) ? nextValue : 0); + }} + /> +
+
+ +
+
+
+ )} + + {/* Change Password */} +
+
+

Change Password

+

Update your account password for security.

+
+
+
+ + setCurrentPassword(e.target.value)} + placeholder="Enter current password" + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder="Enter new password" + /> +

Must be at least 8 characters long.

+
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + /> +
+ + +
+
+ + {/* Account Actions */} +
+
+

Account Actions

+
+
+
+
Sign out
+

Log out of SkyMoney on this device.

+ +
+
+
Export Data
+

Download a copy of all your financial data including transactions, expenses, and fixed expenses.

+ +
+
+
+

Delete Account

+

Permanently delete your account and all associated data. This action cannot be undone.

+
+ +
+
+
+
+ ); +} diff --git a/web/src/pages/settings/CategoriesPage.tsx b/web/src/pages/settings/CategoriesPage.tsx index c75cf9b..9b1de90 100644 --- a/web/src/pages/settings/CategoriesPage.tsx +++ b/web/src/pages/settings/CategoriesPage.tsx @@ -1,30 +1,91 @@ -import { useMemo, useState, type ChangeEvent, type FormEvent } from "react"; +// 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 { + useCategories, + useCreateCategory, + useUpdateCategory, + useDeleteCategory, +} from "../../hooks/useCategories"; import { useToast } from "../../components/Toast"; -type Row = { id: number; name: string; percent: number; priority: number; isSavings: boolean; balanceCents: number }; +type Row = { + id: string; + name: string; + percent: number; + priority: number; + isSavings: boolean; + balanceCents: number; +}; function SumBadge({ total }: { total: number }) { - const ok = total === 100; + 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 ( -
- Total: {total}% +
+ {label}: {total}%
); } export default function SettingsCategoriesPage() { const { data, isLoading, error, refetch, isFetching } = useDashboard(); - const cats: Row[] = useCategories(); + 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 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(""); @@ -32,45 +93,157 @@ export default function SettingsCategoriesPage() { const [priority, setPriority] = useState(""); const [isSavings, setIsSavings] = useState(false); - const addDisabled = !name || !percent || !priority || Number(percent) < 0 || Number(percent) > 100; + 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: name.trim(), - percent: Math.max(0, Math.min(100, Math.floor(Number(percent) || 0))), - priority: Math.max(0, Math.floor(Number(priority) || 0)), - isSavings + 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", "Category created"); - setName(""); setPercent(""); setPriority(""); setIsSavings(false); + push("ok", "Expense created"); + setName(""); + setPercent(""); + setPriority(""); + setIsSavings(false); }, - onError: (err: any) => push("err", err?.message ?? "Create failed") + onError: (err: any) => + push("err", err?.message ?? "Create failed"), }); }; - const onEdit = (id: number, patch: Partial) => { - updateM.mutate({ id, body: patch }, { - onError: (err: any) => push("err", err?.message ?? "Update 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: number) => { + const onDelete = (id: string) => { deleteM.mutate(id, { - onSuccess: () => push("ok", "Category deleted"), - onError: (err: any) => push("err", err?.message ?? "Delete failed") + onSuccess: () => push("ok", "Expense deleted"), + onError: (err: any) => + push("err", err?.message ?? "Delete failed"), }); }; - if (isLoading) return
Loading…
; + 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 categories.

- + +

Couldn't load expenses.

+
); } @@ -78,60 +251,165 @@ export default function SettingsCategoriesPage() { 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)} /> -
@@ -140,40 +418,135 @@ export default function SettingsCategoriesPage() { } /* --- tiny inline editors --- */ -function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) { +function InlineEditText({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { const [v, setV] = useState(value); const [editing, setEditing] = useState(false); - const commit = () => { if (v !== value) onChange(v.trim()); setEditing(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 /> + 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; }) { +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))); + 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 /> + setV(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === "Enter" && commit()} + autoFocus + /> ) : ( - + ); } -function InlineEditCheckbox({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { +function InlineEditCheckbox({ + checked, + onChange, +}: { + checked: boolean; + onChange: (v: boolean) => void; +}) { return ( ); -} \ No newline at end of file +} + +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/CategoriesSettings.tsx b/web/src/pages/settings/CategoriesSettings.tsx new file mode 100644 index 0000000..d7ca654 --- /dev/null +++ b/web/src/pages/settings/CategoriesSettings.tsx @@ -0,0 +1,650 @@ +// web/src/pages/settings/CategoriesSettings.tsx +import { + forwardRef, + useImperativeHandle, + useMemo, + useState, + useEffect, + useCallback, + type FormEvent, +} from "react"; +import type React 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 { useToast } from "../../components/Toast"; +import { categoriesApi } from "../../api/categories"; + +type Row = { + id: string; + name: string; + percent: number; + priority: number; + isSavings: boolean; + balanceCents: number; +}; + +type LocalRow = Row & { _isNew?: boolean; _isDeleted?: boolean }; + +const MIN_SAVINGS_PERCENT = 20; + +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}% +
+ ); +} + +interface CategoriesSettingsProps { + onDirtyChange?: (dirty: boolean) => void; +} + +export type CategoriesSettingsHandle = { + save: () => Promise; +}; + +function CategoriesSettingsInner( + { onDirtyChange }: CategoriesSettingsProps, + ref: React.ForwardedRef +) { + const { data, isLoading, error, refetch, isFetching } = useDashboard(); + const serverCats = (data?.variableCategories ?? []) as Row[]; + + const { push } = useToast(); + const normalizeName = useCallback((value: string) => value.trim().toLowerCase(), []); + const recalcBalances = useCallback((rows: LocalRow[]) => { + const active = rows.filter((c) => !c._isDeleted); + if (active.length === 0) return rows; + + const totalBalance = active.reduce((sum, c) => sum + (c.balanceCents ?? 0), 0); + const percentTotal = active.reduce((sum, c) => sum + (c.percent || 0), 0); + if (totalBalance <= 0 || percentTotal <= 0) return rows; + + const targets = active.map((cat) => { + const raw = (totalBalance * cat.percent) / percentTotal; + const floored = Math.floor(raw); + return { id: cat.id, target: floored, frac: raw - floored }; + }); + + let remainder = totalBalance - targets.reduce((sum, t) => sum + t.target, 0); + targets + .slice() + .sort((a, b) => b.frac - a.frac) + .forEach((t) => { + if (remainder > 0) { + t.target += 1; + remainder -= 1; + } + }); + + const targetById = new Map(targets.map((t) => [t.id, t.target])); + return rows.map((cat) => + cat._isDeleted ? cat : { ...cat, balanceCents: targetById.get(cat.id) ?? cat.balanceCents } + ); + }, []); + + // Local editable state + const [localCats, setLocalCats] = useState([]); + const [initialized, setInitialized] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + // Initialize local state from server once + useEffect(() => { + if (!initialized && serverCats && serverCats.length > 0) { + setLocalCats(serverCats.map((c) => ({ ...c }))); + setInitialized(true); + } + }, [serverCats, initialized]); + + const resetToServer = useCallback(() => { + setLocalCats((serverCats ?? []).map((c) => ({ ...c }))); + setInitialized(true); + }, [serverCats]); + + const activeCats = useMemo( + () => localCats.filter((c) => !c._isDeleted), + [localCats] + ); + + const total = useMemo( + () => activeCats.reduce((s, c) => s + c.percent, 0), + [activeCats] + ); + + const savingsCount = useMemo( + () => activeCats.filter((c) => c.isSavings).length, + [activeCats] + ); + const savingsTotal = useMemo( + () => activeCats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0), + [activeCats] + ); + + const duplicateNames = useMemo(() => { + const counts = new Map(); + activeCats.forEach((c) => { + const key = normalizeName(c.name); + if (!key) return; + counts.set(key, (counts.get(key) ?? 0) + 1); + }); + return Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([name]) => name); + }, [activeCats, normalizeName]); + + const hasChanges = useMemo(() => { + if (localCats.length === 0) return false; + + if (localCats.some((c) => c._isNew || c._isDeleted)) return true; + + for (const local of localCats) { + if (local._isNew || local._isDeleted) continue; + const server = serverCats.find((s) => s.id === local.id); + if (!server) return true; + + if ( + local.name !== server.name || + local.percent !== server.percent || + local.priority !== server.priority || + local.isSavings !== server.isSavings + ) { + return true; + } + } + + for (const server of serverCats) { + if (!localCats.find((l) => l.id === server.id)) return true; + } + + return false; + }, [localCats, serverCats]); + + useEffect(() => { + onDirtyChange?.(hasChanges); + }, [hasChanges, onDirtyChange]); + + // Drag ordering + const [order, setOrder] = useState([]); + useEffect(() => { + const sorted = activeCats + .slice() + .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)); + const next = sorted.map((c) => c.id); + + if (order.length !== next.length || next.some((id, i) => order[i] !== id)) { + setOrder(next); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeCats.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; + + const onAdd = (e: FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + const normalized = normalizeName(name); + if (activeCats.some((c) => normalizeName(c.name) === normalized)) { + push("err", `Expense name '${normalized}' already exists`); + return; + } + + const tempId = `temp_${Date.now()}`; + const newCat: LocalRow = { + id: tempId, + name: normalized, + percent: parsedPercent, + priority: parsedPriority || activeCats.length + 1, + isSavings, + balanceCents: 0, + _isNew: true, + }; + + setLocalCats((prev) => recalcBalances([...prev, newCat])); + setName(""); + setPercent(""); + setPriority(""); + setIsSavings(false); + }; + + const onEdit = (id: string, patch: Partial) => { + setLocalCats((prev) => + prev.map((c) => { + if (c.id !== id) return c; + const updated = { + ...c, + ...patch, + ...(patch.name !== undefined ? { name: normalizeName(patch.name) } : {}), + }; + + if (patch.percent !== undefined) { + updated.percent = Math.max( + 0, + Math.min(100, Math.floor(patch.percent)) + ); + } + if (patch.priority !== undefined) { + updated.priority = Math.max(0, Math.floor(patch.priority)); + } + return updated; + }) + ); + }; + + const onDelete = (id: string) => { + setLocalCats((prev) => + prev + .map((c) => (c.id === id ? { ...c, _isDeleted: true } : c)) + .filter((c) => !(c._isNew && c._isDeleted)) + ); + }; + + 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); + + const updates = onDragOrderApply(next); + setLocalCats((prevCats) => + prevCats.map((c) => { + const update = updates.find((u) => u.id === c.id); + if (update && c.priority !== update.priority) { + return { ...c, priority: update.priority }; + } + return c; + }) + ); + + return next; + }); + }; + + const onCancel = () => { + resetToServer(); + push("ok", "Changes discarded"); + }; + + const onSave = useCallback(async (): Promise => { + const normalizedPriorityOrder = activeCats + .slice() + .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) + .map((cat, index) => ({ id: cat.id, priority: index + 1 })); + const priorityById = new Map( + normalizedPriorityOrder.map((item) => [item.id, item.priority]) + ); + const normalizedCats = localCats.map((cat) => + cat._isDeleted ? cat : { ...cat, priority: priorityById.get(cat.id) ?? cat.priority } + ); + + if (duplicateNames.length > 0) { + push("err", `Duplicate expense names: ${duplicateNames.join(", ")}`); + return false; + } + if (total !== 100) { + push("err", `Percentages must sum to 100% (currently ${total}%)`); + return false; + } + if (savingsCount === 0) { + push("err", "You must have at least one Savings expense"); + return false; + } + if (savingsTotal < MIN_SAVINGS_PERCENT) { + push( + "err", + `Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${savingsTotal}%)` + ); + return false; + } + + setIsSaving(true); + try { + const hasNew = normalizedCats.some((c) => c._isNew && !c._isDeleted); + + // Deletes + const toDelete = normalizedCats.filter((c) => c._isDeleted && !c._isNew); + for (const cat of toDelete) { + await categoriesApi.delete(cat.id); + } + + // Creates + const toCreate = normalizedCats.filter((c) => c._isNew && !c._isDeleted); + for (const cat of toCreate) { + await categoriesApi.create({ + name: normalizeName(cat.name), + percent: cat.percent, + priority: cat.priority, + isSavings: cat.isSavings, + }); + } + + // Updates + const toUpdate = normalizedCats.filter((c) => !c._isNew && !c._isDeleted); + for (const local of toUpdate) { + const server = serverCats.find((s) => s.id === local.id); + if (!server) continue; + + const patch: Partial = {}; + if (local.name !== server.name) patch.name = normalizeName(local.name); + if (local.percent !== server.percent) patch.percent = local.percent; + if (local.priority !== server.priority) patch.priority = local.priority; + if (local.isSavings !== server.isSavings) + patch.isSavings = local.isSavings; + + if (Object.keys(patch).length > 0) { + await categoriesApi.update(local.id, patch); + } + } + + if (hasNew) { + try { + await categoriesApi.rebalance(); + } catch (err: any) { + push("err", err?.message ?? "Failed to rebalance expenses"); + } + } + + push("ok", "Expenses saved successfully"); + const refreshed = await refetch(); + const nextCats = + (refreshed.data?.variableCategories ?? serverCats) as Row[]; + setLocalCats(nextCats.map((c) => ({ ...c }))); + setInitialized(true); + return true; + } catch (err: any) { + push("err", err?.message ?? "Save failed"); + return false; + } finally { + setIsSaving(false); + } + }, [ + total, + savingsCount, + localCats, + serverCats, + refetch, + resetToServer, + push, + ]); + + useImperativeHandle(ref, () => ({ save: onSave }), [onSave]); + + if (isLoading) return
Loading expenses...
; + + if (error || !data) { + return ( +
+

Couldn't load expenses.

+ +
+ ); + } + + return ( +
+
+
+

Expense Categories

+ {hasChanges && ( + Unsaved changes + )} +
+

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

+
+ +
+ setName(e.target.value)} + /> + setPercent(e.target.value)} + /> + + +
+ +
+ +
+ + {activeCats.length === 0 ? ( +
No expenses yet.
+ ) : ( + + +
+ {order + .map((id) => activeCats.find((c) => c.id === id)) + .filter(Boolean) + .map((c) => ( + + {(dragListeners: any) => ( +
+
+ + ⋮⋮ + +
+
+ onEdit(c!.id, { name: v })} + /> + {c!._isNew && ( + + New + + )} + {c!.isSavings && ( + + Savings + + )} +
+
+ +
+
+
+ onEdit(c!.id, { percent: v })} + /> + % +
+
+
+ + +
+
+ )} +
+ ))} +
+
+
+ )} + + {hasChanges && ( +
+ + +
+ )} +
+ ); +} + +const CategoriesSettings = forwardRef(CategoriesSettingsInner); +export default CategoriesSettings; + +/* --- 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 = () => { + const next = v.trim(); + if (next !== value) onChange(next); + 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 SortableRow({ + id, + children, +}: { + id: string; + children: (dragListeners: any) => React.ReactNode; +}) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + } as React.CSSProperties; + return ( +
+ {children(listeners)} +
+ ); +} + +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 index 83af362..939a070 100644 --- a/web/src/pages/settings/PlansPage.tsx +++ b/web/src/pages/settings/PlansPage.tsx @@ -1,28 +1,48 @@ // web/src/pages/settings/PlansPage.tsx -import { useMemo, useState, type ChangeEvent, type FormEvent } from "react"; +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 { + 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 isoDateLocal(d: Date = new Date()) { - const z = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); - return z.toISOString().slice(0, 10); -} - -function daysUntil(iso: string) { - const today = new Date(isoDateLocal()); - const due = new Date(isoDateLocal(new Date(iso))); - const diffMs = due.getTime() - today.getTime(); +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 }: { dueISO: string }) { - const d = daysUntil(dueISO); - if (d < 0) return Overdue; +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 ; + return ( + + ); } export default function SettingsPlansPage() { @@ -31,136 +51,490 @@ export default function SettingsPlansPage() { 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(isoDateLocal()); + 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) }; + 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]); - if (isLoading) return
Loading…
; + 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 plans.

- + +

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 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: new Date(due).toISOString(), + 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(isoDateLocal()); + 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"), + onError: (err: any) => + push("err", err?.message ?? "Create failed"), }); }; - const onEdit = (id: number, 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)) { + 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"), + 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 onDelete = (id: number) => { - 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 */} -
-

Funded

-

Total

-

Remaining

+
+ + + + + + + + + + + + {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)} /> - + + 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 plans yet.
+
+ 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); + .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 ( - - - - - + + + + + + ); @@ -173,46 +547,201 @@ export default function SettingsPlansPage() { ); } -/* --- Inline editors (minimal) --- */ -function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) { +/* --- 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); - const commit = () => { const t = v.trim(); if (t && t !== value) onChange(t); setEditing(false); }; + 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 /> - ) : ; + 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 }) { +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)); - const commit = () => { const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0))); if (n !== value) onChange(n); setEditing(false); }; + 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 /> - ) : ; + setV(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === "Enter" && commit()} + autoFocus + /> + ) : ( + + ); } -function InlineEditMoney({ valueCents, onChange }: { valueCents: number; onChange: (cents: number) => void }) { +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)); + 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 /> - ) : ; + setV(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === "Enter" && commit()} + autoFocus + /> + ) : ( + + ); } -function InlineEditDate({ value, onChange }: { value: string; onChange: (iso: string) => void }) { +function InlineEditDate({ + value, + onChange, + timezone, +}: { + value: string; + onChange: (iso: string) => void; + timezone: string; +}) { const [editing, setEditing] = useState(false); - const local = new Date(value); - const [v, setV] = useState(local.toISOString().slice(0, 10)); - const commit = () => { const iso = new Date(v + "T00:00:00Z").toISOString(); if (iso !== value) onChange(iso); setEditing(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 /> - ) : ; + setV(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === "Enter" && commit()} + autoFocus + /> + ) : ( + + ); } diff --git a/web/src/pages/settings/PlansSettings.tsx b/web/src/pages/settings/PlansSettings.tsx new file mode 100644 index 0000000..4a9e608 --- /dev/null +++ b/web/src/pages/settings/PlansSettings.tsx @@ -0,0 +1,894 @@ +// web/src/pages/settings/PlansSettings.tsx +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, + type FormEvent, +} from "react"; +import { Money } from "../../components/ui"; +import { useDashboard } from "../../hooks/useDashboard"; +import { useToast } from "../../components/Toast"; +import { + dateStringToUTCMidnight, + formatDateInTimezone, + getBrowserTimezone, + getTodayInTimezone, + isoToDateString, +} from "../../utils/timezone"; +import { fixedPlansApi } from "../../api/fixedPlans"; + +type FixedPlan = { + id: string; + name: string; + totalCents: number; + fundedCents: number; + priority: number; + dueOn: string; + cycleStart: string; + frequency?: "one-time" | "weekly" | "biweekly" | "monthly"; + autoPayEnabled?: boolean; + paymentSchedule?: any; + nextPaymentDate?: string | null; +}; + +type LocalPlan = FixedPlan & { _isNew?: boolean; _isDeleted?: boolean }; + +export type PlansSettingsHandle = { + save: () => Promise; +}; + +interface PlansSettingsProps { + onDirtyChange?: (dirty: boolean) => void; +} + +const PlansSettings = forwardRef( + function PlansSettings({ onDirtyChange }, ref) { + const { data, isLoading, error, refetch, isFetching } = useDashboard(); + const plans = (data?.fixedPlans ?? []) as FixedPlan[]; + const { push } = useToast(); + + // Get user timezone from dashboard data + const userTimezone = data?.user?.timezone || getBrowserTimezone(); + const incomeType = data?.user?.incomeType ?? "regular"; + const incomeFrequency = data?.user?.incomeFrequency; + const firstIncomeDate = data?.user?.firstIncomeDate ?? null; + + // Local editable state (preview mode) + const [localPlans, setLocalPlans] = useState([]); + const [initialized, setInitialized] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [deletePrompt, setDeletePrompt] = useState(null); + + useEffect(() => { + if (!initialized && plans.length > 0) { + setLocalPlans(plans.map((p) => ({ ...p }))); + setInitialized(true); + } + }, [plans, initialized]); + + const resetToServer = useCallback( + (nextPlans: FixedPlan[] = plans) => { + setLocalPlans(nextPlans.map((p) => ({ ...p }))); + setInitialized(true); + }, + [plans] + ); + + const activePlans = useMemo( + () => localPlans.filter((p) => !p._isDeleted), + [localPlans] + ); + + const hasChanges = useMemo(() => { + if (localPlans.length === 0) return false; + if (localPlans.some((p) => p._isNew || p._isDeleted)) return true; + + for (const local of localPlans) { + if (local._isNew || local._isDeleted) continue; + const server = plans.find((p) => p.id === local.id); + if (!server) return true; + + const scheduleEqual = + JSON.stringify(local.paymentSchedule ?? null) === + JSON.stringify(server.paymentSchedule ?? null); + + if ( + local.name !== server.name || + local.totalCents !== server.totalCents || + local.priority !== server.priority || + local.dueOn !== server.dueOn || + (local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false) || + (local.frequency ?? null) !== (server.frequency ?? null) || + !scheduleEqual || + (local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null) + ) { + return true; + } + } + + for (const server of plans) { + if (!localPlans.find((p) => p.id === server.id)) return true; + } + + return false; + }, [localPlans, plans]); + + useEffect(() => { + onDirtyChange?.(hasChanges); + }, [hasChanges, onDirtyChange]); + + // Form state for adding new plan + const [name, setName] = useState(""); + const [total, setTotal] = useState(""); + const [priority, setPriority] = useState(""); + const [due, setDue] = useState(getTodayInTimezone(userTimezone)); + const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly"); + const [autoPayEnabled, setAutoPayEnabled] = useState(false); + + const totalCents = Math.max(0, Math.round((Number(total) || 0) * 100)); + const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0)); + const addDisabled = !name.trim() || totalCents <= 0 || !due || isSaving; + + function mapScheduleFrequency( + nextFrequency?: FixedPlan["frequency"] + ): "daily" | "weekly" | "biweekly" | "monthly" | "custom" { + if (nextFrequency === "weekly") return "weekly"; + if (nextFrequency === "biweekly") return "biweekly"; + return "monthly"; + } + + function buildDefaultSchedule(nextFrequency?: FixedPlan["frequency"]) { + return { + frequency: mapScheduleFrequency(nextFrequency), + minFundingPercent: 100, + }; + } + + const onAdd = (e: FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + const dueOnISO = dateStringToUTCMidnight(due, userTimezone); + const schedule = autoPayEnabled + ? buildDefaultSchedule(frequency || undefined) + : null; + const nextPaymentDate = + autoPayEnabled && schedule + ? calculateNextPaymentDate(dueOnISO, schedule, userTimezone) + : null; + const tempId = `temp_${Date.now()}`; + const newPlan: LocalPlan = { + id: tempId, + name: name.trim(), + totalCents, + fundedCents: 0, + priority: parsedPriority || localPlans.length + 1, + dueOn: dueOnISO, + cycleStart: dueOnISO, + frequency: frequency || undefined, + autoPayEnabled, + paymentSchedule: schedule, + nextPaymentDate, + _isNew: true, + }; + setLocalPlans((prev) => [...prev, newPlan]); + setName(""); + setTotal(""); + setPriority(""); + setDue(getTodayInTimezone(userTimezone)); + setFrequency("monthly"); + setAutoPayEnabled(false); + }; + + function toUserMidnight(iso: string, timezone: string) { + const dateStr = isoToDateString(iso, timezone); + return new Date(dateStringToUTCMidnight(dateStr, timezone)); + } + + function countPayPeriodsBetween( + startIso: string, + endIso: string, + firstIncomeIso: string, + frequency: NonNullable, + timezone: string + ) { + let count = 0; + let nextPayDate = toUserMidnight(firstIncomeIso, timezone); + const normalizedStart = toUserMidnight(startIso, timezone); + const normalizedEnd = toUserMidnight(endIso, timezone); + + const targetDay = Number(isoToDateString(firstIncomeIso, timezone).split("-")[2] || "1"); + const advanceByPeriod = () => { + if (frequency === "monthly") { + const year = nextPayDate.getUTCFullYear(); + const month = nextPayDate.getUTCMonth() + 1; + const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth))); + } else { + const days = frequency === "biweekly" ? 14 : 7; + nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000); + } + }; + + while (nextPayDate < normalizedStart) { + advanceByPeriod(); + } + while (nextPayDate < normalizedEnd) { + count++; + advanceByPeriod(); + } + return Math.max(1, count); + } + + function getFundingAhead(plan: FixedPlan) { + if ( + incomeType !== "regular" || + !incomeFrequency || + !firstIncomeDate || + !plan.cycleStart || + !plan.dueOn + ) { + return null; + } + + const now = new Date().toISOString(); + let cycleStart = plan.cycleStart; + const dueOn = plan.dueOn; + + let cycleStartDate: Date; + let dueDate: Date; + let nowDate: Date; + try { + cycleStartDate = toUserMidnight(cycleStart, userTimezone); + dueDate = toUserMidnight(dueOn, userTimezone); + nowDate = toUserMidnight(now, userTimezone); + } catch { + return null; + } + if (cycleStartDate >= dueDate || cycleStartDate > nowDate) { + cycleStart = now; + } + + const totalPeriods = countPayPeriodsBetween( + cycleStart, + dueOn, + firstIncomeDate, + incomeFrequency, + userTimezone + ); + const elapsedPeriods = countPayPeriodsBetween( + cycleStart, + now, + firstIncomeDate, + incomeFrequency, + userTimezone + ); + const targetFunded = Math.min( + plan.totalCents, + Math.ceil((plan.totalCents * elapsedPeriods) / totalPeriods) + ); + const aheadBy = Math.max(0, plan.fundedCents - targetFunded); + return aheadBy > 0 ? aheadBy : null; + } + + function calculateNextPaymentDate( + dueOnISO: string, + schedule: any, + timezone: string + ): string | null { + if (!schedule || !schedule.frequency) return null; + const dateStr = isoToDateString(dueOnISO, timezone); + const [year, month, day] = dateStr.split("-").map(Number); + const base = new Date(Date.UTC(year, month - 1, day)); + const toDateString = (d: Date) => + `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String( + d.getUTCDate() + ).padStart(2, "0")}`; + + switch (schedule.frequency) { + case "daily": + base.setUTCDate(base.getUTCDate() + 1); + break; + case "weekly": { + const targetDay = schedule.dayOfWeek ?? 0; + const currentDay = base.getUTCDay(); + const daysUntilTarget = (targetDay - currentDay + 7) % 7; + base.setUTCDate(base.getUTCDate() + (daysUntilTarget || 7)); + break; + } + case "biweekly": + base.setUTCDate(base.getUTCDate() + 14); + break; + case "monthly": { + const targetDay = schedule.dayOfMonth; + const nextMonth = base.getUTCMonth() + 1; + const nextYear = base.getUTCFullYear() + Math.floor(nextMonth / 12); + const normalizedMonth = nextMonth % 12; + const daysInMonth = new Date(Date.UTC(nextYear, normalizedMonth + 1, 0)).getUTCDate(); + base.setUTCFullYear(nextYear, normalizedMonth, targetDay ? Math.min(targetDay, daysInMonth) : base.getUTCDate()); + break; + } + case "custom": + base.setUTCDate(base.getUTCDate() + Math.max(1, Number(schedule.everyNDays || 0))); + break; + default: + return null; + } + + return dateStringToUTCMidnight(toDateString(base), timezone); + } + + const onEdit = (id: string, patch: Partial) => { + setLocalPlans((prev) => + prev.map((p) => { + if (p.id !== id) return p; + const next: LocalPlan = { ...p, ...patch }; + if (patch.frequency !== undefined && next.autoPayEnabled) { + const schedule = next.paymentSchedule ?? buildDefaultSchedule(patch.frequency); + next.paymentSchedule = { + ...schedule, + frequency: mapScheduleFrequency(patch.frequency), + }; + } + if (patch.totalCents !== undefined) { + next.totalCents = Math.max(0, Math.round(patch.totalCents)); + } + if (patch.priority !== undefined) { + next.priority = Math.max(0, Math.floor(patch.priority)); + } + if (patch.autoPayEnabled !== undefined && !patch.autoPayEnabled) { + next.nextPaymentDate = null; + } + if (next.autoPayEnabled && next.paymentSchedule) { + const dueOnISO = patch.dueOn ?? next.dueOn; + next.nextPaymentDate = calculateNextPaymentDate( + dueOnISO, + next.paymentSchedule, + userTimezone + ); + } + return next; + }) + ); + }; + + const onDelete = (id: string) => { + setLocalPlans((prev) => + prev + .map((p) => { + if (p.id !== id) return p; + if (p._isNew) return { ...p, _isDeleted: true }; + return { ...p, _isDeleted: true }; + }) + .filter((p) => !(p._isNew && p._isDeleted)) + ); + }; + + const onCancel = () => { + resetToServer(); + push("ok", "Changes discarded"); + }; + + const onSave = useCallback(async (): Promise => { + const normalizedPriorityOrder = activePlans + .slice() + .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) + .map((plan, index) => ({ id: plan.id, priority: index + 1 })); + const priorityById = new Map( + normalizedPriorityOrder.map((item) => [item.id, item.priority]) + ); + const normalizedPlans = localPlans.map((plan) => + plan._isDeleted ? plan : { ...plan, priority: priorityById.get(plan.id) ?? plan.priority } + ); + + for (const plan of localPlans) { + if (plan._isDeleted) continue; + if (plan.totalCents < (plan.fundedCents ?? 0)) { + push( + "err", + `Total for ${plan.name} cannot be less than funded amount.` + ); + return false; + } + } + + setIsSaving(true); + try { + const toDelete = normalizedPlans.filter((p) => p._isDeleted && !p._isNew); + for (const plan of toDelete) { + await fixedPlansApi.delete(plan.id); + } + + const toCreate = normalizedPlans.filter((p) => p._isNew && !p._isDeleted); + for (const plan of toCreate) { + const created = await fixedPlansApi.create({ + name: plan.name, + totalCents: plan.totalCents, + fundedCents: plan.fundedCents ?? 0, + priority: plan.priority, + dueOn: plan.dueOn, + frequency: plan.frequency, + autoPayEnabled: plan.autoPayEnabled ?? false, + paymentSchedule: plan.paymentSchedule ?? undefined, + nextPaymentDate: plan.nextPaymentDate ?? undefined, + }); + if (plan.autoPayEnabled) { + try { + const res = await fixedPlansApi.fundFromAvailable(created.id); + if (res.funded) { + const dollars = (res.fundedAmountCents / 100).toFixed(2); + push("ok", `Funded $${dollars} toward ${plan.name}.`); + } else { + push("err", `Not enough budget to fund ${plan.name}.`); + } + } catch (err: any) { + push( + "err", + err?.message ?? `Funding ${plan.name} failed` + ); + } + } + } + + const toUpdate = normalizedPlans.filter((p) => !p._isNew && !p._isDeleted); + for (const local of toUpdate) { + const server = plans.find((p) => p.id === local.id); + if (!server) continue; + + const patch: Partial = {}; + if (local.name !== server.name) patch.name = local.name; + if (local.totalCents !== server.totalCents) + patch.totalCents = local.totalCents; + if (local.priority !== server.priority) + patch.priority = local.priority; + if (local.dueOn !== server.dueOn) patch.dueOn = local.dueOn; + if ((local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false)) + patch.autoPayEnabled = local.autoPayEnabled; + if ( + JSON.stringify(local.paymentSchedule ?? null) !== + JSON.stringify(server.paymentSchedule ?? null) + ) + patch.paymentSchedule = local.paymentSchedule; + if ((local.frequency ?? null) !== (server.frequency ?? null)) + patch.frequency = local.frequency; + if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null)) + patch.nextPaymentDate = local.nextPaymentDate ?? null; + + if (Object.keys(patch).length > 0) { + await fixedPlansApi.update(local.id, patch); + } + + const paymentPlanEnabled = + !!local.autoPayEnabled && local.paymentSchedule !== null && local.paymentSchedule !== undefined; + const amountChanged = local.totalCents !== server.totalCents; + const dueChanged = local.dueOn !== server.dueOn; + + if (paymentPlanEnabled && (amountChanged || dueChanged)) { + try { + const res = await fixedPlansApi.catchUpFunding(local.id); + if (res.funded) { + const dollars = (res.fundedAmountCents / 100).toFixed(2); + push("ok", `Funded $${dollars} toward ${local.name}.`); + } else if (res.message === "Insufficient available budget") { + push("err", `Not enough budget to fund ${local.name}.`); + } + } catch (err: any) { + push("err", err?.message ?? `Funding ${local.name} failed`); + } + } + } + + push("ok", "Fixed expenses saved successfully"); + const refreshed = await refetch(); + const nextPlans = (refreshed.data?.fixedPlans ?? plans) as FixedPlan[]; + resetToServer(nextPlans); + return true; + } catch (err: any) { + push("err", err?.message ?? "Save failed"); + return false; + } finally { + setIsSaving(false); + } + }, [localPlans, plans, refetch, resetToServer, push]); + + useImperativeHandle(ref, () => ({ save: onSave }), [onSave]); + + if (isLoading) + return ( +
Loading fixed expenses...
+ ); + + if (error) { + return ( +
+

Couldn't load fixed expenses.

+ +
+ ); + } + + return ( +
+
+
+

Fixed Expenses

+ {hasChanges && ( + Unsaved changes + )} +
+

+ Bills and recurring expenses that get funded over time until due. +

+
+ + {/* Add form */} +
+ setName(e.target.value)} + /> + setTotal(e.target.value)} + /> + setDue(e.target.value)} + /> + + + + + + {activePlans.length === 0 ? ( +
No fixed expenses yet.
+ ) : ( +
+ {activePlans.map((plan) => { + const aheadCents = getFundingAhead(plan); + const fundedCents = plan.fundedCents ?? 0; + const totalCents = plan.totalCents || 1; // Avoid division by zero + const progressPercent = Math.min(100, (fundedCents / totalCents) * 100); + return ( +
+ {/* Header row */} +
+
+ onEdit(plan.id, { name: v })} + /> +
+
+ onEdit(plan.id, { totalCents: cents })} + /> +
+
+ + {/* Details row */} +
+
+ Due + onEdit(plan.id, { dueOn: iso })} + /> +
+
+ Freq + + onEdit(plan.id, { + frequency: (v || undefined) as FixedPlan["frequency"], + }) + } + /> +
+ {aheadCents !== null && ( +
+ +{new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format((aheadCents ?? 0) / 100)} ahead +
+ )} +
+ + {/* Progress bar */} +
+
+
+
+
+ / +
+
+ + {/* Actions row */} +
+ + +
+ + {plan.autoPayEnabled && ( +
+ {incomeType === "regular" + ? "Auto-funded each paycheck until fully funded" + : "Prioritized in budget allocation" + } +
+ )} +
+ )})} +
+ )} + {deletePrompt && ( +
+
+

Delete fixed expense?

+

+ Are you sure you want to delete {deletePrompt.name}? This action cannot be undone. +

+ {!deletePrompt._isNew && (deletePrompt.fundedCents ?? 0) > 0 && ( +
+ Funded amount{" "} + + + {" "} + will be refunded to your available budget. +
+ )} +
+ + +
+
+
+ )} + {hasChanges && ( +
+ + +
+ )} +
+ ); + } +); + +export default PlansSettings; + +// Inline editor components +function InlineEditText({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; +}) { + 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()} + placeholder={placeholder} + autoFocus + /> + ) : ( + + ); +} + +function InlineEditMoney({ + cents, + onChange, + placeholder, +}: { + cents: number; + onChange: (cents: number) => void; + placeholder?: string; +}) { + const [editing, setEditing] = useState(false); + const [v, setV] = useState((cents / 100).toFixed(2)); + + useEffect(() => setV((cents / 100).toFixed(2)), [cents]); + + const commit = () => { + const newCents = Math.max(0, Math.round((Number(v) || 0) * 100)); + if (newCents !== cents) onChange(newCents); + setEditing(false); + }; + + return editing ? ( + setV(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === "Enter" && commit()} + placeholder={placeholder} + autoFocus + /> + ) : ( + + ); +} + +function InlineEditSelect({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function InlineEditDate({ + value, + timezone, + onChange, +}: { + value: string; + timezone: string; + onChange: (iso: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [v, setV] = useState(isoToDateString(value, timezone)); + + useEffect(() => setV(isoToDateString(value, timezone)), [value, timezone]); + + const commit = () => { + if (v) { + const nextISO = dateStringToUTCMidnight(v, timezone); + if (nextISO !== value) onChange(nextISO); + } + setEditing(false); + }; + + return editing ? ( + setV(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === "Enter" && commit()} + autoFocus + /> + ) : ( + + ); +} diff --git a/web/src/pages/settings/ReconcileSettings.tsx b/web/src/pages/settings/ReconcileSettings.tsx new file mode 100644 index 0000000..4d4ebba --- /dev/null +++ b/web/src/pages/settings/ReconcileSettings.tsx @@ -0,0 +1,137 @@ +// web/src/pages/settings/ReconcileSettings.tsx +import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import CurrencyInput from "../../components/CurrencyInput"; +import { Money } from "../../components/ui"; +import { useToast } from "../../components/Toast"; +import { useDashboard } from "../../hooks/useDashboard"; +import { budgetApi } from "../../api/budget"; + +export default function ReconcileSettings() { + const { data, isLoading, isError } = useDashboard(); + const { push } = useToast(); + const qc = useQueryClient(); + const [bankTotalCents, setBankTotalCents] = useState(null); + const [pending, setPending] = useState(false); + + useEffect(() => { + if (bankTotalCents === null && data) { + setBankTotalCents(data.totals.incomeCents); + } + }, [bankTotalCents, data]); + + const currentTotalCents = data?.totals.incomeCents ?? 0; + const fixedFundedCents = useMemo( + () => data?.fixedPlans.reduce((sum, plan) => sum + (plan.fundedCents || 0), 0) ?? 0, + [data] + ); + const deltaCents = + bankTotalCents === null ? 0 : bankTotalCents - currentTotalCents; + const belowFixed = bankTotalCents !== null && bankTotalCents < fixedFundedCents; + const isNoChange = bankTotalCents !== null && deltaCents === 0; + + const onSubmit = async () => { + if (bankTotalCents === null || belowFixed || isNoChange) return; + setPending(true); + try { + const result = await budgetApi.reconcile({ + bankTotalCents, + }); + if (result.deltaCents === 0) { + push("ok", "No changes needed."); + } else { + const direction = result.deltaCents > 0 ? "Added" : "Removed"; + push( + "ok", + `${direction} $${Math.abs(result.deltaCents / 100).toFixed( + 2 + )} to match your bank balance.` + ); + } + await qc.invalidateQueries({ queryKey: ["dashboard"] }); + } catch (err: any) { + push("err", err?.message ?? "Reconciliation failed"); + } finally { + setPending(false); + } + }; + + if (isLoading) { + return
Loading…
; + } + if (isError || !data) { + return
Unable to load reconciliation data.
; + } + + return ( +
+
+

Balance Reconciliation

+

+ Enter the combined total of all your bank accounts (checking + savings). + We’ll adjust your available budget to match without affecting fixed plans. +

+
+ +
+
+
SkyMoney Total
+
+ +
+
+
+
Available Budget
+
+ +
+
+
+
Funded Fixed Total
+
+ +
+
+
+ +
+ + + {belowFixed && ( +
+ Bank total can’t be lower than funded fixed expenses. +
+ )} + + {!belowFixed && bankTotalCents !== null && ( +
+ {deltaCents > 0 && ( + <>This will add ${(deltaCents / 100).toFixed(2)} to your available budget. + )} + {deltaCents < 0 && ( + <>This will remove ${Math.abs(deltaCents / 100).toFixed(2)} from your available budget. + )} + {deltaCents === 0 && <>No adjustment needed.} +
+ )} + +
+ +
+
+
+ ); +} diff --git a/web/src/pages/settings/SettingsPage.tsx b/web/src/pages/settings/SettingsPage.tsx new file mode 100644 index 0000000..501d0c3 --- /dev/null +++ b/web/src/pages/settings/SettingsPage.tsx @@ -0,0 +1,213 @@ +// web/src/pages/settings/SettingsPage.tsx +import { useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; +import { useBlocker } from "react-router"; + +import CategoriesSettings, { + type CategoriesSettingsHandle, +} from "./CategoriesSettings"; +import PlansSettings, { type PlansSettingsHandle } from "./PlansSettings"; +import AccountSettings from "./AccountSettings"; +import ThemeSettings from "./ThemeSettings"; +import ReconcileSettings from "./ReconcileSettings"; + +type Tab = "categories" | "plans" | "account" | "theme" | "reconcile"; + +export default function SettingsPage() { + const location = useLocation(); + + const getActiveTab = (): Tab => { + if (location.pathname.includes("/settings/plans")) return "plans"; + if (location.pathname.includes("/settings/account")) return "account"; + if (location.pathname.includes("/settings/theme")) return "theme"; + if (location.pathname.includes("/settings/reconcile")) return "reconcile"; + return "categories"; + }; + + const [activeTab, setActiveTab] = useState(getActiveTab()); + + // Dirty / confirm state + const [isDirty, setIsDirty] = useState(false); + const [pendingTab, setPendingTab] = useState(null); + const [showConfirm, setShowConfirm] = useState(false); + + // ref so the modal can "Save" for the current tab + const categoriesRef = useRef(null); + const plansRef = useRef(null); + + // Block leaving /settings when dirty (Navbar links, back button, etc.) + const blocker = useBlocker(({ currentLocation, nextLocation }) => { + const leavingSettings = + currentLocation.pathname.startsWith("/settings") && + nextLocation.pathname !== currentLocation.pathname; + return isDirty && leavingSettings; + }); + + // If router blocks, show modal + useEffect(() => { + if (blocker.state === "blocked") setShowConfirm(true); + }, [blocker.state]); + + // Sync tab with URL changes (e.g., direct navigation) + useEffect(() => { + setActiveTab(getActiveTab()); + }, [location.pathname]); + + // Blocks refresh/close tab when dirty + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (!isDirty) return; + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + + const tabs = [ + { id: "categories" as const, label: "Expenses" }, + { id: "plans" as const, label: "Fixed Expenses" }, + { id: "account" as const, label: "Account" }, + { id: "theme" as const, label: "Theme" }, + { id: "reconcile" as const, label: "Reconcile" }, + ]; + + function requestTabChange(next: Tab) { + if (next === activeTab) return; + if (isDirty) { + setPendingTab(next); + setShowConfirm(true); + return; + } + setActiveTab(next); + } + + function stayHere() { + setPendingTab(null); + setShowConfirm(false); + if (blocker.state === "blocked") blocker.reset(); + } + + function discardAndLeave() { + setIsDirty(false); + setShowConfirm(false); + + if (pendingTab) { + setActiveTab(pendingTab); + setPendingTab(null); + return; + } + + if (blocker.state === "blocked") blocker.proceed(); + } + + async function saveAndLeave() { + let ok = true; + + if (activeTab === "categories") { + ok = (await categoriesRef.current?.save()) ?? false; + } else if (activeTab === "plans") { + ok = (await plansRef.current?.save()) ?? false; + } else { + ok = false; + } + + if (!ok) return; + + setIsDirty(false); + setShowConfirm(false); + + if (pendingTab) { + setActiveTab(pendingTab); + setPendingTab(null); + return; + } + + if (blocker.state === "blocked") blocker.proceed(); + } + + const renderTabContent = () => { + switch (activeTab) { + case "categories": + return ( + + ); + case "plans": + return ( + + ); + case "account": + return ; + case "theme": + return ; + case "reconcile": + return ; + default: + return ( + + ); + } + }; + + return ( +
+
+

Settings

+
+ +
+ {tabs.map((tab) => ( + + ))} +
+ +
{renderTabContent()}
+ + {showConfirm && ( +
+
+

Unsaved Changes

+

+ You have unsaved changes. Do you want to save them before leaving, discard them, or stay here? +

+ +
+ + + + + +
+
+
+ )} +
+ ); +} diff --git a/web/src/pages/settings/ThemeSettings.tsx b/web/src/pages/settings/ThemeSettings.tsx new file mode 100644 index 0000000..af39d93 --- /dev/null +++ b/web/src/pages/settings/ThemeSettings.tsx @@ -0,0 +1,145 @@ +// web/src/pages/settings/ThemeSettings.tsx +import { useState, useEffect } from "react"; +import { useToast } from "../../components/Toast"; +import { useTheme } from "../../theme/useTheme"; + +type Theme = "light" | "dark" | "system"; +type ColorScheme = "blue" | "green" | "purple" | "orange"; + +export default function ThemeSettings() { + const { theme, setTheme } = useTheme(); + const [colorScheme, setColorScheme] = useState("blue"); + const { push } = useToast(); + + // Load settings from localStorage on mount + useEffect(() => { + const savedColorScheme = localStorage.getItem("colorScheme") as ColorScheme; + + if (savedColorScheme) setColorScheme(savedColorScheme); + }, []); + + // Apply color scheme changes + useEffect(() => { + const root = document.documentElement; + + // Remove existing color scheme classes + root.classList.remove("scheme-blue", "scheme-green", "scheme-purple", "scheme-orange"); + + // Add new color scheme class + root.classList.add(`scheme-${colorScheme}`); + + // Save to localStorage + localStorage.setItem("colorScheme", colorScheme); + }, [colorScheme]); + + const handleResetSettings = () => { + setTheme("system"); + setColorScheme("blue"); + localStorage.removeItem("theme"); + localStorage.removeItem("colorScheme"); + push("ok", "Theme settings reset to defaults"); + }; + + return ( +
+ {/* Theme Selection */} +
+
+

Theme

+

Choose your preferred color mode.

+
+
+
+ + {/* Color Scheme */} +
+
+

Color Scheme

+

Pick an accent color for the app.

+
+
+ {(["blue", "green", "purple", "orange"] as const).map((scheme) => ( +
+ + {/* Reset */} +
+
+

Reset

+

Theme settings are saved automatically as you make changes.

+
+ +
+
+ ); +} diff --git a/web/src/pages/settings/_SettingsNav.tsx b/web/src/pages/settings/_SettingsNav.tsx index b5806d0..27c0343 100644 --- a/web/src/pages/settings/_SettingsNav.tsx +++ b/web/src/pages/settings/_SettingsNav.tsx @@ -3,12 +3,12 @@ export default function SettingsNav() { const link = (to: string, label: string) => `link ${isActive ? "link-active" : ""}`}>{label}; return ( -
+

Settings

-
- {link("/settings/categories", "Categories")} - {link("/settings/plans", "Fixed Plans")} +
+ {link("/settings/categories", "Expenses")} + {link("/settings/plans", "Fixed Expenses")}
); -} \ No newline at end of file +} diff --git a/web/src/styles.css b/web/src/styles.css index b304659..70ed3ec 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,29 +1,274 @@ +@import "tailwindcss"; + +@variant dark (&:where(.dark, .dark *)); + +/* DEFAULT TO LIGHT THEME */ +@theme { + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-panel: #dbeafe; + --color-ink: #93c5fd; + --color-border: #bfdbfe; + --color-accent: #3b82f6; + --color-accent-strong: #2563eb; + --color-text: #0f172a; + --color-muted: #64748b; + + --color-warning: #d97706; + --color-warning-bg: #fef3c7; + --color-warning-border: #f59e0b; + --color-warning-text: #92400e; + + --shadow-1: 0 12px 30px rgba(0, 0, 0, 0.1); + --radius-xl: 18px; + --radius-lg: 14px; + --radius-md: 10px; + --transition-fast: 200ms ease; + --color-sage: var(--color-muted); +} + :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); + color-scheme: light; +} + +/* DARK THEME VARIANT */ +:root.dark, +:root[data-theme="dark"] { + color-scheme: dark; + --color-bg: #0a0f1e; + --color-surface: #0f1629; + --color-panel: #141b2e; + --color-ink: #1e2841; + --color-border: #2a3f66; + --color-accent: #60a5fa; + --color-accent-strong: #3b82f6; + --color-text: #e2e8f0; + --color-muted: #cdd6e4; + + --color-warning: #f59e0b; + --color-warning-bg: #451a03; + --color-warning-border: #92400e; + --color-warning-text: #fbbf24; + + --shadow-1: 0 15px 45px rgba(0, 0, 0, 0.35); +} + +/* COLOR SCHEME VARIANTS - LIGHT */ +:root.scheme-blue { + --color-bg: #f6f8ff; + --color-surface: #ffffff; + --color-panel: #dbeafe; + --color-ink: #93c5fd; + --color-border: #bfdbfe; + --color-accent: #3b82f6; + --color-accent-strong: #2563eb; + --color-text: #0f172a; + --color-muted: #64748b; +} + +:root.scheme-green { + --color-bg: #f3fbf6; + --color-surface: #ffffff; + --color-panel: #d1fae5; + --color-ink: #6ee7b7; + --color-border: #a7f3d0; + --color-accent: #10b981; + --color-accent-strong: #059669; + --color-text: #0f172a; + --color-muted: #64748b; +} + +:root.scheme-purple { + --color-bg: #faf6ff; + --color-surface: #ffffff; + --color-panel: #e9d5ff; + --color-ink: #c084fc; + --color-border: #d8b4fe; + --color-accent: #a855f7; + --color-accent-strong: #9333ea; + --color-text: #0f172a; + --color-muted: #64748b; +} + +:root.scheme-orange { + --color-bg: #fff6ee; + --color-surface: #ffffff; + --color-panel: #fed7aa; + --color-ink: #fb923c; + --color-border: #fdba74; + --color-accent: #f97316; + --color-accent-strong: #ea580c; + --color-text: #0f172a; + --color-muted: #64748b; +} + +/* COLOR SCHEME VARIANTS - DARK */ +:root.dark.scheme-blue, +:root[data-theme="dark"].scheme-blue { + --color-bg: #0a0f1e; + --color-surface: #0f1629; + --color-panel: #141b2e; + --color-ink: #1e2841; + --color-border: #2a3f66; + --color-accent: #60a5fa; + --color-accent-strong: #3b82f6; + --color-text: #e2e8f0; + --color-muted: #94a3b8; +} + +:root.dark.scheme-green, +:root[data-theme="dark"].scheme-green { + --color-bg: #0a1410; + --color-surface: #0f1f1a; + --color-panel: #132620; + --color-ink: #1e3a31; + --color-border: #27533f; + --color-accent: #34d399; + --color-accent-strong: #10b981; + --color-text: #e2e8f0; + --color-muted: #94a3b8; +} + +:root.dark.scheme-purple, +:root[data-theme="dark"].scheme-purple { + --color-bg: #13091f; + --color-surface: #1a0f29; + --color-panel: #1f1433; + --color-ink: #2e1f4a; + --color-border: #3f2d5e; + --color-accent: #c084fc; + --color-accent-strong: #a855f7; + --color-text: #e2e8f0; + --color-muted: #94a3b8; +} + +:root.dark.scheme-orange, +:root[data-theme="dark"].scheme-orange { + --color-bg: #1a0f06; + --color-surface: #251610; + --color-panel: #2d1b0f; + --color-ink: #3d2814; + --color-border: #5a3c1e; + --color-accent: #fb923c; + --color-accent-strong: #f97316; + --color-text: #e2e8f0; + --color-muted: #94a3b8; +} + +/* If data-theme is light, force light tokens even if .dark is still present */ +:root[data-theme="light"].dark { + color-scheme: light; + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-panel: #f0f4ff; + --color-ink: #c7d6ef; + --color-border: #d6e0f5; + --color-accent: #3b82f6; + --color-accent-strong: #2563eb; + --color-text: #0f172a; + --color-muted: #cdd6e4; + --color-warning: #d97706; + --color-warning-bg: #fef3c7; + --color-warning-border: #f59e0b; + --color-warning-text: #92400e; + --shadow-1: 0 12px 30px rgba(0, 0, 0, 0.1); +} + +:root[data-theme="light"].dark.scheme-blue { + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-panel: #f0f4ff; + --color-ink: #c7d6ef; + --color-border: #d6e0f5; + --color-accent: #3b82f6; + --color-accent-strong: #2563eb; + --color-text: #0f172a; + --color-muted: #cdd6e4; +} + +:root[data-theme="light"].dark.scheme-green { + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-panel: #f0fdf4; + --color-ink: #bbf7d0; + --color-border: #d1f4dc; + --color-accent: #10b981; + --color-accent-strong: #059669; + --color-text: #0f172a; + --color-muted: #cdd6e4; +} + +:root[data-theme="light"].dark.scheme-purple { + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-panel: #faf5ff; + --color-ink: #e9d5ff; + --color-border: #ede9fe; + --color-accent: #a855f7; + --color-accent-strong: #9333ea; + --color-text: #0f172a; + --color-muted: #cdd6e4; +} + +:root[data-theme="light"].dark.scheme-orange { + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-panel: #fff7ed; + --color-ink: #fed7aa; + --color-border: #ffe4c4; + --color-accent: #f97316; + --color-accent-strong: #ea580c; + --color-text: #0f172a; + --color-muted: #cdd6e4; } * { 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; } +html, body, #root { height: 100%; max-width: 100vw; } +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + transition: background var(--transition-fast), color var(--transition-fast); + overflow-x: hidden; +} +a { color: inherit; text-decoration: none; transition: color var(--transition-fast); } -.container { width: min(1100px, 100%); margin-inline: auto; padding: 0 16px; } -.muted { opacity: 0.7; } +.container { width: min(1200px, 100%); margin-inline: auto; padding: 20px 16px; max-width: 100%; box-sizing: border-box; } +.muted { color: var(--color-muted); } + +@media (max-width: 480px) { + .container { + padding: 12px 8px; + } +} .card { - background: var(--color-panel); - border: 1px solid var(--color-ink); + display: block; + background: var(--color-surface); + border: 1px solid color-mix(in srgb, var(--color-ink), transparent 35%); border-radius: var(--radius-xl); - padding: 16px; - box-shadow: var(--shadow-1); + padding: 24px; + box-shadow: + 0 2px 8px color-mix(in srgb, var(--color-accent), transparent 92%), + 0 18px 40px rgba(15, 23, 42, 0.12); + backdrop-filter: blur(14px); + transition: transform var(--transition-fast), box-shadow var(--transition-fast); + box-sizing: border-box; + max-width: 100%; + min-width: 0; +} +.card:hover { + transform: translateY(-2px); + box-shadow: + 0 4px 12px color-mix(in srgb, var(--color-accent), transparent 88%), + 0 24px 50px rgba(15, 23, 42, 0.16); +} + +@media (max-width: 480px) { + .card { + padding: 1rem; + } } .row { display: flex; align-items: center; } @@ -31,54 +276,122 @@ a { color: inherit; text-decoration: none; } .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; +.chart-md { + width: 100%; + height: 240px; + min-height: 240px; + min-width: 200px; + position: relative; } -.input:focus { border-color: var(--color-accent); } +.chart-lg { + width: 100%; + height: 320px; + min-height: 320px; + min-width: 200px; + position: relative; +} + +.input { + background: color-mix(in srgb, var(--color-surface), #000 6%); + color: var(--color-text); + border: 1px solid color-mix(in srgb, var(--color-ink), transparent 15%); + border-radius: var(--radius-lg); + padding: 10px 14px; + outline: none; + transition: border var(--transition-fast), box-shadow var(--transition-fast); +} +.input:focus { border-color: var(--color-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent), transparent 70%); } .btn { background: var(--color-accent); - color: #062016; + color: var(--color-bg); border: none; border-radius: var(--radius-lg); - padding: 8px 12px; + padding: 12px 20px; + min-height: 44px; font-weight: 700; cursor: pointer; + transition: transform var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; } -.btn[disabled] { opacity: 0.5; cursor: default; } +.btn.primary { background: var(--color-accent-strong); color: #fff; } +.btn:hover:not([disabled]) { + transform: translateY(-1px); + box-shadow: + 0 6px 16px color-mix(in srgb, var(--color-accent), transparent 70%), + 0 10px 22px rgba(15, 23, 42, 0.12); +} +.btn[disabled] { opacity: 0.5; cursor: default; box-shadow: none; } + .badge { - background: var(--color-ink); + background: color-mix(in srgb, var(--color-ink), transparent 10%); border-radius: var(--radius-lg); padding: 4px 8px; font-size: 12px; + color: var(--color-muted); } .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; + text-align: left; font-size: 12px; color: var(--color-muted); padding: 0 8px; } .table tbody tr { - background: var(--color-panel); - border: 1px solid var(--color-ink); + background: var(--color-surface); + border: 1px solid color-mix(in srgb, var(--color-ink), transparent 30%); border-radius: var(--radius-xl); + transition: transform var(--transition-fast); } -.table td { padding: 8px; } +.table tbody tr:hover { transform: translateY(-1px); } +.table td { padding: 12px; } .toast-err { - background: #b21d2a; + background: #c6374b; color: white; border-radius: var(--radius-lg); - padding: 10px 12px; + padding: 12px 16px; } -.border { border: 1px solid var(--color-ink); } +/* --- Top Navigation --- */ +.topnav { + position: sticky; top: 0; z-index: 20; + backdrop-filter: blur(8px); + border-bottom: 1px solid color-mix(in srgb, var(--color-ink), transparent 30%); + background: color-mix(in srgb, var(--color-bg), transparent 50%); +} +.topnav-links { display: none; } +.topnav-theme { display: none; } +.topnav-mobile { display: block; } +.nav-brand { color: var(--color-text); font-weight: 700; } +.brand-mark { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 7px; background: color-mix(in srgb, var(--color-accent), transparent 75%); } +.nav-link { display: inline-flex; align-items: center; gap: 6px; padding: 10px 14px; min-height: 44px; border-radius: 12px; color: var(--color-muted); transition: background var(--transition-fast), color var(--transition-fast); } +.nav-link:hover { background: color-mix(in srgb, var(--color-ink), transparent 55%); color: var(--color-text); } +.nav-link-active { background: color-mix(in srgb, var(--color-ink), transparent 40%); color: var(--color-text); } + +.link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; min-height: 40px; border-radius: 10px; color: var(--color-text); transition: background var(--transition-fast), color var(--transition-fast); } +.link:hover { background: var(--color-panel); } +.link-active { background: var(--color-panel); } + +/* --- Drag handle --- */ +.drag-handle { cursor: grab; user-select: none; color: var(--color-muted); padding: 8px 6px; } +.drag-handle:active { cursor: grabbing; } + +.border { border: 1px solid color-mix(in srgb, var(--color-ink), transparent 20%); } +.border-2 { border: 2px solid color-mix(in srgb, var(--color-ink), transparent 20%); } .rounded-xl { border-radius: var(--radius-xl); } -.divide-y > * + * { border-top: 1px solid var(--color-ink); } +.divide-y > * + * { border-top: 1px solid color-mix(in srgb, var(--color-ink), transparent 35%); } + +/* Warning/amber theme-aware classes */ +.border-warning { border-color: var(--color-warning); } +.text-warning { color: var(--color-warning-text); } +.text-warning-light { color: var(--color-warning); } +.bg-warning { background-color: var(--color-warning-bg); } +.bg-warning-solid { background-color: var(--color-warning); } + +/* Hover states for warning */ +.hover\:bg-warning-hover:hover { background-color: color-mix(in srgb, var(--color-warning), transparent 10%); } /* utility-ish */ .w-44 { width: 11rem; } @@ -100,8 +413,13 @@ a { color: inherit; text-decoration: none; } .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); } +.bg-\[--color-bg\] { background: var(--color-bg); } +.bg-\[--color-surface\] { background: var(--color-surface); } +.text-\[--color-text\] { color: var(--color-text); } +.text-\[--color-muted\] { color: var(--color-muted); } .text-\[--color-fg\] { color: var(--color-fg); } .border-\[--color-ink\] { border-color: var(--color-ink); } +.border-\[--color-border\] { border-color: var(--color-border); } .rounded-\[--radius-xl\] { border-radius: var(--radius-xl); } .p-2 { padding: 0.5rem; } .p-3 { padding: 0.75rem; } @@ -112,13 +430,21 @@ a { color: inherit; text-decoration: none; } .text-center { text-align: center; } .text-right { text-align: right; } .opacity-70 { opacity: .7; } -.grid { display: grid; } +.grid { display: grid; min-width: 0; } .md\:grid-cols-2 { grid-template-columns: 1fr; } .md\:grid-cols-3 { grid-template-columns: 1fr; } +.lg\:grid-cols-2 { grid-template-columns: 1fr; } @media (min-width: 768px) { + .topnav-links { display: flex; } + .topnav-theme { display: flex; } + .topnav-mobile { display: none; } .md\:grid-cols-2 { grid-template-columns: 1fr 1fr; } .md\:grid-cols-3 { grid-template-columns: 1fr 1fr 1fr; } } +@media (min-width: 1024px) { + .lg\:grid-cols-2 { grid-template-columns: 1fr 1fr; } + .lg\:hidden { display: none; } +} .shadow-sm { box-shadow: 0 2px 12px rgba(0,0,0,0.2); } .underline { text-decoration: underline; } .fixed { position: fixed; } @@ -130,3 +456,1838 @@ a { color: inherit; text-decoration: none; } .space-y-8 > * + * { margin-top: 2rem; } .space-y-6 > * + * { margin-top: 1.5rem; } .overflow-x-auto { overflow-x: auto; } +.self-start { align-self: start; } +.order-1 { order: 1; } +.order-2 { order: 2; } +@media (min-width: 768px) { + .md\:order-1 { order: 1; } + .md\:order-2 { order: 2; } +} + +/* smooth fade */ +.fade-in { + animation: fadeIn 400ms ease forwards; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Stepper emphasis */ +ol > li.bg-\[--color-ink\] { color: var(--color-text); } +ol > li.bg-\[--color-panel\] { color: var(--color-muted); } + +/* Drag “lift” feedback when dragging */ +[draggable="true"] { transition: transform var(--transition-fast); } +[draggable="true"]:active { transform: scale(.997); } + + +.drag-handle { + transition: + transform var(--transition-fast), + box-shadow var(--transition-fast), + background-color var(--transition-fast); +} +.drag-handle:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0,0,0,0.25); +} + +.sortable-row { + transition: + transform var(--transition-fast), + box-shadow var(--transition-fast), + background-color var(--transition-fast); +} +.sortable-row:hover { + background-color: rgba(255,255,255,0.02); + box-shadow: 0 3px 10px rgba(0,0,0,0.25); +} + +.brand { transition: transform var(--transition-fast), opacity var(--transition-fast); } +.brand:hover { transform: translateY(-1px); opacity: .95; } + +/* Make nav links a touch more prominent in the header */ +.topnav .nav-link { padding: 6px 10px; border-radius: 10px; } +.topnav .nav-link-active { background: var(--color-ink); color: var(--color-bg); } + +/* Brand logo sizing */ +/* keep logo tiny */ +.brand-logo { height: 28px; width: auto; max-width: 2.25rem; object-fit: contain; } + +/* nav track scrolls horizontally on narrow widths */ +.hide-scrollbar::-webkit-scrollbar { display: none; } +.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } + +/* guarantee bar height */ +.topnav { min-height: 3.5rem; } /* matches h-14 */ + +.chip { + display:inline-flex; align-items:center; gap:.35rem; + padding:.125rem .5rem; border-radius:999px; + background: var(--color-ink); color: var(--color-bg); + font-size:.75rem; font-weight:600; line-height:1; +} +.chip-muted { + display:inline-flex; align-items:center; gap:.35rem; + padding:.125rem .5rem; border-radius:999px; + background: var(--color-panel); color: var(--color-muted); + font-size:.75rem; font-weight:600; line-height:1; +} + +/* ——— List row shell (better alignment than ad-hoc spans) ——— */ +.list-row { + display:flex; align-items:center; gap:.75rem; + padding:.5rem .75rem; border-radius: .75rem; + background: var(--color-panel); border: 1px solid var(--color-border); +} +.list-row:hover { background: color-mix(in oklab, var(--color-panel), #ffffff 2%); } +.list-row .left { display:flex; align-items:center; gap:.5rem; min-width: 0; } +.list-row .title { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.list-row .meta { font-size:.8rem; color: var(--color-muted); white-space:nowrap; } +.list-row .right { margin-left:auto; font-weight:600; } + +/* ——— Subtle section underlines (for “Name”, “Monthly Budget”, etc.) ——— */ +.under-title { border-bottom:1px solid var(--color-border); padding-bottom:.25rem; } + +/* ——— Drag affordance ——— */ +.drag-handle { cursor: grab; } +.drag-handle:active { cursor: grabbing; } + +/* ——— Onboarding Page Mobile Styles ——— */ +.onboarding-page { + padding-bottom: 2rem; + max-width: 100vw; +} + +.onboarding-header { + text-align: center; + padding: 1.5rem 0 1rem; + position: relative; + max-width: 100%; +} + +.onboarding-theme-toggle { + position: absolute; + top: 0.5rem; + right: 0.75rem; + z-index: 10; +} + +/* On mobile, integrate theme toggle better */ +@media (max-width: 480px) { + .onboarding-theme-toggle { + position: relative; + top: 0; + right: 0; + display: flex; + justify-content: center; + margin-bottom: 0.5rem; + } +} + +.onboarding-hero { + padding: 0 1rem; +} + +.onboarding-title { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 0.5rem; + background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-strong) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.onboarding-subtitle { + color: var(--color-muted); + font-size: 0.875rem; +} + +/* Stepper - Mobile optimized */ +.stepper-list { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.4rem; + margin-bottom: 1.5rem; + padding: 0 0.25rem; + list-style: none; + max-width: 100%; +} + +.stepper-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.35rem 0.5rem; + border-radius: 2rem; + border: 1.5px solid transparent; + font-size: 0.7rem; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.stepper-active { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); + box-shadow: 0 4px 12px color-mix(in srgb, var(--color-accent), transparent 60%); +} + +.stepper-done { + background: color-mix(in srgb, var(--color-ink), transparent 30%); + color: var(--color-text); + border-color: var(--color-ink); + opacity: 0.85; +} + +.stepper-pending { + background: var(--color-panel); + color: var(--color-muted); + border-color: var(--color-border); +} + +.stepper-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.1rem; + height: 1.1rem; + font-size: 0.6rem; + font-weight: 700; + border-radius: 50%; + background: rgba(255,255,255,0.15); +} + +.stepper-badge-active { + background: rgba(255,255,255,0.25); +} + +.stepper-badge-done { + background: var(--color-accent); + color: #fff; +} + +.stepper-label { + font-weight: 500; +} + +/* Mobile-specific onboarding adjustments */ +@media (max-width: 480px) { + .onboarding-header { + padding: 1rem 0 0.5rem; + } + + .onboarding-title { + font-size: 1.5rem; + } + + .onboarding-subtitle { + font-size: 0.8rem; + } + + .stepper-item { + padding: 0.3rem 0.45rem; + font-size: 0.65rem; + gap: 0.2rem; + } + + .stepper-badge { + min-width: 0.95rem; + height: 0.95rem; + font-size: 0.55rem; + } + + .stepper-list { + gap: 0.3rem; + margin-bottom: 1rem; + } + + .onboarding-page .card { + padding: 1.25rem; + margin-left: 0.5rem; + margin-right: 0.5rem; + } + + .onboarding-page .container { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + .onboarding-wrap.space-y-6 > * + * { + margin-top: 1rem; + } +} + +/* Tablet and up - nicer stepper */ +@media (min-width: 640px) { + .onboarding-title { + font-size: 2rem; + } + + .stepper-item { + padding: 0.6rem 1rem; + font-size: 0.85rem; + } + + .stepper-list { + gap: 0.75rem; + } +} + +/* Income type selection cards */ +.income-type-card { + display: block; + width: 100%; + padding: 1rem; + text-align: left; + transition: all var(--transition-fast); + border: 2px solid; + border-radius: var(--radius-xl); + background: var(--color-surface); + cursor: pointer; +} + +.income-type-card:hover { + transform: translateY(-2px); +} + +.income-type-selected { + box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent), transparent 70%); +} + +@media (min-width: 640px) { + .income-type-card { + padding: 1.5rem; + } +} + +/* Onboarding card styling */ +.onboarding-card { + margin-left: 0; + margin-right: 0; + max-width: 100%; + box-sizing: border-box; + overflow: hidden; +} + +.onboarding-step { + padding-left: 0.5rem; + padding-right: 0.5rem; + max-width: 100%; + box-sizing: border-box; +} + +.onboarding-step .input { + max-width: 100%; + box-sizing: border-box; +} + +/* Onboarding list item (Step 4 - Variable Categories) */ +.category-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border-radius: var(--radius-lg); + background: var(--color-panel); + border: 1px solid var(--color-border); + cursor: grab; + transition: all var(--transition-fast); +} + +.category-row:hover { + background: color-mix(in srgb, var(--color-panel), var(--color-accent) 5%); +} + +.category-row:active { + cursor: grabbing; +} + +.category-row-savings { + background: color-mix(in srgb, #10b981, transparent 88%); + border-color: #10b981; + box-shadow: 0 0 0 1px color-mix(in srgb, #10b981, transparent 60%); +} + +.category-row-top { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; +} + +.category-row-bottom { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.category-priority { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background: var(--color-ink); + color: var(--color-text); + font-size: 0.7rem; + font-weight: 700; + flex-shrink: 0; +} + +.category-row-savings .category-priority { + background: #10b981; + color: white; +} + +.category-name-input { + flex: 1; + min-width: 0; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + font-size: 0.9rem; + outline: none; + transition: border-color var(--transition-fast); +} + +.category-name-input:focus { + border-color: var(--color-accent); +} + +.category-name-savings { + border-color: #10b981; + font-weight: 600; +} + +.category-percent { + display: flex; + align-items: center; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding-right: 0.4rem; + overflow: hidden; +} + +.category-percent-input { + width: 2.5rem; + padding: 0.4rem 0.25rem; + border: none; + background: transparent; + color: var(--color-text); + font-size: 0.9rem; + font-family: ui-monospace, monospace; + text-align: center; + outline: none; + -moz-appearance: textfield; + appearance: textfield; +} + +.category-percent-input::-webkit-outer-spin-button, +.category-percent-input::-webkit-inner-spin-button { + -webkit-appearance: none; + appearance: none; + margin: 0; +} + +.category-percent-symbol { + font-size: 0.8rem; + color: var(--color-muted); + font-weight: 500; +} + +.category-savings-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.4rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-muted); + cursor: pointer; + font-size: 0.8rem; + transition: all var(--transition-fast); +} + +.category-savings-btn:hover { + background: var(--color-panel); + color: var(--color-text); +} + +.category-savings-active { + background: color-mix(in srgb, #10b981, transparent 80%); + border-color: #10b981; + color: #10b981; + font-weight: 600; +} + +.category-remove-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.4rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-muted); + cursor: pointer; + font-size: 0.8rem; + transition: all var(--transition-fast); +} + +.category-remove-btn:hover { + background: color-mix(in srgb, #ef4444, transparent 90%); + border-color: #ef4444; + color: #ef4444; +} + +.onboarding-list-item { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + border-radius: var(--radius-lg); + background: var(--color-panel); + width: 100%; + box-sizing: border-box; +} + +.onboarding-item-header { + display: flex; + align-items: center; + gap: 0.35rem; + width: 100%; + min-width: 0; +} + +.onboarding-item-header .input { + flex: 1; + min-width: 0; + font-size: 0.8rem; + padding: 0.5rem; +} + +.onboarding-item-controls { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: nowrap; +} + +.onboarding-percent-input { + display: flex; + align-items: center; + gap: 0.2rem; +} + +.onboarding-percent-input .input { + width: 3rem; + padding: 0.4rem; + font-size: 0.8rem; +} + +.onboarding-savings-toggle { + display: flex; + align-items: center; + gap: 0.2rem; + font-size: 0.7rem; + cursor: pointer; + padding: 0.3rem 0.4rem; + border-radius: var(--radius-md); + border: 1px solid; + transition: all var(--transition-fast); +} + +/* Onboarding fixed item (Step 5) */ +/* Fixed expense rows - Step 5 */ +.fixed-expense-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border-radius: var(--radius-lg); + background: var(--color-panel); + border: 1px solid var(--color-border); + width: 100%; + box-sizing: border-box; +} + +.fixed-expense-top { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + flex-wrap: wrap; +} + +.fixed-expense-name { + flex: 1; + min-width: 0; + font-size: 0.9rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: inherit; +} + +.fixed-expense-fields { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.fixed-expense-input, +.fixed-expense-select { + width: 100%; + font-size: 0.85rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: inherit; + box-sizing: border-box; +} + +.fixed-expense-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + background: var(--color-surface); + cursor: pointer; +} + +.fixed-expense-checkbox input[type="checkbox"] { + flex-shrink: 0; +} + +@media (min-width: 480px) { + .fixed-expense-fields { + flex-direction: row; + flex-wrap: wrap; + } + + .fixed-expense-input { + flex: 1; + min-width: 100px; + } + + .fixed-expense-select { + flex: 1; + min-width: 120px; + } +} + +/* Budget Review - Step 6 Summary Cards */ +.review-summary-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + margin-bottom: 2rem; +} + +.review-summary-card { + background: var(--color-panel); + border-radius: var(--radius-lg); + padding: 1rem 0.75rem; + text-align: center; + border: 1px solid var(--color-border); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.review-summary-card.review-card-highlight { + background: linear-gradient(135deg, var(--color-panel), var(--color-surface)); + border-color: var(--color-accent); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.review-card-icon { + font-size: 1.5rem; + margin-bottom: 0.25rem; +} + +.review-card-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); + margin: 0; +} + +.review-card-value { + font-size: 1rem; + font-weight: 700; + color: var(--color-text); +} + +/* Budget Review - Sections */ +.review-sections { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.review-section { + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: 1rem; + border: 1px solid var(--color-border); +} + +.review-section-title { + font-size: 0.9rem; + font-weight: 700; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.review-items { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.review-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + background: var(--color-panel); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.review-item-savings { + background: var(--color-success-bg, rgba(34, 197, 94, 0.1)); + border-color: var(--color-success, #22c55e); +} + +.review-item-priority { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-muted); + background: var(--color-surface); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.review-item-name { + flex: 1; + font-weight: 600; + font-size: 0.9rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.review-item-value { + font-weight: 700; + font-family: var(--font-mono, monospace); + font-size: 0.9rem; + flex-shrink: 0; +} + +.review-item-fixed { + flex-wrap: wrap; +} + +.review-item-details { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.review-item-meta { + font-size: 0.75rem; + color: var(--color-muted); +} + +.review-item-badge { + font-size: 0.65rem; + background: var(--color-accent); + color: white; + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); + width: fit-content; +} + +.review-total { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--color-surface); + border-radius: var(--radius-md); + font-weight: 600; + margin-top: 0.5rem; + border: 1px dashed var(--color-border); +} + +.review-empty { + text-align: center; + padding: 1.5rem; + color: var(--color-muted); + font-size: 0.875rem; +} + +@media (min-width: 480px) { + .review-card-icon { + font-size: 2rem; + } + + .review-card-value { + font-size: 1.25rem; + } + + .review-summary-card { + padding: 1.25rem 1rem; + } +} + +@media (min-width: 640px) { + .review-summary-grid { + gap: 1rem; + } + + .review-summary-card { + padding: 1.5rem; + } + + .review-card-icon { + font-size: 2.5rem; + } + + .review-card-label { + font-size: 0.75rem; + } + + .review-card-value { + font-size: 1.5rem; + } + + .review-sections { + flex-direction: row; + } + + .review-section { + flex: 1; + } +} + +/* Keep old summary styles for backwards compat */ +.onboarding-summary-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.onboarding-summary-card { + background: var(--color-panel); + border-radius: var(--radius-lg); + padding: 0.75rem 0.5rem; + text-align: center; +} + +@media (min-width: 640px) { + .onboarding-list-item { + flex-direction: row; + align-items: center; + } + + .onboarding-item-header { + flex: 1; + } + + .onboarding-item-controls { + flex-wrap: nowrap; + } + + .onboarding-fixed-fields { + grid-template-columns: 1fr 1fr 1fr; + } + + .onboarding-fixed-fields select { + grid-column: span 1; + } + + .onboarding-summary-grid { + gap: 1rem; + } + + .onboarding-summary-card { + padding: 1rem; + } +} + +@media (min-width: 640px) { + .onboarding-step { + padding-left: 1.5rem; + padding-right: 1.5rem; + } +} + +/* Onboarding action buttons */ +.onboarding-actions { + display: flex; + flex-direction: column-reverse; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.onboarding-btn-next { + width: 100%; + padding: 0.875rem 2rem; + font-size: 1rem; +} + +.onboarding-btn-back { + width: 100%; + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.onboarding-btn-back:hover:not([disabled]) { + background: var(--color-panel); + transform: none; + box-shadow: none; +} + +@media (min-width: 640px) { + .onboarding-actions { + flex-direction: row; + justify-content: center; + } + + .onboarding-btn-next { + width: auto; + min-width: 140px; + } + + .onboarding-btn-back { + width: auto; + min-width: 100px; + } +} + +/* sm: breakpoint utilities */ +@media (min-width: 640px) { + .sm\:gap-2 { gap: 0.5rem; } + .sm\:gap-3 { gap: 0.75rem; } + .sm\:gap-4 { gap: 1rem; } + .sm\:text-sm { font-size: 0.875rem; } + .sm\:text-lg { font-size: 1.125rem; } + .sm\:block { display: block; } + .sm\:mb-6 { margin-bottom: 1.5rem; } + .sm\:flex-row { flex-direction: row; } + .sm\:w-auto { width: auto; } +} + +/* md: breakpoint utilities */ +@media (min-width: 768px) { + .md\:block { display: block !important; } + .md\:hidden { display: none !important; } +} + +/* Mobile hide utility */ +.hidden { display: none; } +@media (min-width: 640px) { + .sm\:block { display: block !important; } +} + +/* Onboarding Tracker (Live Budget Tracker) */ +.onboarding-tracker { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + border-radius: var(--radius-lg); + border: 2px solid color-mix(in srgb, var(--color-accent), transparent 70%); + background: linear-gradient(to bottom right, + color-mix(in srgb, #3b82f6, transparent 95%), + color-mix(in srgb, #10b981, transparent 95%)); + width: 100%; + box-sizing: border-box; +} + +.tracker-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + border-radius: var(--radius-md); +} + +.tracker-budget { + background: color-mix(in srgb, #3b82f6, transparent 92%); +} + +.tracker-fixed { + background: color-mix(in srgb, #f97316, transparent 92%); +} + +.tracker-variable { + background: color-mix(in srgb, #10b981, transparent 92%); +} + +.tracker-breakdown { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid currentColor; + opacity: 0.6; +} + +.tracker-breakdown-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.7rem; +} + +.tracker-breakdown-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +html[data-theme="dark"] .tracker-budget { + background: color-mix(in srgb, #3b82f6, transparent 80%); +} + +html[data-theme="dark"] .tracker-fixed { + background: color-mix(in srgb, #f97316, transparent 80%); +} + +html[data-theme="dark"] .tracker-variable { + background: color-mix(in srgb, #10b981, transparent 80%); +} + +html[data-theme="light"], +html[data-theme="light"] body, +html[data-theme="dark"], +html[data-theme="dark"] body { + background: var(--color-bg); + color: var(--color-text); +} + +html[data-theme="light"] .card { + border-color: var(--color-border); + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.95), + 0 3px 10px color-mix(in srgb, var(--color-accent), transparent 90%), + 0 22px 45px rgba(15, 23, 42, 0.14); + outline: 2px solid color-mix(in srgb, var(--color-accent), transparent 70%); +} + +html[data-theme="light"] .input { + background: #ffffff; + border-color: var(--color-border); +} + +html[data-theme="light"] .list-row { + border-color: var(--color-border); +} + +html[data-theme="light"] body { + background: var(--color-bg); +} + +/* Light-mode: give the page a very subtle scheme tint so white surfaces pop */ +html[data-theme="light"].scheme-blue body { + background: linear-gradient(180deg, #f6f8ff 0%, #ffffff 55%, #ffffff 100%); +} +html[data-theme="light"].scheme-green body { + background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 55%, #ffffff 100%); +} +html[data-theme="light"].scheme-purple body { + background: linear-gradient(180deg, #faf5ff 0%, #ffffff 55%, #ffffff 100%); +} +html[data-theme="light"].scheme-orange body { + background: linear-gradient(180deg, #fff7ed 0%, #ffffff 55%, #ffffff 100%); +} + +/* ====== Settings Page Styles ====== */ + +/* Settings page header */ +.settings-header { + margin-bottom: 1.5rem; +} + +.settings-header h1 { + font-size: 1.5rem; + font-weight: 700; + margin: 0; +} + +/* Settings tab navigation */ +.settings-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding-bottom: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.settings-tab { + padding: 0.625rem 1rem; + border-radius: var(--radius-lg); + font-size: 0.875rem; + font-weight: 500; + color: var(--color-muted); + background: transparent; + border: none; + cursor: pointer; + transition: all var(--transition-fast); +} + +.settings-tab:hover { + color: var(--color-text); + background: var(--color-panel); +} + +.settings-tab.active { + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent), transparent 90%); +} + +/* Settings section container */ +.settings-section { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.settings-section-header { + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-border); +} + +.settings-section-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.25rem 0; +} + +.settings-section-desc { + font-size: 0.8rem; + color: var(--color-muted); + margin: 0; +} + +/* Settings form field */ +.settings-field { + margin-bottom: 1rem; +} + +.settings-field:last-child { + margin-bottom: 0; +} + +.settings-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; +} + +.settings-input { + width: 100%; + max-width: 24rem; +} + +.settings-help { + font-size: 0.75rem; + color: var(--color-muted); + margin-top: 0.375rem; +} + +/* Settings action buttons */ +.settings-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +/* Settings panel/card for options (theme, etc) */ +.settings-option-card { + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1rem; + cursor: pointer; + transition: all var(--transition-fast); +} + +.settings-option-card:hover { + background: var(--color-surface); + border-color: var(--color-accent); +} + +.settings-option-card.selected { + border-color: var(--color-accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent), transparent 70%); +} + +.settings-option-card .option-icon { + width: 2rem; + height: 2rem; + border-radius: 50%; + margin-bottom: 0.5rem; +} + +.settings-option-card .option-label { + font-weight: 600; + font-size: 0.875rem; +} + +.settings-option-card .option-desc { + font-size: 0.75rem; + color: var(--color-muted); +} + +/* Settings grid for options */ +.settings-options-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; +} + +/* Settings list item (for categories, plans, etc) */ +.settings-list-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + transition: all var(--transition-fast); +} + +.settings-list-item:hover { + background: var(--color-surface); +} + +.settings-list-item.savings { + border-color: var(--color-success, #22c55e); + background: color-mix(in srgb, var(--color-success, #22c55e), transparent 95%); +} + +.settings-list-item .item-priority { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-muted); + background: var(--color-surface); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.settings-list-item .item-name { + flex: 1; + font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.settings-list-item .item-value { + font-weight: 600; + font-family: var(--font-mono, monospace); + flex-shrink: 0; +} + +.settings-list-item .item-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +/* Settings inline form (add new category/plan) */ +.settings-inline-form { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 1rem; + background: var(--color-panel); + border: 1px dashed var(--color-border); + border-radius: var(--radius-lg); + margin-top: 1rem; +} + +.settings-inline-form .input { + flex: 1; + min-width: 120px; +} + +/* Settings danger zone */ +.settings-danger-section { + border-color: var(--color-danger, #ef4444); + background: color-mix(in srgb, var(--color-danger, #ef4444), transparent 97%); +} + +.settings-danger-section .settings-section-title { + color: var(--color-danger, #ef4444); +} + +/* Mobile responsive settings */ +@media (max-width: 640px) { + .settings-section { + padding: 1rem; + } + + .settings-options-grid { + grid-template-columns: repeat(2, 1fr); + } + + .settings-list-item { + flex-wrap: wrap; + } + + .settings-list-item .item-actions { + width: 100%; + margin-top: 0.5rem; + } + + .settings-inline-form { + flex-direction: column; + } + + .settings-inline-form .input { + width: 100%; + } +} + +/* ====== Settings Categories Page ====== */ + +/* Add form */ +.settings-add-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.settings-add-form .input { + flex: 1; + min-width: 80px; + max-width: 100%; +} + +.settings-add-form .input:first-child { + flex: 2; + min-width: 120px; +} + +.settings-checkbox-label { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + color: var(--color-muted); + cursor: pointer; + white-space: nowrap; +} + +.settings-checkbox-label.small { + font-size: 0.75rem; +} + +.settings-checkbox-label input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--color-accent); +} + +/* Category list */ +.settings-category-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Category row card */ +.settings-category-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + transition: all var(--transition-fast); +} + +.settings-category-row:hover { + background: color-mix(in srgb, var(--color-panel), var(--color-accent) 5%); +} + +.settings-category-row.savings { + background: color-mix(in srgb, #10b981, transparent 90%); + border-color: #10b981; +} + +/* Main row: drag handle, info, percent */ +.settings-category-main { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; +} + +.settings-drag-handle { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + background: var(--color-surface); + color: var(--color-muted); + font-size: 0.875rem; + cursor: grab; + flex-shrink: 0; + transition: all var(--transition-fast); +} + +.settings-drag-handle:hover { + background: var(--color-ink); + color: var(--color-text); +} + +.settings-drag-handle:active { + cursor: grabbing; +} + +/* Info section: name + balance */ +.settings-category-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.settings-category-name { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.settings-category-name .link { + font-weight: 600; + font-size: 0.9rem; +} + +.settings-category-balance { + font-size: 0.8rem; + color: var(--color-muted); + font-family: var(--font-mono, monospace); +} + +/* Percent display */ +.settings-category-percent { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.75rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-family: var(--font-mono, monospace); + font-weight: 600; + font-size: 0.9rem; + flex-shrink: 0; +} + +/* Actions row */ +.settings-category-actions { + display: flex; + align-items: center; + gap: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border); +} + +.btn-sm { + padding: 0.5rem 0.875rem; + min-height: 38px; + font-size: 0.875rem; +} + +.btn-danger { + color: #ef4444; + border-color: color-mix(in srgb, #ef4444, transparent 60%); +} + +.btn-danger:hover { + background: color-mix(in srgb, #ef4444, transparent 90%); + border-color: #ef4444; +} + +/* Desktop: put actions inline */ +@media (min-width: 640px) { + .settings-category-row { + flex-direction: row; + align-items: center; + gap: 1rem; + } + + .settings-category-main { + flex: 1; + } + + .settings-category-actions { + padding-top: 0; + border-top: none; + flex-shrink: 0; + } + + .settings-add-form .input { + max-width: 200px; + } + + .settings-add-form .input:first-child { + max-width: 250px; + } +} + +/* ===== Fixed Plans (Fixed Expenses) Settings ===== */ +.settings-plans-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.settings-plan-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + transition: border-color var(--transition-fast); +} + +.settings-plan-card:hover { + border-color: var(--color-border-hover); +} + +.settings-plan-card.auto-fund { + border-left: 3px solid var(--color-accent); +} + +.settings-plan-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.settings-plan-title { + font-weight: 600; + font-size: 1rem; + min-width: 0; + flex: 1; +} + +.settings-plan-amount { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + flex-shrink: 0; +} + +.settings-plan-details { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.5rem; + align-items: center; +} + +.settings-plan-detail { + display: flex; + gap: 0.4rem; + font-size: 0.875rem; + align-items: center; +} + +.settings-plan-detail .label { + color: var(--color-muted); + font-weight: 500; +} + +.settings-plan-badge { + font-size: 0.75rem; + padding: 0.2rem 0.5rem; + border-radius: var(--radius-md); + font-weight: 500; +} + +.settings-plan-badge.ahead { + background: rgba(16, 185, 129, 0.15); + color: rgb(52, 211, 153); +} + +.settings-plan-progress { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.settings-plan-progress-bar { + height: 6px; + background: var(--color-panel); + border-radius: 999px; + overflow: hidden; +} + +.settings-plan-progress-fill { + height: 100%; + background: var(--color-accent); + border-radius: 999px; + transition: width var(--transition-normal); +} + +.settings-plan-progress-text { + font-size: 0.75rem; + color: var(--color-muted); + text-align: right; +} + +.settings-plan-actions { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border); +} + +.settings-plan-status { + font-size: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + margin-top: 0.25rem; +} + +.settings-plan-status.funded { + background: rgba(34, 197, 94, 0.1); + color: rgb(74, 222, 128); +} + +.settings-plan-status.planned { + background: rgba(59, 130, 246, 0.1); + color: rgb(96, 165, 250); +} + +.settings-checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + cursor: pointer; +} + +.settings-checkbox-label input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--color-accent); +} + +/* Mobile adjustments for plan cards */ +@media (max-width: 640px) { + .settings-plan-header { + flex-direction: column; + gap: 0.25rem; + } + + .settings-plan-amount { + font-size: 1rem; + } + + .settings-plan-details { + gap: 0.5rem 1rem; + } +} + +/* ===== Theme Settings ===== */ +.settings-theme-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; +} + +.settings-theme-card { + background: var(--color-surface); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + cursor: pointer; + transition: all var(--transition-fast); + text-align: center; +} + +.settings-theme-card:hover { + border-color: var(--color-border-hover); + transform: translateY(-2px); +} + +.settings-theme-card.selected { + border-color: var(--color-accent); + background: var(--color-panel); +} + +.settings-theme-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + border: 2px solid var(--color-border); + flex-shrink: 0; +} + +.settings-theme-icon.light { + background: #ffffff; + border-color: #d1d5db; +} + +.settings-theme-icon.dark { + background: #1f2937; + border-color: #4b5563; +} + +.settings-theme-icon.system { + background: linear-gradient(135deg, #ffffff 0%, #ffffff 50%, #1f2937 50%, #1f2937 100%); + border-color: #6b7280; +} + +.settings-theme-info { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.settings-theme-label { + font-weight: 600; + font-size: 0.875rem; +} + +.settings-theme-desc { + font-size: 0.75rem; + color: var(--color-muted); +} + +/* ===== Color Scheme Settings ===== */ +.settings-color-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 0.75rem; +} + +.settings-color-card { + background: var(--color-surface); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + cursor: pointer; + transition: all var(--transition-fast); +} + +.settings-color-card:hover { + border-color: var(--color-border-hover); + transform: translateY(-2px); +} + +.settings-color-card.selected { + border-color: var(--color-accent); + background: var(--color-panel); +} + +.settings-color-swatch { + width: 48px; + height: 48px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.settings-color-swatch.blue { + background: #3b82f6; +} + +.settings-color-swatch.green { + background: #10b981; +} + +.settings-color-swatch.purple { + background: #a855f7; +} + +.settings-color-swatch.orange { + background: #f97316; +} + +.settings-color-label { + font-size: 0.875rem; + font-weight: 500; + text-transform: capitalize; +} + +/* Mobile adjustments for theme settings */ +@media (max-width: 640px) { + .settings-theme-grid { + grid-template-columns: 1fr; + } + + .settings-theme-card { + flex-direction: row; + justify-content: flex-start; + text-align: left; + } + + .settings-color-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/web/src/styles.css.bak b/web/src/styles.css.bak new file mode 100644 index 0000000..1086306 --- /dev/null +++ b/web/src/styles.css.bak @@ -0,0 +1,132 @@ +: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; } diff --git a/web/src/theme/useTheme.ts b/web/src/theme/useTheme.ts new file mode 100644 index 0000000..ecaef59 --- /dev/null +++ b/web/src/theme/useTheme.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; + +export type Theme = "dark" | "light" | "system"; +const KEY = "theme"; + +function readTheme(): Theme { + return (localStorage.getItem(KEY) as Theme) || "system"; +} + +function resolve(t: Theme) { + if (t !== "system") return t; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export function useTheme() { + const [theme, setThemeState] = useState(() => readTheme()); + + useEffect(() => { + const actual = resolve(theme); + const root = document.documentElement; + const body = document.body; + // your styles.css uses :root (dark) + :root[data-theme="light"] for light + if (actual === "dark") { + root.setAttribute("data-theme", "dark"); + root.classList.add("dark"); + body.classList.add("dark"); + body.setAttribute("data-theme", "dark"); + } else { + root.setAttribute("data-theme", "light"); + root.classList.remove("dark"); + body.classList.remove("dark"); + body.setAttribute("data-theme", "light"); + } + }, [theme]); + + useEffect(() => { + const sync = () => { + const next = readTheme(); + setThemeState((prev) => (prev === next ? prev : next)); + }; + const onStorage = (e: StorageEvent) => { + if (e.key === KEY) sync(); + }; + window.addEventListener("storage", onStorage); + window.addEventListener("theme-change", sync); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("theme-change", sync); + }; + }, []); + + const setTheme = (next: Theme) => { + localStorage.setItem(KEY, next); + setThemeState(next); + window.dispatchEvent(new Event("theme-change")); + }; + + return { theme, setTheme }; +} diff --git a/web/src/utils/allocatorPreview.ts b/web/src/utils/allocatorPreview.ts index 94823c9..3dbcc3b 100644 --- a/web/src/utils/allocatorPreview.ts +++ b/web/src/utils/allocatorPreview.ts @@ -1,5 +1,7 @@ import type { FixedPlan, VariableCategory } from "../hooks/useDashboard"; +export type { FixedPlan, VariableCategory }; + export function previewAllocation( amountCents: number, fixedPlans: FixedPlan[], diff --git a/web/src/utils/funding.ts b/web/src/utils/funding.ts new file mode 100644 index 0000000..097a78a --- /dev/null +++ b/web/src/utils/funding.ts @@ -0,0 +1,101 @@ +export type IncomeFrequency = "weekly" | "biweekly" | "monthly"; +export type UserType = "regular" | "irregular"; + +export interface FixedPlanClient { + id: string; + name?: string; + totalCents: number; + fundedCents?: number; + currentFundedCents?: number; + dueOn: string | Date; + cycleStart?: string | Date | null; + autoPayEnabled?: boolean; +} + +export function computeFixedFundingStatus( + userType: UserType | string | undefined, + incomeFrequency: IncomeFrequency | string | undefined, + fixedPlans: FixedPlanClient[] = [], + now = new Date(), + crisisActive = false, + bufferCents = 100 +) { + // Crisis: always flag + if (crisisActive) return { needsFunding: true, plans: fixedPlans.map((p) => ({ id: p.id, needsFunding: true })) }; + + // Irregular users: prompt if any remaining > 0 + if (userType === "irregular") { + const eligiblePlans = fixedPlans.filter((p) => p.autoPayEnabled); + const any = eligiblePlans.some((p) => { + const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0); + return Math.max(0, Number(p.totalCents) - funded) > 0; + }); + return { + needsFunding: any, + plans: fixedPlans.map((p) => ({ + id: p.id, + needsFunding: p.autoPayEnabled + ? Math.max(0, Number(p.totalCents) - Number(p.currentFundedCents ?? p.fundedCents ?? 0)) > 0 + : false, + })), + }; + } + + const DAY_MS = 24 * 60 * 60 * 1000; + const daysPerPaycheck = incomeFrequency === "weekly" ? 7 : incomeFrequency === "biweekly" ? 14 : 30; + + const eligiblePlans = fixedPlans.filter((p) => p.autoPayEnabled); + const planResults = fixedPlans.map((plan) => { + if (!plan.autoPayEnabled) { + return { id: plan.id, needsFunding: false, reason: "auto_fund_disabled" }; + } + const funded = Math.max(0, Math.floor(Number(plan.currentFundedCents ?? plan.fundedCents ?? 0))); + const total = Math.max(0, Math.floor(Number(plan.totalCents ?? 0))); + const due = new Date(plan.dueOn); + const cycleStart = plan.cycleStart ? new Date(plan.cycleStart as any) : null; + + const daysUntilDue = Math.max(0, Math.ceil((due.getTime() - now.getTime()) / DAY_MS)); + const remainingCents = Math.max(0, total - funded); + + if (remainingCents <= 0) { + return { id: plan.id, needsFunding: false, reason: "already_funded" }; + } + + // Determine funding window using cycleStart when available + const start = cycleStart ?? new Date(due.getTime() - Math.max(1, daysUntilDue) * DAY_MS); + const totalWindowDays = Math.max(1, Math.ceil((due.getTime() - start.getTime()) / DAY_MS)); + + const totalPaychecks = Math.max(1, Math.ceil(totalWindowDays / daysPerPaycheck)); + const daysSinceStart = Math.max(0, Math.ceil((now.getTime() - start.getTime()) / DAY_MS)); + const elapsedPaychecks = Math.min(totalPaychecks, Math.floor(daysSinceStart / daysPerPaycheck)); + + const expectedCumulative = Math.round((total * elapsedPaychecks) / totalPaychecks); + const needsFunding = funded < Math.max(0, expectedCumulative - bufferCents); + + return { + id: plan.id, + needsFunding, + reason: needsFunding ? "behind_schedule" : "on_schedule", + funded, + expectedCumulative, + totalPaychecks, + elapsedPaychecks, + remainingCents, + daysUntilDue, + }; + }); + + const overallNeeds = eligiblePlans.length > 0 && planResults.some((r) => r.needsFunding); + return { needsFunding: overallNeeds, plans: planResults }; +} + +export function computeNeedsFixedFunding( + userType: UserType | string | undefined, + incomeFrequency: IncomeFrequency | string | undefined, + fixedPlans: FixedPlanClient[] = [], + now = new Date(), + crisisActive = false, + bufferCents = 100 +) { + return computeFixedFundingStatus(userType, incomeFrequency, fixedPlans, now, crisisActive, bufferCents).needsFunding; +} diff --git a/web/src/utils/timezone.ts b/web/src/utils/timezone.ts new file mode 100644 index 0000000..96a6963 --- /dev/null +++ b/web/src/utils/timezone.ts @@ -0,0 +1,156 @@ +/** + * Timezone utility functions for consistent date handling across the application. + * + * All dates should be: + * 1. Stored in the backend as UTC ISO strings + * 2. Displayed to users in their saved timezone + * 3. Input from users interpreted in their saved timezone + * + * The user's timezone is stored in the database and should be fetched from the dashboard. + */ + +/** + * Get today's date in the user's timezone as YYYY-MM-DD format. + * This should be used for date inputs to ensure consistency with user's timezone. + * + * @param userTimezone - IANA timezone string (e.g., "America/New_York") + * @returns Date string in YYYY-MM-DD format + */ +export function getTodayInTimezone(userTimezone: string): string { + const now = new Date(); + // Use Intl.DateTimeFormat to get the date in the user's timezone + const formatter = new Intl.DateTimeFormat('en-CA', { // en-CA gives YYYY-MM-DD format + timeZone: userTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + return formatter.format(now); +} + +/** + * Convert a date input string (YYYY-MM-DD) to an ISO string that represents + * midnight in the user's timezone. + * + * This should be used when sending date-only data to the backend. + * + * @param dateString - Date in YYYY-MM-DD format + * @param userTimezone - IANA timezone string + * @returns ISO string representing midnight in the user's timezone + */ +export function dateStringToUTCMidnight(dateString: string, userTimezone: string): string { + // Parse the date string as-is (YYYY-MM-DD) + const [year, month, day] = dateString.split('-').map(Number); + + // Create a date object representing midnight in the user's timezone + // We format a string that includes timezone info + const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T00:00:00`; + + // Get the date/time string in the user's timezone to calculate offset + const tzDate = new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: userTimezone })); + const utcDate = new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'UTC' })); + const offset = tzDate.getTime() - utcDate.getTime(); + + // Create final date adjusted for timezone + const adjustedDate = new Date(Date.UTC(year, month - 1, day, 0, 0, 0) - offset); + + return adjustedDate.toISOString(); +} + +/** + * Format an ISO date string for display in the user's timezone. + * + * @param isoString - ISO date string from backend + * @param userTimezone - IANA timezone string + * @param options - Intl.DateTimeFormatOptions for formatting + * @returns Formatted date string + */ +export function formatDateInTimezone( + isoString: string, + userTimezone: string, + options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' } +): string { + const date = new Date(isoString); + return new Intl.DateTimeFormat('en-US', { + ...options, + timeZone: userTimezone, + }).format(date); +} + +/** + * Convert an ISO string to YYYY-MM-DD format in the user's timezone. + * This is useful for populating date inputs. + * + * @param isoString - ISO date string from backend + * @param userTimezone - IANA timezone string + * @returns Date string in YYYY-MM-DD format + */ +export function isoToDateString(isoString: string, userTimezone: string): string { + const date = new Date(isoString); + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: userTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + return formatter.format(date); +} + +/** + * Get the current date and time as an ISO string in UTC. + * This should be used for timestamps (not date-only fields). + * + * @returns ISO string in UTC + */ +export function getCurrentTimestamp(): string { + return new Date().toISOString(); +} + +/** + * Compare two date strings (YYYY-MM-DD) to determine if date1 is before date2. + * This is timezone-safe because it compares date strings directly. + * + * @param date1 - First date string + * @param date2 - Second date string + * @returns true if date1 is before date2 + */ +export function isDateBefore(date1: string, date2: string): boolean { + return date1 < date2; +} + +/** + * Compare two date strings (YYYY-MM-DD) to determine if date1 is after date2. + * This is timezone-safe because it compares date strings directly. + * + * @param date1 - First date string + * @param date2 - Second date string + * @returns true if date1 is after date2 + */ +export function isDateAfter(date1: string, date2: string): boolean { + return date1 > date2; +} + +/** + * Add days to a date string, accounting for the user's timezone. + * + * @param dateString - Date in YYYY-MM-DD format + * @param days - Number of days to add + * @param userTimezone - IANA timezone string + * @returns New date string in YYYY-MM-DD format + */ +export function addDaysToDate(dateString: string, days: number, userTimezone: string): string { + const baseISO = dateStringToUTCMidnight(dateString, userTimezone); + const base = new Date(baseISO); + base.setUTCDate(base.getUTCDate() + days); + return isoToDateString(base.toISOString(), userTimezone); +} + +/** + * Get the user's browser timezone as a fallback. + * This should only be used when the backend timezone is not available. + * + * @returns IANA timezone string + */ +export function getBrowserTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} diff --git a/web/tests/allocatorPreview.test.ts b/web/tests/allocatorPreview.test.ts index 8e3ebdd..9ff7247 100644 --- a/web/tests/allocatorPreview.test.ts +++ b/web/tests/allocatorPreview.test.ts @@ -3,7 +3,7 @@ import { previewAllocation, type FixedPlan, type VariableCategory } from "../src const cats = (defs: Array & { name: string }>): VariableCategory[] => defs.map((d, i) => ({ - id: i + 1, + id: String(i + 1), name: d.name, percent: d.percent ?? 0, isSavings: d.isSavings ?? false, @@ -12,12 +12,13 @@ const cats = (defs: Array & { name: string }>): Variab const plans = (defs: Array & { name: string; totalCents: number }>): FixedPlan[] => defs.map((d, i) => ({ - id: i + 1, + id: String(i + 1), name: d.name, totalCents: d.totalCents, fundedCents: d.fundedCents ?? 0, priority: d.priority ?? 100, dueOn: d.dueOn ?? new Date().toISOString(), + cycleStart: d.cycleStart ?? new Date().toISOString(), })); describe("previewAllocation — basics", () => { diff --git a/web/tests/funding.test.ts b/web/tests/funding.test.ts new file mode 100644 index 0000000..4547f08 --- /dev/null +++ b/web/tests/funding.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { computeFixedFundingStatus } from '../src/utils/funding'; + +describe('computeFixedFundingStatus', () => { + it('weekly pay, 3-week window, after 1 week funded one-third -> no funding needed', () => { + const cycleStart = new Date('2025-11-01T00:00:00.000Z'); + const now = new Date('2025-11-08T00:00:00.000Z'); // 7 days later => 1 paycheck elapsed + const due = new Date(cycleStart.getTime() + 21 * 24 * 60 * 60 * 1000); // 21 days after start + const total = 30000; // $300.00 + const funded = Math.round(total / 3); // after 1 paycheck + + const plans = [{ id: 'p1', name: 'Rent', totalCents: total, fundedCents: funded, dueOn: due.toISOString(), cycleStart: cycleStart.toISOString() }]; + + const res = computeFixedFundingStatus('regular', 'weekly', plans, now, false, 100); + expect(res.needsFunding).toBe(false); + expect(res.plans[0].needsFunding).toBe(false); + }); + + it('weekly pay, 3-week window, after 1 week underfunded -> needs funding', () => { + const cycleStart = new Date('2025-11-01T00:00:00.000Z'); + const now = new Date('2025-11-08T00:00:00.000Z'); // 7 days later + const due = new Date(cycleStart.getTime() + 21 * 24 * 60 * 60 * 1000); + const total = 30000; // $300.00 + const funded = Math.round(total / 3) - 500; // under by $5 + + const plans = [{ id: 'p1', name: 'Rent', totalCents: total, fundedCents: funded, dueOn: due.toISOString(), cycleStart: cycleStart.toISOString() }]; + + const res = computeFixedFundingStatus('regular', 'weekly', plans, now, false, 100); + expect(res.needsFunding).toBe(true); + expect(res.plans[0].needsFunding).toBe(true); + }); +}); diff --git a/web/tests/onboarding-tracker.test.ts b/web/tests/onboarding-tracker.test.ts new file mode 100644 index 0000000..ce81f1e --- /dev/null +++ b/web/tests/onboarding-tracker.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import { previewAllocation } from "../src/utils/allocatorPreview"; + +/** + * Test case: Regular income user with payment plans + * Budget: $2,000 per paycheck (weekly) + * Fixed expenses: + * 1. Rent: $1,200 (due in 4 weeks, payment plan enabled) - should fund $300/week + * 2. Insurance: $400 (due in 2 weeks, payment plan enabled) - should fund $200/week + * Variable categories: + * 1. Groceries: 40% + * 2. Entertainment: 30% + * 3. Savings: 30% + * + * Expected allocation this paycheck: + * - Fixed: $500 ($300 + $200) + * - Variable: $1,500 (remaining after fixed) + * - Groceries: $600 (40% of $1,500) + * - Entertainment: $450 (30% of $1,500) + * - Savings: $450 (30% of $1,500) + */ + +describe("OnboardingTracker vs API Allocator", () => { + it("should allocate budget correctly with payment plans", () => { + const budgetCents = 200000; // $2,000 + + // Today's date for testing + const now = new Date("2025-11-26"); + + // Fixed plans with payment plans enabled + const fixedPlans = [ + { + id: "rent", + name: "Rent", + totalCents: 120000, // $1,200 + fundedCents: 0, // Not yet funded + dueOn: "2025-12-24", // 4 weeks from now (28 days) + priority: 1, + cycleStart: "2025-11-26", + }, + { + id: "insurance", + name: "Insurance", + totalCents: 40000, // $400 + fundedCents: 0, // Not yet funded + dueOn: "2025-12-10", // 2 weeks from now (14 days) + priority: 2, + cycleStart: "2025-11-26", + }, + ]; + + // Variable categories + const variableCategories = [ + { + id: "groceries", + name: "Groceries", + percent: 40, + balanceCents: 0, + isSavings: false, + priority: 1, + }, + { + id: "entertainment", + name: "Entertainment", + percent: 30, + balanceCents: 0, + isSavings: false, + priority: 2, + }, + { + id: "savings", + name: "Savings", + percent: 30, + balanceCents: 0, + isSavings: true, + priority: 3, + }, + ]; + + // Calculate allocation using previewAllocation + const result = previewAllocation(budgetCents, fixedPlans, variableCategories); + + console.log("\n=== ONBOARDING TRACKER TEST CASE ==="); + console.log("Budget:", budgetCents / 100, "($2,000)"); + console.log("\nFixed Expenses:"); + result.fixed.forEach((f) => { + const original = fixedPlans.find((fp) => fp.id === f.id); + const daysUntilDue = Math.ceil( + (new Date(original!.dueOn).getTime() - now.getTime()) / (24 * 60 * 60 * 1000) + ); + const weeksUntilDue = Math.ceil(daysUntilDue / 7); + const expectedPerPaycheck = original!.totalCents / weeksUntilDue; + + console.log(` ${f.name}: $${f.amountCents / 100} allocated`); + console.log(` Total needed: $${original!.totalCents / 100}`); + console.log(` Due in: ${daysUntilDue} days (${weeksUntilDue} weeks)`); + console.log(` Expected per paycheck: $${expectedPerPaycheck / 100}`); + console.log(` Percentage funded: ${Math.round((f.amountCents / original!.totalCents) * 100)}%`); + }); + + const totalFixedAllocated = result.fixed.reduce((sum, f) => sum + f.amountCents, 0); + console.log(` Total Fixed Allocated: $${totalFixedAllocated / 100}`); + + console.log("\nVariable Categories:"); + const totalVariableAllocated = result.variable.reduce((sum, v) => sum + v.amountCents, 0); + console.log(` Total Available: $${totalVariableAllocated / 100}`); + + result.variable.forEach((v) => { + const original = variableCategories.find((vc) => vc.id === v.id); + const percentage = original?.percent || 0; + const expectedAmount = Math.floor((totalVariableAllocated * percentage) / 100); + + console.log(` ${v.name}: $${v.amountCents / 100} (${percentage}%)`); + console.log(` Expected: $${expectedAmount / 100}`); + }); + + console.log("\nUnallocated:", result.unallocatedCents / 100); + + // Assertions + + // 1. Rent should get $300 (1200/4 weeks) + const rentAllocation = result.fixed.find((f) => f.id === "rent"); + expect(rentAllocation?.amountCents).toBe(30000); // $300 + + // 2. Insurance should get $200 (400/2 weeks) + const insuranceAllocation = result.fixed.find((f) => f.id === "insurance"); + expect(insuranceAllocation?.amountCents).toBe(20000); // $200 + + // 3. Total fixed should be $500 + expect(totalFixedAllocated).toBe(50000); + + // 4. Variable budget should be $1,500 (2000 - 500) + expect(totalVariableAllocated).toBe(150000); + + // 5. Groceries should get $600 (40% of $1,500) + const groceriesAllocation = result.variable.find((v) => v.id === "groceries"); + expect(groceriesAllocation?.amountCents).toBe(60000); // $600 + + // 6. Entertainment should get $450 (30% of $1,500) + const entertainmentAllocation = result.variable.find((v) => v.id === "entertainment"); + expect(entertainmentAllocation?.amountCents).toBe(45000); // $450 + + // 7. Savings should get $450 (30% of $1,500) + const savingsAllocation = result.variable.find((v) => v.id === "savings"); + expect(savingsAllocation?.amountCents).toBe(45000); // $450 + + // 8. No unallocated funds + expect(result.unallocatedCents).toBe(0); + + console.log("\n✓ All assertions passed!"); + }); + + it("should handle partial funding correctly", () => { + const budgetCents = 100000; // $1,000 + + // Fixed plan that needs $600 total, already has $300 funded + const fixedPlans = [ + { + id: "expense1", + name: "Expense 1", + totalCents: 60000, // $600 total + fundedCents: 30000, // $300 already funded + dueOn: "2025-12-10", + priority: 1, + cycleStart: "2025-11-26", + }, + ]; + + // Variable categories + const variableCategories = [ + { + id: "cat1", + name: "Category 1", + percent: 50, + balanceCents: 0, + isSavings: false, + priority: 1, + }, + { + id: "cat2", + name: "Category 2", + percent: 50, + balanceCents: 0, + isSavings: false, + priority: 2, + }, + ]; + + const result = previewAllocation(budgetCents, fixedPlans, variableCategories); + + console.log("\n=== PARTIAL FUNDING TEST ==="); + console.log("Budget: $1,000"); + console.log("Fixed expense needs: $600 total, $300 already funded"); + console.log("Should allocate: $300 to fixed, $700 to variable"); + + const totalFixedAllocated = result.fixed.reduce((sum, f) => sum + f.amountCents, 0); + const totalVariableAllocated = result.variable.reduce((sum, v) => sum + v.amountCents, 0); + + console.log("\nFixed allocated:", totalFixedAllocated / 100); + console.log("Variable allocated:", totalVariableAllocated / 100); + + // Fixed should only get $300 (remaining need) + expect(totalFixedAllocated).toBe(30000); + + // Variable should get $700 + expect(totalVariableAllocated).toBe(70000); + + // Category 1 should get $350 (50% of $700) + const cat1 = result.variable.find((v) => v.id === "cat1"); + expect(cat1?.amountCents).toBe(35000); + + // Category 2 should get $350 (50% of $700) + const cat2 = result.variable.find((v) => v.id === "cat2"); + expect(cat2?.amountCents).toBe(35000); + + console.log("✓ Partial funding test passed!"); + }); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index cbd92ec..892d3fd 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -8,7 +8,13 @@ export default defineConfig({ tailwindcss(), ], - -}) - + server: { + proxy: { + "/api": { + target: "http://localhost:8080", + changeOrigin: true, + }, + }, + }, +})
NameDuePriorityFundedTotalRemainingStatusNameDuePriorityFundedTotalRemainingStatusAuto-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)) })} + + onEdit(p.id, { name: v }) + } /> - onEdit(p.id, { totalCents: Math.max(cents, 0), fundedCents: Math.min(p.fundedCents, cents) })} + + 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) + } + + )} +
+
- +