Compare commits

46 Commits

Author SHA1 Message Date
ad82e914fc fixed due date overlay to now read expenses past due date as well as on due date
All checks were successful
Deploy / deploy (push) Successful in 56s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 26s
2026-03-21 17:37:20 -05:00
9c7f4d5139 removed unneccesary files
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 24s
2026-03-21 17:30:11 -05:00
952684fc25 phase 8: site-access and admin simplified and compacted
All checks were successful
Deploy / deploy (push) Successful in 1m32s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 27s
2026-03-18 06:43:19 -05:00
a8e5443b0d phase 7: income, payday. and budget handling routes simplified and compacted
All checks were successful
Deploy / deploy (push) Successful in 1m31s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 26s
2026-03-17 22:05:17 -05:00
020d55a77e phase 5: fixed expense logic simplified and compacted
All checks were successful
Deploy / deploy (push) Successful in 1m31s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 26s
2026-03-17 20:28:08 -05:00
181c3bdc9e phase 4: simplify all transaction routes
All checks were successful
Deploy / deploy (push) Successful in 2m22s
Security Tests / security-non-db (push) Successful in 27s
Security Tests / security-db (push) Successful in 31s
2026-03-17 09:00:48 -05:00
4a63309153 phase 3: all variable cateogry references simplified
All checks were successful
Deploy / deploy (push) Successful in 1m33s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 26s
2026-03-16 15:20:12 -05:00
a430dfadcf phase 2: register, login, logout, verify, session, forgat password, delete and cofirm, refresh session all simplified
All checks were successful
Deploy / deploy (push) Successful in 1m31s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 25s
2026-03-16 14:19:13 -05:00
60cdcf1fcf phase 1 of cleanup: move GET health, GET auth/session, and PATCH endpoints
All checks were successful
Deploy / deploy (push) Successful in 1m27s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 25s
2026-03-15 15:05:38 -05:00
8a6d7d0cb0 fix: compose yml did not translate env vars for matienence mode. fixed,
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 25s
2026-03-15 14:53:50 -05:00
ba549f6c84 added udner construction for file compaction, planning for unbloating
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 25s
2026-03-15 14:44:47 -05:00
512e21276c added tracking notes
All checks were successful
Deploy / deploy (push) Successful in 1m12s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 26s
2026-03-13 19:26:16 -05:00
58545b2da7 fix: fix bug in rebalanace, stale session values being read
All checks were successful
Deploy / deploy (push) Successful in 1m30s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 24s
2026-03-12 13:51:46 -05:00
a03fbea5e7 update notice 8
All checks were successful
Deploy / deploy (push) Successful in 1m8s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 23s
2026-03-11 22:32:11 -05:00
234ecc56e9 more ui fix for rebalance, and safegaurd as well.
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 24s
2026-03-11 22:24:20 -05:00
e6dac3f344 fixed rebalance ui, helper feature redistruvbtion
All checks were successful
Deploy / deploy (push) Successful in 57s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 23s
2026-03-11 22:11:15 -05:00
d39928a3f7 fix: fixed reset button and safeguards on rebalance feature
All checks were successful
Deploy / deploy (push) Successful in 57s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 23s
2026-03-11 21:56:12 -05:00
51510f1685 fix: fix no budget session in rebalance get route
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 25s
2026-03-11 21:44:19 -05:00
90131d61fc fix rebalance feature get route, moved to top level nav
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 24s
2026-03-11 21:39:11 -05:00
3199e676a8 test run for user rebalance expenses feature, added safeguard for estimate exepenses acceptance in onboarding
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 24s
2026-03-11 21:17:45 -05:00
cccce2c854 fixed inputs to accept decimals
All checks were successful
Deploy / deploy (push) Successful in 59s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 24s
2026-03-11 20:02:32 -05:00
72334b2583 ui fixes, input fixes, better dev workflow
All checks were successful
Deploy / deploy (push) Successful in 2m2s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 24s
2026-03-10 23:01:44 -05:00
809b75ea4e Added step in security-db to run npx prisma migrate deploy against TEST_DATABASE_URL before tests.
All checks were successful
Deploy / deploy (push) Successful in 57s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 24s
2026-03-10 21:31:17 -05:00
a7e3448d28 added update notice, re-running db
Some checks failed
Deploy / deploy (push) Successful in 1m9s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Failing after 19s
2026-03-10 21:25:17 -05:00
fe96bf85da added db guard changes to prevent deletion
Some checks failed
Deploy / deploy (push) Successful in 57s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Failing after 20s
2026-03-10 21:19:24 -05:00
479a5ff9d7 attempt to add variable fixed epenses in onboaring
All checks were successful
Deploy / deploy (push) Successful in 58s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 23s
2026-03-10 19:35:22 -05:00
5fa82adc20 attempting to fix frozen deploy step 2
All checks were successful
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 22s
Deploy / deploy (push) Successful in 57s
2026-03-02 14:11:03 -06:00
78595a052a attempting to fix frozen deploy step
Some checks failed
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 22s
Deploy / deploy (push) Failing after 42s
2026-03-02 14:05:52 -06:00
cfbda7c3cd diagnose and fix: removed rm for skymoney data in deploy)
Some checks failed
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 23s
Deploy / deploy (push) Has been cancelled
2026-03-02 13:56:23 -06:00
503ad3e3f8 fixed deployment error
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 23s
2026-03-02 13:38:08 -06:00
d5dc65981a created proper db backup on push to ensure this wont happen again
Some checks failed
Deploy / deploy (push) Failing after 43s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 23s
2026-03-02 13:35:43 -06:00
1d95056e23 fix: change expected host for deploy 2
All checks were successful
Deploy / deploy (push) Successful in 56s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 23s
2026-03-02 11:26:01 -06:00
45a496505e fix: change expected host for deploy
Some checks failed
Deploy / deploy (push) Failing after 53s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 23s
2026-03-02 11:23:13 -06:00
bddd9d4081 fix: change deploy for permission again
Some checks failed
Deploy / deploy (push) Failing after 53s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 22s
2026-03-02 11:20:45 -06:00
76b1893898 fix: change permission in deployment for script
Some checks failed
Deploy / deploy (push) Failing after 43s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 22s
2026-03-02 11:18:55 -06:00
d9df9b0fe4 fix: adding db recovery practices (bye bye db)
Some checks failed
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 23s
Deploy / deploy (push) Has been cancelled
2026-03-02 11:16:52 -06:00
301b3f8967 feat: added estimate fixed expenses
All checks were successful
Deploy / deploy (push) Successful in 1m26s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 22s
2026-03-02 10:49:12 -06:00
e0313df24b fix: added better UI form validation for password registration
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 23s
2026-03-01 22:04:28 -06:00
f2b80a1ca0 fix: fix test script for forgot password again
All checks were successful
Deploy / deploy (push) Successful in 56s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 23s
2026-03-01 21:54:50 -06:00
b6db624d92 fix: fix test script for forgot password
Some checks failed
Deploy / deploy (push) Successful in 56s
Security Tests / security-non-db (push) Failing after 19s
Security Tests / security-db (push) Successful in 22s
2026-03-01 21:52:12 -06:00
15e0c0a88a feat: implement forgot password, added security updates
Some checks failed
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Failing after 18s
Security Tests / security-db (push) Failing after 22s
2026-03-01 21:47:15 -06:00
c7c72e8199 chore: add update message
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 22s
2026-03-01 21:09:13 -06:00
079b8b9492 chore: root commit of OWSAP security testing/tightening
All checks were successful
Deploy / deploy (push) Successful in 1m42s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 22s
2026-03-01 20:46:47 -06:00
1645896e54 chore: ran security check for OWASP top 10
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-03-01 20:44:55 -06:00
023587c48c Merge pull request 'added favicon and web title' (#2) from feature/email-verification-prod-hardening into main
All checks were successful
Deploy / deploy (push) Successful in 47s
Reviewed-on: #2
2026-02-09 21:02:48 +00:00
fe1f42a6f0 Merge pull request 'feat: email verification + delete confirmation + smtp/cors/prod hardening' (#1) from feature/email-verification-prod-hardening into main
All checks were successful
Deploy / deploy (push) Successful in 1m58s
Reviewed-on: #1
2026-02-09 20:47:53 +00:00
155 changed files with 9616 additions and 9883 deletions

28
.env
View File

@@ -7,6 +7,7 @@ DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@postgres:5432/skym
BACKUP_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/skymoney
RESTORE_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/skymoney_restore_test
ADMIN_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/postgres
TEST_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@postgres:5432/skymoney_test
APP_ORIGIN=https://skymoneybudget.com
CORS_ORIGINS=https://skymoneybudget.com
@@ -26,10 +27,29 @@ SMTP_REQUIRE_TLS=true
SMTP_TLS_REJECT_UNAUTHORIZED=true
SMTP_USER=skymoney-smtp
SMTP_PASS=skymoneysmtp124521
EMAIL_FROM=SkyMoney Budget <no-reply@skymoneybudget.com>
EMAIL_FROM="SkyMoney Budget <no-reply@skymoneybudget.com>"
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
EMAIL_REPLY_TO=support@skymoneybudget.com
UPDATE_NOTICE_VERSION=1
UPDATE_NOTICE_TITLE=SkyMoney Update
UPDATE_NOTICE_BODY=We added email verification and account-delete confirmation
UPDATE_NOTICE_VERSION=10
UPDATE_NOTICE_TITLE="Tracking Notes for Expenses"
UPDATE_NOTICE_BODY="Notes for daily transactions are now tracked in the database and are displayed in the records page next to the correlating transaction."
ALLOW_INSECURE_AUTH_FOR_DEV=false
JWT_ISSUER=skymoney-api
JWT_AUDIENCE=skymoney-web
AUTH_MAX_FAILED_ATTEMPTS=5
AUTH_LOCKOUT_WINDOW_MS=900000
PASSWORD_RESET_TTL_MINUTES=30
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE=5
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE=10
EXPECTED_PROD_DB_HOST=postgres
EXPECTED_PROD_DB_NAME=skymoney
EXPECTED_BACKUP_DB_HOST=127.0.0.1
EXPECTED_BACKUP_DB_NAME=skymoney
PROD_DB_VOLUME_NAME=skymoney_pgdata
ALLOW_EMPTY_PROD_VOLUME=0
EMAIL_VERIFY_DEV_CODE_EXPOSE=false
UNDER_CONSTRUCTION_ENABLED=true
BREAK_GLASS_VERIFY_ENABLED=true
BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ

View File

@@ -4,6 +4,7 @@ PORT=8080
CORS_ORIGIN=http://localhost:5173
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://skymoneybudget.com
AUTH_DISABLED=false
ALLOW_INSECURE_AUTH_FOR_DEV=false
SEED_DEFAULT_BUDGET=false
ROLLOVER_SCHEDULE_CRON=0 6 * * *
APP_ORIGIN=http://localhost:5173
@@ -18,11 +19,37 @@ DATABASE_URL=postgres://skymoney_app:change-me@postgres:5432/skymoney
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
TEST_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney_test
EXPECTED_PROD_DB_HOST=postgres
EXPECTED_PROD_DB_NAME=skymoney
EXPECTED_BACKUP_DB_HOST=127.0.0.1
EXPECTED_BACKUP_DB_NAME=skymoney
PROTECTED_DB_NAMES=skymoney,postgres,template0,template1
REQUIRE_TEST_DB_NAME=1
PROD_DB_VOLUME_NAME=skymoney_pgdata
ALLOW_EMPTY_PROD_VOLUME=0
ARCHIVE_EXISTING_RESTORE_DB=1
RESTORE_ARCHIVE_DIR=./backups/restore-archives
# Auth secrets (min 32 chars)
JWT_SECRET=replace-with-32+-chars
JWT_ISSUER=skymoney-api
JWT_AUDIENCE=skymoney-web
COOKIE_SECRET=replace-with-32+-chars
COOKIE_DOMAIN=skymoneybudget.com
# Leave unset for local development. Set for production (example: skymoneybudget.com).
# COOKIE_DOMAIN=skymoneybudget.com
EMAIL_VERIFY_DEV_CODE_EXPOSE=false
BREAK_GLASS_VERIFY_ENABLED=false
BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars
UNDER_CONSTRUCTION_ENABLED=false
AUTH_MAX_FAILED_ATTEMPTS=5
AUTH_LOCKOUT_WINDOW_MS=900000
PASSWORD_RESET_TTL_MINUTES=30
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE=5
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE=10
UPDATE_NOTICE_VERSION=3
UPDATE_NOTICE_TITLE=SkyMoney Update
UPDATE_NOTICE_BODY=We shipped account security improvements, including a new password reset flow and stronger session protections.
# Email (verification + delete confirmation)
SMTP_HOST=smtp.example.com

32
.env.localdev Normal file
View File

@@ -0,0 +1,32 @@
# Local development env (safe defaults; separate from production .env)
NODE_ENV=development
PORT=8080
# API/web local origins
APP_ORIGIN=http://localhost:5173
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Database
POSTGRES_DB=skymoney
POSTGRES_USER=skymoney_app
POSTGRES_PASSWORD=RicearoniSkyMoney124521!
DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@postgres:5432/skymoney
BACKUP_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/skymoney
RESTORE_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/skymoney_restore_test
ADMIN_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/postgres
TEST_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@postgres:5432/skymoney_test
# Auth
JWT_SECRET=AF1dCMElMFR+IeMwA17ZNUo7ft/j4qLsx2C/zndtKug=
JWT_ISSUER=skymoney-api
JWT_AUDIENCE=skymoney-web
COOKIE_SECRET= PYjozZs+CxkU+In/FX/EI/5SB5ETAEw2AzCAF+G4Zgc=
# Leave unset in local dev so host-only cookie is used.
# COOKIE_DOMAIN=
AUTH_DISABLED=false
ALLOW_INSECURE_AUTH_FOR_DEV=true
SEED_DEFAULT_BUDGET=false
BREAK_GLASS_VERIFY_ENABLED=true
BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ
EMAIL_VERIFY_DEV_CODE_EXPOSE=true
UNDER_CONSTRUCTION_ENABLED=false
NODE_ENV=development

33
.env.localdev.example Normal file
View File

@@ -0,0 +1,33 @@
# Local development env (safe defaults; separate from production .env)
NODE_ENV=development
PORT=8080
# API/web local origins
APP_ORIGIN=http://localhost:5173
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Database
POSTGRES_DB=skymoney
POSTGRES_USER=skymoney_app
POSTGRES_PASSWORD=change-me
DATABASE_URL=postgres://skymoney_app:change-me@postgres:5432/skymoney
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://skymoney_app:change-me@127.0.0.1:5432/postgres
TEST_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney_test
# Auth
JWT_SECRET=replace-with-32+-chars
JWT_ISSUER=skymoney-api
JWT_AUDIENCE=skymoney-web
COOKIE_SECRET=replace-with-32+-chars
# Leave unset in local dev so host-only cookie is used.
# COOKIE_DOMAIN=
EMAIL_VERIFY_DEV_CODE_EXPOSE=true
BREAK_GLASS_VERIFY_ENABLED=true
BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars
UNDER_CONSTRUCTION_ENABLED=false
AUTH_DISABLED=false
ALLOW_INSECURE_AUTH_FOR_DEV=false
SEED_DEFAULT_BUDGET=true

View File

@@ -8,16 +8,30 @@ jobs:
deploy:
runs-on: vps-host
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4.2.2
- name: Supply chain checks (production dependencies)
run: |
set -euo pipefail
cd api
npm ci
npm audit --omit=dev --audit-level=high
cd ../web
npm ci
npm audit --omit=dev --audit-level=high
- name: Build Web
run: |
cd web
npm ci
npm run build
- name: Deploy with Docker Compose
run: |
set -euo pipefail
# Fail fast if sudo requires interactive password in runner context
sudo -n docker ps >/dev/null
# Deploy directory
APP_DIR=/opt/skymoney
mkdir -p $APP_DIR
@@ -29,6 +43,7 @@ jobs:
--exclude=.git \
--exclude=.gitea \
--exclude=backups \
--exclude=forensics \
--exclude=exporting \
./ $APP_DIR/
@@ -38,14 +53,34 @@ jobs:
cd $APP_DIR
# Validate migration target before touching containers
export EXPECTED_PROD_DB_HOST="${EXPECTED_PROD_DB_HOST:-postgres}"
export EXPECTED_PROD_DB_NAME="${EXPECTED_PROD_DB_NAME:-skymoney}"
chmod +x ./scripts/validate-prod-db-target.sh ./scripts/guard-prod-volume.sh ./scripts/backup.sh
bash ./scripts/validate-prod-db-target.sh
PROD_DB_VOLUME_NAME="${PROD_DB_VOLUME_NAME:-skymoney_pgdata}" \
ALLOW_EMPTY_PROD_VOLUME="${ALLOW_EMPTY_PROD_VOLUME:-0}" \
PROD_VOLUME_GUARD_TIMEOUT_SEC="${PROD_VOLUME_GUARD_TIMEOUT_SEC:-20}" \
DOCKER_CMD="sudo -n docker" \
bash ./scripts/guard-prod-volume.sh
# Build and start all services
sudo docker-compose up -d --build
sudo -n docker-compose -p skymoney up -d --build
# Wait for database to be ready
sleep 10
# Mandatory pre-migration backup
export EXPECTED_BACKUP_DB_HOST="${EXPECTED_BACKUP_DB_HOST:-127.0.0.1}"
export EXPECTED_BACKUP_DB_NAME="${EXPECTED_BACKUP_DB_NAME:-skymoney}"
BACKUP_ENFORCE_TARGET_CHECK=1 \
EXPECTED_BACKUP_DB_HOST="$EXPECTED_BACKUP_DB_HOST" \
EXPECTED_BACKUP_DB_NAME="$EXPECTED_BACKUP_DB_NAME" \
BACKUP_DIR=/opt/skymoney/backups \
bash ./scripts/backup.sh
# Run Prisma migrations inside the API container
sudo docker-compose exec -T api npx prisma migrate deploy
sudo -n docker-compose -p skymoney exec -T api npx prisma migrate deploy
- name: Reload Nginx
run: sudo systemctl reload nginx

View File

@@ -0,0 +1,73 @@
name: Security Tests
on:
pull_request:
push:
branches: [main]
jobs:
security-non-db:
runs-on: vps-host
steps:
- uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.2.0
with:
node-version: "20"
cache: "npm"
cache-dependency-path: api/package-lock.json
- name: Install API dependencies
run: |
cd api
npm ci
- name: Run OWASP security suite (non-DB)
run: |
cd api
SECURITY_DB_TESTS=0 npx vitest run -c vitest.security.config.ts
security-db:
if: ${{ secrets.TEST_DATABASE_URL != '' }}
runs-on: vps-host
steps:
- uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.2.0
with:
node-version: "20"
cache: "npm"
cache-dependency-path: api/package-lock.json
- name: Install API dependencies
run: |
cd api
npm ci
- name: Guard TEST_DATABASE_URL target
env:
TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
EXPECTED_PROD_DB_NAME: skymoney
PROTECTED_DB_NAMES: skymoney,postgres,template0,template1
REQUIRE_TEST_DB_NAME: "1"
run: |
chmod +x ./scripts/validate-test-db-target.sh
bash ./scripts/validate-test-db-target.sh
- name: Apply Prisma schema to TEST_DATABASE_URL
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
run: |
cd api
npx prisma migrate deploy
- name: Run OWASP security suite (DB-backed)
env:
TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
PROTECTED_DB_NAMES: skymoney,postgres,template0,template1
REQUIRE_TEST_DB_NAME: "1"
run: |
cd api
SECURITY_DB_TESTS=1 npx vitest run -c vitest.security.config.ts

View File

@@ -1,5 +0,0 @@
{
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore",
"less.lint.unknownAtRules": "ignore"
}

View File

@@ -23,6 +23,8 @@
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
}

View File

@@ -11,6 +11,8 @@ skymoneybudget.com {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
Referrer-Policy "strict-origin-when-cross-origin"
}
# Serve static SPA

View File

@@ -1,121 +0,0 @@
# Timezone-Aware Job Scheduling
## How It Works
### Cron Schedule (Every 15 Minutes)
```
*/15 * * * * → Runs at: 00:00, 00:15, 00:30, 00:45, 01:00, 01:15, ...
```
### Each Run:
1. **Query** all candidate plans (across ALL users, all timezones)
2. **For each plan**, check: "Is it past target hour in THIS user's timezone?"
3. **If yes** → process the plan
4. **If no** → skip until next run (15 min later)
## Real World Example: Rollover at 6 AM Local Time
### Timeline Across Timezones (Dec 17, 2025)
| UTC Time | Los Angeles (UTC-8) | New York (UTC-5) | London (UTC+0) | Tokyo (UTC+9) | Action |
|----------|---------------------|------------------|----------------|---------------|--------|
| 21:00 Dec 16 | 1:00 PM Dec 16 | 4:00 PM Dec 16 | 9:00 PM Dec 16 | **6:00 AM Dec 17** | ✅ Process Tokyo users |
| 00:00 Dec 17 | 4:00 PM Dec 16 | 7:00 PM Dec 16 | 12:00 AM Dec 17 | 9:00 AM Dec 17 | (Tokyo already done) |
| 06:00 Dec 17 | 10:00 PM Dec 16 | 1:00 AM Dec 17 | **6:00 AM Dec 17** | 3:00 PM Dec 17 | ✅ Process London users |
| 11:00 Dec 17 | 3:00 AM Dec 17 | **6:00 AM Dec 17** | 11:00 AM Dec 17 | 8:00 PM Dec 17 | ✅ Process NYC users |
| 14:00 Dec 17 | **6:00 AM Dec 17** | 9:00 AM Dec 17 | 2:00 PM Dec 17 | 11:00 PM Dec 17 | ✅ Process LA users |
### Processing Window
- **With 15-min cron**: Users processed within **0-15 minutes** after their local 6 AM
- **With hourly cron**: Users processed within **0-60 minutes** after their local 6 AM
- **With 5-min cron**: Users processed within **0-5 minutes** after their local 6 AM
## Why This Approach?
### ✅ Advantages
1. **No per-user scheduling** needed - single cron handles all users
2. **Automatic timezone handling** - works for any timezone without config
3. **Scalable** - adding users doesn't increase job complexity
4. **Self-correcting** - if a job misses a run, next run catches it
### ⚠️ Considerations
1. **Small delay** - Users processed within 15 min (not exactly at 6:00 AM)
2. **Query overhead** - Queries all candidate plans every 15 min
3. **Database filtering** - Good indexes on `dueOn` and `nextPaymentDate` are important
### 🔄 Alternative Approach (Not Implemented)
Store each user's next run time as UTC timestamp:
```sql
nextRolloverAt = '2025-12-17T21:00:00Z' -- for Tokyo user's 6 AM
```
Then query: `WHERE nextRolloverAt <= NOW()`
**Trade-offs:**
- ✅ Exact timing - no delay
- ✅ More efficient query - index on single timestamp column
- ❌ More complex - need to update nextRolloverAt after each run
- ❌ DST complications - need to recalculate when timezone rules change
## Configuration
### Environment Variables
```bash
# Rollover: default = every 15 minutes
ROLLOVER_SCHEDULE_CRON="*/15 * * * *"
# Auto-payment: default = every 15 minutes
AUTO_PAYMENT_SCHEDULE_CRON="*/15 * * * *"
# For high-precision (every 5 minutes):
ROLLOVER_SCHEDULE_CRON="*/5 * * * *"
# For lower load (hourly):
ROLLOVER_SCHEDULE_CRON="0 * * * *"
```
### Cron Format
```
* * * * *
│ │ │ │ │
│ │ │ │ └─ Day of week (0-7, both 0 and 7 = Sunday)
│ │ │ └─── Month (1-12)
│ │ └───── Day of month (1-31)
│ └─────── Hour (0-23)
└───────── Minute (0-59)
Examples:
*/15 * * * * → Every 15 minutes
0 * * * * → Every hour at :00
0 6 * * * → Once daily at 6 AM UTC
*/5 * * * * → Every 5 minutes
```
## Testing
### Test Specific Timezone
```bash
# 1. Change user timezone
docker compose exec -T postgres psql -U app -d skymoney -c \
"UPDATE \"User\" SET timezone = 'Asia/Tokyo' WHERE id = 'user-id';"
# 2. Run test script
npx tsx src/scripts/test-timezone-jobs.ts user-id
```
### Simulate Specific UTC Time
```typescript
import { rolloverFixedPlans } from "./src/jobs/rollover.js";
// Simulate running at 21:00 UTC (= 6 AM Tokyo)
await rolloverFixedPlans(prisma, "2025-12-17T21:00:00Z", { dryRun: true });
```
### Test Different Timezones
```bash
# Tokyo (UTC+9) - 6 AM = 21:00 UTC previous day
npx tsx -e "import { rolloverFixedPlans } from './src/jobs/rollover.js'; ..."
# Los Angeles (UTC-8) - 6 AM = 14:00 UTC same day
# London (UTC+0) - 6 AM = 06:00 UTC same day
# New York (UTC-5) - 6 AM = 11:00 UTC same day
```

View File

View File

View File

@@ -1,34 +0,0 @@
const {PrismaClient} = require('@prisma/client');
async function checkAllocations() {
const p = new PrismaClient();
try {
const user = await p.user.findUnique({
where: { email: 'test@skymoney.com' }
});
const income = await p.incomeEvent.findFirst({
where: { userId: user.id },
orderBy: { postedAt: 'desc' },
include: { allocations: true }
});
console.log('\n💵 LATEST INCOME:', Number(income.amountCents)/100);
console.log('\n📊 ALLOCATIONS:');
for (const a of income.allocations) {
if (a.kind === 'fixed') {
const plan = await p.fixedPlan.findUnique({ where: { id: a.toId } });
console.log(' Fixed -', plan.name + ':', Number(a.amountCents)/100);
} else if (a.kind === 'variable') {
const cat = await p.variableCategory.findUnique({ where: { id: a.toId } });
console.log(' Variable -', cat.name + ':', Number(a.amountCents)/100);
}
}
} finally {
await p.$disconnect();
}
}
checkAllocations();

View File

@@ -1,49 +0,0 @@
// Script to check overdue status of test user
const { PrismaClient } = require('@prisma/client');
async function main() {
const prisma = new PrismaClient();
try {
const user = await prisma.user.findUnique({
where: { email: 'test@skymoney.com' }
});
if (!user) {
console.log('❌ Test user not found. Run create-test-user.cjs first.');
return;
}
console.log('✅ Found test user:', user.email);
const plans = await prisma.fixedPlan.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
isOverdue: true,
overdueAmount: true,
overdueSince: true,
},
});
console.log('\n📋 Fixed Plans:');
for (const plan of plans) {
console.log(`\n ${plan.name}:`);
console.log(` Total: $${Number(plan.totalCents) / 100}`);
console.log(` Funded: $${Number(plan.fundedCents) / 100}`);
console.log(` Overdue: ${plan.isOverdue ? 'YES' : 'NO'}`);
if (plan.isOverdue) {
console.log(` Overdue Amount: $${plan.overdueAmount / 100}`);
console.log(` Overdue Since: ${plan.overdueSince}`);
}
}
} finally {
await prisma.$disconnect();
}
}
main();

View File

@@ -1,238 +0,0 @@
/* SkyMoney SDK: zero-dep, Fetch-based, TypeScript-first.
Usage:
import { SkyMoney } from "./sdk";
const api = new SkyMoney({ baseUrl: import.meta.env.VITE_API_URL });
const dash = await api.dashboard.get();
*/
export type TransactionKind = "variable_spend" | "fixed_payment";
export interface OkResponse { ok: true }
export interface ErrorResponse {
ok: false; code: string; message: string; requestId: string;
}
export interface VariableCategory {
id: string;
userId?: string;
name: string;
percent: number; // 0..100
isSavings: boolean;
priority: number;
balanceCents?: number;
}
export interface FixedPlan {
id: string;
userId?: string;
name: string;
totalCents?: number;
fundedCents?: number;
priority: number;
dueOn: string; // ISO
cycleStart?: string;// ISO
}
export interface Transaction {
id: string;
userId?: string;
kind: TransactionKind;
amountCents: number;
occurredAt: string; // ISO
categoryId?: string | null;
planId?: string | null;
}
export interface TransactionList {
items: Transaction[];
page: number;
limit: number;
total: number;
}
export interface DashboardResponse {
totals: {
incomeCents: number;
variableBalanceCents: number;
fixedRemainingCents: number;
};
percentTotal: number;
variableCategories: VariableCategory[];
fixedPlans: FixedPlan[];
recentTransactions: Transaction[];
}
export interface IncomeRequest { amountCents: number; }
export interface AllocationItem {
id: string; name: string; amountCents: number;
}
export interface IncomePreviewResponse {
fixed: AllocationItem[];
variable: AllocationItem[];
unallocatedCents: number;
}
// allocateIncome returns a richer object; tests expect these fields:
export interface IncomeAllocationResponse {
fixedAllocations?: AllocationItem[];
variableAllocations?: AllocationItem[];
remainingUnallocatedCents?: number;
// allow any extra fields without type errors:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[k: string]: any;
}
export type FetchLike = typeof fetch;
export type SDKOptions = {
baseUrl?: string;
fetch?: FetchLike;
requestIdFactory?: () => string; // to set x-request-id if desired
};
function makeQuery(params: Record<string, unknown | undefined>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
sp.set(k, String(v));
}
const s = sp.toString();
return s ? `?${s}` : "";
}
export class SkyMoney {
readonly baseUrl: string;
private readonly f: FetchLike;
private readonly reqId?: () => string;
constructor(opts: SDKOptions = {}) {
this.baseUrl = (opts.baseUrl || "http://localhost:8080").replace(/\/+$/, "");
this.f = opts.fetch || fetch;
this.reqId = opts.requestIdFactory;
}
private async request<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
body?: unknown,
query?: Record<string, unknown>,
headers?: Record<string, string>
): Promise<T> {
const url = `${this.baseUrl}${path}${query ? makeQuery(query) : ""}`;
const h: Record<string, string> = { ...(headers || {}) };
if (this.reqId) h["x-request-id"] = this.reqId();
const hasBody = body !== undefined && body !== null;
const res = await this.f(url, {
method,
credentials: "include",
headers: {
...(hasBody ? { "content-type": "application/json" } : {}),
...h,
},
body: hasBody ? JSON.stringify(body) : undefined,
});
// Attempt to parse JSON; fall back to text
const text = await res.text();
const data = text ? safeJson(text) : undefined;
if (!res.ok) {
const err = new Error((data as any)?.message || `HTTP ${res.status}`);
(err as any).status = res.status;
(err as any).body = data ?? text;
throw err;
}
return data as T;
}
// ---- Health
health = {
get: () => this.request<{ ok: true }>("GET", "/health"),
db: () => this.request<{ ok: true; nowISO: string; latencyMs: number }>("GET", "/health/db"),
};
// ---- Dashboard
dashboard = {
get: () => this.request<DashboardResponse>("GET", "/dashboard"),
};
// ---- Income
income = {
preview: (amountCents: number) =>
this.request<IncomePreviewResponse>("POST", "/income/preview", { amountCents }),
create: (amountCents: number) =>
this.request<IncomeAllocationResponse>("POST", "/income", { amountCents }),
};
// ---- Transactions
transactions = {
list: (args: {
from?: string; // YYYY-MM-DD
to?: string; // YYYY-MM-DD
kind?: TransactionKind;
q?: string;
page?: number;
limit?: number;
}) =>
this.request<TransactionList>("GET", "/transactions", undefined, args),
create: (payload: {
kind: TransactionKind;
amountCents: number;
occurredAtISO: string;
categoryId?: string;
planId?: string;
}) => this.request<Transaction>("POST", "/transactions", payload),
};
// ---- Variable Categories
variableCategories = {
create: (payload: {
name: string;
percent: number;
isSavings: boolean;
priority: number;
}) => this.request<OkResponse>("POST", "/variable-categories", payload),
update: (id: string, patch: Partial<{
name: string;
percent: number;
isSavings: boolean;
priority: number;
}>) => this.request<OkResponse>("PATCH", `/variable-categories/${encodeURIComponent(id)}`, patch),
delete: (id: string) =>
this.request<OkResponse>("DELETE", `/variable-categories/${encodeURIComponent(id)}`),
};
// ---- Fixed Plans
fixedPlans = {
create: (payload: {
name: string;
totalCents: number;
fundedCents?: number;
priority: number;
dueOn: string; // ISO
cycleStart?: string; // ISO
}) => this.request<OkResponse>("POST", "/fixed-plans", payload),
update: (id: string, patch: Partial<{
name: string;
totalCents: number;
fundedCents: number;
priority: number;
dueOn: string;
cycleStart: string;
}>) => this.request<OkResponse>("PATCH", `/fixed-plans/${encodeURIComponent(id)}`, patch),
delete: (id: string) =>
this.request<OkResponse>("DELETE", `/fixed-plans/${encodeURIComponent(id)}`),
};
}
// ---------- helpers ----------
function safeJson(s: string) {
try { return JSON.parse(s) } catch { return s }
}

View File

@@ -1,135 +0,0 @@
const argon2 = require('argon2');
const { PrismaClient } = require('@prisma/client');
async function createTestUser() {
const prisma = new PrismaClient({
datasourceUrl: 'postgres://app:app@localhost:5432/skymoney'
});
try {
// Delete existing test user if exists
await prisma.user.deleteMany({
where: { email: 'test@skymoney.com' }
});
console.log('✓ Cleaned up old test user');
// Create user
const hash = await argon2.hash('password123');
const user = await prisma.user.create({
data: {
email: 'test@skymoney.com',
passwordHash: hash,
displayName: 'Test User',
timezone: 'America/New_York'
}
});
console.log('✓ Created user:', user.id);
// Create categories (must total 100%)
await prisma.variableCategory.create({
data: {
userId: user.id,
name: 'Groceries',
percent: 50,
balanceCents: 150000n // $1500
}
});
await prisma.variableCategory.create({
data: {
userId: user.id,
name: 'Other',
percent: 50,
balanceCents: 150000n // $1500
}
});
console.log('✓ Created categories (100% total)');
const today = new Date();
today.setHours(0, 0, 0, 0);
// Create 3 overdue bills with different overdue dates (oldest first priority)
// 1. RENT - Overdue 5 days ago (OLDEST = HIGHEST PRIORITY)
const rentOverdue = new Date(today);
rentOverdue.setDate(rentOverdue.getDate() - 5);
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Rent',
totalCents: 150000n, // $1500 total
fundedCents: 100000n, // $1000 funded
currentFundedCents: 100000n,
dueOn: rentOverdue,
cycleStart: rentOverdue,
frequency: 'monthly',
needsFundingThisPeriod: true,
isOverdue: true,
overdueAmount: 50000n, // $500 overdue
overdueSince: rentOverdue
}
});
console.log('✓ Rent: $1500 total, $500 overdue (5 days ago - OLDEST)');
// 2. UTILITIES - Overdue 3 days ago (SECOND PRIORITY)
const utilOverdue = new Date(today);
utilOverdue.setDate(utilOverdue.getDate() - 3);
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Utilities',
totalCents: 20000n, // $200 total
fundedCents: 10000n, // $100 funded
currentFundedCents: 10000n,
dueOn: utilOverdue,
cycleStart: utilOverdue,
frequency: 'monthly',
needsFundingThisPeriod: true,
isOverdue: true,
overdueAmount: 10000n, // $100 overdue
overdueSince: utilOverdue
}
});
console.log('✓ Utilities: $200 total, $100 overdue (3 days ago)');
// 3. PHONE - Overdue 1 day ago (NEWEST = LOWEST PRIORITY)
const phoneOverdue = new Date(today);
phoneOverdue.setDate(phoneOverdue.getDate() - 1);
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Phone',
totalCents: 10000n, // $100 total
fundedCents: 5000n, // $50 funded
currentFundedCents: 5000n,
dueOn: phoneOverdue,
cycleStart: phoneOverdue,
frequency: 'monthly',
needsFundingThisPeriod: true,
isOverdue: true,
overdueAmount: 5000n, // $50 overdue
overdueSince: phoneOverdue
}
});
console.log('✓ Phone: $100 total, $50 overdue (1 day ago - NEWEST)');
console.log('\n✅ Multi-overdue test user ready!');
console.log(' Email: test@skymoney.com');
console.log(' Password: password123');
console.log('\n OVERDUE BILLS (priority order):');
console.log(' 1. Rent: $500 (5 days overdue)');
console.log(' 2. Utilities: $100 (3 days overdue)');
console.log(' 3. Phone: $50 (1 day overdue)');
console.log(' TOTAL OVERDUE: $650');
console.log('\n Test scenarios:');
console.log(' - Post $500 income → Should pay Rent only');
console.log(' - Post $600 income → Should pay Rent ($500) + Utilities ($100)');
console.log(' - Post $700 income → Should pay all 3 overdue bills');
} catch (error) {
console.error('❌ Error:', error.message);
} finally {
await prisma.$disconnect();
}
}
createTestUser();

View File

@@ -1,133 +0,0 @@
// Create test user with MULTIPLE overdue bills
const argon2 = require('argon2');
const { PrismaClient } = require('@prisma/client');
async function main() {
const prisma = new PrismaClient({
datasourceUrl: 'postgres://app:app@localhost:5432/skymoney'
});
try {
const email = 'test@skymoney.com';
const password = 'password123';
// Clean up existing user
await prisma.user.deleteMany({ where: { email } });
console.log('✓ Cleaned up old test user');
// Create user
const passwordHash = await argon2.hash(password);
const user = await prisma.user.create({
data: {
email,
passwordHash,
displayName: 'Test User',
incomeFrequency: 'biweekly',
totalBudgetCents: BigInt(300000), // $3000
timezone: 'America/New_York',
},
});
console.log('✓ Created user:', user.id);
// Create income source
await prisma.incomeEvent.create({
data: {
id: '00000000-0000-0000-0000-000000000001',
userId: user.id,
postedAt: new Date(),
amountCents: BigInt(300000),
note: 'Initial budget',
},
});
console.log('✓ Created income: $3000');
// Create categories
await prisma.variableCategory.createMany({
data: [
{ userId: user.id, name: 'Groceries', percent: 50, priority: 1, balanceCents: BigInt(150000) },
{ userId: user.id, name: 'Other', percent: 50, priority: 2, balanceCents: BigInt(150000) },
],
});
console.log('✓ Created categories (100% total)');
const today = new Date();
today.setHours(6, 0, 0, 0); // 6am today
const threeDaysAgo = new Date(today);
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
const oneWeekAgo = new Date(today);
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
// Create THREE overdue bills with different dates
// 1. Rent - $1500, $1000 funded, $500 overdue (oldest - 7 days ago)
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Rent',
cycleStart: oneWeekAgo,
dueOn: today,
totalCents: BigInt(150000), // $1500
fundedCents: BigInt(100000), // $1000 funded
currentFundedCents: BigInt(100000),
priority: 1,
isOverdue: true,
overdueAmount: BigInt(50000), // $500 overdue
overdueSince: oneWeekAgo,
},
});
// 2. Utilities - $200, $100 funded, $100 overdue (3 days ago)
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Utilities',
cycleStart: threeDaysAgo,
dueOn: today,
totalCents: BigInt(20000), // $200
fundedCents: BigInt(10000), // $100 funded
currentFundedCents: BigInt(10000),
priority: 2,
isOverdue: true,
overdueAmount: BigInt(10000), // $100 overdue
overdueSince: threeDaysAgo,
},
});
// 3. Phone - $100, $50 funded, $50 overdue (today)
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Phone',
cycleStart: today,
dueOn: today,
totalCents: BigInt(10000), // $100
fundedCents: BigInt(5000), // $50 funded
currentFundedCents: BigInt(5000),
priority: 3,
isOverdue: true,
overdueAmount: BigInt(5000), // $50 overdue
overdueSince: today,
},
});
console.log('✓ Created 3 overdue plans:');
console.log(' - Rent: $1500 total, $1000 funded, $500 overdue (7 days ago)');
console.log(' - Utilities: $200 total, $100 funded, $100 overdue (3 days ago)');
console.log(' - Phone: $100 total, $50 funded, $50 overdue (today)');
console.log('\n✅ Test user ready!');
console.log(' Email: test@skymoney.com');
console.log(' Password: password123');
console.log(' Total overdue: $650');
console.log('\n💡 Post $1000 income to see priority order:');
console.log(' 1st: Rent $500 (oldest)');
console.log(' 2nd: Utilities $100');
console.log(' 3rd: Phone $50');
console.log(' Remaining $350 → normal allocation');
} finally {
await prisma.$disconnect();
}
}
main();

View File

@@ -1,91 +0,0 @@
const argon2 = require('argon2');
const { PrismaClient } = require('@prisma/client');
async function createTestUser() {
const prisma = new PrismaClient({
datasourceUrl: 'postgres://app:app@localhost:5432/skymoney'
});
try {
// Delete existing test user if exists
await prisma.user.deleteMany({
where: { email: 'test@skymoney.com' }
});
console.log('✓ Cleaned up old test user');
// Create user
const hash = await argon2.hash('password123');
const user = await prisma.user.create({
data: {
email: 'test@skymoney.com',
passwordHash: hash,
displayName: 'Test User',
timezone: 'America/New_York'
}
});
console.log('✓ Created user:', user.id);
// Create income
await prisma.incomeEvent.create({
data: {
userId: user.id,
amountCents: 300000n, // $3000
postedAt: new Date(),
isScheduledIncome: true
}
});
console.log('✓ Created income: $3000');
// Create categories (must total 100%)
await prisma.variableCategory.create({
data: {
userId: user.id,
name: 'Groceries',
percent: 50,
balanceCents: 150000n // $1500
}
});
await prisma.variableCategory.create({
data: {
userId: user.id,
name: 'Other',
percent: 50,
balanceCents: 0n
}
});
console.log('✓ Created categories (100% total)');
// Create rent bill due today - PARTIALLY FUNDED & OVERDUE
const today = new Date();
today.setHours(0, 0, 0, 0);
await prisma.fixedPlan.create({
data: {
userId: user.id,
name: 'Rent',
totalCents: 150000n, // $1500 total
fundedCents: 100000n, // $1000 funded (partial)
currentFundedCents: 100000n, // $1000 available
dueOn: today,
cycleStart: today,
frequency: 'monthly',
needsFundingThisPeriod: true,
isOverdue: true, // Marked overdue
overdueAmount: 50000n, // $500 outstanding
overdueSince: new Date()
}
});
console.log('✓ Created Rent plan: $1500 total, $1000 funded, $500 overdue');
console.log('\n✅ Test user ready!');
console.log(' Email: test@skymoney.com');
console.log(' Password: password123');
console.log(' Rent: $1500 due today (partially funded, overdue)');
} catch (error) {
console.error('❌ Error:', error.message);
} finally {
await prisma.$disconnect();
}
}
createTestUser();

View File

View File

View File

@@ -1,552 +0,0 @@
openapi: 3.0.3
info:
title: SkyMoney API
version: 0.1.0
description: |
Fastify backend for budgeting/allocations.
Authentication uses secure httpOnly session cookies (Fastify JWT). During tests
or local development you can set `AUTH_DISABLED=1` to use the legacy `x-user-id`
header for impersonation, but production relies on the session cookie.
servers:
- url: http://localhost:8080
tags:
- name: Health
- name: Dashboard
- name: Income
- name: Transactions
- name: VariableCategories
- name: FixedPlans
paths:
/health:
get:
tags: [Health]
summary: Liveness check
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/HealthOk' }
/health/db:
get:
tags: [Health]
summary: DB health + latency
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: DB OK
content:
application/json:
schema: { $ref: '#/components/schemas/DbHealth' }
/dashboard:
get:
tags: [Dashboard]
summary: Aggregated dashboard data
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
responses:
'200':
description: Dashboard payload
content:
application/json:
schema: { $ref: '#/components/schemas/DashboardResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/income:
post:
tags: [Income]
summary: Create income event and allocate funds
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/IncomeRequest' }
responses:
'200':
description: Allocation result
content:
application/json:
schema: { $ref: '#/components/schemas/IncomeAllocationResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/income/preview:
post:
tags: [Income]
summary: Preview allocation of a hypothetical income amount
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/IncomeRequest' }
responses:
'200':
description: Preview
content:
application/json:
schema: { $ref: '#/components/schemas/IncomePreviewResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/transactions:
get:
tags: [Transactions]
summary: List transactions with filters and pagination
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: query
name: from
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
description: Inclusive start date (YYYY-MM-DD)
- in: query
name: to
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
description: Inclusive end date (YYYY-MM-DD)
- in: query
name: kind
schema: { $ref: '#/components/schemas/TransactionKind' }
- in: query
name: q
schema: { type: string }
description: Simple search (currently numeric amount match)
- in: query
name: page
schema: { type: integer, minimum: 1, default: 1 }
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
'200':
description: List
content:
application/json:
schema: { $ref: '#/components/schemas/TransactionList' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
post:
tags: [Transactions]
summary: Create a transaction
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/TransactionCreate' }
responses:
'200':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/Transaction' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/variable-categories:
post:
tags: [VariableCategories]
summary: Create a variable category (sum of percents must be 100)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/VariableCategoryCreate' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/variable-categories/{id}:
patch:
tags: [VariableCategories]
summary: Update a variable category (sum of percents must be 100)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/VariableCategoryPatch' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
delete:
tags: [VariableCategories]
summary: Delete a variable category (sum of percents must remain 100)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/fixed-plans:
post:
tags: [FixedPlans]
summary: Create a fixed plan
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/FixedPlanCreate' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalError'
/fixed-plans/{id}:
patch:
tags: [FixedPlans]
summary: Update a fixed plan (fundedCents cannot exceed totalCents)
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/FixedPlanPatch' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
delete:
tags: [FixedPlans]
summary: Delete a fixed plan
parameters:
- $ref: '#/components/parameters/UserId'
- $ref: '#/components/parameters/RequestId'
- in: path
name: id
required: true
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/OkResponse' }
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
components:
parameters:
UserId:
in: header
name: x-user-id
required: false
description: |
Dev/test-only tenant selector when AUTH_DISABLED=1. Production requests rely
on the session cookie instead and should omit this header.
schema: { type: string }
description: Override the stubbed user id for the request.
RequestId:
in: header
name: x-request-id
required: false
schema: { type: string, maxLength: 64 }
description: Custom request id (echoed back by server).
responses:
BadRequest:
description: Validation or guard failed
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
NotFound:
description: Resource not found
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
InternalError:
description: Unexpected server error
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
schemas:
HealthOk:
type: object
properties:
ok: { type: boolean, const: true }
required: [ok]
DbHealth:
type: object
properties:
ok: { type: boolean, const: true }
nowISO: { type: string, format: date-time }
latencyMs: { type: integer, minimum: 0 }
required: [ok, nowISO, latencyMs]
OkResponse:
type: object
properties:
ok: { type: boolean, const: true }
required: [ok]
ErrorResponse:
type: object
properties:
ok: { type: boolean, const: false }
code: { type: string }
message: { type: string }
requestId: { type: string }
required: [ok, code, message, requestId]
VariableCategory:
type: object
properties:
id: { type: string }
userId: { type: string }
name: { type: string }
percent: { type: integer, minimum: 0, maximum: 100 }
isSavings: { type: boolean }
priority: { type: integer, minimum: 0 }
balanceCents:
type: integer
description: Current balance; may be omitted or 0 when not loaded.
required: [id, userId, name, percent, isSavings, priority]
FixedPlan:
type: object
properties:
id: { type: string }
userId: { type: string }
name: { type: string }
totalCents: { type: integer, minimum: 0 }
fundedCents: { type: integer, minimum: 0 }
priority: { type: integer, minimum: 0 }
dueOn: { type: string, format: date-time }
cycleStart: { type: string, format: date-time }
required: [id, userId, name, priority, dueOn]
TransactionKind:
type: string
enum: [variable_spend, fixed_payment]
Transaction:
type: object
properties:
id: { type: string }
userId: { type: string }
kind: { $ref: '#/components/schemas/TransactionKind' }
amountCents: { type: integer, minimum: 0 }
occurredAt: { type: string, format: date-time }
categoryId: { type: string, nullable: true }
planId: { type: string, nullable: true }
required: [id, userId, kind, amountCents, occurredAt]
TransactionList:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Transaction' }
page: { type: integer, minimum: 1 }
limit: { type: integer, minimum: 1, maximum: 100 }
total: { type: integer, minimum: 0 }
required: [items, page, limit, total]
TransactionCreate:
type: object
properties:
kind: { $ref: '#/components/schemas/TransactionKind' }
amountCents: { type: integer, minimum: 1 }
occurredAtISO: { type: string, format: date-time }
categoryId: { type: string, nullable: true }
planId: { type: string, nullable: true }
required: [kind, amountCents, occurredAtISO]
DashboardResponse:
type: object
properties:
totals:
type: object
properties:
incomeCents: { type: integer, minimum: 0 }
variableBalanceCents: { type: integer, minimum: 0 }
fixedRemainingCents: { type: integer, minimum: 0 }
required: [incomeCents, variableBalanceCents, fixedRemainingCents]
percentTotal: { type: integer, minimum: 0, maximum: 100 }
variableCategories:
type: array
items: { $ref: '#/components/schemas/VariableCategory' }
fixedPlans:
type: array
items: { $ref: '#/components/schemas/FixedPlan' }
recentTransactions:
type: array
items: { $ref: '#/components/schemas/Transaction' }
required: [totals, percentTotal, variableCategories, fixedPlans, recentTransactions]
IncomeRequest:
type: object
properties:
amountCents: { type: integer, minimum: 0 }
required: [amountCents]
AllocationItem:
type: object
properties:
id: { type: string }
name: { type: string }
amountCents: { type: integer, minimum: 0 }
required: [id, name, amountCents]
IncomePreviewResponse:
type: object
properties:
fixed:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
variable:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
unallocatedCents: { type: integer, minimum: 0 }
required: [fixed, variable, unallocatedCents]
IncomeAllocationResponse:
type: object
description: >
Shape returned by allocateIncome. Tests expect:
fixedAllocations, variableAllocations, remainingUnallocatedCents.
Additional fields may be present.
properties:
fixedAllocations:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
variableAllocations:
type: array
items: { $ref: '#/components/schemas/AllocationItem' }
remainingUnallocatedCents: { type: integer, minimum: 0 }
additionalProperties: true
VariableCategoryCreate:
type: object
properties:
name: { type: string }
percent: { type: integer, minimum: 0, maximum: 100 }
isSavings: { type: boolean }
priority: { type: integer, minimum: 0 }
required: [name, percent, isSavings, priority]
VariableCategoryPatch:
type: object
properties:
name: { type: string }
percent: { type: integer, minimum: 0, maximum: 100 }
isSavings: { type: boolean }
priority: { type: integer, minimum: 0 }
additionalProperties: false
FixedPlanCreate:
type: object
properties:
name: { type: string }
totalCents: { type: integer, minimum: 0 }
fundedCents: { type: integer, minimum: 0 }
priority: { type: integer, minimum: 0 }
dueOn: { type: string, format: date-time }
cycleStart: { type: string, format: date-time }
required: [name, totalCents, priority, dueOn]
FixedPlanPatch:
type: object
properties:
name: { type: string }
totalCents: { type: integer, minimum: 0 }
fundedCents: { type: integer, minimum: 0 }
priority: { type: integer, minimum: 0 }
dueOn: { type: string, format: date-time }
cycleStart: { type: string, format: date-time }
additionalProperties: false

245
api/package-lock.json generated
View File

@@ -791,9 +791,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz",
"integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -805,9 +805,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz",
"integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -819,9 +819,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz",
"integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -833,9 +833,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz",
"integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -847,9 +847,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz",
"integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -861,9 +861,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz",
"integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -875,9 +875,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz",
"integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -889,9 +889,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz",
"integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -903,9 +903,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz",
"integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -917,9 +917,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz",
"integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -931,9 +931,23 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz",
"integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -945,9 +959,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz",
"integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -959,9 +987,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz",
"integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -973,9 +1001,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz",
"integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -987,9 +1015,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz",
"integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -1001,9 +1029,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz",
"integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -1015,9 +1043,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz",
"integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -1028,10 +1056,24 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz",
"integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -1043,9 +1085,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz",
"integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -1057,9 +1099,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz",
"integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -1071,9 +1113,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz",
"integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -1085,9 +1127,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz",
"integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -1283,9 +1325,9 @@
"license": "MIT"
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -1386,9 +1428,9 @@
}
},
"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==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/cac": {
@@ -2464,9 +2506,9 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -2539,9 +2581,9 @@
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz",
"integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2555,28 +2597,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.2",
"@rollup/rollup-android-arm64": "4.53.2",
"@rollup/rollup-darwin-arm64": "4.53.2",
"@rollup/rollup-darwin-x64": "4.53.2",
"@rollup/rollup-freebsd-arm64": "4.53.2",
"@rollup/rollup-freebsd-x64": "4.53.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.2",
"@rollup/rollup-linux-arm-musleabihf": "4.53.2",
"@rollup/rollup-linux-arm64-gnu": "4.53.2",
"@rollup/rollup-linux-arm64-musl": "4.53.2",
"@rollup/rollup-linux-loong64-gnu": "4.53.2",
"@rollup/rollup-linux-ppc64-gnu": "4.53.2",
"@rollup/rollup-linux-riscv64-gnu": "4.53.2",
"@rollup/rollup-linux-riscv64-musl": "4.53.2",
"@rollup/rollup-linux-s390x-gnu": "4.53.2",
"@rollup/rollup-linux-x64-gnu": "4.53.2",
"@rollup/rollup-linux-x64-musl": "4.53.2",
"@rollup/rollup-openharmony-arm64": "4.53.2",
"@rollup/rollup-win32-arm64-msvc": "4.53.2",
"@rollup/rollup-win32-ia32-msvc": "4.53.2",
"@rollup/rollup-win32-x64-gnu": "4.53.2",
"@rollup/rollup-win32-x64-msvc": "4.53.2",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},

View File

@@ -12,7 +12,8 @@
"test": "vitest --run",
"test:watch": "vitest",
"rollover": "tsx src/scripts/run-rollover.ts",
"plan:manage": "tsx src/scripts/manage-plan.ts"
"plan:manage": "tsx src/scripts/manage-plan.ts",
"verify:break-glass": "tsx src/scripts/verify-break-glass.ts"
},
"prisma": {
"seed": "tsx --tsconfig prisma/tsconfig.seed.json prisma/seed.ts"

View File

2173
api/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "passwordChangedAt" TIMESTAMP(3);

View File

@@ -0,0 +1,6 @@
ALTER TABLE "FixedPlan"
ADD COLUMN IF NOT EXISTS "amountMode" TEXT NOT NULL DEFAULT 'fixed',
ADD COLUMN IF NOT EXISTS "estimatedCents" BIGINT,
ADD COLUMN IF NOT EXISTS "actualCents" BIGINT,
ADD COLUMN IF NOT EXISTS "actualCycleDueOn" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "actualRecordedAt" TIMESTAMP(3);

View File

@@ -1,7 +1,7 @@
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
binaryTargets = ["native", "debian-openssl-3.0.x", "windows"]
}
datasource db {
@@ -24,6 +24,7 @@ model User {
id String @id @default(uuid())
email String @unique
passwordHash String?
passwordChangedAt DateTime?
displayName String?
emailVerified Boolean @default(false)
seenUpdateVersion Int @default(0)
@@ -69,6 +70,11 @@ model FixedPlan {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
amountMode String @default("fixed") // "fixed" | "estimated"
estimatedCents BigInt?
actualCents BigInt?
actualCycleDueOn DateTime?
actualRecordedAt DateTime?
cycleStart DateTime
dueOn DateTime
totalCents BigInt

View File

@@ -297,6 +297,7 @@ async function getInputs(
currentFundedCents: true,
dueOn: true,
priority: true,
fundingMode: true,
needsFundingThisPeriod: true,
paymentSchedule: true,
autoPayEnabled: true,
@@ -328,7 +329,8 @@ export function buildPlanStates(
userIncomeType?: string,
isScheduledIncome?: boolean
): PlanState[] {
const timezone = config.timezone;
const timezone = config.timezone ?? "UTC";
const firstIncomeDate = config.firstIncomeDate ?? null;
const freqDays = frequencyDays[config.incomeFrequency];
// Only handle regular income frequencies
@@ -342,7 +344,8 @@ export function buildPlanStates(
const remainingCents = Math.max(0, total - funded);
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
const autoFundEnabled = !!p.autoPayEnabled;
const autoFundEnabled =
!p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual";
// Calculate preliminary crisis status to determine if we should override funding restrictions
// Use timezone-aware date comparison
@@ -357,14 +360,14 @@ export function buildPlanStates(
let isPrelimCrisis = false;
let dueBeforeNextPayday = false;
let daysUntilPayday = 0;
if (isPaymentPlanUser && config.firstIncomeDate) {
const nextPayday = calculateNextPayday(config.firstIncomeDate, config.incomeFrequency, now, timezone);
if (isPaymentPlanUser && firstIncomeDate) {
const nextPayday = calculateNextPayday(firstIncomeDate, config.incomeFrequency, now, timezone);
const normalizedNextPayday = getUserMidnight(timezone, nextPayday);
daysUntilPayday = Math.max(0, Math.ceil((normalizedNextPayday.getTime() - userNow.getTime()) / DAY_MS));
dueBeforeNextPayday = userDueDate.getTime() < normalizedNextPayday.getTime();
}
if (remainingCents >= CRISIS_MINIMUM_CENTS) {
if (isPaymentPlanUser && config.firstIncomeDate) {
if (isPaymentPlanUser && firstIncomeDate) {
isPrelimCrisis = daysUntilDuePrelim < daysUntilPayday && fundedPercent < 90;
} else {
isPrelimCrisis = fundedPercent < 70 && daysUntilDuePrelim <= 14;
@@ -430,10 +433,10 @@ export function buildPlanStates(
// Calculate payment periods more accurately using firstIncomeDate
let cyclesLeft: number;
if (config.firstIncomeDate) {
if (firstIncomeDate) {
// Count actual pay dates between now and due date based on the recurring pattern
// established by firstIncomeDate (pass timezone for correct date handling)
cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, config.firstIncomeDate, config.incomeFrequency, timezone);
cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, firstIncomeDate, config.incomeFrequency, timezone);
} else {
// Fallback to old calculation if firstIncomeDate not set
cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays));
@@ -1377,7 +1380,9 @@ function computeBudgetAllocation(
const availableBudget = inputs.availableBefore;
const totalPool = availableBudget + newIncome;
const eligiblePlans = inputs.plans.filter((plan) => plan.autoPayEnabled);
const eligiblePlans = inputs.plans.filter(
(plan) => !plan.fundingMode || String(plan.fundingMode).toLowerCase() !== "manual"
);
const planStates = buildBudgetPlanStates(eligiblePlans, now, inputs.config.timezone);
// Calculate total remaining needed across all fixed plans
@@ -1505,7 +1510,8 @@ function buildBudgetPlanStates(
const userDueDate = getUserMidnightFromDateOnly(timezone, p.dueOn);
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS));
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
const autoFundEnabled = !!p.autoPayEnabled;
const autoFundEnabled =
!p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual";
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
// For irregular income, crisis mode triggers earlier (14 days)

View File

@@ -8,6 +8,33 @@ const BoolFromEnv = z
return normalized === "true" || normalized === "1";
});
function isLoopbackOrPrivateHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
if (!normalized) return true;
if (
normalized === "localhost" ||
normalized === "::1" ||
normalized === "0.0.0.0" ||
normalized.endsWith(".local")
) {
return true;
}
if (normalized.startsWith("127.")) return true;
if (normalized.startsWith("10.")) return true;
if (normalized.startsWith("192.168.")) return true;
if (normalized.startsWith("169.254.")) return true;
const parts = normalized.split(".");
if (parts.length === 4 && parts.every((part) => /^\d+$/.test(part))) {
const octets = parts.map((part) => Number(part));
if (octets.some((n) => n < 0 || n > 255)) return true;
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true;
return false;
}
return false;
}
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(8080),
@@ -18,11 +45,23 @@ const Env = z.object({
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),
JWT_ISSUER: z.string().min(1).default("skymoney-api"),
JWT_AUDIENCE: z.string().min(1).default("skymoney-web"),
COOKIE_SECRET: z.string().min(32),
COOKIE_DOMAIN: z.string().optional(),
EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false),
BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false),
BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(),
UNDER_CONSTRUCTION_ENABLED: BoolFromEnv.default(false),
AUTH_DISABLED: BoolFromEnv.optional().default(false),
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5),
AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000),
PASSWORD_RESET_TTL_MINUTES: z.coerce.number().int().positive().default(30),
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(5),
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(10),
APP_ORIGIN: z.string().min(1).default("http://localhost:5173"),
UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0),
UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"),
@@ -51,11 +90,23 @@ const rawEnv = {
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",
JWT_ISSUER: process.env.JWT_ISSUER,
JWT_AUDIENCE: process.env.JWT_AUDIENCE,
COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE,
BREAK_GLASS_VERIFY_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED,
BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE,
UNDER_CONSTRUCTION_ENABLED: process.env.UNDER_CONSTRUCTION_ENABLED,
AUTH_DISABLED: process.env.AUTH_DISABLED,
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES,
AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS,
AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS,
PASSWORD_RESET_TTL_MINUTES: process.env.PASSWORD_RESET_TTL_MINUTES,
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE,
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE,
APP_ORIGIN: process.env.APP_ORIGIN,
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
@@ -92,6 +143,32 @@ if (parsed.NODE_ENV === "production") {
if (!parsed.APP_ORIGIN) {
throw new Error("APP_ORIGIN must be set in production.");
}
if (!parsed.APP_ORIGIN.startsWith("https://")) {
throw new Error("APP_ORIGIN must use https:// in production.");
}
let appOriginUrl: URL;
try {
appOriginUrl = new URL(parsed.APP_ORIGIN);
} catch {
throw new Error("APP_ORIGIN must be a valid URL in production.");
}
if (isLoopbackOrPrivateHostname(appOriginUrl.hostname)) {
throw new Error(
"APP_ORIGIN must not point to localhost or private network hosts in production."
);
}
}
if (parsed.AUTH_DISABLED && parsed.NODE_ENV !== "test" && !parsed.ALLOW_INSECURE_AUTH_FOR_DEV) {
throw new Error(
"AUTH_DISABLED requires ALLOW_INSECURE_AUTH_FOR_DEV=true outside test environments."
);
}
if (parsed.BREAK_GLASS_VERIFY_ENABLED && !parsed.BREAK_GLASS_VERIFY_CODE) {
throw new Error(
"BREAK_GLASS_VERIFY_ENABLED=true requires BREAK_GLASS_VERIFY_CODE (32+ chars)."
);
}
export const env = parsed;

View File

@@ -196,64 +196,64 @@ export function calculateNextPaymentDate(
timezone: string
): Date {
const next = toZonedTime(currentDate, timezone);
const hours = next.getUTCHours();
const minutes = next.getUTCMinutes();
const seconds = next.getUTCSeconds();
const ms = next.getUTCMilliseconds();
const hours = next.getHours();
const minutes = next.getMinutes();
const seconds = next.getSeconds();
const ms = next.getMilliseconds();
switch (schedule.frequency) {
case "daily":
next.setUTCDate(next.getUTCDate() + 1);
next.setDate(next.getDate() + 1);
break;
case "weekly":
// Move to next occurrence of specified day of week
{
const targetDay = schedule.dayOfWeek ?? 0;
const currentDay = next.getUTCDay();
const currentDay = next.getDay();
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7));
next.setDate(next.getDate() + (daysUntilTarget || 7));
}
break;
case "biweekly":
{
const targetDay = schedule.dayOfWeek ?? next.getUTCDay();
const currentDay = next.getUTCDay();
const targetDay = schedule.dayOfWeek ?? next.getDay();
const currentDay = next.getDay();
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
// ensure at least one full week gap to make it biweekly
daysUntilTarget = daysUntilTarget === 0 ? 14 : daysUntilTarget + 7;
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
next.setDate(next.getDate() + daysUntilTarget);
}
break;
case "monthly":
{
const targetDay = schedule.dayOfMonth ?? next.getUTCDate();
const targetDay = schedule.dayOfMonth ?? next.getDate();
// Avoid month overflow (e.g., Jan 31 -> Feb) by resetting to day 1 before adding months.
next.setUTCDate(1);
next.setUTCMonth(next.getUTCMonth() + 1);
next.setDate(1);
next.setMonth(next.getMonth() + 1);
const lastDay = getLastDayOfMonth(next);
next.setUTCDate(Math.min(targetDay, lastDay));
next.setDate(Math.min(targetDay, lastDay));
}
break;
case "custom":
{
const days = schedule.everyNDays && schedule.everyNDays > 0 ? schedule.everyNDays : periodDays;
next.setUTCDate(next.getUTCDate() + days);
next.setDate(next.getDate() + days);
}
break;
default:
// Fallback to periodDays
next.setUTCDate(next.getUTCDate() + periodDays);
next.setDate(next.getDate() + periodDays);
}
next.setUTCHours(hours, minutes, seconds, ms);
next.setHours(hours, minutes, seconds, ms);
return fromZonedTime(next, timezone);
}
function getLastDayOfMonth(date: Date): number {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
}

View File

@@ -4,8 +4,10 @@ import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js";
const addDaysInTimezone = (date: Date, days: number, timezone: string) => {
const zoned = toZonedTime(date, timezone);
zoned.setUTCDate(zoned.getUTCDate() + days);
zoned.setUTCHours(0, 0, 0, 0);
// Advance by calendar days in the user's local timezone, then normalize
// to local midnight before converting back to UTC for storage.
zoned.setDate(zoned.getDate() + days);
zoned.setHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
};

33
api/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { rolloverFixedPlans } from "../jobs/rollover.js";
type AdminRoutesOptions = {
authDisabled: boolean;
isInternalClientIp: (ip: string) => boolean;
};
const adminRoutes: FastifyPluginAsync<AdminRoutesOptions> = async (app, opts) => {
app.post("/admin/rollover", async (req, reply) => {
if (!opts.authDisabled) {
return reply.code(403).send({ ok: false, message: "Forbidden" });
}
if (!opts.isInternalClientIp(req.ip || "")) {
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 };
});
};
export default adminRoutes;

View File

@@ -0,0 +1,711 @@
import type { FastifyPluginAsync } from "fastify";
import argon2 from "argon2";
import { z } from "zod";
import type { AppConfig } from "../server.js";
type EmailTokenType = "signup" | "delete" | "password_reset";
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
};
};
type AuthAccountRoutesOptions = {
config: Pick<
AppConfig,
| "APP_ORIGIN"
| "NODE_ENV"
| "SEED_DEFAULT_BUDGET"
| "SESSION_TIMEOUT_MINUTES"
| "PASSWORD_RESET_TTL_MINUTES"
>;
cookieDomain?: string;
exposeDevVerificationCode: boolean;
authRateLimit: RateLimitRouteOptions;
codeVerificationRateLimit: RateLimitRouteOptions;
codeIssueRateLimit: RateLimitRouteOptions;
passwordResetRequestRateLimit: RateLimitRouteOptions;
passwordResetConfirmRateLimit: RateLimitRouteOptions;
emailTokenTtlMs: number;
deleteTokenTtlMs: number;
emailTokenCooldownMs: number;
normalizeEmail: (email: string) => string;
fingerprintEmail: (email: string) => string;
logSecurityEvent: (
req: any,
event: string,
outcome: "success" | "failure" | "blocked",
details?: Record<string, unknown>
) => void;
getLoginLockout: (email: string) => { locked: false } | { locked: true; retryAfterSeconds: number };
registerFailedLoginAttempt: (email: string) => { locked: false } | { locked: true; retryAfterSeconds: number };
clearFailedLoginAttempts: (email: string) => void;
seedDefaultBudget: (prisma: any, userId: string) => Promise<void>;
clearEmailTokens: (userId: string, type?: EmailTokenType) => Promise<void>;
issueEmailToken: (
userId: string,
type: EmailTokenType,
ttlMs: number,
token?: string
) => Promise<{ code: string; expiresAt: Date }>;
assertEmailTokenCooldown: (
userId: string,
type: EmailTokenType,
cooldownMs: number
) => Promise<void>;
sendEmail: (payload: { to: string; subject: string; text: string; html?: string }) => Promise<void>;
hashToken: (token: string) => string;
generatePasswordResetToken: () => string;
ensureCsrfCookie: (reply: any, existing?: string) => string;
};
const PASSWORD_MIN_LENGTH = 12;
const passwordSchema = z
.string()
.min(PASSWORD_MIN_LENGTH)
.max(128)
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a symbol");
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id,
memoryCost: 19_456,
timeCost: 3,
parallelism: 1,
};
const RegisterBody = z.object({
email: z.string().email(),
password: passwordSchema,
});
const LoginBody = z.object({
email: z.string().email(),
password: z.string().min(1).max(128),
});
const VerifyBody = z.object({
email: z.string().email(),
code: z.string().min(4),
});
const ForgotPasswordRequestBody = z.object({
email: z.string().email(),
});
const ForgotPasswordConfirmBody = z.object({
uid: z.string().uuid(),
token: z.string().min(16).max(512),
newPassword: passwordSchema,
});
const normalizeOrigin = (origin: string) => origin.replace(/\/$/, "");
const authAccountRoutes: FastifyPluginAsync<AuthAccountRoutesOptions> = async (
app,
opts
) => {
// First resend is allowed immediately; cooldown applies starting on the next resend.
const verifyResendCountByUser = new Map<string, number>();
// Includes initial delete-code issue + resends.
const deleteCodeIssueCountByUser = new Map<string, number>();
app.post("/auth/register", opts.authRateLimit, async (req, reply) => {
const parsed = RegisterBody.safeParse(req.body);
if (!parsed.success) {
const firstIssue = parsed.error.issues[0];
const message = firstIssue?.message || "Invalid payload";
return reply.code(400).send({ ok: false, message });
}
const { email, password } = parsed.data;
const normalizedEmail = opts.normalizeEmail(email);
const existing = await app.prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (existing) {
opts.logSecurityEvent(req, "auth.register", "blocked", {
reason: "email_in_use",
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
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,
emailVerified: false,
},
});
if (opts.config.SEED_DEFAULT_BUDGET) {
await opts.seedDefaultBudget(app.prisma, user.id);
}
await opts.clearEmailTokens(user.id, "signup");
const { code } = await opts.issueEmailToken(user.id, "signup", opts.emailTokenTtlMs);
verifyResendCountByUser.set(user.id, 0);
const origin = normalizeOrigin(opts.config.APP_ORIGIN);
await opts.sendEmail({
to: normalizedEmail,
subject: "Verify your SkyMoney account",
text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`,
html: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, you can also verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
opts.logSecurityEvent(req, "auth.register", "success", {
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return {
ok: true,
needsVerification: true,
...(opts.exposeDevVerificationCode ? { verificationCode: code } : {}),
};
});
app.post("/auth/login", opts.authRateLimit, async (req, reply) => {
const parsed = LoginBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" });
const { email, password } = parsed.data;
const normalizedEmail = opts.normalizeEmail(email);
const lockout = opts.getLoginLockout(normalizedEmail);
if (lockout.locked) {
reply.header("Retry-After", String(lockout.retryAfterSeconds));
opts.logSecurityEvent(req, "auth.login", "blocked", {
reason: "login_locked",
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
retryAfterSeconds: lockout.retryAfterSeconds,
});
return reply.code(429).send({
ok: false,
code: "LOGIN_LOCKED",
message: "Too many failed login attempts. Please try again later.",
});
}
const user = await app.prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (!user?.passwordHash) {
const failed = opts.registerFailedLoginAttempt(normalizedEmail);
if (failed.locked) {
reply.header("Retry-After", String(failed.retryAfterSeconds));
opts.logSecurityEvent(req, "auth.login", "blocked", {
reason: "login_locked",
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
retryAfterSeconds: failed.retryAfterSeconds,
});
return reply.code(429).send({
ok: false,
code: "LOGIN_LOCKED",
message: "Too many failed login attempts. Please try again later.",
});
}
opts.logSecurityEvent(req, "auth.login", "failure", {
reason: "invalid_credentials",
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) {
const failed = opts.registerFailedLoginAttempt(normalizedEmail);
if (failed.locked) {
reply.header("Retry-After", String(failed.retryAfterSeconds));
opts.logSecurityEvent(req, "auth.login", "blocked", {
reason: "login_locked",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
retryAfterSeconds: failed.retryAfterSeconds,
});
return reply.code(429).send({
ok: false,
code: "LOGIN_LOCKED",
message: "Too many failed login attempts. Please try again later.",
});
}
opts.logSecurityEvent(req, "auth.login", "failure", {
reason: "invalid_credentials",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
opts.clearFailedLoginAttempts(normalizedEmail);
if (!user.emailVerified) {
opts.logSecurityEvent(req, "auth.login", "blocked", {
reason: "email_not_verified",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return reply
.code(403)
.send({ ok: false, code: "EMAIL_NOT_VERIFIED", message: "Email not verified" });
}
await app.ensureUser(user.id);
const token = await reply.jwtSign({ sub: user.id });
const maxAge = opts.config.SESSION_TIMEOUT_MINUTES * 60;
reply.setCookie("session", token, {
httpOnly: true,
sameSite: "lax",
secure: opts.config.NODE_ENV === "production",
path: "/",
maxAge,
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
opts.ensureCsrfCookie(reply);
opts.logSecurityEvent(req, "auth.login", "success", {
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return { ok: true };
});
app.post("/auth/logout", async (req, reply) => {
reply.clearCookie("session", {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: opts.config.NODE_ENV === "production",
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
opts.logSecurityEvent(req, "auth.logout", "success", { userId: req.userId });
return { ok: true };
});
app.post("/auth/verify", opts.codeVerificationRateLimit, async (req, reply) => {
const parsed = VerifyBody.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const normalizedEmail = opts.normalizeEmail(parsed.data.email);
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) {
opts.logSecurityEvent(req, "auth.verify", "failure", {
reason: "invalid_code",
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
const tokenHash = opts.hashToken(parsed.data.code.trim());
const token = await app.prisma.emailToken.findFirst({
where: { userId: user.id, type: "signup", tokenHash },
});
if (!token) {
opts.logSecurityEvent(req, "auth.verify", "failure", {
reason: "invalid_code",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
if (token.expiresAt < new Date()) {
await app.prisma.emailToken.deleteMany({ where: { userId: user.id, type: "signup" } });
opts.logSecurityEvent(req, "auth.verify", "failure", {
reason: "code_expired",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
}
await app.prisma.user.update({
where: { id: user.id },
data: { emailVerified: true },
});
await opts.clearEmailTokens(user.id, "signup");
verifyResendCountByUser.delete(user.id);
const jwt = await reply.jwtSign({ sub: user.id });
const maxAge = opts.config.SESSION_TIMEOUT_MINUTES * 60;
reply.setCookie("session", jwt, {
httpOnly: true,
sameSite: "lax",
secure: opts.config.NODE_ENV === "production",
path: "/",
maxAge,
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
opts.ensureCsrfCookie(reply);
opts.logSecurityEvent(req, "auth.verify", "success", {
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return { ok: true };
});
app.post("/auth/verify/resend", opts.codeIssueRateLimit, async (req, reply) => {
const parsed = z.object({ email: z.string().email() }).safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const normalizedEmail = opts.normalizeEmail(parsed.data.email);
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) {
opts.logSecurityEvent(req, "auth.verify_resend", "failure", {
reason: "unknown_email",
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return reply.code(200).send({ ok: true });
}
if (user.emailVerified) {
verifyResendCountByUser.delete(user.id);
opts.logSecurityEvent(req, "auth.verify_resend", "blocked", {
reason: "already_verified",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return { ok: true, alreadyVerified: true };
}
const resendCount = verifyResendCountByUser.get(user.id) ?? 0;
try {
if (resendCount > 0) {
await opts.assertEmailTokenCooldown(user.id, "signup", opts.emailTokenCooldownMs);
}
} catch (err: any) {
if (err?.code === "EMAIL_TOKEN_COOLDOWN") {
if (typeof err.retryAfterSeconds === "number") {
reply.header("Retry-After", String(err.retryAfterSeconds));
}
opts.logSecurityEvent(req, "auth.verify_resend", "blocked", {
reason: "email_token_cooldown",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return reply.code(429).send({ ok: false, code: err.code, message: err.message });
}
throw err;
}
await opts.clearEmailTokens(user.id, "signup");
const { code } = await opts.issueEmailToken(user.id, "signup", opts.emailTokenTtlMs);
verifyResendCountByUser.set(user.id, resendCount + 1);
const origin = normalizeOrigin(opts.config.APP_ORIGIN);
await opts.sendEmail({
to: user.email,
subject: "Verify your SkyMoney account",
text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`,
html: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
opts.logSecurityEvent(req, "auth.verify_resend", "success", {
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return { ok: true, ...(opts.exposeDevVerificationCode ? { verificationCode: code } : {}) };
});
app.post(
"/auth/forgot-password/request",
opts.passwordResetRequestRateLimit,
async (req, reply) => {
const parsed = ForgotPasswordRequestBody.safeParse(req.body);
const genericResponse = {
ok: true,
message: "If an account exists, reset instructions were sent.",
};
if (!parsed.success) {
return reply.code(200).send(genericResponse);
}
const normalizedEmail = opts.normalizeEmail(parsed.data.email);
const user = await app.prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true, email: true, emailVerified: true },
});
opts.logSecurityEvent(req, "auth.password_reset.request", "success", {
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
hasAccount: !!user,
emailVerified: !!user?.emailVerified,
});
if (!user || !user.emailVerified) {
return reply.code(200).send(genericResponse);
}
try {
await opts.assertEmailTokenCooldown(user.id, "password_reset", opts.emailTokenCooldownMs);
} catch (err: any) {
if (err?.code === "EMAIL_TOKEN_COOLDOWN") {
opts.logSecurityEvent(req, "auth.password_reset.request", "blocked", {
reason: "email_token_cooldown",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
return reply.code(200).send(genericResponse);
}
throw err;
}
const rawToken = opts.generatePasswordResetToken();
await opts.clearEmailTokens(user.id, "password_reset");
await opts.issueEmailToken(
user.id,
"password_reset",
opts.config.PASSWORD_RESET_TTL_MINUTES * 60_000,
rawToken
);
const origin = normalizeOrigin(opts.config.APP_ORIGIN);
const resetUrl = `${origin}/reset-password?uid=${encodeURIComponent(user.id)}&token=${encodeURIComponent(rawToken)}`;
try {
await opts.sendEmail({
to: user.email,
subject: "Reset your SkyMoney password",
text:
`Use this link to reset your SkyMoney password: ${resetUrl}\n\n` +
"This link expires soon. If you did not request this, you can ignore this email.",
html:
`<p>Use this link to reset your SkyMoney password:</p><p><a href=\"${resetUrl}\">${resetUrl}</a></p>` +
"<p>This link expires soon. If you did not request this, you can ignore this email.</p>",
});
opts.logSecurityEvent(req, "auth.password_reset.email", "success", {
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
} catch {
opts.logSecurityEvent(req, "auth.password_reset.email", "failure", {
userId: user.id,
emailFingerprint: opts.fingerprintEmail(normalizedEmail),
});
}
return reply.code(200).send(genericResponse);
}
);
app.post(
"/auth/forgot-password/confirm",
opts.passwordResetConfirmRateLimit,
async (req, reply) => {
const parsed = ForgotPasswordConfirmBody.safeParse(req.body);
if (!parsed.success) {
return reply
.code(400)
.send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" });
}
const { uid, token, newPassword } = parsed.data;
const tokenHash = opts.hashToken(token.trim());
const now = new Date();
const consumed = await app.prisma.$transaction(async (tx) => {
const candidate = await tx.emailToken.findFirst({
where: {
userId: uid,
type: "password_reset",
tokenHash,
usedAt: null,
expiresAt: { gt: now },
},
select: { id: true, userId: true },
});
if (!candidate) return null;
const updated = await tx.emailToken.updateMany({
where: { id: candidate.id, usedAt: null },
data: { usedAt: now },
});
if (updated.count !== 1) return null;
const nextPasswordHash = await argon2.hash(newPassword, HASH_OPTIONS);
await tx.user.update({
where: { id: uid },
data: {
passwordHash: nextPasswordHash,
passwordChangedAt: now,
},
});
await tx.emailToken.deleteMany({
where: {
userId: uid,
type: "password_reset",
id: { not: candidate.id },
},
});
return candidate.userId;
});
if (!consumed) {
opts.logSecurityEvent(req, "auth.password_reset.confirm", "failure", {
reason: "invalid_or_expired_token",
userId: uid,
});
return reply
.code(400)
.send({ ok: false, code: "INVALID_OR_EXPIRED_RESET_LINK", message: "Invalid or expired reset link" });
}
opts.logSecurityEvent(req, "auth.password_reset.confirm", "success", { userId: consumed });
return { ok: true };
}
);
app.post("/account/delete-request", opts.codeIssueRateLimit, async (req, reply) => {
const Body = z.object({
password: z.string().min(1),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const user = await app.prisma.user.findUnique({
where: { id: req.userId },
select: { id: true, email: true, passwordHash: true },
});
if (!user?.passwordHash) {
opts.logSecurityEvent(req, "account.delete_request", "failure", {
reason: "invalid_credentials",
userId: req.userId,
});
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const valid = await argon2.verify(user.passwordHash, parsed.data.password);
if (!valid) {
opts.logSecurityEvent(req, "account.delete_request", "failure", {
reason: "invalid_credentials",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(user.email),
});
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const deleteIssueCount = deleteCodeIssueCountByUser.get(user.id) ?? 0;
try {
if (deleteIssueCount >= 2) {
await opts.assertEmailTokenCooldown(user.id, "delete", opts.emailTokenCooldownMs);
}
} catch (err: any) {
if (err?.code === "EMAIL_TOKEN_COOLDOWN") {
if (typeof err.retryAfterSeconds === "number") {
reply.header("Retry-After", String(err.retryAfterSeconds));
}
opts.logSecurityEvent(req, "account.delete_request", "blocked", {
reason: "email_token_cooldown",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(user.email),
});
return reply.code(429).send({ ok: false, code: err.code, message: err.message });
}
throw err;
}
await opts.clearEmailTokens(user.id, "delete");
const { code } = await opts.issueEmailToken(user.id, "delete", opts.deleteTokenTtlMs);
deleteCodeIssueCountByUser.set(user.id, deleteIssueCount + 1);
await opts.sendEmail({
to: user.email,
subject: "Confirm deletion of your SkyMoney account",
text: `Your SkyMoney delete confirmation code is ${code}. Enter it in the app to delete your account.`,
html: `<p>Your SkyMoney delete confirmation code is <strong>${code}</strong>.</p><p>Enter it in the app to delete your account.</p>`,
});
opts.logSecurityEvent(req, "account.delete_request", "success", {
userId: user.id,
emailFingerprint: opts.fingerprintEmail(user.email),
});
return { ok: true };
});
app.post("/account/confirm-delete", opts.codeVerificationRateLimit, async (req, reply) => {
const Body = z.object({
email: z.string().email(),
code: z.string().min(4),
password: z.string().min(1),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const normalizedEmail = opts.normalizeEmail(parsed.data.email);
const user = await app.prisma.user.findUnique({ where: { id: req.userId } });
if (!user) {
opts.logSecurityEvent(req, "account.confirm_delete", "failure", {
reason: "user_not_found",
userId: req.userId,
});
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
if (user.email.toLowerCase() !== normalizedEmail) {
opts.logSecurityEvent(req, "account.confirm_delete", "blocked", {
reason: "email_mismatch",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(user.email),
});
return reply.code(403).send({ ok: false, message: "Forbidden" });
}
if (!user.passwordHash) {
opts.logSecurityEvent(req, "account.confirm_delete", "failure", {
reason: "invalid_credentials",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(user.email),
});
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password);
if (!passwordOk) {
opts.logSecurityEvent(req, "account.confirm_delete", "failure", {
reason: "invalid_credentials",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(user.email),
});
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const tokenHash = opts.hashToken(parsed.data.code.trim());
const token = await app.prisma.emailToken.findFirst({
where: { userId: user.id, type: "delete", tokenHash },
});
if (!token) {
opts.logSecurityEvent(req, "account.confirm_delete", "failure", {
reason: "invalid_code",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(user.email),
});
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
if (token.expiresAt < new Date()) {
await opts.clearEmailTokens(user.id, "delete");
opts.logSecurityEvent(req, "account.confirm_delete", "failure", {
reason: "code_expired",
userId: user.id,
emailFingerprint: opts.fingerprintEmail(user.email),
});
return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
}
await opts.clearEmailTokens(user.id, "delete");
deleteCodeIssueCountByUser.delete(user.id);
await app.prisma.user.delete({ where: { id: user.id } });
reply.clearCookie("session", {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: opts.config.NODE_ENV === "production",
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
opts.logSecurityEvent(req, "account.confirm_delete", "success", {
userId: user.id,
emailFingerprint: opts.fingerprintEmail(user.email),
});
return { ok: true };
});
app.post("/auth/refresh", async (req, reply) => {
const userId = req.userId;
const token = await reply.jwtSign({ sub: userId });
const maxAge = opts.config.SESSION_TIMEOUT_MINUTES * 60;
reply.setCookie("session", token, {
httpOnly: true,
sameSite: "lax",
secure: opts.config.NODE_ENV === "production",
path: "/",
maxAge,
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
opts.ensureCsrfCookie(reply, (req.cookies as any)?.csrf);
return { ok: true, expiresInMinutes: opts.config.SESSION_TIMEOUT_MINUTES };
});
};
export default authAccountRoutes;

219
api/src/routes/budget.ts Normal file
View File

@@ -0,0 +1,219 @@
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { allocateBudget, applyIrregularIncome } from "../allocator.js";
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
};
};
type PercentCategory = {
id: string;
percent: number;
balanceCents: bigint | null;
};
type ShareResult =
| { ok: true; shares: Array<{ id: string; share: number }> }
| { ok: false; reason: string };
type BudgetRoutesOptions = {
mutationRateLimit: RateLimitRouteOptions;
computeDepositShares: (categories: PercentCategory[], amountCents: number) => ShareResult;
computeWithdrawShares: (categories: PercentCategory[], amountCents: number) => ShareResult;
isProd: boolean;
};
const BudgetBody = z.object({
newIncomeCents: z.number().int().nonnegative(),
fixedExpensePercentage: z.number().min(0).max(100).default(30),
postedAtISO: z.string().datetime().optional(),
});
const ReconcileBody = z.object({
bankTotalCents: z.number().int().nonnegative(),
});
const budgetRoutes: FastifyPluginAsync<BudgetRoutesOptions> = async (app, opts) => {
app.post("/budget/allocate", opts.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: opts.isProd ? undefined : parsed.data },
"Budget allocation failed"
);
return reply.code(500).send({ message: "Budget allocation failed" });
}
});
app.post("/budget/fund", opts.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: opts.isProd ? undefined : parsed.data },
"Budget funding failed"
);
return reply.code(500).send({ message: "Budget funding failed" });
}
});
app.post("/budget/reconcile", opts.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 = opts.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 = opts.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,
};
});
});
};
export default budgetRoutes;

332
api/src/routes/dashboard.ts Normal file
View File

@@ -0,0 +1,332 @@
import type { FastifyPluginAsync } from "fastify";
import { getUserMidnightFromDateOnly } from "../allocator.js";
import { getUserTimezone } from "../services/user-context.js";
const DAY_MS = 24 * 60 * 60 * 1000;
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 dashboardRoutes: FastifyPluginAsync = async (app) => {
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, recentTxs, agg, allocAgg, incomeEvents, spendTxs, user] = await Promise.all([
app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
}),
app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
}),
app.prisma.transaction.findMany({
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 },
}),
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 totalIncomeCents = Number(agg._sum?.amountCents ?? 0n);
const totalAllocatedCents = Number(allocAgg._sum?.amountCents ?? 0n);
const availableBudgetCents = Math.max(0, totalIncomeCents - totalAllocatedCents);
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);
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;
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) {
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 {
isCrisis = fundedPercent < 70 && daysUntilDue <= 14;
}
}
return {
...plan,
fundedCents: funded,
currentFundedCents: funded,
remainingCents,
daysUntilDue,
percentFunded,
isCrisis,
};
});
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,
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<string, number>();
incomeEvents.forEach((evt) => {
const key = monthKey(evt.postedAt);
incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n));
});
const spendByMonth = new Map<string, number>();
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,
}));
function shouldFundFixedPlans(
userType: string,
incomeFrequency: string,
currentPlans: any[],
crisisActive: boolean
) {
if (crisisActive) return true;
if (userType === "irregular") {
return currentPlans.some((plan) => {
const remaining = Number(plan.remainingCents ?? 0);
return remaining > 0;
});
}
return currentPlans.some((plan) => {
const remaining = Number(plan.remainingCents ?? 0);
if (remaining <= 0) return false;
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 getUserTimezone(app.prisma, userId);
const { getUserMidnight } = await import("../allocator.js");
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,
};
});
};
export default dashboardRoutes;

File diff suppressed because it is too large Load Diff

24
api/src/routes/health.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { FastifyPluginAsync } from "fastify";
type HealthRoutesOptions = {
nodeEnv: string;
};
const healthRoutes: FastifyPluginAsync<HealthRoutesOptions> = async (
app,
opts
) => {
app.get("/health", async () => ({ ok: true }));
if (opts.nodeEnv !== "production") {
app.get("/health/db", async () => {
const start = Date.now();
const [{ now }] =
await app.prisma.$queryRaw<{ now: Date }[]>`SELECT now() as now`;
const latencyMs = Date.now() - start;
return { ok: true, nowISO: now.toISOString(), latencyMs };
});
}
};
export default healthRoutes;

View File

@@ -1,98 +0,0 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { previewAllocation, previewIrregularAllocation } from "../allocator.js";
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;
const user = await app.prisma.user.findUnique({
where: { id: userId },
select: { incomeType: true, fixedExpensePercentage: true },
});
let result: PreviewResult;
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,
};
}
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,
}));
return {
fixedAllocations: result.fixedAllocations,
variableAllocations: result.variableAllocations,
fixedPreview,
availableBudgetAfterCents: result.availableBudgetAfterCents,
crisis: result.crisis,
unallocatedCents: result.remainingUnallocatedCents,
};
});
}

139
api/src/routes/income.ts Normal file
View File

@@ -0,0 +1,139 @@
import { randomUUID } from "node:crypto";
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import {
allocateIncome,
allocateIncomeManual,
previewAllocation,
} from "../allocator.js";
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
};
};
type IncomeRoutesOptions = {
mutationRateLimit: RateLimitRouteOptions;
};
const AllocationOverrideSchema = z.object({
type: z.enum(["fixed", "variable"]),
id: z.string().min(1),
amountCents: z.number().int().nonnegative(),
});
const incomeRoutes: FastifyPluginAsync<IncomeRoutesOptions> = async (app, opts) => {
app.post("/income", opts.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;
});
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,
};
});
});
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;
});
};
export default incomeRoutes;

140
api/src/routes/payday.ts Normal file
View File

@@ -0,0 +1,140 @@
import type { FastifyPluginAsync } from "fastify";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { fromZonedTime } from "date-fns-tz";
import { getUserMidnight } from "../allocator.js";
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
};
};
type PaydayRoutesOptions = {
mutationRateLimit: RateLimitRouteOptions;
isProd: boolean;
};
const paydayRoutes: FastifyPluginAsync<PaydayRoutesOptions> = async (app, opts) => {
const logDebug = (message: string, data?: Record<string, unknown>) => {
if (!opts.isProd) {
app.log.info(data ?? {}, message);
}
};
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("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.fixedPlan.count({
where: {
userId,
paymentSchedule: { not: Prisma.DbNull },
},
}),
]);
if (!user) {
if (!opts.isProd) {
app.log.warn({ userId }, "User not found");
}
return reply.code(404).send({ message: "User not found" });
}
logDebug("Payday user data retrieved", {
userId,
incomeType: user.incomeType,
incomeFrequency: user.incomeFrequency,
firstIncomeDate: user.firstIncomeDate?.toISOString(),
pendingScheduledIncome: user.pendingScheduledIncome,
paymentPlansCount,
});
const hasPaymentPlans = paymentPlansCount > 0;
const isRegularUser = user.incomeType === "regular";
if (!isRegularUser || !hasPaymentPlans || !user.firstIncomeDate) {
logDebug("Payday check skipped - not applicable", {
userId,
isRegularUser,
hasPaymentPlans,
hasFirstIncomeDate: !!user.firstIncomeDate,
});
return {
shouldShowOverlay: false,
pendingScheduledIncome: false,
nextPayday: null,
};
}
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("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(),
};
});
app.post("/payday/dismiss", opts.mutationRateLimit, async (_req, _reply) => {
return { ok: true };
});
};
export default paydayRoutes;

101
api/src/routes/session.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { FastifyPluginAsync } from "fastify";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import type { AppConfig } from "../server.js";
type SessionRoutesOptions = {
config: Pick<
AppConfig,
"NODE_ENV" | "UPDATE_NOTICE_VERSION" | "UPDATE_NOTICE_TITLE" | "UPDATE_NOTICE_BODY"
>;
cookieDomain?: string;
mutationRateLimit: {
config: {
rateLimit: {
max: number;
timeWindow: number;
};
};
};
};
const CSRF_COOKIE = "csrf";
const sessionRoutes: FastifyPluginAsync<SessionRoutesOptions> = async (
app,
opts
) => {
const ensureCsrfCookie = (reply: any, existing?: string) => {
const token = existing ?? randomUUID().replace(/-/g, "");
reply.setCookie(CSRF_COOKIE, token, {
httpOnly: false,
sameSite: "lax",
secure: opts.config.NODE_ENV === "production",
path: "/",
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
return token;
};
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,
emailVerified: true,
seenUpdateVersion: true,
},
});
const noticeVersion = opts.config.UPDATE_NOTICE_VERSION;
const shouldShowNotice =
noticeVersion > 0 &&
!!user &&
user.emailVerified &&
user.seenUpdateVersion < noticeVersion;
return {
ok: true,
userId: req.userId,
email: user?.email ?? null,
displayName: user?.displayName ?? null,
emailVerified: user?.emailVerified ?? false,
updateNotice: shouldShowNotice
? {
version: noticeVersion,
title: opts.config.UPDATE_NOTICE_TITLE,
body: opts.config.UPDATE_NOTICE_BODY,
}
: null,
};
});
app.post(
"/app/update-notice/ack",
opts.mutationRateLimit,
async (req, reply) => {
const Body = z.object({
version: z.coerce.number().int().nonnegative().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const targetVersion =
parsed.data.version ?? opts.config.UPDATE_NOTICE_VERSION;
await app.prisma.user.updateMany({
where: {
id: req.userId,
seenUpdateVersion: { lt: targetVersion },
},
data: { seenUpdateVersion: targetVersion },
});
return { ok: true };
}
);
};
export default sessionRoutes;

View File

@@ -0,0 +1,90 @@
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
};
};
type SiteAccessRoutesOptions = {
underConstructionEnabled: boolean;
breakGlassVerifyEnabled: boolean;
breakGlassVerifyCode: string | null;
siteAccessExpectedToken: string | null;
cookieDomain?: string;
secureCookie: boolean;
siteAccessCookieName: string;
siteAccessMaxAgeSeconds: number;
authRateLimit: RateLimitRouteOptions;
mutationRateLimit: RateLimitRouteOptions;
hasSiteAccessBypass: (req: { cookies?: Record<string, unknown> }) => boolean;
safeEqual: (a: string, b: string) => boolean;
};
const siteAccessRoutes: FastifyPluginAsync<SiteAccessRoutesOptions> = async (app, opts) => {
app.get("/site-access/status", async (req) => {
if (!opts.underConstructionEnabled) {
return { ok: true, enabled: false, unlocked: true };
}
return {
ok: true,
enabled: true,
unlocked: opts.hasSiteAccessBypass(req),
};
});
app.post("/site-access/unlock", opts.authRateLimit, async (req, reply) => {
const Body = z.object({
code: z.string().min(1).max(512),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, code: "INVALID_PAYLOAD", message: "Invalid payload" });
}
if (!opts.underConstructionEnabled) {
return { ok: true, enabled: false, unlocked: true };
}
if (!opts.breakGlassVerifyEnabled || !opts.siteAccessExpectedToken) {
return reply.code(503).send({
ok: false,
code: "UNDER_CONSTRUCTION_MISCONFIGURED",
message: "Under-construction access is not configured.",
});
}
if (!opts.breakGlassVerifyCode || !opts.safeEqual(parsed.data.code, opts.breakGlassVerifyCode)) {
return reply.code(401).send({
ok: false,
code: "INVALID_ACCESS_CODE",
message: "Invalid access code.",
});
}
reply.setCookie(opts.siteAccessCookieName, opts.siteAccessExpectedToken, {
httpOnly: true,
sameSite: "lax",
secure: opts.secureCookie,
path: "/",
maxAge: opts.siteAccessMaxAgeSeconds,
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
return { ok: true, enabled: true, unlocked: true };
});
app.post("/site-access/lock", opts.mutationRateLimit, async (_req, reply) => {
reply.clearCookie(opts.siteAccessCookieName, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: opts.secureCookie,
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
return { ok: true, enabled: opts.underConstructionEnabled, unlocked: false };
});
};
export default siteAccessRoutes;

View File

@@ -1,71 +1,764 @@
// api/src/routes/transactions.ts
import fp from "fastify-plugin";
import type { FastifyPluginAsync } from "fastify";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { getUserDateRangeFromDateOnly } from "../allocator.js";
const Query = z.object({
from: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // YYYY-MM-DD
to: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
q: z.string().trim().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
};
};
export default fp(async function transactionsRoute(app) {
app.get("/transactions", async (req, reply) => {
if (typeof req.userId !== "string") {
return reply.code(401).send({ message: "Unauthorized" });
type PercentCategory = {
id: string;
percent: number;
balanceCents: bigint | null;
};
type ShareResult =
| { ok: true; shares: Array<{ id: string; share: number }> }
| { ok: false; reason: string };
type TransactionsRoutesOptions = {
mutationRateLimit: RateLimitRouteOptions;
computeDepositShares: (categories: PercentCategory[], amountCents: number) => ShareResult;
computeWithdrawShares: (categories: PercentCategory[], amountCents: number) => ShareResult;
computeOverdraftShares: (categories: PercentCategory[], amountCents: number) => ShareResult;
calculateNextDueDate: (currentDueDate: Date, frequency: string, timezone?: string) => Date;
toBig: (n: number | string | bigint) => bigint;
parseCurrencyToCents: (value: string) => number;
};
const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
const transactionsRoutes: FastifyPluginAsync<TransactionsRoutesOptions> = async (
app,
opts
) => {
app.post("/transactions", opts.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(),
useAvailableBudget: z.boolean().optional(),
})
.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" });
}
const {
kind,
amountCents,
occurredAtISO,
categoryId,
planId,
note,
receiptUrl,
isReconciled,
allowOverdraft,
useAvailableBudget,
} = parsed.data;
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const amt = opts.toBig(amountCents);
return await app.prisma.$transaction(async (tx) => {
let deletePlanAfterPayment = false;
let paidAmount = amountCents;
let updatedDueOn: Date | undefined;
if (kind === "variable_spend") {
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 shareResult = allowOverdraft
? opts.computeOverdraftShares(categories, amountCents)
: opts.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) {
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 },
});
if (updated.count === 0) {
return reply.code(404).send({ message: "Category not found" });
}
}
} 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 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 = opts.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 = opts.calculateNextDueDate(plan.dueOn, plan.frequency, userTimezone);
} else {
updatedDueOn = plan.dueOn ?? undefined;
}
}
} else {
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 = opts.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;
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 = opts.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,
},
});
}
}
}
const row = await tx.transaction.create({
data: {
userId,
occurredAt: new Date(occurredAtISO),
kind,
amountCents: opts.toBig(paidAmount),
categoryId: kind === "variable_spend" ? categoryId ?? 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 (kind === "fixed_payment") {
if (deletePlanAfterPayment) {
await tx.fixedPlan.deleteMany({ where: { id: planId, userId } });
}
return {
...row,
planId,
nextDueOn: updatedDueOn || undefined,
} as any;
}
return row;
});
});
app.get("/transactions", async (req, reply) => {
const Query = z.object({
from: z.string().refine(isDate, "YYYY-MM-DD").optional(),
to: z.string().refine(isDate, "YYYY-MM-DD").optional(),
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
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 where: any = { userId };
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: Record<string, unknown> = { userId };
if (from || to) {
where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, from, to);
}
if (kind) where.kind = kind;
if (kind) {
where.kind = kind;
} else {
where.kind = { in: ["variable_spend", "fixed_payment"] };
}
const flexibleOr: any[] = [];
if (typeof q === "string" && q.trim() !== "") {
const ors: any[] = [];
const asNumber = Number(q);
if (Number.isFinite(asNumber)) {
ors.push({ amountCents: BigInt(asNumber) });
}
if (ors.length > 0) {
where.OR = ors;
}
}
const qTrim = q.trim();
const asCents = opts.parseCurrencyToCents(qTrim);
if (asCents > 0) {
flexibleOr.push({ amountCents: opts.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 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 }];
const [total, items] = await Promise.all([
const txInclude = Prisma.validator<Prisma.TransactionInclude>()({
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({
(app.prisma.transaction.findMany({
where,
orderBy: { occurredAt: "desc" },
orderBy,
skip,
take: limit,
select: { id: true, kind: true, amountCents: true, occurredAt: true },
}),
include: txInclude,
}) as Promise<TxWithRelations[]>),
]);
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 };
});
});
app.patch("/transactions/:id", opts.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", opts.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 = opts.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 = opts.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 };
});
});
};
export default transactionsRoutes;

216
api/src/routes/user.ts Normal file
View File

@@ -0,0 +1,216 @@
import type { FastifyPluginAsync } from "fastify";
import argon2 from "argon2";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { getUserMidnightFromDateOnly } from "../allocator.js";
const PASSWORD_MIN_LENGTH = 12;
const passwordSchema = z
.string()
.min(PASSWORD_MIN_LENGTH)
.max(128)
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a symbol");
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id,
memoryCost: 19_456,
timeCost: 3,
parallelism: 1,
};
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(),
fixedExpensePercentage: z.number().int().min(0).max(100).optional(),
});
const userRoutes: FastifyPluginAsync = async (app) => {
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: passwordSchema,
});
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" });
}
const valid = await argon2.verify(
user.passwordHash,
parsed.data.currentPassword
);
if (!valid) {
return reply
.code(401)
.send({ ok: false, message: "Current password is incorrect" });
}
const newHash = await argon2.hash(parsed.data.newPassword, HASH_OPTIONS);
await app.prisma.user.update({
where: { id: req.userId },
data: { passwordHash: newHash, passwordChangedAt: new Date() },
});
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,
};
});
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,
};
});
};
export default userRoutes;

View File

@@ -1,151 +1,173 @@
import { FastifyPluginAsync } from "fastify";
import { Prisma } from "@prisma/client";
import type { FastifyPluginAsync } from "fastify";
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import { prisma } from "../prisma.js";
import { ensureBudgetSessionAvailableSynced } from "../services/budget-session.js";
const NewCat = z.object({
name: z.string().min(1).max(100),
percent: z.number().int().min(0).max(100),
isSavings: z.boolean().default(false),
priority: z.number().int().min(0).max(10_000),
});
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,
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
});
};
};
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;
}
});
type PercentCategory = {
id: string;
percent: number;
balanceCents: bigint | null;
};
return { ok: true as const, targets };
}
type DepositShareResult =
| { ok: true; shares: Array<{ id: string; share: number }> }
| { ok: false; reason: string };
async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: string) {
const g = await tx.variableCategory.groupBy({
by: ["userId"],
type VariableCategoriesRoutesOptions = {
mutationRateLimit: RateLimitRouteOptions;
computeDepositShares: (categories: PercentCategory[], amountCents: number) => DepositShareResult;
};
const CatBody = z.object({
name: z.string().trim().min(1),
percent: z.number().int().min(0).max(100),
isSavings: z.boolean(),
priority: z.number().int().min(0),
});
const ManualRebalanceBody = z.object({
targets: z.array(
z.object({
id: z.string().min(1),
targetCents: z.number().int().min(0),
})
),
forceLowerSavings: z.boolean().optional(),
confirmOver80: z.boolean().optional(),
});
async function assertPercentTotal(
tx: PrismaClient | Prisma.TransactionClient,
userId: string
) {
const categories = await tx.variableCategory.findMany({
where: { userId },
_sum: { percent: true },
select: { percent: true, isSavings: true },
});
const sum = g[0]?._sum.percent ?? 0;
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 = new Error(`Percents cannot exceed 100 (got ${sum}).`) as any;
const err: any = new Error("Percents must sum to 100");
err.statusCode = 400;
err.code = "PERCENT_TOTAL_OVER_100";
throw err;
}
// For now, allow partial completion during onboarding
// The frontend will ensure 100% total before finishing onboarding
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;
}
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
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 normalizedName = body.data.name.trim().toLowerCase();
try {
const result = await prisma.variableCategory.create({
data: { ...body.data, userId, name: normalizedName },
select: { id: true },
});
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;
const variableCategoriesRoutes: FastifyPluginAsync<VariableCategoriesRoutesOptions> = async (
app,
opts
) => {
app.post("/variable-categories", opts.mutationRateLimit, async (req, reply) => {
const parsed = CatBody.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const userId = req.userId;
const normalizedName = parsed.data.name.trim().toLowerCase();
return await app.prisma.$transaction(async (tx) => {
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;
}
});
});
// UPDATE
app.patch("/variable-categories/:id", async (req, reply) => {
app.patch("/variable-categories/:id", opts.mutationRateLimit, async (req, reply) => {
const patch = CatBody.partial().safeParse(req.body);
if (!patch.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const id = String((req.params as any).id);
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() });
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,
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" });
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 };
});
if (updated.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
return reply.send({ ok: true });
});
// DELETE
app.delete("/variable-categories/:id", async (req, reply) => {
app.delete("/variable-categories/:id", opts.mutationRateLimit, async (req, reply) => {
const id = String((req.params as any).id);
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() });
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 },
const exists = await app.prisma.variableCategory.findFirst({
where: { id, userId },
});
if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" });
if (!exists) return reply.code(404).send({ message: "Not found" });
return reply.send({ ok: true });
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 };
});
// REBALANCE balances based on current percents
app.post("/variable-categories/rebalance", async (req, reply) => {
app.post("/variable-categories/rebalance", opts.mutationRateLimit, async (req, reply) => {
const userId = req.userId;
const categories = await prisma.variableCategory.findMany({
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 reply.send({ ok: true, applied: false });
return { ok: true, applied: false };
}
const hasNegative = categories.some(
(c) => Number(c.balanceCents ?? 0n) < 0
);
const hasNegative = categories.some((c) => Number(c.balanceCents ?? 0n) < 0);
if (hasNegative) {
return reply.code(400).send({
ok: false,
@@ -154,16 +176,13 @@ const plugin: FastifyPluginAsync = async (app) => {
});
}
const totalBalance = categories.reduce(
(sum, c) => sum + Number(c.balanceCents ?? 0n),
0
);
const totalBalance = categories.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
if (totalBalance <= 0) {
return reply.send({ ok: true, applied: false });
return { ok: true, applied: false };
}
const targetResult = computeBalanceTargets(categories, totalBalance);
if (!targetResult.ok) {
const shareResult = opts.computeDepositShares(categories, totalBalance);
if (!shareResult.ok) {
return reply.code(400).send({
ok: false,
code: "NO_PERCENT",
@@ -171,17 +190,127 @@ const plugin: FastifyPluginAsync = async (app) => {
});
}
await prisma.$transaction(
targetResult.targets.map((t) =>
prisma.variableCategory.update({
where: { id: t.id },
data: { balanceCents: BigInt(t.target) },
await app.prisma.$transaction(
shareResult.shares.map((s) =>
app.prisma.variableCategory.update({
where: { id: s.id },
data: { balanceCents: BigInt(s.share) },
})
)
);
return reply.send({ ok: true, applied: true, totalBalance });
return { ok: true, applied: true, totalBalance };
});
app.get("/variable-categories/manual-rebalance", async (req, reply) => {
const userId = req.userId;
const cats = await app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
});
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
await ensureBudgetSessionAvailableSynced(app.prisma, userId, totalBalance);
return reply.send({
ok: true,
availableCents: totalBalance,
categories: cats.map((c) => ({ ...c, balanceCents: Number(c.balanceCents ?? 0n) })),
});
});
app.post("/variable-categories/manual-rebalance", async (req, reply) => {
const userId = req.userId;
const parsed = ManualRebalanceBody.safeParse(req.body);
if (!parsed.success || parsed.data.targets.length === 0) {
return reply.code(400).send({ ok: false, code: "INVALID_BODY" });
}
const cats = await app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
});
if (cats.length === 0) return reply.code(400).send({ ok: false, code: "NO_CATEGORIES" });
const totalBalance = cats.reduce((s, c) => s + Number(c.balanceCents ?? 0n), 0);
const availableCents = totalBalance;
await ensureBudgetSessionAvailableSynced(app.prisma, userId, availableCents);
const targetMap = new Map<string, number>();
for (const t of parsed.data.targets) {
if (targetMap.has(t.id)) return reply.code(400).send({ ok: false, code: "DUPLICATE_ID" });
targetMap.set(t.id, t.targetCents);
}
if (targetMap.size !== cats.length || cats.some((c) => !targetMap.has(c.id))) {
return reply.code(400).send({ ok: false, code: "MISSING_CATEGORY" });
}
const targets = cats.map((c) => ({
...c,
target: targetMap.get(c.id)!,
currentBalance: Number(c.balanceCents ?? 0n),
}));
if (targets.some((t) => t.target < 0)) {
return reply.code(400).send({ ok: false, code: "NEGATIVE_TARGET" });
}
const sumTargets = targets.reduce((s, t) => s + t.target, 0);
if (sumTargets !== availableCents) {
return reply
.code(400)
.send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` });
}
const maxAllowed = Math.floor(availableCents * 0.8);
const over80 = availableCents > 0 && targets.some((t) => t.target > maxAllowed);
if (over80 && !parsed.data.confirmOver80) {
return reply.code(400).send({
ok: false,
code: "OVER_80_CONFIRM_REQUIRED",
message: "A category exceeds 80% of available. Confirm to proceed.",
});
}
const totalSavingsBefore = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.currentBalance, 0);
const totalSavingsAfter = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.target, 0);
const savingsFloor = Math.floor(availableCents * 0.2);
const loweringSavings = totalSavingsAfter < totalSavingsBefore;
const belowFloor = totalSavingsAfter < savingsFloor;
if ((loweringSavings || belowFloor) && !parsed.data.forceLowerSavings) {
return reply
.code(400)
.send({ ok: false, code: "SAVINGS_FLOOR", message: "Lowering savings requires confirmation." });
}
await app.prisma.$transaction(async (tx) => {
for (const t of targets) {
await tx.variableCategory.update({
where: { id: t.id },
data: { balanceCents: BigInt(t.target) },
});
}
await tx.transaction.create({
data: {
userId,
kind: "rebalance",
amountCents: 0n,
occurredAt: new Date(),
note: JSON.stringify({
availableCents,
before: targets.map((t) => ({ id: t.id, balanceCents: t.currentBalance })),
after: targets.map((t) => ({ id: t.id, balanceCents: t.target })),
totalSavingsBefore,
totalSavingsAfter,
}),
},
});
});
return reply.send({
ok: true,
availableCents,
categories: targets.map((t) => ({ id: t.id, balanceCents: t.target })),
});
});
};
export default plugin;
export default variableCategoriesRoutes;

View File

@@ -0,0 +1,94 @@
import { PrismaClient } from "@prisma/client";
import { timingSafeEqual } from "node:crypto";
function parseArgs() {
const args = process.argv.slice(2);
const parsed: Record<string, string> = {};
for (const arg of args) {
if (!arg.startsWith("--")) continue;
const [key, ...rest] = arg.slice(2).split("=");
parsed[key] = rest.join("=");
}
return parsed;
}
function parseBool(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function safeEqual(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
const prisma = new PrismaClient();
async function main() {
const args = parseArgs();
const email = (args.email || "").trim().toLowerCase();
const providedCode = args.code || process.env.BREAK_GLASS_VERIFY_CODE_INPUT || "";
const expectedCode = process.env.BREAK_GLASS_VERIFY_CODE || "";
const enabled = parseBool(process.env.BREAK_GLASS_VERIFY_ENABLED);
if (!enabled) {
throw new Error("BREAK_GLASS_VERIFY_ENABLED must be true to use this command.");
}
if (expectedCode.length < 32) {
throw new Error("BREAK_GLASS_VERIFY_CODE must be set and at least 32 characters.");
}
if (!email || !email.includes("@")) {
throw new Error("Usage: npm run verify:break-glass -- --email=user@example.com --code=<long-secret>");
}
if (!providedCode) {
throw new Error("Missing --code (or BREAK_GLASS_VERIFY_CODE_INPUT).");
}
if (!safeEqual(providedCode, expectedCode)) {
throw new Error("Invalid break-glass code.");
}
const user = await prisma.user.findUnique({
where: { email },
select: { id: true, email: true, emailVerified: true },
});
if (!user) {
throw new Error(`No user found for email: ${email}`);
}
if (!user.emailVerified) {
await prisma.user.update({
where: { id: user.id },
data: { emailVerified: true },
});
}
await prisma.emailToken.deleteMany({
where: { userId: user.id, type: "signup" },
});
console.log(
JSON.stringify(
{
ok: true,
email: user.email,
wasAlreadyVerified: user.emailVerified,
action: "email_marked_verified_break_glass",
},
null,
2
)
);
}
main()
.catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
export class ApiError extends Error {
statusCode: number;
code: string;
details?: unknown;
constructor(statusCode: number, code: string, message: string, details?: unknown) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
export function badRequest(code: string, message: string, details?: unknown) {
return new ApiError(400, code, message, details);
}
export function unauthorized(code: string, message: string, details?: unknown) {
return new ApiError(401, code, message, details);
}
export function forbidden(code: string, message: string, details?: unknown) {
return new ApiError(403, code, message, details);
}
export function notFound(code: string, message: string, details?: unknown) {
return new ApiError(404, code, message, details);
}
export function conflict(code: string, message: string, details?: unknown) {
return new ApiError(409, code, message, details);
}
export function toErrorBody(err: unknown, requestId: string) {
if (err instanceof ApiError) {
return {
statusCode: err.statusCode,
body: {
ok: false,
code: err.code,
message: err.message,
requestId,
...(err.details !== undefined ? { details: err.details } : {}),
},
};
}
const fallback = err as { statusCode?: number; code?: string; message?: string };
const statusCode =
typeof fallback?.statusCode === "number" ? fallback.statusCode : 500;
return {
statusCode,
body: {
ok: false,
code: fallback?.code ?? "INTERNAL",
message:
statusCode >= 500
? "Something went wrong"
: fallback?.message ?? "Bad request",
requestId,
},
};
}

View File

@@ -0,0 +1,70 @@
import type { Prisma, PrismaClient } from "@prisma/client";
type BudgetSessionAccessor =
| Pick<PrismaClient, "budgetSession">
| Pick<Prisma.TransactionClient, "budgetSession">;
function normalizeAvailableCents(value: number): bigint {
if (!Number.isFinite(value)) return 0n;
return BigInt(Math.max(0, Math.trunc(value)));
}
export async function getLatestBudgetSession(
db: BudgetSessionAccessor,
userId: string
) {
return db.budgetSession.findFirst({
where: { userId },
orderBy: { periodStart: "desc" },
});
}
export async function ensureBudgetSession(
db: BudgetSessionAccessor,
userId: string,
fallbackAvailableCents = 0,
now = new Date()
) {
const existing = await getLatestBudgetSession(db, userId);
if (existing) return existing;
const start = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0)
);
const end = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)
);
const normalizedAvailable = normalizeAvailableCents(fallbackAvailableCents);
return db.budgetSession.create({
data: {
userId,
periodStart: start,
periodEnd: end,
totalBudgetCents: normalizedAvailable,
allocatedCents: 0n,
fundedCents: 0n,
availableCents: normalizedAvailable,
},
});
}
export async function ensureBudgetSessionAvailableSynced(
db: BudgetSessionAccessor,
userId: string,
availableCents: number
) {
const normalizedAvailable = normalizeAvailableCents(availableCents);
const session = await ensureBudgetSession(
db,
userId,
Number(normalizedAvailable)
);
if ((session.availableCents ?? 0n) === normalizedAvailable) return session;
return db.budgetSession.update({
where: { id: session.id },
data: { availableCents: normalizedAvailable },
});
}

View File

@@ -0,0 +1,200 @@
export type PercentCategory = {
id: string;
percent: number;
balanceCents: bigint | number | null;
};
type ShareRow = {
id: string;
share: number;
};
type ShareResult =
| { ok: true; shares: ShareRow[] }
| { ok: false; reason: "no_percent" | "insufficient_balances" };
function toWholeCents(value: bigint | number | null | undefined): number {
if (typeof value === "bigint") return Number(value);
if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
return 0;
}
function normalizedAmountCents(amountCents: number): number {
if (!Number.isFinite(amountCents)) return 0;
return Math.max(0, Math.trunc(amountCents));
}
export function computePercentShares(
categories: PercentCategory[],
amountCents: number
): ShareResult {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
balanceCents: toWholeCents(cat.balanceCents),
share: floored,
frac: raw - floored,
};
});
let remainder =
normalizedAmountCents(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, reason: "insufficient_balances" };
}
return { ok: true, shares: shares.map(({ id, share }) => ({ id, share })) };
}
export function computeWithdrawShares(
categories: PercentCategory[],
amountCents: number
): ShareResult {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
const working = categories.map((cat) => ({
id: cat.id,
percent: cat.percent,
balanceCents: toWholeCents(cat.balanceCents),
share: 0,
}));
let remaining = normalizedAmountCents(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, reason: "insufficient_balances" };
}
return {
ok: true,
shares: working.map((c) => ({ id: c.id, share: c.share })),
};
}
export function computeOverdraftShares(
categories: PercentCategory[],
amountCents: number
): ShareResult {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
share: floored,
frac: raw - floored,
};
});
let remainder =
normalizedAmountCents(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, shares: shares.map(({ id, share }) => ({ id, share })) };
}
export function computeDepositShares(
categories: PercentCategory[],
amountCents: number
): ShareResult {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
share: floored,
frac: raw - floored,
};
});
let remainder =
normalizedAmountCents(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, shares: shares.map(({ id, share }) => ({ id, share })) };
}

View File

@@ -0,0 +1,19 @@
import type { Prisma, PrismaClient } from "@prisma/client";
export const DEFAULT_USER_TIMEZONE = "America/New_York";
type UserReader =
| Pick<PrismaClient, "user">
| Pick<Prisma.TransactionClient, "user">;
export async function getUserTimezone(
db: UserReader,
userId: string,
fallback: string = DEFAULT_USER_TIMEZONE
): Promise<string> {
const user = await db.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
return user?.timezone ?? fallback;
}

View File

@@ -1,25 +0,0 @@
#!/bin/bash
# Login and save cookie
echo "<22><><EFBFBD> Logging in..."
curl -c cookies.txt -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@skymoney.com","password":"password123"}' > /dev/null 2>&1
# Check current plans
echo "<22><><EFBFBD> Plans BEFORE income:"
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans | jq '.plans[] | {name: .name, funded: (.fundedCents/100), total: (.totalCents/100), isOverdue: .isOverdue, overdueAmt: (.overdueAmount/100)}'
# Post $1000 income
echo -e "\n<><6E><EFBFBD> Posting $1000 income..."
RESULT=$(curl -s -b cookies.txt -X POST http://localhost:8080/api/income \
-H "Content-Type: application/json" \
-d "{\"amountCents\": 100000, \"postedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"note\": \"Test income\"}")
echo "$RESULT" | jq '{overduePaid, fixedAllocations, variableAllocations}'
# Check plans after
echo -e "\n<><6E><EFBFBD> Plans AFTER income:"
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans | jq '.plans[] | {name: .name, funded: (.fundedCents/100), total: (.totalCents/100), isOverdue: .isOverdue, overdueAmt: (.overdueAmount/100)}'
rm -f cookies.txt

View File

@@ -1,228 +0,0 @@
/**
* Test script for monthly income payday calculations with TIMEZONE awareness
* Run with: node test-monthly-income.cjs
*
* This replicates the actual allocator.ts logic including timezone handling
*/
// Simulating date-fns-tz behavior (simplified for testing)
function toZonedTime(date, timezone) {
// For testing, we'll use a simple offset approach
// In real code, this uses proper timezone rules
const utc = date.getTime();
const tzOffset = getTimezoneOffset(timezone, date);
return new Date(utc + tzOffset);
}
function fromZonedTime(date, timezone) {
const tzOffset = getTimezoneOffset(timezone, date);
return new Date(date.getTime() - tzOffset);
}
// Simplified timezone offset (real implementation uses IANA database)
function getTimezoneOffset(timezone, date) {
const offsets = {
'UTC': 0,
'America/New_York': -5 * 60 * 60 * 1000, // EST (ignoring DST for simplicity)
'America/Los_Angeles': -8 * 60 * 60 * 1000, // PST
'Asia/Tokyo': 9 * 60 * 60 * 1000, // JST
};
return offsets[timezone] || 0;
}
function getUserMidnight(timezone, date = new Date()) {
const zonedDate = toZonedTime(date, timezone);
zonedDate.setHours(0, 0, 0, 0);
return fromZonedTime(zonedDate, timezone);
}
const frequencyDays = {
weekly: 7,
biweekly: 14,
monthly: 30, // Not used for monthly anymore
};
function calculateNextPayday(firstIncomeDate, frequency, fromDate = new Date(), timezone = 'UTC') {
const normalizedFrom = getUserMidnight(timezone, fromDate);
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
// Get the target day in the USER'S timezone
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
const targetDay = zonedFirstIncome.getDate();
let iterations = 0;
while (nextPayDate < normalizedFrom) {
if (frequency === 'monthly') {
// Work in user's timezone for month advancement
const zonedPayDate = toZonedTime(nextPayDate, timezone);
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
zonedPayDate.setHours(0, 0, 0, 0);
const newPayDate = fromZonedTime(zonedPayDate, timezone);
nextPayDate.setTime(newPayDate.getTime());
} else {
nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]);
}
iterations++;
}
return { nextPayDate, iterations, targetDay };
}
function countPayPeriodsBetween(startDate, endDate, firstIncomeDate, frequency, timezone = 'UTC') {
let count = 0;
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
const normalizedStart = getUserMidnight(timezone, startDate);
const normalizedEnd = getUserMidnight(timezone, endDate);
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
const targetDay = zonedFirstIncome.getDate();
const advanceByPeriod = () => {
if (frequency === 'monthly') {
const zonedPayDate = toZonedTime(nextPayDate, timezone);
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
zonedPayDate.setHours(0, 0, 0, 0);
const newPayDate = fromZonedTime(zonedPayDate, timezone);
nextPayDate.setTime(newPayDate.getTime());
} else {
nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]);
}
};
while (nextPayDate < normalizedStart) {
advanceByPeriod();
}
while (nextPayDate < normalizedEnd) {
count++;
advanceByPeriod();
}
return Math.max(1, count);
}
// Helper to format dates
const fmt = (d) => d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
const fmtISO = (d) => d.toISOString().split('T')[0];
console.log('═══════════════════════════════════════════════════════════════');
console.log(' MONTHLY INCOME PAYDAY CALCULATION TESTS (Timezone-Aware)');
console.log('═══════════════════════════════════════════════════════════════\n');
// Test 1: Monthly payday on the 15th - America/New_York
console.log('TEST 1: Monthly payday on the 15th (America/New_York)');
console.log('─────────────────────────────────────');
const firstPayday15 = new Date('2025-01-15T05:00:00.000Z'); // Midnight EST = 5am UTC
const today = new Date('2025-12-20T05:00:00.000Z');
const result1 = calculateNextPayday(firstPayday15, 'monthly', today, 'America/New_York');
console.log(`First income (UTC): ${firstPayday15.toISOString()}`);
console.log(`Today (UTC): ${today.toISOString()}`);
console.log(`Target day: ${result1.targetDay}th of month`);
console.log(`Next payday (UTC): ${result1.nextPayDate.toISOString()}`);
console.log(`Iterations: ${result1.iterations}`);
console.log(`✓ Should be Jan 15, 2026 in EST\n`);
// Test 2: Edge case - payday stored as UTC midnight crossing timezone boundary
console.log('TEST 2: Timezone boundary edge case');
console.log('─────────────────────────────────────');
// If user in LA set payday to "15th", it might be stored as 2025-01-15T08:00:00Z (midnight PST)
const firstPaydayLA = new Date('2025-01-15T08:00:00.000Z');
const todayLA = new Date('2025-12-20T08:00:00.000Z');
const resultLA = calculateNextPayday(firstPaydayLA, 'monthly', todayLA, 'America/Los_Angeles');
console.log(`Timezone: America/Los_Angeles`);
console.log(`First income (UTC): ${firstPaydayLA.toISOString()}`);
console.log(`Target day: ${resultLA.targetDay}th of month`);
console.log(`Next payday (UTC): ${resultLA.nextPayDate.toISOString()}`);
console.log(`✓ Target day should be 15, not 14 or 16\n`);
// Test 3: Compare UTC vs timezone-aware for same "15th" payday
console.log('TEST 3: UTC vs Timezone-aware comparison');
console.log('─────────────────────────────────────');
const sameDate = new Date('2025-01-15T00:00:00.000Z'); // Midnight UTC
const fromDate = new Date('2025-06-01T00:00:00.000Z');
const resultUTC = calculateNextPayday(sameDate, 'monthly', fromDate, 'UTC');
const resultEST = calculateNextPayday(sameDate, 'monthly', fromDate, 'America/New_York');
const resultTokyo = calculateNextPayday(sameDate, 'monthly', fromDate, 'Asia/Tokyo');
console.log(`Date stored: ${sameDate.toISOString()}`);
console.log(`From date: ${fromDate.toISOString()}`);
console.log(`UTC target day: ${resultUTC.targetDay} → Next: ${fmtISO(resultUTC.nextPayDate)}`);
console.log(`EST target day: ${resultEST.targetDay} → Next: ${fmtISO(resultEST.nextPayDate)}`);
console.log(`JST target day: ${resultTokyo.targetDay} → Next: ${fmtISO(resultTokyo.nextPayDate)}`);
console.log(`⚠️ Same UTC date shows different "day of month" in different timezones!\n`);
// Test 4: Monthly payday on 31st with day clamping
console.log('TEST 4: Monthly payday on 31st (day clamping)');
console.log('─────────────────────────────────────');
const firstPayday31 = new Date('2025-01-31T05:00:00.000Z');
console.log(`First payday: Jan 31, 2025`);
let tempDate = getUserMidnight('America/New_York', firstPayday31);
console.log(`\nPayday progression:`);
for (let i = 0; i < 6; i++) {
const zoned = toZonedTime(tempDate, 'America/New_York');
console.log(` ${zoned.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`);
// Advance by month
zoned.setMonth(zoned.getMonth() + 1);
const maxDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate();
zoned.setDate(Math.min(31, maxDay));
zoned.setHours(0, 0, 0, 0);
tempDate = fromZonedTime(zoned, 'America/New_York');
}
console.log(`✓ Feb shows 28th (clamped), other months show 31st or 30th\n`);
// Test 5: Count pay periods with timezone
console.log('TEST 5: Count pay periods (timezone-aware)');
console.log('─────────────────────────────────────');
const firstIncome = new Date('2025-01-15T05:00:00.000Z');
const nowDate = new Date('2025-12-20T05:00:00.000Z');
const billDue = new Date('2026-03-01T05:00:00.000Z');
const periodsEST = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'America/New_York');
const periodsUTC = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'UTC');
console.log(`Now: Dec 20, 2025`);
console.log(`Bill due: Mar 1, 2026`);
console.log(`First income: Jan 15, 2025`);
console.log(`Periods (EST): ${periodsEST}`);
console.log(`Periods (UTC): ${periodsUTC}`);
console.log(`✓ Should be 2-3 periods (Jan 15, Feb 15)\n`);
// Test 6: OLD vs NEW comparison (with timezone)
console.log('TEST 6: OLD (30 days) vs NEW (actual month) - with timezone');
console.log('─────────────────────────────────────');
const startDate = new Date('2025-01-15T05:00:00.000Z');
let oldDate = new Date(startDate);
let newResult = calculateNextPayday(startDate, 'monthly', startDate, 'America/New_York');
let newDate = new Date(newResult.nextPayDate);
console.log('Month | OLD (30 days) | NEW (timezone) | Drift');
console.log('──────┼────────────────┼─────────────────┼───────');
for (let i = 0; i < 12; i++) {
oldDate.setDate(oldDate.getDate() + 30);
// For new method, advance one month from previous
const nextFrom = new Date(newDate.getTime() + 24 * 60 * 60 * 1000); // +1 day to get next
newResult = calculateNextPayday(startDate, 'monthly', nextFrom, 'America/New_York');
newDate = newResult.nextPayDate;
const drift = Math.round((oldDate - newDate) / (24 * 60 * 60 * 1000));
console.log(` ${String(i + 1).padStart(2)} | ${fmtISO(oldDate)} | ${fmtISO(newDate)} | ${drift > 0 ? '+' : ''}${drift} days`);
}
console.log('\n✓ NEW method stays on the 15th (in user\'s timezone)!');
console.log('✓ OLD method drifts 5-6 days early after 12 months\n');
console.log('═══════════════════════════════════════════════════════════════');
console.log(' ALL TESTS COMPLETE - Timezone handling verified');
console.log('═══════════════════════════════════════════════════════════════');

View File

@@ -1,41 +0,0 @@
#!/bin/bash
# Test overdue payment via API endpoint
# Login to get token
echo "🔐 Logging in..."
LOGIN_RESPONSE=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@skymoney.com","password":"password123"}')
TOKEN=$(echo $LOGIN_RESPONSE | grep -o '"token":"[^"]*' | cut -d'"' -f4)
if [ -z "$TOKEN" ]; then
echo "❌ Login failed"
exit 1
fi
echo "✅ Logged in successfully"
# Check current state
echo -e "\n📋 Checking current plans..."
curl -s -X GET http://localhost:8080/api/fixed-plans \
-H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}'
# Post $500 income - should pay $500 to overdue (was $1000, now $500 remaining)
echo -e "\n💰 Posting $500 income..."
INCOME_RESPONSE=$(curl -s -X POST http://localhost:8080/api/income \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"amountCents": 50000,
"postedAt": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'",
"note": "Test income for overdue"
}')
echo $INCOME_RESPONSE | jq '.'
# Check state after income
echo -e "\n📋 Checking plans after income..."
curl -s -X GET http://localhost:8080/api/fixed-plans \
-H "Authorization: Bearer $TOKEN" | jq '.plans[] | {name: .name, funded: .fundedCents, total: .totalCents, isOverdue: .isOverdue, overdueAmount: .overdueAmount}'

View File

@@ -1,133 +0,0 @@
// Script to post test income and verify overdue payment
const { PrismaClient } = require('@prisma/client');
const { randomUUID } = require('crypto');
async function main() {
const prisma = new PrismaClient();
try {
const user = await prisma.user.findUnique({
where: { email: 'test@skymoney.com' }
});
if (!user) {
console.log('❌ Test user not found. Run create-test-user.cjs first.');
return;
}
console.log('✅ Found test user:', user.email);
// Check overdue status BEFORE posting income
const plansBefore = await prisma.fixedPlan.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
isOverdue: true,
overdueAmount: true,
},
});
console.log('\n📋 Plans BEFORE income:');
for (const plan of plansBefore) {
console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`);
}
// Post $1000 income - should pay $500 to overdue first, then allocate $500 normally
const incomeAmount = 100000; // $1000 in cents
console.log(`\n💰 Posting income: $${incomeAmount / 100}`);
const incomeId = randomUUID();
const now = new Date().toISOString();
// Simulate what the allocateIncome function does
const result = await prisma.$transaction(async (tx) => {
await tx.incomeEvent.create({
data: {
id: incomeId,
userId: user.id,
postedAt: now,
amountCents: BigInt(incomeAmount),
note: 'Test income for overdue payment',
},
});
// Find overdue plans
const overduePlans = await tx.fixedPlan.findMany({
where: {
userId: user.id,
isOverdue: true,
overdueAmount: { gt: 0 },
},
orderBy: { overdueSince: 'asc' },
});
console.log(`\n🔍 Found ${overduePlans.length} overdue plan(s)`);
let remaining = incomeAmount;
for (const plan of overduePlans) {
if (remaining <= 0) break;
const overdueAmount = Number(plan.overdueAmount);
const amountToPay = Math.min(overdueAmount, remaining);
console.log(` Paying $${amountToPay / 100} to ${plan.name} (was $${overdueAmount / 100} overdue)`);
// Create allocation
await tx.allocation.create({
data: {
userId: user.id,
kind: 'fixed',
toId: plan.id,
amountCents: BigInt(amountToPay),
incomeId,
},
});
// Update plan
const newOverdueAmount = overdueAmount - amountToPay;
await tx.fixedPlan.update({
where: { id: plan.id },
data: {
fundedCents: (plan.fundedCents ?? 0n) + BigInt(amountToPay),
currentFundedCents: (plan.currentFundedCents ?? plan.fundedCents ?? 0n) + BigInt(amountToPay),
overdueAmount: newOverdueAmount,
isOverdue: newOverdueAmount > 0,
lastFundingDate: new Date(now),
},
});
remaining -= amountToPay;
}
return { remaining };
});
console.log(`\n💵 Remaining after overdue payments: $${result.remaining / 100}`);
// Check overdue status AFTER posting income
const plansAfter = await prisma.fixedPlan.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
isOverdue: true,
overdueAmount: true,
},
});
console.log('\n📋 Plans AFTER income:');
for (const plan of plansAfter) {
console.log(` ${plan.name}: $${Number(plan.fundedCents) / 100}/$${Number(plan.totalCents) / 100} (Overdue: ${plan.isOverdue ? `$${Number(plan.overdueAmount) / 100}` : 'NO'})`);
}
} finally {
await prisma.$disconnect();
}
}
main();

View File

@@ -1,19 +0,0 @@
#!/bin/bash
echo "<22><><EFBFBD> Logging in..."
curl -s -c cookies.txt -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@skymoney.com","password":"password123"}' > /dev/null
echo "<22><><EFBFBD> Plans BEFORE:"
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans
echo -e "\n\n<><6E><EFBFBD> Posting $1000 income..."
curl -s -b cookies.txt -X POST http://localhost:8080/api/income \
-H "Content-Type: application/json" \
-d "{\"amountCents\": 100000, \"postedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"note\": \"Test\"}"
echo -e "\n\n<><6E><EFBFBD> Plans AFTER:"
curl -s -b cookies.txt http://localhost:8080/api/fixed-plans
rm -f cookies.txt

View File

@@ -0,0 +1,83 @@
import { createHash, randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: true, SEED_DEFAULT_BUDGET: false });
await app.ready();
});
afterAll(async () => {
if (app) {
await app.close();
}
await prisma.$disconnect();
});
describe("Account delete access control", () => {
it("rejects confirm-delete when payload email targets another user", async () => {
const attackerEmail = `attacker-${Date.now()}@test.dev`;
const victimEmail = `victim-${Date.now()}@test.dev`;
const victimPassword = "VictimPass123!";
const deleteCode = "654321";
const [attacker, victim] = await Promise.all([
prisma.user.create({
data: {
email: attackerEmail,
passwordHash: await argon2.hash("AttackerPass123!"),
emailVerified: true,
},
}),
prisma.user.create({
data: {
email: victimEmail,
passwordHash: await argon2.hash(victimPassword),
emailVerified: true,
},
}),
]);
try {
await prisma.emailToken.create({
data: {
userId: victim.id,
type: "delete",
tokenHash: createHash("sha256").update(deleteCode).digest("hex"),
expiresAt: new Date(Date.now() + 60_000),
},
});
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/account/confirm-delete")
.set("x-user-id", attacker.id)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({
email: victim.email,
code: deleteCode,
password: victimPassword,
});
expect(res.status).toBe(403);
const victimStillExists = await prisma.user.findUnique({
where: { id: victim.id },
select: { id: true },
});
expect(victimStillExists?.id).toBe(victim.id);
} finally {
await prisma.user.deleteMany({
where: { id: { in: [attacker.id, victim.id] } },
});
}
});
});

View File

@@ -0,0 +1,113 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let authEnabledApp: FastifyInstance;
let authDisabledApp: FastifyInstance;
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
if (!setCookie) return null;
for (const raw of setCookie) {
const firstPart = raw.split(";")[0] ?? "";
const [name, value] = firstPart.split("=");
if (name?.trim() === cookieName && value) return value.trim();
}
return null;
}
beforeAll(async () => {
authEnabledApp = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false });
await authEnabledApp.ready();
authDisabledApp = await buildApp({
NODE_ENV: "production",
AUTH_DISABLED: true,
ALLOW_INSECURE_AUTH_FOR_DEV: true,
SEED_DEFAULT_BUDGET: false,
CORS_ORIGINS: "https://allowed.example.com",
APP_ORIGIN: "https://allowed.example.com",
});
await authDisabledApp.ready();
});
afterAll(async () => {
if (authEnabledApp) await authEnabledApp.close();
if (authDisabledApp) await authDisabledApp.close();
await prisma.$disconnect();
});
describe("/admin/rollover access control", () => {
it("returns 401 without a valid authenticated session when AUTH_DISABLED=false", async () => {
const res = await request(authEnabledApp.server)
.post("/admin/rollover")
.send({ dryRun: true });
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("returns 403 for authenticated users when AUTH_DISABLED=false", async () => {
const agent = request.agent(authEnabledApp.server);
const email = `rollover-auth-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: {
email,
passwordHash: await argon2.hash(password),
emailVerified: true,
},
});
try {
const login = await agent.post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
expect(csrf).toBeTruthy();
const res = await agent
.post("/admin/rollover")
.set("x-csrf-token", csrf as string)
.send({ dryRun: true });
expect(res.status).toBe(403);
expect(res.body.ok).toBe(false);
} finally {
await prisma.user.deleteMany({ where: { email } });
}
});
it("returns 403 for non-internal client IP when AUTH_DISABLED=true", async () => {
const csrf = randomUUID().replace(/-/g, "");
const res = await request(authDisabledApp.server)
.post("/admin/rollover")
.set("x-user-id", `external-${Date.now()}`)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.set("x-forwarded-for", "8.8.8.8")
.send({ dryRun: true });
expect(res.status).toBe(403);
expect(res.body.ok).toBe(false);
});
it("allows internal client IP when AUTH_DISABLED=true", async () => {
const csrf = randomUUID().replace(/-/g, "");
const res = await request(authDisabledApp.server)
.post("/admin/rollover")
.set("x-user-id", `internal-${Date.now()}`)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.set("x-forwarded-for", "10.23.45.67")
.send({ dryRun: true });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.dryRun).toBe(true);
expect(typeof res.body.processed).toBe("number");
});
});

View File

@@ -2,18 +2,33 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
if (!setCookie) return null;
for (const raw of setCookie) {
const firstPart = raw.split(";")[0] ?? "";
const [name, value] = firstPart.split("=");
if (name?.trim() === cookieName && value) {
return value.trim();
}
}
return null;
}
beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: true });
await app.ready();
});
afterAll(async () => {
await app.close();
if (app) {
await app.close();
}
await prisma.$disconnect();
});
@@ -24,6 +39,26 @@ describe("Auth routes", () => {
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("rejects spoofed x-user-id when auth is enabled", async () => {
const res = await request(app.server)
.get("/dashboard")
.set("x-user-id", "spoofed-user-id");
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("rejects weak passwords on registration", async () => {
const agent = request.agent(app.server);
const email = `weak-${Date.now()}@test.dev`;
const password = "weakpass123";
const register = await agent.post("/auth/register").send({ email, password });
expect(register.status).toBe(400);
expect(register.body.ok).toBe(false);
const user = await prisma.user.findUnique({ where: { email } });
expect(user).toBeNull();
});
it("registers a user and grants access via cookie session", async () => {
const agent = request.agent(app.server);
const email = `reg-${Date.now()}@test.dev`;
@@ -31,9 +66,10 @@ describe("Auth routes", () => {
const register = await agent.post("/auth/register").send({ email, password });
expect(register.status).toBe(200);
expect(register.body.needsVerification).toBe(true);
const dash = await agent.get("/dashboard");
expect(dash.status).toBe(200);
expect(dash.status).toBe(401);
const created = await prisma.user.findUniqueOrThrow({ where: { email } });
const [catCount, planCount] = await Promise.all([
@@ -52,7 +88,10 @@ describe("Auth routes", () => {
const password = "SupersAFE123!";
await agent.post("/auth/register").send({ email, password });
await agent.post("/auth/logout");
await prisma.user.update({
where: { email },
data: { emailVerified: true },
});
const login = await agent.post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
@@ -69,15 +108,54 @@ describe("Auth routes", () => {
const password = "SupersAFE123!";
await agent.post("/auth/register").send({ email, password });
await prisma.user.update({
where: { email },
data: { emailVerified: true },
});
const login = await agent.post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
expect(csrf).toBeTruthy();
const session = await agent.get("/auth/session");
expect(session.status).toBe(200);
expect(session.body.userId).toBeDefined();
await agent.post("/auth/logout");
await agent
.post("/auth/logout")
.set("x-csrf-token", csrf as string);
const afterLogout = await agent.get("/dashboard");
expect(afterLogout.status).toBe(401);
await prisma.user.deleteMany({ where: { email } });
});
it("locks login after repeated failed attempts", async () => {
const agent = request.agent(app.server);
const email = `locked-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: {
email,
passwordHash: await argon2.hash(password),
emailVerified: true,
},
});
for (let attempt = 1; attempt <= 4; attempt++) {
const res = await agent.post("/auth/login").send({ email, password: "WrongPass123!" });
expect(res.status).toBe(401);
}
const locked = await agent.post("/auth/login").send({ email, password: "WrongPass123!" });
expect(locked.status).toBe(429);
expect(locked.body.code).toBe("LOGIN_LOCKED");
expect(locked.headers["retry-after"]).toBeTruthy();
const blockedValid = await agent.post("/auth/login").send({ email, password });
expect(blockedValid.status).toBe(429);
expect(blockedValid.body.code).toBe("LOGIN_LOCKED");
await prisma.user.deleteMany({ where: { email } });
});
});

View File

@@ -0,0 +1,150 @@
import { createHmac } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { buildApp } from "../src/server";
const JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
const COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
let app: FastifyInstance;
function base64UrlEncode(value: string): string {
return Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function signHs256Token(payload: Record<string, unknown>, secret: string): string {
const header = { alg: "HS256", typ: "JWT" };
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = createHmac("sha256", secret)
.update(signingInput)
.digest("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
return `${signingInput}.${signature}`;
}
beforeAll(async () => {
app = await buildApp({
NODE_ENV: "test",
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
JWT_SECRET,
COOKIE_SECRET,
JWT_ISSUER: "skymoney-api",
JWT_AUDIENCE: "skymoney-web",
APP_ORIGIN: "http://localhost:5173",
});
await app.ready();
// Keep JWT verification behavior under test without requiring DB connectivity.
(app as any).ensureUser = async () => undefined;
});
afterAll(async () => {
if (app) await app.close();
});
describe("A04 Cryptographic Failures (runtime adversarial checks)", () => {
const csrf = "runtime-test-csrf-token";
it("rejects token with wrong issuer", async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const token = signHs256Token(
{
sub: `wrong-iss-${Date.now()}`,
iss: "attacker-issuer",
aud: "skymoney-web",
iat: nowSeconds,
exp: nowSeconds + 600,
},
JWT_SECRET
);
const res = await request(app.server)
.post("/auth/refresh")
.set("x-csrf-token", csrf)
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("rejects token with wrong audience", async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const token = signHs256Token(
{
sub: `wrong-aud-${Date.now()}`,
iss: "skymoney-api",
aud: "attacker-app",
iat: nowSeconds,
exp: nowSeconds + 600,
},
JWT_SECRET
);
const res = await request(app.server)
.post("/auth/refresh")
.set("x-csrf-token", csrf)
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("rejects token with alg=none (unsigned token)", async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const encodedHeader = base64UrlEncode(JSON.stringify({ alg: "none", typ: "JWT" }));
const encodedPayload = base64UrlEncode(
JSON.stringify({
sub: `none-alg-${Date.now()}`,
iss: "skymoney-api",
aud: "skymoney-web",
iat: nowSeconds,
exp: nowSeconds + 600,
})
);
const token = `${encodedHeader}.${encodedPayload}.`;
const res = await request(app.server)
.post("/auth/refresh")
.set("x-csrf-token", csrf)
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("accepts token with correct signature, issuer, and audience", async () => {
const userId = `valid-${Date.now()}`;
const findUniqueMock = vi
.spyOn((app as any).prisma.user, "findUnique")
.mockResolvedValue({ id: userId, passwordChangedAt: null });
const nowSeconds = Math.floor(Date.now() / 1000);
const token = signHs256Token(
{
sub: userId,
iss: "skymoney-api",
aud: "skymoney-web",
iat: nowSeconds,
exp: nowSeconds + 600,
},
JWT_SECRET
);
const res = await request(app.server)
.post("/auth/refresh")
.set("x-csrf-token", csrf)
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
findUniqueMock.mockRestore();
});
});

View File

@@ -0,0 +1,83 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
process.env = { ...ORIGINAL_ENV };
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
function setRequiredBaseEnv() {
process.env.DATABASE_URL = "postgres://app:app@127.0.0.1:5432/skymoney";
process.env.JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
process.env.COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
process.env.CORS_ORIGINS = "https://allowed.example.com";
process.env.AUTH_DISABLED = "0";
process.env.SEED_DEFAULT_BUDGET = "0";
}
describe("A04 Cryptographic Failures", () => {
it("rejects non-https APP_ORIGIN in production", async () => {
setRequiredBaseEnv();
process.env.NODE_ENV = "production";
process.env.APP_ORIGIN = "http://allowed.example.com";
await expect(import("../src/env")).rejects.toThrow(
"APP_ORIGIN must use https:// in production."
);
});
it("applies secure JWT defaults for issuer and audience", async () => {
setRequiredBaseEnv();
process.env.NODE_ENV = "production";
process.env.APP_ORIGIN = "https://allowed.example.com";
delete process.env.JWT_ISSUER;
delete process.env.JWT_AUDIENCE;
const envModule = await import("../src/env");
expect(envModule.env.JWT_ISSUER).toBe("skymoney-api");
expect(envModule.env.JWT_AUDIENCE).toBe("skymoney-web");
});
it("registers fastify-jwt with explicit algorithm and claim validation", async () => {
const jwtPlugin = vi.fn(async () => undefined);
vi.doMock("@fastify/jwt", () => ({
default: jwtPlugin,
}));
const { buildApp } = await import("../src/server");
const app = await buildApp({
NODE_ENV: "test",
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
JWT_SECRET: "test-jwt-secret-32-chars-min-abcdef",
COOKIE_SECRET: "test-cookie-secret-32-chars-abcdef",
AUTH_DISABLED: true,
SEED_DEFAULT_BUDGET: false,
APP_ORIGIN: "http://localhost:5173",
});
try {
await app.ready();
expect(jwtPlugin.mock.calls.length).toBeGreaterThan(0);
const jwtCall = jwtPlugin.mock.calls.find((call) => {
const opts = call[1] as Record<string, any> | undefined;
return !!opts?.sign && !!opts?.verify;
});
expect(jwtCall).toBeTruthy();
const jwtOptions = jwtCall?.[1] as Record<string, any>;
expect(jwtOptions.sign.algorithm).toBe("HS256");
expect(jwtOptions.sign.iss).toBe("skymoney-api");
expect(jwtOptions.sign.aud).toBe("skymoney-web");
expect(jwtOptions.verify.algorithms).toEqual(["HS256"]);
expect(jwtOptions.verify.allowedIss).toBe("skymoney-api");
expect(jwtOptions.verify.allowedAud).toBe("skymoney-web");
} finally {
await app.close();
}
});
});

View File

@@ -0,0 +1,179 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { buildApp } from "../src/server";
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
let app: FastifyInstance;
const CSRF = "test-csrf";
function mutate(path: string) {
return request(app.server)
.post(path)
.set("x-user-id", U)
.set("x-csrf-token", CSRF)
.set("Cookie", [`csrf=${CSRF}`]);
}
beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: true, SEED_DEFAULT_BUDGET: false });
await app.ready();
});
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
await prisma.variableCategory.createMany({
data: [
{ userId: U, name: "Essentials", percent: 60, priority: 10, balanceCents: 10_000n },
{ userId: U, name: "Savings", percent: 40, priority: 20, balanceCents: 10_000n, isSavings: true },
],
});
});
afterAll(async () => {
if (app) await app.close();
await closePrisma();
});
describe("estimated fixed plans true-up", () => {
it("creates estimated mode plans with estimate fields", async () => {
const dueOn = new Date("2026-04-01T00:00:00.000Z").toISOString();
const res = await mutate("/fixed-plans").send({
name: "Water",
amountMode: "estimated",
estimatedCents: 12000,
totalCents: 12000,
fundedCents: 0,
priority: 10,
dueOn,
frequency: "monthly",
autoPayEnabled: false,
});
expect(res.status).toBe(201);
const created = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: res.body.id } });
expect(created.amountMode).toBe("estimated");
expect(Number(created.estimatedCents ?? 0n)).toBe(12000);
expect(Number(created.totalCents ?? 0n)).toBe(12000);
});
it("true-up deficit auto-pulls from available budget and leaves remaining shortfall", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Water Deficit",
amountMode: "estimated",
estimatedCents: 10000n,
totalCents: 10000n,
fundedCents: 6000n,
currentFundedCents: 6000n,
priority: 10,
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
dueOn: new Date("2026-04-01T00:00:00.000Z"),
frequency: "monthly",
},
});
const res = await mutate(`/fixed-plans/${plan.id}/true-up-actual`).send({ actualCents: 23000 });
expect(res.status).toBe(200);
expect(res.body.deltaCents).toBe(13000);
expect(res.body.autoPulledCents).toBe(13000);
expect(res.body.remainingShortfallCents).toBe(4000);
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
expect(Number(updated.totalCents ?? 0n)).toBe(23000);
expect(Number(updated.currentFundedCents ?? 0n)).toBe(19000);
expect(Number(updated.actualCents ?? 0n)).toBe(23000);
expect(updated.actualCycleDueOn?.toISOString()).toBe(updated.dueOn.toISOString());
expect(updated.actualRecordedAt).toBeTruthy();
});
it("true-up surplus refunds funded excess back to available budget", async () => {
const categoriesBefore = await prisma.variableCategory.findMany({ where: { userId: U } });
const budgetBefore = categoriesBefore.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Water Surplus",
amountMode: "estimated",
estimatedCents: 15000n,
totalCents: 15000n,
fundedCents: 15000n,
currentFundedCents: 15000n,
priority: 10,
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
dueOn: new Date("2026-04-01T00:00:00.000Z"),
frequency: "monthly",
},
});
const res = await mutate(`/fixed-plans/${plan.id}/true-up-actual`).send({ actualCents: 9000 });
expect(res.status).toBe(200);
expect(res.body.deltaCents).toBe(-6000);
expect(res.body.refundedCents).toBe(6000);
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
expect(Number(updated.totalCents ?? 0n)).toBe(9000);
expect(Number(updated.currentFundedCents ?? 0n)).toBe(9000);
const categoriesAfter = await prisma.variableCategory.findMany({ where: { userId: U } });
const budgetAfter = categoriesAfter.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
expect(budgetAfter - budgetBefore).toBe(6000);
});
it("rejects true-up for fixed mode plans", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Rent Fixed",
amountMode: "fixed",
totalCents: 120000n,
fundedCents: 120000n,
currentFundedCents: 120000n,
priority: 10,
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
dueOn: new Date("2026-04-01T00:00:00.000Z"),
frequency: "monthly",
},
});
const res = await mutate(`/fixed-plans/${plan.id}/true-up-actual`).send({ actualCents: 100000 });
expect(res.status).toBe(400);
expect(res.body.code).toBe("PLAN_NOT_ESTIMATED");
});
it("pay-now rollover resets estimated plan target back to estimate and clears cycle actual metadata", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Water Rollover",
amountMode: "estimated",
estimatedCents: 10000n,
totalCents: 16000n,
fundedCents: 16000n,
currentFundedCents: 16000n,
actualCents: 16000n,
actualCycleDueOn: new Date("2026-04-01T00:00:00.000Z"),
actualRecordedAt: new Date(),
priority: 10,
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
dueOn: new Date("2026-04-01T00:00:00.000Z"),
frequency: "monthly",
},
});
const res = await mutate(`/fixed-plans/${plan.id}/pay-now`).send({});
expect(res.status).toBe(200);
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
expect(Number(updated.totalCents ?? 0n)).toBe(10000);
expect(updated.actualCents).toBeNull();
expect(updated.actualCycleDueOn).toBeNull();
expect(updated.actualRecordedAt).toBeNull();
});
});

View File

@@ -0,0 +1,176 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { createHash } from "node:crypto";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
beforeAll(async () => {
app = await buildApp({
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: 20,
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: 20,
});
await app.ready();
});
afterAll(async () => {
if (app) await app.close();
await prisma.$disconnect();
});
describe("Forgot password security", () => {
it("returns generic success and only issues reset token for verified users", async () => {
const verifiedEmail = `fp-verified-${Date.now()}@test.dev`;
const unverifiedEmail = `fp-unverified-${Date.now()}@test.dev`;
const passwordHash = await argon2.hash("SupersAFE123!");
const [verifiedUser, unverifiedUser] = await Promise.all([
prisma.user.create({
data: { email: verifiedEmail, passwordHash, emailVerified: true },
}),
prisma.user.create({
data: { email: unverifiedEmail, passwordHash, emailVerified: false },
}),
]);
const unknownRes = await request(app.server)
.post("/auth/forgot-password/request")
.send({ email: `missing-${Date.now()}@test.dev` });
const verifiedRes = await request(app.server)
.post("/auth/forgot-password/request")
.send({ email: verifiedEmail });
const unverifiedRes = await request(app.server)
.post("/auth/forgot-password/request")
.send({ email: unverifiedEmail });
expect(unknownRes.status).toBe(200);
expect(verifiedRes.status).toBe(200);
expect(unverifiedRes.status).toBe(200);
expect(unknownRes.body).toEqual({
ok: true,
message: "If an account exists, reset instructions were sent.",
});
expect(verifiedRes.body).toEqual(unknownRes.body);
expect(unverifiedRes.body).toEqual(unknownRes.body);
const [verifiedTokens, unverifiedTokens] = await Promise.all([
prisma.emailToken.count({
where: { userId: verifiedUser.id, type: "password_reset", usedAt: null },
}),
prisma.emailToken.count({
where: { userId: unverifiedUser.id, type: "password_reset", usedAt: null },
}),
]);
expect(verifiedTokens).toBe(1);
expect(unverifiedTokens).toBe(0);
await prisma.user.deleteMany({
where: { email: { in: [verifiedEmail, unverifiedEmail] } },
});
});
it("consumes reset token once, updates password, and invalidates prior session", async () => {
const email = `fp-confirm-${Date.now()}@test.dev`;
const oldPassword = "SupersAFE123!";
const newPassword = "EvenStrong3rPass!";
const rawToken = "reset-token-value-0123456789abcdef";
const user = await prisma.user.create({
data: {
email,
emailVerified: true,
passwordHash: await argon2.hash(oldPassword),
},
});
const login = await request(app.server).post("/auth/login").send({ email, password: oldPassword });
expect(login.status).toBe(200);
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c: string) => c.startsWith("session="));
expect(sessionCookie).toBeTruthy();
await prisma.emailToken.create({
data: {
userId: user.id,
type: "password_reset",
tokenHash: hashToken(rawToken),
expiresAt: new Date(Date.now() + 30 * 60_000),
},
});
const confirm = await request(app.server)
.post("/auth/forgot-password/confirm")
.send({ uid: user.id, token: rawToken, newPassword });
expect(confirm.status).toBe(200);
expect(confirm.body.ok).toBe(true);
const tokenRecord = await prisma.emailToken.findFirst({
where: { userId: user.id, type: "password_reset", tokenHash: hashToken(rawToken) },
});
expect(tokenRecord?.usedAt).toBeTruthy();
const secondUse = await request(app.server)
.post("/auth/forgot-password/confirm")
.send({ uid: user.id, token: rawToken, newPassword: "AnotherStrongPass4!" });
expect(secondUse.status).toBe(400);
expect(secondUse.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK");
const oldSessionAccess = await request(app.server)
.get("/dashboard")
.set("Cookie", [sessionCookie as string]);
expect(oldSessionAccess.status).toBe(401);
const oldPasswordLogin = await request(app.server)
.post("/auth/login")
.send({ email, password: oldPassword });
expect(oldPasswordLogin.status).toBe(401);
const newPasswordLogin = await request(app.server)
.post("/auth/login")
.send({ email, password: newPassword });
expect(newPasswordLogin.status).toBe(200);
await prisma.user.deleteMany({ where: { id: user.id } });
});
it("rejects uid/token mismatches with generic error", async () => {
const emailA = `fp-a-${Date.now()}@test.dev`;
const emailB = `fp-b-${Date.now()}@test.dev`;
const rawToken = "mismatch-token-value-abcdef0123456789";
const [userA, userB] = await Promise.all([
prisma.user.create({
data: { email: emailA, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") },
}),
prisma.user.create({
data: { email: emailB, emailVerified: true, passwordHash: await argon2.hash("SupersAFE123!") },
}),
]);
await prisma.emailToken.create({
data: {
userId: userA.id,
type: "password_reset",
tokenHash: hashToken(rawToken),
expiresAt: new Date(Date.now() + 30 * 60_000),
},
});
const mismatch = await request(app.server)
.post("/auth/forgot-password/confirm")
.send({ uid: userB.id, token: rawToken, newPassword: "MismatchPass9!" });
expect(mismatch.status).toBe(400);
expect(mismatch.body.code).toBe("INVALID_OR_EXPIRED_RESET_LINK");
await prisma.user.deleteMany({ where: { id: { in: [userA.id, userB.id] } } });
});
});

View File

@@ -23,6 +23,7 @@ export async function resetUser(userId: string) {
prisma.incomeEvent.deleteMany({ where: { userId } }),
prisma.fixedPlan.deleteMany({ where: { userId } }),
prisma.variableCategory.deleteMany({ where: { userId } }),
prisma.budgetSession.deleteMany({ where: { userId } }),
]);
await prisma.user.deleteMany({ where: { id: userId } });
}
@@ -31,8 +32,8 @@ export async function resetUser(userId: string) {
export async function ensureUser(userId: string) {
await prisma.user.upsert({
where: { id: userId },
update: {},
create: { id: userId, email: `${userId}@demo.local` },
update: { timezone: "UTC" },
create: { id: userId, email: `${userId}@demo.local`, timezone: "UTC" },
});
}

View File

@@ -0,0 +1,94 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
if (!setCookie) return null;
for (const raw of setCookie) {
const firstPart = raw.split(";")[0] ?? "";
const [name, value] = firstPart.split("=");
if (name?.trim() === cookieName && value) return value.trim();
}
return null;
}
beforeAll(async () => {
app = await buildApp({
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
AUTH_MAX_FAILED_ATTEMPTS: 3,
AUTH_LOCKOUT_WINDOW_MS: 120_000,
});
await app.ready();
});
afterAll(async () => {
if (app) await app.close();
await prisma.$disconnect();
});
describe("A07 Identification and Authentication Failures", () => {
it("rejects weak passwords on registration and password updates", async () => {
const regEmail = `weak-reg-${Date.now()}@test.dev`;
const weakRegister = await request(app.server)
.post("/auth/register")
.send({ email: regEmail, password: "weakpass123" });
expect(weakRegister.status).toBe(400);
const email = `pw-update-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: { email, passwordHash: await argon2.hash(password), emailVerified: true },
});
const login = await request(app.server).post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c) => c.startsWith("session="));
expect(csrf).toBeTruthy();
expect(sessionCookie).toBeTruthy();
const weakUpdate = await request(app.server)
.patch("/me/password")
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
.set("x-csrf-token", csrf as string)
.send({ currentPassword: password, newPassword: "weakpass123" });
expect(weakUpdate.status).toBe(400);
await prisma.user.deleteMany({ where: { email } });
});
it("locks login according to configured threshold/window", async () => {
const email = `lockout-threshold-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: { email, passwordHash: await argon2.hash(password), emailVerified: true },
});
for (let i = 0; i < 2; i++) {
const fail = await request(app.server).post("/auth/login").send({
email,
password: "WrongPass123!",
});
expect(fail.status).toBe(401);
}
const locked = await request(app.server).post("/auth/login").send({
email,
password: "WrongPass123!",
});
expect(locked.status).toBe(429);
expect(locked.body.code).toBe("LOGIN_LOCKED");
expect(locked.headers["retry-after"]).toBeTruthy();
const blockedValid = await request(app.server).post("/auth/login").send({ email, password });
expect(blockedValid.status).toBe(429);
await prisma.user.deleteMany({ where: { email } });
});
});

View File

@@ -3,6 +3,7 @@ import request from "supertest";
import { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { resetUser } from "./helpers";
import { randomUUID } from "node:crypto";
// Ensure env BEFORE importing the server
process.env.NODE_ENV = process.env.NODE_ENV || "test";
@@ -12,6 +13,7 @@ process.env.DATABASE_URL =
const prisma = new PrismaClient();
let app: FastifyInstance;
const csrf = randomUUID().replace(/-/g, "");
beforeAll(async () => {
// dynamic import AFTER env is set
@@ -61,6 +63,8 @@ describe("POST /income integration", () => {
const res = await request(app.server)
.post("/income")
.set("x-user-id", "demo-user-1")
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ amountCents: 5000 });
expect(res.status).toBe(200);

View File

@@ -4,8 +4,10 @@ import { describe, it, expect, beforeEach, afterAll, beforeAll } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
let app: FastifyInstance;
const csrf = randomUUID().replace(/-/g, "");
beforeAll(async () => {
app = await appFactory(); // <-- await the app
@@ -51,6 +53,8 @@ describe("POST /income", () => {
const res = await request(app.server)
.post("/income")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ amountCents: 15000 });
expect(res.statusCode).toBe(200);

View File

@@ -0,0 +1,99 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
describe("A05 Injection Safety", () => {
function runNormalizedScript(scriptPath: string, env: Record<string, string>) {
const tmpScriptDir = mkdtempSync(join(tmpdir(), "skymoney-a05-script-"));
const normalizedScript = join(tmpScriptDir, "script.sh");
writeFileSync(normalizedScript, readFileSync(scriptPath, "utf8").replace(/\r/g, ""));
chmodSync(normalizedScript, 0o755);
const res = spawnSync("bash", [normalizedScript], {
env: { ...process.env, ...env },
encoding: "utf8",
});
rmSync(tmpScriptDir, { recursive: true, force: true });
return res;
}
it("contains no unsafe Prisma raw SQL APIs across API source", () => {
const apiRoot = resolve(__dirname, "..");
const badPatterns = ["$queryRawUnsafe(", "$executeRawUnsafe("];
const queue = [resolve(apiRoot, "src")];
const offenders: string[] = [];
while (queue.length > 0) {
const current = queue.pop() as string;
for (const name of readdirSync(current)) {
const full = join(current, name);
if (statSync(full).isDirectory()) {
queue.push(full);
continue;
}
if (!full.endsWith(".ts")) continue;
const content = readFileSync(full, "utf8");
if (badPatterns.some((p) => content.includes(p))) offenders.push(full);
}
}
expect(offenders).toEqual([]);
});
it("rejects malicious restore DB identifiers before DB command execution", () => {
const repoRoot = resolve(__dirname, "..", "..");
const restoreScriptPath = resolve(repoRoot, "scripts/restore.sh");
const tmpRoot = mkdtempSync(join(tmpdir(), "skymoney-a05-restore-"));
const fakeBin = join(tmpRoot, "bin");
const backupFile = join(tmpRoot, "sample.dump");
const checksumFile = `${backupFile}.sha256`;
const markerFile = join(tmpRoot, "db_called.marker");
try {
mkdirSync(fakeBin, { recursive: true });
writeFileSync(backupFile, "valid-content");
const backupBytes = readFileSync(backupFile);
const hash = createHash("sha256").update(backupBytes).digest("hex");
writeFileSync(checksumFile, `${hash} sample.dump\n`);
const fakePsql = join(fakeBin, "psql");
const fakePgRestore = join(fakeBin, "pg_restore");
const fakeSha256sum = join(fakeBin, "sha256sum");
writeFileSync(fakePsql, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
writeFileSync(fakePgRestore, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
writeFileSync(
fakeSha256sum,
`#!/usr/bin/env bash\nset -euo pipefail\necho "${hash} $1"\n`
);
chmodSync(fakePsql, 0o755);
chmodSync(fakePgRestore, 0o755);
chmodSync(fakeSha256sum, 0o755);
const res = runNormalizedScript(restoreScriptPath, {
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
BACKUP_FILE: backupFile,
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
RESTORE_DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney_restore_test",
RESTORE_DB: 'bad-name"; DROP DATABASE skymoney; --',
});
expect(res.status).not.toBe(0);
expect(`${res.stdout}${res.stderr}`).toContain("RESTORE_DB must match");
expect(existsSync(markerFile)).toBe(false);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,114 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
if (!setCookie) return null;
for (const raw of setCookie) {
const firstPart = raw.split(";")[0] ?? "";
const [name, value] = firstPart.split("=");
if (name?.trim() === cookieName && value) return value.trim();
}
return null;
}
beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false });
await app.ready();
});
afterAll(async () => {
if (app) await app.close();
await prisma.$disconnect();
});
describe("A06 Insecure Design", () => {
it("allows one immediate verify resend, then enforces cooldown with 429 and Retry-After", async () => {
const email = `cooldown-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await request(app.server).post("/auth/register").send({ email, password });
const firstResend = await request(app.server).post("/auth/verify/resend").send({ email });
expect(firstResend.status).toBe(200);
expect(firstResend.body.ok).toBe(true);
const secondResend = await request(app.server).post("/auth/verify/resend").send({ email });
expect(secondResend.status).toBe(429);
expect(secondResend.body.code).toBe("EMAIL_TOKEN_COOLDOWN");
expect(secondResend.headers["retry-after"]).toBeTruthy();
await prisma.user.deleteMany({ where: { email } });
});
it("allows one immediate delete resend, then enforces cooldown with 429 and Retry-After", async () => {
const email = `delete-cooldown-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: {
email,
passwordHash: await argon2.hash(password),
emailVerified: true,
},
});
const login = await request(app.server).post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c) => c.startsWith("session="));
expect(csrf).toBeTruthy();
expect(sessionCookie).toBeTruthy();
const first = await request(app.server)
.post("/account/delete-request")
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
.set("x-csrf-token", csrf as string)
.send({ password });
expect(first.status).toBe(200);
const second = await request(app.server)
.post("/account/delete-request")
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
.set("x-csrf-token", csrf as string)
.send({ password });
expect(second.status).toBe(200);
const third = await request(app.server)
.post("/account/delete-request")
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
.set("x-csrf-token", csrf as string)
.send({ password });
expect(third.status).toBe(429);
expect(third.body.code).toBe("EMAIL_TOKEN_COOLDOWN");
expect(third.headers["retry-after"]).toBeTruthy();
await prisma.user.deleteMany({ where: { email } });
});
it("rate-limits repeated invalid verification attempts", async () => {
const email = `verify-rate-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await request(app.server).post("/auth/register").send({ email, password });
let limited = false;
for (let i = 0; i < 12; i++) {
const res = await request(app.server)
.post("/auth/verify")
.send({ email, code: randomUUID().slice(0, 6) });
if (res.status === 429) {
limited = true;
break;
}
}
expect(limited).toBe(true);
await prisma.user.deleteMany({ where: { email } });
});
});

View File

@@ -1,5 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest";
import { randomUUID } from "node:crypto";
import { toZonedTime, fromZonedTime } from "date-fns-tz";
import appFactory from "./appFactory";
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
@@ -16,15 +18,76 @@ beforeEach(async () => {
});
afterAll(async () => {
await app.close();
if (app) {
await app.close();
}
await closePrisma();
});
function getUserMidnightFromDateOnly(timezone: string, date: Date): Date {
const zoned = toZonedTime(date, timezone);
zoned.setHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
}
function calculateNextDueDateLikeServer(
currentDueDate: Date,
frequency: string,
timezone: string = "UTC"
): Date {
const base = getUserMidnightFromDateOnly(timezone, currentDueDate);
const zoned = toZonedTime(base, timezone);
switch (frequency) {
case "weekly":
zoned.setDate(zoned.getDate() + 7);
break;
case "biweekly":
zoned.setDate(zoned.getDate() + 14);
break;
case "monthly": {
const targetDay = zoned.getDate();
zoned.setDate(1);
zoned.setMonth(zoned.getMonth() + 1);
const lastDay = new Date(
zoned.getFullYear(),
zoned.getMonth() + 1,
0
).getDate();
zoned.setDate(Math.min(targetDay, lastDay));
break;
}
default:
return base;
}
zoned.setHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
}
describe("Payment-Triggered Rollover", () => {
async function getUserTimezoneOrDefault() {
const user = await prisma.user.findUnique({
where: { id: U },
select: { timezone: true },
});
return user?.timezone ?? "America/New_York";
}
function postTransactionsWithCsrf() {
const csrf = randomUUID().replace(/-/g, "");
return request(app.server)
.post("/transactions")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`);
}
it("advances due date for weekly frequency on payment", async () => {
// Create a fixed plan with weekly frequency
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Weekly Subscription",
totalCents: 1000n,
@@ -38,14 +101,13 @@ describe("Payment-Triggered Rollover", () => {
});
// Make payment
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 1000,
planId: plan.id,
isReconciled: true,
});
if (txRes.status !== 200) {
@@ -58,12 +120,19 @@ describe("Payment-Triggered Rollover", () => {
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");
const userTimezone = await getUserTimezoneOrDefault();
const expectedDue = calculateNextDueDateLikeServer(
new Date("2025-12-01T00:00:00Z"),
"weekly",
userTimezone
);
expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString());
});
it("advances due date for biweekly frequency on payment", async () => {
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Biweekly Bill",
totalCents: 5000n,
@@ -76,26 +145,32 @@ describe("Payment-Triggered Rollover", () => {
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 5000,
planId: plan.id,
isReconciled: true,
});
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");
const userTimezone = await getUserTimezoneOrDefault();
const expectedDue = calculateNextDueDateLikeServer(
new Date("2025-12-01T00:00:00Z"),
"biweekly",
userTimezone
);
expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString());
});
it("advances due date for monthly frequency on payment", async () => {
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Monthly Rent",
totalCents: 100000n,
@@ -108,27 +183,33 @@ describe("Payment-Triggered Rollover", () => {
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 100000,
planId: plan.id,
isReconciled: true,
});
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");
const userTimezone = await getUserTimezoneOrDefault();
const expectedDue = calculateNextDueDateLikeServer(
new Date("2025-12-01T00:00:00Z"),
"monthly",
userTimezone
);
expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString());
});
it("does not advance due date for one-time frequency", async () => {
it("deletes one-time plan after payment", async () => {
const originalDueDate = new Date("2025-12-01T00:00:00Z");
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "One-time Expense",
totalCents: 2000n,
@@ -141,28 +222,26 @@ describe("Payment-Triggered Rollover", () => {
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 2000,
planId: plan.id,
isReconciled: true,
});
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());
expect(updated).toBeNull();
});
it("does not advance due date when no frequency is set", async () => {
it("treats null frequency as one-time and deletes plan after payment", async () => {
const originalDueDate = new Date("2025-12-01T00:00:00Z");
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Manual Bill",
totalCents: 3000n,
@@ -175,27 +254,25 @@ describe("Payment-Triggered Rollover", () => {
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 3000,
planId: plan.id,
isReconciled: true,
});
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());
expect(updated).toBeNull();
});
it("prevents payment when insufficient funded amount", async () => {
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Underfunded Bill",
totalCents: 10000n,
@@ -209,18 +286,17 @@ describe("Payment-Triggered Rollover", () => {
});
// Try to pay more than funded amount
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 10000,
planId: plan.id,
isReconciled: true,
});
expect(txRes.status).toBe(400);
expect(txRes.body.code).toBe("OVERDRAFT_PLAN");
expect(txRes.body.code).toBe("INSUFFICIENT_AVAILABLE_BUDGET");
// Plan should remain unchanged
const unchanged = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });

View File

@@ -23,7 +23,8 @@ describe("rolloverFixedPlans", () => {
select: { id: true },
});
const results = await rolloverFixedPlans(prisma, "2025-01-10T00:00:00Z");
// Rollover job only processes after 6 AM in the user's timezone.
const results = await rolloverFixedPlans(prisma, "2025-01-10T10:00:00Z");
const match = results.find((r) => r.planId === plan.id);
expect(match?.cyclesAdvanced).toBe(1);
expect(match?.deficitCents).toBe(4000);
@@ -48,7 +49,8 @@ describe("rolloverFixedPlans", () => {
select: { id: true },
});
const results = await rolloverFixedPlans(prisma, "2025-02-05T00:00:00Z");
// Rollover job only processes after 6 AM in the user's timezone.
const results = await rolloverFixedPlans(prisma, "2025-02-05T10:00:00Z");
const match = results.find((r) => r.planId === plan.id);
expect(match?.cyclesAdvanced).toBe(2);
expect(match?.carryForwardCents).toBe(2000);

View File

@@ -0,0 +1,97 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { buildApp } from "../src/server";
let authApp: FastifyInstance;
let csrfApp: FastifyInstance;
const capturedEvents: Array<Record<string, unknown>> = [];
function attachSecurityEventCapture(app: FastifyInstance) {
const logger = app.log as any;
const originalChild = logger.child.bind(logger);
logger.child = (...args: any[]) => {
const child = originalChild(...args);
const originalWarn = child.warn.bind(child);
child.warn = (obj: unknown, ...rest: unknown[]) => {
if (obj && typeof obj === "object") {
const payload = obj as Record<string, unknown>;
if (typeof payload.securityEvent === "string") capturedEvents.push(payload);
}
return originalWarn(obj, ...rest);
};
return child;
};
}
beforeAll(async () => {
authApp = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false });
await authApp.ready();
(authApp as any).ensureUser = async () => undefined;
attachSecurityEventCapture(authApp);
csrfApp = await buildApp({ AUTH_DISABLED: true, SEED_DEFAULT_BUDGET: false });
await csrfApp.ready();
(csrfApp as any).ensureUser = async () => undefined;
attachSecurityEventCapture(csrfApp);
});
afterAll(async () => {
if (authApp) await authApp.close();
if (csrfApp) await csrfApp.close();
});
describe("A09 Security Logging and Monitoring Failures", () => {
it("emits structured security log for unauthenticated protected-route access", async () => {
capturedEvents.length = 0;
const res = await request(authApp.server).get("/dashboard");
expect(res.status).toBe(401);
const event = capturedEvents.find((payload) => {
return payload.securityEvent === "auth.unauthenticated_request";
});
expect(event).toBeTruthy();
expect(event?.outcome).toBe("failure");
expect(typeof event?.requestId).toBe("string");
expect(typeof event?.ip).toBe("string");
});
it("emits structured security log for csrf validation failures", async () => {
capturedEvents.length = 0;
const res = await request(csrfApp.server)
.post("/me")
.set("x-user-id", `csrf-user-${Date.now()}`)
.send({ displayName: "NoCsrf" });
expect(res.status).toBe(403);
expect(res.body.code).toBe("CSRF");
const event = capturedEvents.find((payload) => payload.securityEvent === "csrf.validation");
expect(event).toBeTruthy();
expect(event?.outcome).toBe("failure");
expect(typeof event?.requestId).toBe("string");
expect(typeof event?.ip).toBe("string");
});
it("emits structured security log for forgot-password requests without raw token data", async () => {
capturedEvents.length = 0;
const findUniqueMock = vi
.spyOn((authApp as any).prisma.user, "findUnique")
.mockResolvedValue(null);
const res = await request(authApp.server)
.post("/auth/forgot-password/request")
.send({ email: `missing-${Date.now()}@test.dev` });
expect(res.status).toBe(200);
const event = capturedEvents.find(
(payload) => payload.securityEvent === "auth.password_reset.request"
);
expect(event).toBeTruthy();
expect(event?.outcome).toBe("success");
expect(event && "token" in event).toBe(false);
findUniqueMock.mockRestore();
});
});

View File

@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
afterEach(async () => {
vi.resetAllMocks();
vi.resetModules();
vi.doUnmock("nodemailer");
});
describe("A02 Security Misconfiguration", () => {
it("enforces CORS allowlist in production", async () => {
const { buildApp } = await import("../src/server");
const app = await buildApp({
NODE_ENV: "production",
CORS_ORIGINS: "https://allowed.example.com",
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
APP_ORIGIN: "https://allowed.example.com",
});
await app.ready();
try {
const allowedOrigin = "https://allowed.example.com";
const deniedOrigin = "https://denied.example.com";
const allowed = await app.inject({
method: "OPTIONS",
url: "/health",
headers: {
origin: allowedOrigin,
"access-control-request-method": "GET",
},
});
expect(allowed.statusCode).toBe(204);
expect(allowed.headers["access-control-allow-origin"]).toBe(allowedOrigin);
expect(allowed.headers["access-control-allow-credentials"]).toBe("true");
const denied = await app.inject({
method: "OPTIONS",
url: "/health",
headers: {
origin: deniedOrigin,
"access-control-request-method": "GET",
},
});
expect([204, 404]).toContain(denied.statusCode);
expect(denied.headers["access-control-allow-origin"]).toBeUndefined();
expect(denied.headers["access-control-allow-credentials"]).toBeUndefined();
} finally {
await app.close();
}
});
it("applies expected CORS response headers at runtime for allowed origins", async () => {
const { buildApp } = await import("../src/server");
const app = await buildApp({
NODE_ENV: "production",
CORS_ORIGINS: "https://allowed.example.com",
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
APP_ORIGIN: "https://allowed.example.com",
});
await app.ready();
try {
const response = await app.inject({
method: "OPTIONS",
url: "/health",
headers: {
origin: "https://allowed.example.com",
"access-control-request-method": "GET",
},
});
expect(response.statusCode).toBe(204);
expect(response.headers["access-control-allow-origin"]).toBe(
"https://allowed.example.com"
);
expect(response.headers["access-control-allow-credentials"]).toBe("true");
expect(response.headers.vary).toContain("Origin");
} finally {
await app.close();
}
});
it("disables SMTP debug logger in production mailer config", async () => {
const verify = vi.fn(async () => undefined);
const sendMail = vi.fn(async () => ({ messageId: "mock-message-id" }));
const createTransport = vi.fn(() => ({ verify, sendMail }));
vi.doMock("nodemailer", () => ({
default: { createTransport },
}));
const { buildApp } = await import("../src/server");
const app = await buildApp({
NODE_ENV: "production",
SMTP_HOST: "smtp.example.com",
SMTP_PORT: 587,
SMTP_REQUIRE_TLS: true,
SMTP_TLS_REJECT_UNAUTHORIZED: true,
SMTP_USER: "smtp-user",
SMTP_PASS: "smtp-pass",
CORS_ORIGINS: "https://allowed.example.com",
APP_ORIGIN: "https://allowed.example.com",
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
});
try {
await app.ready();
expect(createTransport).toHaveBeenCalledTimes(1);
const transportOptions = createTransport.mock.calls[0]?.[0] as Record<string, unknown>;
expect(transportOptions.requireTLS).toBe(true);
expect(transportOptions.logger).toBe(false);
expect(transportOptions.debug).toBe(false);
} finally {
await app.close();
}
});
it("defines anti-framing headers in edge configs", () => {
const root = resolve(__dirname, "..", "..");
const caddyProd = readFileSync(resolve(root, "Caddyfile.prod"), "utf8");
const caddyDev = readFileSync(resolve(root, "Caddyfile.dev"), "utf8");
const nginxProd = readFileSync(
resolve(root, "deploy/nginx/skymoneybudget.com.conf"),
"utf8"
);
expect(caddyProd).toContain('X-Frame-Options "DENY"');
expect(caddyProd).toContain(`Content-Security-Policy "frame-ancestors 'none'"`);
expect(caddyDev).toContain('X-Frame-Options "DENY"');
expect(caddyDev).toContain(`Content-Security-Policy "frame-ancestors 'none'"`);
expect(nginxProd).toContain('add_header X-Frame-Options "DENY" always;');
expect(nginxProd).toContain(
`add_header Content-Security-Policy "frame-ancestors 'none'" always;`
);
});
it("keeps docker-compose service ports bound to localhost only", () => {
const root = resolve(__dirname, "..", "..");
const compose = readFileSync(resolve(root, "docker-compose.yml"), "utf8");
expect(compose).toContain('- "127.0.0.1:5432:5432"');
expect(compose).toContain('- "127.0.0.1:8081:8080"');
});
});

View File

@@ -0,0 +1,82 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
process.env = { ...ORIGINAL_ENV };
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
function setRequiredBaseEnv() {
process.env.DATABASE_URL = "postgres://app:app@127.0.0.1:5432/skymoney";
process.env.JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
process.env.COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
process.env.CORS_ORIGINS = "https://allowed.example.com";
process.env.AUTH_DISABLED = "0";
process.env.SEED_DEFAULT_BUDGET = "0";
}
describe("A10 Server-Side Request Forgery", () => {
it("rejects localhost/private APP_ORIGIN values in production", async () => {
const blockedOrigins = [
"https://127.0.0.1:5173",
"https://10.0.0.5:5173",
"https://192.168.1.10:5173",
"https://172.16.5.20:5173",
"https://localhost:5173",
"https://app.local:5173",
"https://[::1]:5173",
];
for (const origin of blockedOrigins) {
setRequiredBaseEnv();
process.env.NODE_ENV = "production";
process.env.APP_ORIGIN = origin;
await expect(import("../src/env")).rejects.toThrow(
"APP_ORIGIN must not point to localhost or private network hosts in production."
);
vi.resetModules();
}
});
it("accepts public APP_ORIGIN in production", async () => {
setRequiredBaseEnv();
process.env.NODE_ENV = "production";
process.env.APP_ORIGIN = "https://app.example.com";
const envModule = await import("../src/env");
expect(envModule.env.APP_ORIGIN).toBe("https://app.example.com");
});
it("keeps API source free of generic outbound HTTP request clients", () => {
const apiRoot = resolve(__dirname, "..");
const srcRoot = resolve(apiRoot, "src");
const queue = [srcRoot];
const offenders: string[] = [];
const patterns = ["fetch(", "axios", "http.request(", "https.request("];
while (queue.length > 0) {
const current = queue.pop() as string;
for (const name of readdirSync(current)) {
const full = join(current, name);
if (statSync(full).isDirectory()) {
if (full.includes(`${join("src", "scripts")}`)) continue;
queue.push(full);
continue;
}
if (!full.endsWith(".ts")) continue;
const content = readFileSync(full, "utf8");
if (patterns.some((p) => content.includes(p))) offenders.push(full);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -1,16 +1,90 @@
import { execSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, afterAll } from "vitest";
import { PrismaClient } from "@prisma/client";
function readEnvValue(filePath: string, key: string): string | undefined {
if (!existsSync(filePath)) return undefined;
const content = readFileSync(filePath, "utf8");
const line = content
.split(/\r?\n/)
.find((raw) => raw.trim().startsWith(`${key}=`));
if (!line) return undefined;
const value = line.slice(line.indexOf("=") + 1).trim();
return value.length > 0 ? value : undefined;
}
function resolveDatabaseUrl(): string {
const normalizeHost = (url: string) => url.replace("@postgres:", "@127.0.0.1:");
if (process.env.TEST_DATABASE_URL?.trim()) return normalizeHost(process.env.TEST_DATABASE_URL.trim());
if (process.env.BACKUP_DATABASE_URL?.trim())
return normalizeHost(process.env.BACKUP_DATABASE_URL.trim());
const envPaths = [resolve(process.cwd(), ".env"), resolve(process.cwd(), "..", ".env")];
for (const envPath of envPaths) {
const testUrl = readEnvValue(envPath, "TEST_DATABASE_URL");
if (testUrl) return normalizeHost(testUrl);
const backupUrl = readEnvValue(envPath, "BACKUP_DATABASE_URL");
if (backupUrl) return normalizeHost(backupUrl);
const dbUrl = readEnvValue(envPath, "DATABASE_URL");
if (dbUrl) return normalizeHost(dbUrl);
}
if (process.env.DATABASE_URL?.trim()) return normalizeHost(process.env.DATABASE_URL.trim());
return "postgres://app:app@127.0.0.1:5432/skymoney_test";
}
function parseDbName(url: string): string {
const parsed = new URL(url);
const dbName = parsed.pathname.replace(/^\/+/, "");
if (!dbName) throw new Error(`DATABASE_URL has no database name: ${url}`);
return dbName;
}
function assertSafeDbTarget(url: string): void {
const requireTestDbName = process.env.REQUIRE_TEST_DB_NAME === "1";
const protectedNamesRaw =
process.env.PROTECTED_DB_NAMES ??
process.env.EXPECTED_PROD_DB_NAME ??
"skymoney,postgres,template0,template1";
const protectedNames = new Set(
protectedNamesRaw
.split(",")
.map((value) => value.trim())
.filter(Boolean)
);
const dbName = parseDbName(url);
if (protectedNames.has(dbName)) {
throw new Error(
`Refusing to run DB tests against protected database '${dbName}'. ` +
"Set TEST_DATABASE_URL to a dedicated test database."
);
}
if (requireTestDbName && !/(test|ci|sandbox|staging|shadow|tmp)/i.test(dbName)) {
throw new Error(
`Refusing to run DB tests against '${dbName}' because it does not look like a test database. ` +
"Set REQUIRE_TEST_DB_NAME=0 only for intentional local exceptions."
);
}
}
process.env.NODE_ENV = process.env.NODE_ENV || "test";
process.env.DATABASE_URL =
process.env.DATABASE_URL ||
"postgres://app:app@localhost:5432/skymoney";
process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port
process.env.DATABASE_URL = resolveDatabaseUrl();
assertSafeDbTarget(process.env.DATABASE_URL);
process.env.PORT = process.env.PORT || "8081";
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";
process.env.JWT_SECRET =
process.env.JWT_SECRET || "test-jwt-secret-32-chars-min-abcdef";
process.env.COOKIE_SECRET =
process.env.COOKIE_SECRET || "test-cookie-secret-32-chars-abcdef";
export const prisma = new PrismaClient();
@@ -25,11 +99,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" });
// Optional schema bootstrap for CI/local environments that can run Prisma CLI.
if (process.env.TEST_APPLY_SCHEMA === "1") {
try {
execSync("npx prisma migrate deploy", { stdio: "inherit" });
} catch {
execSync("npx prisma db push --skip-generate --accept-data-loss", { stdio: "inherit" });
}
}
// Ensure a clean slate: wipe all tables to avoid cross-file leakage
await prisma.$transaction([
prisma.emailToken.deleteMany({}),
prisma.allocation.deleteMany({}),
prisma.transaction.deleteMany({}),
prisma.incomeEvent.deleteMany({}),

View File

@@ -0,0 +1,95 @@
import {
chmodSync,
mkdtempSync,
rmSync,
writeFileSync,
existsSync,
readdirSync,
readFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
function runScript(scriptPath: string, env: Record<string, string>) {
const tmpScriptDir = mkdtempSync(join(tmpdir(), "skymoney-a08-script-"));
const normalizedScript = join(tmpScriptDir, "script.sh");
writeFileSync(normalizedScript, readFileSync(scriptPath, "utf8").replace(/\r/g, ""));
chmodSync(normalizedScript, 0o755);
const result = spawnSync("bash", [normalizedScript], {
env: { ...process.env, ...env },
encoding: "utf8",
});
rmSync(tmpScriptDir, { recursive: true, force: true });
return result;
}
describe("A08 Software and Data Integrity Failures", () => {
it("creates checksum artifacts during backup execution", () => {
const repoRoot = resolve(__dirname, "..", "..");
const backupScriptPath = resolve(repoRoot, "scripts/backup.sh");
const tmpRoot = mkdtempSync(join(tmpdir(), "skymoney-a08-backup-"));
const fakeBin = join(tmpRoot, "bin");
const outDir = join(tmpRoot, "backups");
const fakePgDump = join(fakeBin, "pg_dump");
try {
spawnSync("mkdir", ["-p", fakeBin], { encoding: "utf8" });
writeFileSync(
fakePgDump,
"#!/usr/bin/env bash\nset -euo pipefail\nout=''\nwhile [[ $# -gt 0 ]]; do\n if [[ \"$1\" == '-f' ]]; then out=\"$2\"; shift 2; continue; fi\n shift\ndone\nprintf 'fake-dump-data' > \"$out\"\n"
);
chmodSync(fakePgDump, 0o755);
const res = runScript(backupScriptPath, {
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
BACKUP_DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
BACKUP_DIR: outDir,
});
expect(res.status).toBe(0);
const names = readdirSync(outDir);
expect(names.some((n) => n.endsWith(".dump"))).toBe(true);
expect(names.some((n) => n.endsWith(".sha256"))).toBe(true);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("blocks restore on checksum mismatch before DB commands execute", () => {
const repoRoot = resolve(__dirname, "..", "..");
const restoreScriptPath = resolve(repoRoot, "scripts/restore.sh");
const tmpRoot = mkdtempSync(join(tmpdir(), "skymoney-a08-restore-"));
const fakeBin = join(tmpRoot, "bin");
const backupFile = join(tmpRoot, "sample.dump");
const checksumFile = `${backupFile}.sha256`;
const markerFile = join(tmpRoot, "db_called.marker");
try {
spawnSync("mkdir", ["-p", fakeBin], { encoding: "utf8" });
writeFileSync(backupFile, "tampered-content");
writeFileSync(checksumFile, `${"0".repeat(64)} sample.dump\n`);
const fakePsql = join(fakeBin, "psql");
const fakePgRestore = join(fakeBin, "pg_restore");
writeFileSync(fakePsql, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
writeFileSync(fakePgRestore, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
chmodSync(fakePsql, 0o755);
chmodSync(fakePgRestore, 0o755);
const res = runScript(restoreScriptPath, {
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
BACKUP_FILE: backupFile,
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
RESTORE_DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney_restore_test",
});
expect(res.status).not.toBe(0);
expect(`${res.stdout}${res.stderr}`).toContain("checksum verification failed");
expect(existsSync(markerFile)).toBe(false);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("A03 Software Supply Chain Failures", () => {
it("enforces deploy workflow dependency-audit gate for api and web", () => {
const repoRoot = resolve(__dirname, "..", "..");
const deployWorkflow = readFileSync(
resolve(repoRoot, ".gitea/workflows/deploy.yml"),
"utf8"
);
expect(deployWorkflow).toContain("name: Supply chain checks (production dependencies)");
expect(deployWorkflow).toContain("cd api");
expect(deployWorkflow).toContain("cd ../web");
const npmCiMatches = deployWorkflow.match(/\bnpm ci\b/g) ?? [];
expect(npmCiMatches.length).toBeGreaterThanOrEqual(2);
const auditMatches =
deployWorkflow.match(/npm audit --omit=dev --audit-level=high/g) ?? [];
expect(auditMatches.length).toBeGreaterThanOrEqual(2);
});
it("pins checkout action to an explicit version tag", () => {
const repoRoot = resolve(__dirname, "..", "..");
const deployWorkflow = readFileSync(
resolve(repoRoot, ".gitea/workflows/deploy.yml"),
"utf8"
);
expect(deployWorkflow).toMatch(/uses:\s*actions\/checkout@v\d+\.\d+\.\d+/);
});
it("guards DB-backed security tests from targeting production database", () => {
const repoRoot = resolve(__dirname, "..", "..");
const securityWorkflow = readFileSync(
resolve(repoRoot, ".gitea/workflows/security.yml"),
"utf8"
);
expect(securityWorkflow).toContain("name: Guard TEST_DATABASE_URL target");
expect(securityWorkflow).toContain("bash ./scripts/validate-test-db-target.sh");
expect(securityWorkflow).toContain("TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}");
});
});

View File

@@ -1,7 +1,8 @@
import request from "supertest";
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
import { randomUUID } from "node:crypto";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
import { prisma, resetUser, ensureUser, U, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
@@ -11,7 +12,9 @@ beforeAll(async () => {
});
afterAll(async () => {
await app.close();
if (app) {
await app.close();
}
await closePrisma();
});
@@ -23,16 +26,22 @@ describe("GET /transactions", () => {
await resetUser(U);
await ensureUser(U);
catId = cid("c");
planId = pid("p");
await prisma.variableCategory.create({
data: { id: catId, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n },
});
await prisma.fixedPlan.create({
const category = await prisma.variableCategory.create({
data: {
id: planId,
id: randomUUID(),
userId: U,
name: "Groceries",
percent: 100,
priority: 1,
isSavings: false,
balanceCents: 5000n,
},
});
catId = category.id;
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Rent",
totalCents: 10000n,
@@ -43,6 +52,7 @@ describe("GET /transactions", () => {
fundingMode: "auto-on-deposit",
},
});
planId = plan.id;
await prisma.transaction.createMany({
data: [
@@ -100,16 +110,31 @@ describe("POST /transactions", () => {
let catId: string;
let planId: string;
function postTransactionsWithCsrf() {
const csrf = randomUUID().replace(/-/g, "");
return request(app.server)
.post("/transactions")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`);
}
function patchWithCsrf(path: string) {
const csrf = randomUUID().replace(/-/g, "");
return request(app.server)
.patch(path)
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`);
}
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
catId = cid("cat");
planId = pid("plan");
await prisma.variableCategory.create({
const category = await prisma.variableCategory.create({
data: {
id: catId,
id: randomUUID(),
userId: U,
name: "Dining",
percent: 100,
@@ -118,10 +143,11 @@ describe("POST /transactions", () => {
balanceCents: 5000n,
},
});
catId = category.id;
await prisma.fixedPlan.create({
const plan = await prisma.fixedPlan.create({
data: {
id: planId,
id: randomUUID(),
userId: U,
name: "Loan",
totalCents: 10000n,
@@ -132,12 +158,11 @@ describe("POST /transactions", () => {
fundingMode: "auto-on-deposit",
},
});
planId = plan.id;
});
it("spends from a variable category and updates balance", async () => {
const res = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const res = await postTransactionsWithCsrf()
.send({
kind: "variable_spend",
amountCents: 2000,
@@ -157,10 +182,8 @@ describe("POST /transactions", () => {
expect(tx.isReconciled).toBe(true);
});
it("prevents overdrawing fixed plans", async () => {
const res = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
it("returns insufficient budget when fixed payment requires unavailable top-up", async () => {
const res = await postTransactionsWithCsrf()
.send({
kind: "fixed_payment",
amountCents: 400000, // exceeds funded
@@ -169,13 +192,11 @@ describe("POST /transactions", () => {
});
expect(res.status).toBe(400);
expect(res.body.code).toBe("OVERDRAFT_PLAN");
expect(res.body.code).toBe("INSUFFICIENT_AVAILABLE_BUDGET");
});
it("updates note/receipt and reconciliation via patch", async () => {
const created = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const created = await postTransactionsWithCsrf()
.send({
kind: "variable_spend",
amountCents: 1000,
@@ -185,9 +206,7 @@ describe("POST /transactions", () => {
expect(created.status).toBe(200);
const txId = created.body.id;
const res = await request(app.server)
.patch(`/transactions/${txId}`)
.set("x-user-id", U)
const res = await patchWithCsrf(`/transactions/${txId}`)
.send({
note: "Cleared",
isReconciled: true,

View File

@@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
let app: FastifyInstance;
@@ -34,9 +35,12 @@ describe("Variable Categories guard (sum=100)", () => {
});
it("rejects create that would push sum away from 100", async () => {
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ name: "Oops", percent: 10, isSavings: false, priority: 99 });
expect(res.statusCode).toBe(400);
@@ -45,9 +49,12 @@ describe("Variable Categories guard (sum=100)", () => {
it("rejects update that breaks the sum", async () => {
const existing = await prisma.variableCategory.findFirst({ where: { userId: U } });
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.patch(`/variable-categories/${existing!.id}`)
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ percent: 90 });
expect(res.statusCode).toBe(400);

View File

@@ -0,0 +1,159 @@
import request from "supertest";
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
let app: FastifyInstance;
beforeAll(async () => {
app = await appFactory();
});
afterAll(async () => {
if (app) await app.close();
await closePrisma();
});
async function seedBasics() {
await resetUser(U);
await ensureUser(U);
await prisma.variableCategory.createMany({
data: [
{ id: cid("s"), userId: U, name: "savings", percent: 30, priority: 1, isSavings: true, balanceCents: 3000n },
{ id: cid("f"), userId: U, name: "food", percent: 20, priority: 2, isSavings: false, balanceCents: 2000n },
{ id: cid("g"), userId: U, name: "gas", percent: 30, priority: 3, isSavings: false, balanceCents: 3000n },
{ id: cid("m"), userId: U, name: "misc", percent: 20, priority: 4, isSavings: false, balanceCents: 2000n },
],
});
await prisma.budgetSession.create({
data: {
userId: U,
periodStart: new Date("2026-03-01T00:00:00Z"),
periodEnd: new Date("2026-04-01T00:00:00Z"),
totalBudgetCents: 10_000n,
allocatedCents: 0n,
fundedCents: 0n,
availableCents: 10_000n,
},
});
}
describe("manual rebalance", () => {
beforeEach(async () => {
await seedBasics();
});
it("uses live category balances when session availableCents is stale", async () => {
await prisma.budgetSession.updateMany({
where: { userId: U },
data: { availableCents: 1234n },
});
const getRes = await request(app.server)
.get("/variable-categories/manual-rebalance")
.set("x-user-id", U);
expect(getRes.statusCode).toBe(200);
expect(getRes.body?.availableCents).toBe(10_000);
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000
const csrf = randomUUID().replace(/-/g, "");
const postRes = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets, forceLowerSavings: true });
expect(postRes.statusCode).toBe(200);
expect(postRes.body?.availableCents).toBe(10_000);
const session = await prisma.budgetSession.findFirst({
where: { userId: U },
orderBy: { periodStart: "desc" },
select: { availableCents: true },
});
expect(Number(session?.availableCents ?? 0n)).toBe(10_000);
});
it("rebalances when sums match available", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets, forceLowerSavings: true });
expect(res.statusCode).toBe(200);
expect(res.body?.ok).toBe(true);
const updated = await prisma.variableCategory.findMany({ where: { userId: U } });
expect(updated.every((c) => Number(c.balanceCents) === 2500)).toBe(true);
});
it("rejects sum mismatch", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 5000 : 1000 })); // 5000 + 3*1000 = 8000
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets });
expect(res.statusCode).toBe(400);
expect(res.body?.code).toBe("SUM_MISMATCH");
});
it("requires savings confirmation when lowering below floor", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
// savings to 500 (below 20% of 10000 = 2000)
const targets = cats.map((c) => ({ id: c.id, targetCents: c.isSavings ? 500 : 3166 })); // 500 + 3*3166 = 9998 -> adjust
targets[1].targetCents += 2; // total 10000
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets });
expect(res.statusCode).toBe(400);
expect(res.body?.code).toBe("SAVINGS_FLOOR");
const resOk = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets, forceLowerSavings: true });
expect(resOk.statusCode).toBe(200);
});
it("blocks >80% single category", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 9000 : 333 }));
targets[1].targetCents += 1; // sum 10000
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({ targets });
expect(res.statusCode).toBe(400);
expect(res.body?.code).toBe("OVER_80_CONFIRM_REQUIRED");
});
});

View File

@@ -4,6 +4,7 @@ export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
exclude: ["tests/security-misconfiguration.test.ts"],
// run single-threaded to keep DB tests deterministic
pool: "threads",
poolOptions: { threads: { singleThread: true } },
@@ -11,7 +12,6 @@ export default defineConfig({
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'],

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
const dbSecurityTestsEnabled = process.env.SECURITY_DB_TESTS === "1";
const baseSecurityTests = [
"tests/security-misconfiguration.test.ts",
"tests/software-supply-chain-failures.test.ts",
"tests/cryptographic-failures.test.ts",
"tests/cryptographic-failures.runtime.test.ts",
"tests/injection-safety.test.ts",
"tests/software-data-integrity-failures.test.ts",
"tests/security-logging-monitoring-failures.test.ts",
"tests/server-side-request-forgery.test.ts",
];
const dbSecurityTests = [
"tests/insecure-design.test.ts",
"tests/identification-auth-failures.test.ts",
"tests/forgot-password.security.test.ts",
];
export default defineConfig({
test: {
environment: "node",
include: dbSecurityTestsEnabled
? [...baseSecurityTests, ...dbSecurityTests]
: baseSecurityTests,
pool: "threads",
poolOptions: { threads: { singleThread: true } },
testTimeout: 30_000,
setupFiles: dbSecurityTestsEnabled ? ["tests/setup.ts"] : [],
env: {
NODE_ENV: "test",
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney_test",
AUTH_DISABLED: "1",
SEED_DEFAULT_BUDGET: "1",
JWT_SECRET: "test-jwt-secret-32-chars-min-abcdef",
COOKIE_SECRET: "test-cookie-secret-32-chars-abcdef",
},
},
});

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