Compare commits

..

31 Commits

Author SHA1 Message Date
2d829ee969 Ignore local export object storage output
Local-driver export files are runtime artifacts, not source.
2026-07-29 07:41:53 +05:30
ac13d24c2c Add fresh database migration baseline 2026-07-22 19:24:46 +05:30
f2aa7a487e Update Google mirror status test 2026-07-22 18:37:28 +05:30
9629d4ea8f Validate manual account creation 2026-07-22 18:32:09 +05:30
3163c9018f Make Supabase client lazy 2026-07-22 18:31:32 +05:30
4d9d3aed8a Fix duplicate auth nonce guard 2026-07-22 18:30:53 +05:30
0156d562ea Add personal goal automation alerts 2026-07-17 23:45:07 +05:30
5448dbb7eb Add Sheets-first mirror mode 2026-07-17 23:41:37 +05:30
25a1981909 Add SOC 2 operations checklist 2026-07-17 23:40:00 +05:30
77529d26a8 Harden Stripe production billing 2026-07-17 23:39:06 +05:30
b4b07eda6b Add credit score provider pull 2026-07-17 23:34:59 +05:30
14dec26ccf Add bill payment initiation adapter 2026-07-17 23:32:52 +05:30
fa7b4b81be Add accountant task workflows 2026-07-17 23:28:59 +05:30
49acee6b6a Add planning budgets goals investments and recurring detection 2026-07-17 23:19:36 +05:30
fabe6e341b Add GDPR privacy export and erasure coverage 2026-07-17 22:00:01 +05:30
e7549f3dde Add household future scenario planning 2026-07-16 23:57:07 +05:30
2b19fb876a Add household debt payoff planner 2026-07-16 23:49:50 +05:30
7e8e440c43 Add transaction comments API 2026-07-16 23:43:14 +05:30
00fb77ec1a Add household money date prompts 2026-07-16 23:31:28 +05:30
c0118b6986 Add household privacy mode 2026-07-16 23:22:30 +05:30
a33d9b8f00 Add household fair split calculator 2026-07-16 23:15:20 +05:30
f7160fceab Add SOC 2 readiness evidence endpoint 2026-07-16 22:42:33 +05:30
1b0f1c08d2 Encrypt raw transaction payload storage 2026-07-16 22:27:00 +05:30
6395b63098 Add rotating session nonce binding 2026-07-16 22:17:20 +05:30
d51fc3b954 Add pagination abuse risk signals 2026-07-16 22:11:14 +05:30
5dc1cd796b Implement browser-safe finance presentation APIs 2026-07-16 22:07:33 +05:30
cce14fa5c6 feat: add advisor household roles 2026-07-16 18:11:04 +05:30
aeb23530cd feat: add household health score 2026-07-16 15:37:53 +05:30
5794203c80 feat: add household shared goals 2026-07-16 15:33:52 +05:30
ade95e6fdf feat: implement LedgerOne backend backlog features 2026-07-16 15:07:01 +05:30
bfe489d920 Refactor GoogleService: streamline token handling and improve comments 2026-07-15 21:08:06 +05:30
154 changed files with 14635 additions and 330 deletions

View File

@ -1,11 +1,71 @@
DATABASE_URL=postgresql://user:password@localhost:5432/ledgerone
JWT_SECRET=change_me
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_SERVICE_KEY=your_service_role_key
SUPABASE_ANON_KEY=your_anon_key
JWT_SECRET=replace_with_32_plus_char_secret
JWT_REFRESH_SECRET=replace_with_32_plus_char_refresh_secret
JWT_ACCESS_TTL_SECONDS=60
ENCRYPTION_KEY=replace_with_64_hex_chars_for_aes_256_gcm
PLAID_CLIENT_ID=your_client_id
PLAID_SECRET=your_sandbox_secret
PLAID_ENV=sandbox
PLAID_PRODUCTS=transactions
PLAID_COUNTRY_CODES=US
PLAID_REDIRECT_URI=http://localhost:3001/app/connect
PLAID_REDIRECT_URI=http://localhost:3052/app/connect
PLAID_WEBHOOK_URL=http://localhost:3051/api/plaid/webhook
PLAID_VERIFY_WEBHOOKS=true
TELLER_APPLICATION_ID=
TELLER_ENV=sandbox
TELLER_PRODUCTS=transactions,balance
TELLER_API_BASE_URL=https://api.teller.io
TELLER_CERT_PATH=
TELLER_KEY_PATH=
TELLER_CERT_PEM=
TELLER_KEY_PEM=
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_SERVICE_KEY=your_service_role_key
SUPABASE_ANON_KEY=your_anon_key
EXPORT_OBJECT_STORAGE_DRIVER=local
EXPORT_OBJECT_STORAGE_BUCKET=ledgerone-exports
EXPORT_OBJECT_STORAGE_DIR=data/export-objects
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_PRO=
STRIPE_PRICE_ELITE=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@ledgerone.app
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@ledgerone.app
APP_URL=http://localhost:3052
PORT=3051
NODE_ENV=development
CORS_ORIGIN=http://localhost:3052
AUTO_SYNC_ENABLED=true
AUTO_SYNC_INTERVAL_MINUTES=15
AUTO_SYNC_STALE_MINUTES=15
AUTO_SYNC_LOOKBACK_DAYS=7
AUTO_SYNC_MAX_USERS_PER_RUN=25
SENTRY_DSN=
SENTRY_TRACES_SAMPLE_RATE=0
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=
SOCIAL_AUTH_REDIRECT_URI=http://localhost:3052/auth/social/callback
GOOGLE_AUTH_CLIENT_ID=
GOOGLE_AUTH_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_TEAM_ID=
APPLE_KEY_ID=
APPLE_PRIVATE_KEY=

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ node_modules
dist
.env
*.log
data/export-objects/

View File

@ -16,7 +16,7 @@ WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev --legacy-peer-deps
RUN npm ci --legacy-peer-deps
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma

341
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@supabase/supabase-js": "^2.49.1",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/web-push": "^3.6.4",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
@ -40,7 +41,9 @@
"rxjs": "^7.8.1",
"speakeasy": "^2.0.0",
"stripe": "^20.4.0",
"swagger-ui-express": "^5.0.1"
"swagger-ui-express": "^5.0.1",
"web-push": "^3.6.7",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
@ -56,6 +59,7 @@
"prisma": "^5.18.0",
"supertest": "^7.0.0",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
},
@ -816,6 +820,30 @@
"node": ">=0.1.90"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@fastify/otel": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz",
@ -2918,6 +2946,34 @@
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -3316,6 +3372,15 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -3551,6 +3616,28 @@
"acorn": "^8"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -3691,6 +3778,13 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -3717,6 +3811,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -3951,6 +4057,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@ -4201,6 +4313,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -4415,6 +4540,15 @@
"node": ">= 0.12.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
@ -4620,6 +4754,18 @@
}
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@ -4642,6 +4788,13 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4803,6 +4956,16 @@
"wrappy": "1"
}
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@ -5506,6 +5669,15 @@
"integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==",
"license": "MIT"
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -5977,6 +6149,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -7671,6 +7852,12 @@
"node": ">=6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -9303,6 +9490,18 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@ -9922,6 +10121,50 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/tsconfig-paths": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
@ -10140,6 +10383,13 @@
"node": ">= 0.4.0"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@ -10207,6 +10457,46 @@
"defaults": "^1.0.3"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-push/node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/web-push/node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@ -10320,6 +10610,24 @@
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
@ -10407,6 +10715,27 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@ -10462,6 +10791,16 @@
"node": ">=12"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -25,6 +25,7 @@
"@supabase/supabase-js": "^2.49.1",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/web-push": "^3.6.4",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
@ -44,7 +45,9 @@
"rxjs": "^7.8.1",
"speakeasy": "^2.0.0",
"stripe": "^20.4.0",
"swagger-ui-express": "^5.0.1"
"swagger-ui-express": "^5.0.1",
"web-push": "^3.6.7",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
@ -60,6 +63,7 @@
"prisma": "^5.18.0",
"supertest": "^7.0.0",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,299 @@
-- Baseline schema required before the incremental LedgerOne migrations.
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"fullName" TEXT,
"phone" TEXT,
"companyName" TEXT,
"addressLine1" TEXT,
"addressLine2" TEXT,
"city" TEXT,
"state" TEXT,
"postalCode" TEXT,
"country" TEXT,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
"twoFactorSecret" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SocialAccount" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SocialAccount_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"institutionName" TEXT NOT NULL,
"accountType" TEXT NOT NULL,
"mask" TEXT,
"plaidAccessToken" TEXT,
"plaidItemId" TEXT,
"plaidAccountId" TEXT,
"tellerAccessToken" TEXT,
"tellerEnrollmentId" TEXT,
"tellerAccountId" TEXT,
"currentBalance" DECIMAL(65,30),
"availableBalance" DECIMAL(65,30),
"isoCurrencyCode" TEXT,
"lastBalanceSync" TIMESTAMP(3),
"lastTransactionSync" TIMESTAMP(3),
"lastSyncAttemptAt" TIMESTAMP(3),
"syncStatus" TEXT NOT NULL DEFAULT 'idle',
"lastSyncError" TEXT,
"syncConsecutiveFailures" INTEGER NOT NULL DEFAULT 0,
"plaidWebhookCode" TEXT,
"plaidWebhookAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PlaidWebhookEvent" (
"id" TEXT NOT NULL,
"itemId" TEXT,
"webhookType" TEXT NOT NULL,
"webhookCode" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'received',
"error" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
CONSTRAINT "PlaidWebhookEvent_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TransactionRaw" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"bankTransactionId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"description" TEXT NOT NULL,
"rawPayload" JSONB NOT NULL,
"ingestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"source" TEXT NOT NULL,
CONSTRAINT "TransactionRaw_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TransactionDerived" (
"id" TEXT NOT NULL,
"rawTransactionId" TEXT NOT NULL,
"userCategory" TEXT,
"userNotes" TEXT,
"isHidden" BOOLEAN NOT NULL DEFAULT false,
"modifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"modifiedBy" TEXT NOT NULL,
CONSTRAINT "TransactionDerived_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "CsvImportMapping" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"headerSignature" TEXT NOT NULL,
"name" TEXT,
"mapping" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CsvImportMapping_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Rule" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"priority" INTEGER NOT NULL,
"conditions" JSONB NOT NULL,
"actions" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Rule_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "RuleExecution" (
"id" TEXT NOT NULL,
"ruleId" TEXT NOT NULL,
"transactionId" TEXT NOT NULL,
"executedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"result" JSONB NOT NULL,
CONSTRAINT "RuleExecution_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ExportLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"filters" JSONB NOT NULL,
"rowCount" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExportLog_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "AbuseEvent" (
"id" TEXT NOT NULL,
"userId" TEXT,
"eventType" TEXT NOT NULL,
"riskPoints" INTEGER NOT NULL,
"severity" TEXT NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AbuseEvent_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "GoogleConnection" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"googleEmail" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"accessToken" TEXT,
"spreadsheetId" TEXT,
"isConnected" BOOLEAN NOT NULL DEFAULT true,
"connectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSyncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GoogleConnection_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "EmailVerificationToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailVerificationToken_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"ipHash" TEXT NOT NULL,
"userAgentHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"sessionId" TEXT,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"plan" TEXT NOT NULL DEFAULT 'free',
"stripeCustomerId" TEXT,
"stripeSubId" TEXT,
"currentPeriodEnd" TIMESTAMP(3),
"cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TaxReturn" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"taxYear" INTEGER NOT NULL,
"filingType" TEXT NOT NULL,
"jurisdictions" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"summary" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TaxReturn_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TaxDocument" (
"id" TEXT NOT NULL,
"taxReturnId" TEXT NOT NULL,
"docType" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TaxDocument_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE INDEX "SocialAccount_userId_idx" ON "SocialAccount"("userId");
CREATE UNIQUE INDEX "SocialAccount_provider_providerAccountId_key" ON "SocialAccount"("provider", "providerAccountId");
CREATE UNIQUE INDEX "Account_plaidAccountId_key" ON "Account"("plaidAccountId");
CREATE UNIQUE INDEX "Account_tellerAccountId_key" ON "Account"("tellerAccountId");
CREATE INDEX "Account_plaidItemId_idx" ON "Account"("plaidItemId");
CREATE INDEX "Account_tellerEnrollmentId_idx" ON "Account"("tellerEnrollmentId");
CREATE INDEX "PlaidWebhookEvent_itemId_receivedAt_idx" ON "PlaidWebhookEvent"("itemId", "receivedAt");
CREATE INDEX "PlaidWebhookEvent_webhookType_webhookCode_idx" ON "PlaidWebhookEvent"("webhookType", "webhookCode");
CREATE UNIQUE INDEX "TransactionRaw_bankTransactionId_key" ON "TransactionRaw"("bankTransactionId");
CREATE UNIQUE INDEX "TransactionDerived_rawTransactionId_key" ON "TransactionDerived"("rawTransactionId");
CREATE INDEX "CsvImportMapping_userId_lastUsedAt_idx" ON "CsvImportMapping"("userId", "lastUsedAt");
CREATE UNIQUE INDEX "CsvImportMapping_userId_headerSignature_key" ON "CsvImportMapping"("userId", "headerSignature");
CREATE INDEX "AbuseEvent_userId_createdAt_idx" ON "AbuseEvent"("userId", "createdAt");
CREATE INDEX "AbuseEvent_eventType_createdAt_idx" ON "AbuseEvent"("eventType", "createdAt");
CREATE UNIQUE INDEX "GoogleConnection_userId_key" ON "GoogleConnection"("userId");
CREATE UNIQUE INDEX "EmailVerificationToken_userId_key" ON "EmailVerificationToken"("userId");
CREATE UNIQUE INDEX "EmailVerificationToken_token_key" ON "EmailVerificationToken"("token");
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
CREATE INDEX "Session_userId_revokedAt_idx" ON "Session"("userId", "revokedAt");
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
CREATE INDEX "RefreshToken_sessionId_idx" ON "RefreshToken"("sessionId");
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");
ALTER TABLE "SocialAccount" ADD CONSTRAINT "SocialAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "TransactionRaw" ADD CONSTRAINT "TransactionRaw_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "TransactionDerived" ADD CONSTRAINT "TransactionDerived_rawTransactionId_fkey" FOREIGN KEY ("rawTransactionId") REFERENCES "TransactionRaw"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CsvImportMapping" ADD CONSTRAINT "CsvImportMapping_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Rule" ADD CONSTRAINT "Rule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "RuleExecution" ADD CONSTRAINT "RuleExecution_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "Rule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "RuleExecution" ADD CONSTRAINT "RuleExecution_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "TransactionRaw"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ExportLog" ADD CONSTRAINT "ExportLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AbuseEvent" ADD CONSTRAINT "AbuseEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "GoogleConnection" ADD CONSTRAINT "GoogleConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "EmailVerificationToken" ADD CONSTRAINT "EmailVerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TaxReturn" ADD CONSTRAINT "TaxReturn_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "TaxDocument" ADD CONSTRAINT "TaxDocument_taxReturnId_fkey" FOREIGN KEY ("taxReturnId") REFERENCES "TaxReturn"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,12 @@
ALTER TABLE "ExportLog"
ADD COLUMN "format" TEXT NOT NULL DEFAULT 'csv',
ADD COLUMN "destination" TEXT NOT NULL DEFAULT 'download',
ADD COLUMN "fileName" TEXT,
ADD COLUMN "mimeType" TEXT,
ADD COLUMN "fileHash" TEXT,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "userAgent" TEXT,
ADD COLUMN "metadata" JSONB NOT NULL DEFAULT '{}';
CREATE INDEX "ExportLog_userId_createdAt_idx" ON "ExportLog"("userId", "createdAt");
CREATE INDEX "ExportLog_format_createdAt_idx" ON "ExportLog"("format", "createdAt");

View File

@ -0,0 +1,20 @@
CREATE TABLE "ExportDownloadToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"format" TEXT NOT NULL,
"filters" JSONB NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExportDownloadToken_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ExportDownloadToken_tokenHash_key" ON "ExportDownloadToken"("tokenHash");
CREATE INDEX "ExportDownloadToken_userId_createdAt_idx" ON "ExportDownloadToken"("userId", "createdAt");
CREATE INDEX "ExportDownloadToken_expiresAt_idx" ON "ExportDownloadToken"("expiresAt");
ALTER TABLE "ExportDownloadToken"
ADD CONSTRAINT "ExportDownloadToken_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,9 @@
ALTER TABLE "ExportDownloadToken"
ADD COLUMN "storageProvider" TEXT,
ADD COLUMN "storageKey" TEXT,
ADD COLUMN "fileName" TEXT,
ADD COLUMN "mimeType" TEXT,
ADD COLUMN "rowCount" INTEGER,
ADD COLUMN "fileHash" TEXT;
CREATE INDEX "ExportDownloadToken_storageKey_idx" ON "ExportDownloadToken"("storageKey");

View File

@ -0,0 +1,5 @@
ALTER TABLE "GoogleConnection"
ADD COLUMN "driveMirrorEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "driveMirrorStatus" TEXT NOT NULL DEFAULT 'not_started',
ADD COLUMN "driveMirrorSpreadsheetUrl" TEXT,
ADD COLUMN "driveMirrorLastSyncedAt" TIMESTAMP(3);

View File

@ -0,0 +1,22 @@
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"scopes" TEXT[] NOT NULL DEFAULT ARRAY['transactions:read']::TEXT[],
"lastUsedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
CREATE INDEX "ApiKey_userId_createdAt_idx" ON "ApiKey"("userId", "createdAt");
CREATE INDEX "ApiKey_prefix_idx" ON "ApiKey"("prefix");
ALTER TABLE "ApiKey"
ADD CONSTRAINT "ApiKey_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1 @@
ALTER TABLE "User" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user';

View File

@ -0,0 +1,32 @@
CREATE TABLE "Household" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Household_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "HouseholdMember" (
"id" TEXT NOT NULL,
"householdId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'member',
"status" TEXT NOT NULL DEFAULT 'active',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HouseholdMember_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Household_createdByUserId_createdAt_idx" ON "Household"("createdByUserId", "createdAt");
CREATE UNIQUE INDEX "HouseholdMember_householdId_userId_key" ON "HouseholdMember"("householdId", "userId");
CREATE INDEX "HouseholdMember_userId_status_idx" ON "HouseholdMember"("userId", "status");
CREATE INDEX "HouseholdMember_householdId_role_idx" ON "HouseholdMember"("householdId", "role");
ALTER TABLE "Household" ADD CONSTRAINT "Household_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "HouseholdMember" ADD CONSTRAINT "HouseholdMember_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "HouseholdMember" ADD CONSTRAINT "HouseholdMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,25 @@
CREATE TABLE "HouseholdInvite" (
"id" TEXT NOT NULL,
"householdId" TEXT NOT NULL,
"invitedById" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'member',
"tokenHash" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"acceptedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HouseholdInvite_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HouseholdInvite_tokenHash_key" ON "HouseholdInvite"("tokenHash");
CREATE INDEX "HouseholdInvite_householdId_status_idx" ON "HouseholdInvite"("householdId", "status");
CREATE INDEX "HouseholdInvite_email_status_idx" ON "HouseholdInvite"("email", "status");
CREATE INDEX "HouseholdInvite_expiresAt_idx" ON "HouseholdInvite"("expiresAt");
ALTER TABLE "HouseholdInvite" ADD CONSTRAINT "HouseholdInvite_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "HouseholdInvite" ADD CONSTRAINT "HouseholdInvite_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "HouseholdInvite" ADD CONSTRAINT "HouseholdInvite_acceptedById_fkey" FOREIGN KEY ("acceptedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,10 @@
ALTER TABLE "Account"
ADD COLUMN "householdId" TEXT,
ADD COLUMN "ownerUserId" TEXT,
ADD COLUMN "ownershipType" TEXT NOT NULL DEFAULT 'mine';
CREATE INDEX "Account_householdId_ownershipType_idx" ON "Account"("householdId", "ownershipType");
CREATE INDEX "Account_ownerUserId_idx" ON "Account"("ownerUserId");
ALTER TABLE "Account" ADD CONSTRAINT "Account_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Account" ADD CONSTRAINT "Account_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1 @@
ALTER TABLE "TransactionDerived" ADD COLUMN "attribution" TEXT NOT NULL DEFAULT 'mine';

View File

@ -0,0 +1,3 @@
ALTER TABLE "TransactionDerived" ADD COLUMN "splitMode" TEXT NOT NULL DEFAULT 'none';
ALTER TABLE "TransactionDerived" ADD COLUMN "splitMinePercent" DECIMAL(5, 2);
ALTER TABLE "TransactionDerived" ADD COLUMN "splitYoursPercent" DECIMAL(5, 2);

View File

@ -0,0 +1,25 @@
CREATE TABLE "HouseholdGoal" (
"id" TEXT NOT NULL,
"householdId" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"targetAmount" DECIMAL(18, 2) NOT NULL,
"currentAmount" DECIMAL(18, 2) NOT NULL DEFAULT 0,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"targetDate" TIMESTAMP(3),
"priority" TEXT NOT NULL DEFAULT 'medium',
"status" TEXT NOT NULL DEFAULT 'active',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HouseholdGoal_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "HouseholdGoal_householdId_status_idx" ON "HouseholdGoal"("householdId", "status");
CREATE INDEX "HouseholdGoal_createdByUserId_createdAt_idx" ON "HouseholdGoal"("createdByUserId", "createdAt");
CREATE INDEX "HouseholdGoal_targetDate_idx" ON "HouseholdGoal"("targetDate");
ALTER TABLE "HouseholdGoal" ADD CONSTRAINT "HouseholdGoal_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "HouseholdGoal" ADD CONSTRAINT "HouseholdGoal_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,52 @@
CREATE TABLE "NotificationPreference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
"pushEnabled" BOOLEAN NOT NULL DEFAULT false,
"minSeverity" TEXT NOT NULL DEFAULT 'info',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"severity" TEXT NOT NULL DEFAULT 'info',
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"channels" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PushSubscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId");
CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt");
CREATE INDEX "Notification_userId_readAt_idx" ON "Notification"("userId", "readAt");
CREATE INDEX "Notification_type_createdAt_idx" ON "Notification"("type", "createdAt");
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
CREATE INDEX "PushSubscription_userId_revokedAt_idx" ON "PushSubscription"("userId", "revokedAt");
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,63 @@
CREATE TABLE "BillPayee" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nickname" TEXT,
"category" TEXT,
"website" TEXT,
"accountNumberLast4" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BillPayee_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Bill" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"payeeId" TEXT,
"name" TEXT NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'USD',
"dueDate" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"recurrence" TEXT NOT NULL DEFAULT 'none',
"autopay" BOOLEAN NOT NULL DEFAULT false,
"reminderDays" INTEGER NOT NULL DEFAULT 3,
"notes" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Bill_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BillPayment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"billId" TEXT NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"method" TEXT NOT NULL DEFAULT 'manual',
"confirmationNumber" TEXT,
"notes" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BillPayment_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "BillPayee_userId_name_idx" ON "BillPayee"("userId", "name");
CREATE INDEX "Bill_userId_dueDate_idx" ON "Bill"("userId", "dueDate");
CREATE INDEX "Bill_userId_status_idx" ON "Bill"("userId", "status");
CREATE INDEX "Bill_payeeId_idx" ON "Bill"("payeeId");
CREATE INDEX "BillPayment_userId_paidAt_idx" ON "BillPayment"("userId", "paidAt");
CREATE INDEX "BillPayment_billId_idx" ON "BillPayment"("billId");
ALTER TABLE "BillPayee" ADD CONSTRAINT "BillPayee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Bill" ADD CONSTRAINT "Bill_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Bill" ADD CONSTRAINT "Bill_payeeId_fkey" FOREIGN KEY ("payeeId") REFERENCES "BillPayee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BillPayment" ADD CONSTRAINT "BillPayment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BillPayment" ADD CONSTRAINT "BillPayment_billId_fkey" FOREIGN KEY ("billId") REFERENCES "Bill"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,20 @@
CREATE TABLE "CreditScoreEntry" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"bureau" TEXT NOT NULL DEFAULT 'unknown',
"source" TEXT NOT NULL DEFAULT 'manual',
"model" TEXT NOT NULL DEFAULT 'vantage_score_3',
"scoreDate" TIMESTAMP(3) NOT NULL,
"factors" JSONB NOT NULL DEFAULT '{}',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CreditScoreEntry_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CreditScoreEntry_userId_scoreDate_idx" ON "CreditScoreEntry"("userId", "scoreDate");
CREATE INDEX "CreditScoreEntry_userId_bureau_idx" ON "CreditScoreEntry"("userId", "bureau");
ALTER TABLE "CreditScoreEntry" ADD CONSTRAINT "CreditScoreEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1 @@
ALTER TABLE "Session" ADD COLUMN "nonceHash" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,16 @@
CREATE TABLE "TransactionComment" (
"id" TEXT NOT NULL,
"rawTransactionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TransactionComment_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "TransactionComment_rawTransactionId_createdAt_idx" ON "TransactionComment"("rawTransactionId", "createdAt");
CREATE INDEX "TransactionComment_userId_createdAt_idx" ON "TransactionComment"("userId", "createdAt");
ALTER TABLE "TransactionComment" ADD CONSTRAINT "TransactionComment_rawTransactionId_fkey" FOREIGN KEY ("rawTransactionId") REFERENCES "TransactionRaw"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TransactionComment" ADD CONSTRAINT "TransactionComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,107 @@
CREATE TABLE "HouseholdBudget" (
"id" TEXT NOT NULL,
"householdId" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"period" TEXT NOT NULL DEFAULT 'monthly',
"limitAmount" DECIMAL(65,30) NOT NULL,
"spentAmount" DECIMAL(65,30) NOT NULL DEFAULT 0,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'active',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HouseholdBudget_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PersonalGoal" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"targetAmount" DECIMAL(65,30) NOT NULL,
"currentAmount" DECIMAL(65,30) NOT NULL DEFAULT 0,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"targetDate" TIMESTAMP(3),
"priority" TEXT NOT NULL DEFAULT 'medium',
"status" TEXT NOT NULL DEFAULT 'active',
"automation" JSONB NOT NULL DEFAULT '{}',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PersonalGoal_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "InvestmentHolding" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accountId" TEXT,
"symbol" TEXT NOT NULL,
"name" TEXT NOT NULL,
"assetClass" TEXT NOT NULL DEFAULT 'stock',
"quantity" DECIMAL(65,30) NOT NULL,
"price" DECIMAL(65,30) NOT NULL,
"marketValue" DECIMAL(65,30) NOT NULL,
"costBasis" DECIMAL(65,30),
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"asOfDate" TIMESTAMP(3) NOT NULL,
"source" TEXT NOT NULL DEFAULT 'manual',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InvestmentHolding_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "NetWorthSnapshot" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"snapshotDate" TIMESTAMP(3) NOT NULL,
"assets" DECIMAL(65,30) NOT NULL,
"liabilities" DECIMAL(65,30) NOT NULL,
"netWorth" DECIMAL(65,30) NOT NULL,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"source" TEXT NOT NULL DEFAULT 'computed',
"breakdown" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NetWorthSnapshot_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "RecurringTransaction" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"merchant" TEXT NOT NULL,
"cadence" TEXT NOT NULL,
"averageAmount" DECIMAL(65,30) NOT NULL,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"nextExpectedDate" TIMESTAMP(3),
"lastSeenDate" TIMESTAMP(3),
"occurrenceCount" INTEGER NOT NULL DEFAULT 0,
"confidence" DECIMAL(65,30) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'active',
"source" TEXT NOT NULL DEFAULT 'detected',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RecurringTransaction_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "HouseholdBudget_householdId_status_idx" ON "HouseholdBudget"("householdId", "status");
CREATE INDEX "HouseholdBudget_createdByUserId_createdAt_idx" ON "HouseholdBudget"("createdByUserId", "createdAt");
CREATE INDEX "PersonalGoal_userId_status_idx" ON "PersonalGoal"("userId", "status");
CREATE INDEX "PersonalGoal_targetDate_idx" ON "PersonalGoal"("targetDate");
CREATE INDEX "InvestmentHolding_userId_asOfDate_idx" ON "InvestmentHolding"("userId", "asOfDate");
CREATE INDEX "InvestmentHolding_userId_symbol_idx" ON "InvestmentHolding"("userId", "symbol");
CREATE INDEX "NetWorthSnapshot_userId_snapshotDate_idx" ON "NetWorthSnapshot"("userId", "snapshotDate");
CREATE INDEX "RecurringTransaction_userId_status_idx" ON "RecurringTransaction"("userId", "status");
CREATE INDEX "RecurringTransaction_userId_merchant_idx" ON "RecurringTransaction"("userId", "merchant");
ALTER TABLE "HouseholdBudget" ADD CONSTRAINT "HouseholdBudget_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "HouseholdBudget" ADD CONSTRAINT "HouseholdBudget_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PersonalGoal" ADD CONSTRAINT "PersonalGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InvestmentHolding" ADD CONSTRAINT "InvestmentHolding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InvestmentHolding" ADD CONSTRAINT "InvestmentHolding_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "NetWorthSnapshot" ADD CONSTRAINT "NetWorthSnapshot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RecurringTransaction" ADD CONSTRAINT "RecurringTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,27 @@
-- Add household-scoped accountant/advisor workflow tasks.
CREATE TABLE "AccountantTask" (
"id" TEXT NOT NULL,
"householdId" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"assignedToUserId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"taskType" TEXT NOT NULL DEFAULT 'review',
"status" TEXT NOT NULL DEFAULT 'open',
"priority" TEXT NOT NULL DEFAULT 'medium',
"dueDate" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountantTask_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AccountantTask_householdId_status_idx" ON "AccountantTask"("householdId", "status");
CREATE INDEX "AccountantTask_assignedToUserId_status_idx" ON "AccountantTask"("assignedToUserId", "status");
CREATE INDEX "AccountantTask_dueDate_idx" ON "AccountantTask"("dueDate");
ALTER TABLE "AccountantTask" ADD CONSTRAINT "AccountantTask_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AccountantTask" ADD CONSTRAINT "AccountantTask_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AccountantTask" ADD CONSTRAINT "AccountantTask_assignedToUserId_fkey" FOREIGN KEY ("assignedToUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,7 @@
-- Track provider-backed bill payment initiation state.
ALTER TABLE "BillPayment" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'completed';
ALTER TABLE "BillPayment" ADD COLUMN "provider" TEXT;
ALTER TABLE "BillPayment" ADD COLUMN "providerPaymentId" TEXT;
CREATE INDEX "BillPayment_userId_status_idx" ON "BillPayment"("userId", "status");
CREATE INDEX "BillPayment_provider_providerPaymentId_idx" ON "BillPayment"("provider", "providerPaymentId");

View File

@ -0,0 +1,15 @@
-- Add Stripe webhook idempotency ledger.
CREATE TABLE "StripeWebhookEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'received',
"processedAt" TIMESTAMP(3),
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StripeWebhookEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "StripeWebhookEvent_type_status_idx" ON "StripeWebhookEvent"("type", "status");
CREATE INDEX "StripeWebhookEvent_createdAt_idx" ON "StripeWebhookEvent"("createdAt");

View File

@ -0,0 +1,2 @@
-- Track user-selected data ownership/system-of-record posture.
ALTER TABLE "GoogleConnection" ADD COLUMN "dataSystemMode" TEXT NOT NULL DEFAULT 'backend_db';

View File

@ -0,0 +1,10 @@
-- Align historical incremental migrations with the current Prisma schema.
DROP INDEX IF EXISTS "ExportDownloadToken_storageKey_idx";
ALTER TABLE "HouseholdGoal"
ALTER COLUMN "targetAmount" TYPE DECIMAL(65,30),
ALTER COLUMN "currentAmount" TYPE DECIMAL(65,30);
ALTER TABLE "TransactionDerived"
ALTER COLUMN "splitMinePercent" TYPE DECIMAL(65,30),
ALTER COLUMN "splitYoursPercent" TYPE DECIMAL(65,30);

View File

@ -20,6 +20,7 @@ model User {
state String?
postalCode String?
country String?
role String @default("user")
emailVerified Boolean @default(false)
twoFactorEnabled Boolean @default(false)
twoFactorSecret String?
@ -29,34 +30,468 @@ model User {
accounts Account[]
rules Rule[]
exports ExportLog[]
exportDownloadTokens ExportDownloadToken[]
auditLogs AuditLog[]
abuseEvents AbuseEvent[]
googleConnection GoogleConnection?
apiKeys ApiKey[]
emailVerificationToken EmailVerificationToken?
passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[]
sessions Session[]
socialAccounts SocialAccount[]
subscription Subscription?
taxReturns TaxReturn[]
csvImportMappings CsvImportMapping[]
createdHouseholds Household[] @relation("HouseholdCreator")
householdMemberships HouseholdMember[]
sentHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteInviter")
acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter")
createdAccountantTasks AccountantTask[] @relation("AccountantTaskCreator")
assignedAccountantTasks AccountantTask[] @relation("AccountantTaskAssignee")
ownedAccounts Account[] @relation("AccountOwnerUser")
createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator")
personalGoals PersonalGoal[]
investmentHoldings InvestmentHolding[]
netWorthSnapshots NetWorthSnapshot[]
recurringTransactions RecurringTransaction[]
createdHouseholdBudgets HouseholdBudget[] @relation("HouseholdBudgetCreator")
notificationPreferences NotificationPreference?
notifications Notification[]
pushSubscriptions PushSubscription[]
billPayees BillPayee[]
bills Bill[]
billPayments BillPayment[]
creditScoreEntries CreditScoreEntry[]
transactionComments TransactionComment[]
}
model NotificationPreference {
id String @id @default(uuid())
userId String @unique
emailEnabled Boolean @default(true)
pushEnabled Boolean @default(false)
minSeverity String @default("info")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Notification {
id String @id @default(uuid())
userId String
type String
severity String @default("info")
title String
body String
metadata Json @default("{}")
channels String[] @default([])
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([userId, readAt])
@@index([type, createdAt])
}
model PushSubscription {
id String @id @default(uuid())
userId String
endpoint String @unique
p256dh String
auth String
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsedAt DateTime?
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt])
}
model BillPayee {
id String @id @default(uuid())
userId String
name String
nickname String?
category String?
website String?
accountNumberLast4 String?
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
bills Bill[]
@@index([userId, name])
}
model Bill {
id String @id @default(uuid())
userId String
payeeId String?
name String
amount Decimal
currency String @default("USD")
dueDate DateTime
status String @default("pending")
recurrence String @default("none")
autopay Boolean @default(false)
reminderDays Int @default(3)
notes String?
metadata Json @default("{}")
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
payee BillPayee? @relation(fields: [payeeId], references: [id], onDelete: SetNull)
payments BillPayment[]
@@index([userId, dueDate])
@@index([userId, status])
@@index([payeeId])
}
model BillPayment {
id String @id @default(uuid())
userId String
billId String
amount Decimal
paidAt DateTime @default(now())
method String @default("manual")
status String @default("completed")
provider String?
providerPaymentId String?
confirmationNumber String?
notes String?
metadata Json @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
bill Bill @relation(fields: [billId], references: [id], onDelete: Cascade)
@@index([userId, paidAt])
@@index([userId, status])
@@index([provider, providerPaymentId])
@@index([billId])
}
model CreditScoreEntry {
id String @id @default(uuid())
userId String
score Int
bureau String @default("unknown")
source String @default("manual")
model String @default("vantage_score_3")
scoreDate DateTime
factors Json @default("{}")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, scoreDate])
@@index([userId, bureau])
}
model Household {
id String @id @default(uuid())
name String
createdByUserId String
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User @relation("HouseholdCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
members HouseholdMember[]
invites HouseholdInvite[]
accounts Account[]
goals HouseholdGoal[]
budgets HouseholdBudget[]
accountantTasks AccountantTask[]
@@index([createdByUserId, createdAt])
}
model AccountantTask {
id String @id @default(uuid())
householdId String
createdByUserId String
assignedToUserId String?
title String
description String?
taskType String @default("review")
status String @default("open")
priority String @default("medium")
dueDate DateTime?
completedAt DateTime?
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
createdBy User @relation("AccountantTaskCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
assignedTo User? @relation("AccountantTaskAssignee", fields: [assignedToUserId], references: [id], onDelete: SetNull)
@@index([householdId, status])
@@index([assignedToUserId, status])
@@index([dueDate])
}
model HouseholdBudget {
id String @id @default(uuid())
householdId String
createdByUserId String
name String
category String?
period String @default("monthly")
limitAmount Decimal
spentAmount Decimal @default(0)
isoCurrencyCode String @default("USD")
startDate DateTime?
endDate DateTime?
status String @default("active")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
createdBy User @relation("HouseholdBudgetCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
@@index([householdId, status])
@@index([createdByUserId, createdAt])
}
model PersonalGoal {
id String @id @default(uuid())
userId String
name String
description String?
targetAmount Decimal
currentAmount Decimal @default(0)
isoCurrencyCode String @default("USD")
targetDate DateTime?
priority String @default("medium")
status String @default("active")
automation Json @default("{}")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status])
@@index([targetDate])
}
model InvestmentHolding {
id String @id @default(uuid())
userId String
accountId String?
symbol String
name String
assetClass String @default("stock")
quantity Decimal
price Decimal
marketValue Decimal
costBasis Decimal?
isoCurrencyCode String @default("USD")
asOfDate DateTime
source String @default("manual")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
@@index([userId, asOfDate])
@@index([userId, symbol])
}
model NetWorthSnapshot {
id String @id @default(uuid())
userId String
snapshotDate DateTime
assets Decimal
liabilities Decimal
netWorth Decimal
isoCurrencyCode String @default("USD")
source String @default("computed")
breakdown Json @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, snapshotDate])
}
model RecurringTransaction {
id String @id @default(uuid())
userId String
merchant String
cadence String
averageAmount Decimal
isoCurrencyCode String @default("USD")
nextExpectedDate DateTime?
lastSeenDate DateTime?
occurrenceCount Int @default(0)
confidence Decimal @default(0)
status String @default("active")
source String @default("detected")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status])
@@index([userId, merchant])
}
model HouseholdGoal {
id String @id @default(uuid())
householdId String
createdByUserId String
name String
description String?
targetAmount Decimal
currentAmount Decimal @default(0)
isoCurrencyCode String @default("USD")
targetDate DateTime?
priority String @default("medium")
status String @default("active")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
createdBy User @relation("HouseholdGoalCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
@@index([householdId, status])
@@index([createdByUserId, createdAt])
@@index([targetDate])
}
model HouseholdMember {
id String @id @default(uuid())
householdId String
userId String
role String @default("member")
status String @default("active")
joinedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([householdId, userId])
@@index([userId, status])
@@index([householdId, role])
}
model HouseholdInvite {
id String @id @default(uuid())
householdId String
invitedById String
email String
role String @default("member")
tokenHash String @unique
status String @default("pending")
expiresAt DateTime
acceptedAt DateTime?
acceptedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
invitedBy User @relation("HouseholdInviteInviter", fields: [invitedById], references: [id], onDelete: Cascade)
acceptedBy User? @relation("HouseholdInviteAccepter", fields: [acceptedById], references: [id], onDelete: SetNull)
@@index([householdId, status])
@@index([email, status])
@@index([expiresAt])
}
model SocialAccount {
id String @id @default(uuid())
userId String
provider String
providerAccountId String
email String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
model Account {
id String @id @default(uuid())
userId String
householdId String?
ownerUserId String?
ownershipType String @default("mine")
institutionName String
accountType String
mask String?
plaidAccessToken String?
plaidItemId String?
plaidAccountId String? @unique
tellerAccessToken String?
tellerEnrollmentId String?
tellerAccountId String? @unique
currentBalance Decimal?
availableBalance Decimal?
isoCurrencyCode String?
lastBalanceSync DateTime?
lastTransactionSync DateTime?
lastSyncAttemptAt DateTime?
syncStatus String @default("idle")
lastSyncError String?
syncConsecutiveFailures Int @default(0)
plaidWebhookCode String?
plaidWebhookAt DateTime?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
household Household? @relation(fields: [householdId], references: [id], onDelete: SetNull)
ownerUser User? @relation("AccountOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
transactionsRaw TransactionRaw[]
investmentHoldings InvestmentHolding[]
@@index([householdId, ownershipType])
@@index([ownerUserId])
@@index([plaidItemId])
@@index([tellerEnrollmentId])
}
model PlaidWebhookEvent {
id String @id @default(uuid())
itemId String?
webhookType String
webhookCode String
payload Json
status String @default("received")
error String?
receivedAt DateTime @default(now())
processedAt DateTime?
@@index([itemId, receivedAt])
@@index([webhookType, webhookCode])
}
model TransactionRaw {
@ -73,6 +508,7 @@ model TransactionRaw {
account Account @relation(fields: [accountId], references: [id])
derived TransactionDerived?
ruleExecutions RuleExecution[]
comments TransactionComment[]
}
model TransactionDerived {
@ -80,6 +516,10 @@ model TransactionDerived {
rawTransactionId String @unique
userCategory String?
userNotes String?
attribution String @default("mine")
splitMode String @default("none")
splitMinePercent Decimal?
splitYoursPercent Decimal?
isHidden Boolean @default(false)
modifiedAt DateTime @default(now())
modifiedBy String
@ -87,6 +527,37 @@ model TransactionDerived {
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id])
}
model TransactionComment {
id String @id @default(uuid())
rawTransactionId String
userId String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([rawTransactionId, createdAt])
@@index([userId, createdAt])
}
model CsvImportMapping {
id String @id @default(uuid())
userId String
headerSignature String
name String?
mapping Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, headerSignature])
@@index([userId, lastUsedAt])
}
model Rule {
id String @id @default(uuid())
userId String
@ -113,13 +584,46 @@ model RuleExecution {
}
model ExportLog {
id String @id @default(uuid())
userId String
filters Json
rowCount Int
createdAt DateTime @default(now())
id String @id @default(uuid())
userId String
format String @default("csv")
destination String @default("download")
filters Json
rowCount Int
fileName String?
mimeType String?
fileHash String?
ipAddress String?
userAgent String?
metadata Json @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
@@index([userId, createdAt])
@@index([format, createdAt])
}
model ExportDownloadToken {
id String @id @default(uuid())
userId String
tokenHash String @unique
format String
filters Json
storageProvider String?
storageKey String?
fileName String?
mimeType String?
rowCount Int?
fileHash String?
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([expiresAt])
}
model AuditLog {
@ -132,6 +636,23 @@ model AuditLog {
user User @relation(fields: [userId], references: [id])
}
model AbuseEvent {
id String @id @default(uuid())
userId String?
eventType String
riskPoints Int
severity String
ipAddress String?
userAgent String?
metadata Json @default("{}")
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([eventType, createdAt])
}
model GoogleConnection {
id String @id @default(uuid())
userId String @unique
@ -139,6 +660,11 @@ model GoogleConnection {
refreshToken String
accessToken String?
spreadsheetId String?
driveMirrorEnabled Boolean @default(false)
driveMirrorStatus String @default("not_started")
driveMirrorSpreadsheetUrl String?
driveMirrorLastSyncedAt DateTime?
dataSystemMode String @default("backend_db")
isConnected Boolean @default(true)
connectedAt DateTime @default(now())
lastSyncedAt DateTime?
@ -148,6 +674,24 @@ model GoogleConnection {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model ApiKey {
id String @id @default(uuid())
userId String
name String
prefix String
keyHash String @unique
scopes String[] @default(["transactions:read"])
lastUsedAt DateTime?
revokedAt DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([prefix])
}
model EmailVerificationToken {
id String @id @default(uuid())
userId String @unique
@ -169,15 +713,36 @@ model PasswordResetToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Session {
id String @id @default(uuid())
userId String
ipHash String
userAgentHash String
nonceHash String @default("")
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
expiresAt DateTime
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
refreshTokens RefreshToken[]
@@index([userId, revokedAt])
}
model RefreshToken {
id String @id @default(uuid())
userId String
sessionId String?
tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
session Session? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId])
}
model Subscription {
@ -194,6 +759,19 @@ model Subscription {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model StripeWebhookEvent {
id String @id
type String
status String @default("received")
processedAt DateTime?
error String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([type, status])
@@index([createdAt])
}
model TaxReturn {
id String @id @default(uuid())
userId String

View File

@ -0,0 +1,14 @@
import { Controller, Get } from "@nestjs/common";
import { ok } from "../common/response";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { AbuseService } from "./abuse.service";
@Controller("security")
export class AbuseController {
constructor(private readonly abuseService: AbuseService) {}
@Get("risk")
async risk(@CurrentUser() userId: string) {
return ok(await this.abuseService.getRiskProfile(userId));
}
}

11
src/abuse/abuse.module.ts Normal file
View File

@ -0,0 +1,11 @@
import { Global, Module } from "@nestjs/common";
import { AbuseController } from "./abuse.controller";
import { AbuseService } from "./abuse.service";
@Global()
@Module({
controllers: [AbuseController],
providers: [AbuseService],
exports: [AbuseService],
})
export class AbuseModule {}

187
src/abuse/abuse.service.ts Normal file
View File

@ -0,0 +1,187 @@
import { Injectable } from "@nestjs/common";
import * as crypto from "crypto";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { RequestContext } from "./abuse.types";
type AbuseSeverity = "low" | "medium" | "high";
type RecordAbuseEventInput = RequestContext & {
userId?: string;
eventType: string;
riskPoints: number;
severity: AbuseSeverity;
metadata?: Record<string, unknown>;
};
const RISK_WINDOW_HOURS = 24;
const PAGINATION_EVENT_TYPES = ["PAGINATION_LIMIT_CLAMPED", "PAGINATION_DEEP_SCAN"] as const;
@Injectable()
export class AbuseService {
constructor(private readonly prisma: PrismaService) {}
async recordEvent(input: RecordAbuseEventInput) {
return this.prisma.abuseEvent.create({
data: {
userId: input.userId,
eventType: input.eventType,
riskPoints: input.riskPoints,
severity: input.severity,
ipAddress: input.ipAddress,
userAgent: input.userAgent,
metadata: (input.metadata ?? {}) as Prisma.InputJsonValue,
},
});
}
async recordLoginFailure(email: string, userId: string | undefined, context?: RequestContext) {
await this.recordEvent({
userId,
eventType: "AUTH_LOGIN_FAILURE",
riskPoints: userId ? 8 : 4,
severity: userId ? "medium" : "low",
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
metadata: {
emailHash: this.hashValue(email.toLowerCase().trim()),
knownUser: Boolean(userId),
},
});
}
async recordInvalidToken(context?: RequestContext) {
await this.recordEvent({
eventType: "AUTH_INVALID_TOKEN",
riskPoints: 5,
severity: "low",
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
metadata: {},
});
}
async recordExportActivity(userId: string, rowCount: number, filters: Record<string, string>, context?: RequestContext) {
const since = new Date(Date.now() - 60 * 60 * 1000);
const recentExports = await this.prisma.exportLog.count({
where: { userId, createdAt: { gte: since } },
});
if (rowCount >= 500) {
await this.recordEvent({
userId,
eventType: "EXPORT_LARGE",
riskPoints: rowCount >= 1000 ? 25 : 15,
severity: rowCount >= 1000 ? "high" : "medium",
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
metadata: { rowCount, filters },
});
}
if (recentExports >= 5) {
await this.recordEvent({
userId,
eventType: "EXPORT_REPEATED",
riskPoints: 20,
severity: "high",
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
metadata: { exportsInLastHour: recentExports, rowCount },
});
}
}
async recordPaginationActivity(
userId: string,
resource: string,
page: number,
requestedLimit: number | undefined,
appliedLimit: number,
context?: RequestContext,
) {
const normalizedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
const normalizedRequestedLimit = Number.isFinite(requestedLimit) && requestedLimit ? Math.floor(requestedLimit) : appliedLimit;
const isLimitClamped = normalizedRequestedLimit > appliedLimit;
const isDeepScan = normalizedPage >= 20;
if (!isLimitClamped && !isDeepScan) return;
const since = new Date(Date.now() - 60 * 60 * 1000);
const recentSignals = await this.prisma.abuseEvent.count({
where: {
userId,
eventType: { in: [...PAGINATION_EVENT_TYPES] },
createdAt: { gte: since },
},
});
const repeated = recentSignals >= 10;
if (isLimitClamped) {
await this.recordEvent({
userId,
eventType: "PAGINATION_LIMIT_CLAMPED",
riskPoints: repeated ? 10 : 3,
severity: repeated ? "medium" : "low",
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
metadata: {
resource,
page: normalizedPage,
requestedLimit: normalizedRequestedLimit,
appliedLimit,
recentSignals,
},
});
}
if (isDeepScan) {
await this.recordEvent({
userId,
eventType: "PAGINATION_DEEP_SCAN",
riskPoints: repeated || normalizedPage >= 100 ? 20 : 8,
severity: repeated || normalizedPage >= 100 ? "high" : "medium",
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
metadata: {
resource,
page: normalizedPage,
requestedLimit: normalizedRequestedLimit,
appliedLimit,
recentSignals,
},
});
}
}
async getRiskProfile(userId: string) {
const since = new Date(Date.now() - RISK_WINDOW_HOURS * 60 * 60 * 1000);
const events = await this.prisma.abuseEvent.findMany({
where: { userId, createdAt: { gte: since } },
orderBy: { createdAt: "desc" },
take: 25,
});
const score = Math.min(100, events.reduce((sum, event) => sum + event.riskPoints, 0));
return {
score,
level: this.levelForScore(score),
windowHours: RISK_WINDOW_HOURS,
recentEvents: events.map((event) => ({
id: event.id,
eventType: event.eventType,
severity: event.severity,
riskPoints: event.riskPoints,
createdAt: event.createdAt,
})),
};
}
private levelForScore(score: number) {
if (score >= 70) return "high";
if (score >= 30) return "medium";
return "low";
}
private hashValue(value: string) {
return crypto.createHash("sha256").update(value).digest("hex");
}
}

17
src/abuse/abuse.types.ts Normal file
View File

@ -0,0 +1,17 @@
export type RequestContext = {
ipAddress?: string;
userAgent?: string;
sessionNonce?: string;
};
export function requestContextFrom(req: { ip?: string; headers?: { [key: string]: unknown } }): RequestContext {
const forwardedFor = req.headers?.["x-forwarded-for"];
const userAgent = req.headers?.["user-agent"];
return {
ipAddress: typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : req.ip,
userAgent: typeof userAgent === "string" ? userAgent : undefined,
sessionNonce: typeof req.headers?.["x-ledgerone-session-nonce"] === "string"
? req.headers["x-ledgerone-session-nonce"]
: undefined,
};
}

View File

@ -1,7 +1,9 @@
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
import { ok } from "../common/response";
import { AccountsService } from "./accounts.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateManualAccountDto } from "./dto/create-manual-account.dto";
import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto";
@Controller("accounts")
export class AccountsController {
@ -26,7 +28,7 @@ export class AccountsController {
@Post("manual")
async manual(
@CurrentUser() userId: string,
@Body() payload: { institutionName: string; accountType: string; mask?: string },
@Body() payload: CreateManualAccountDto,
) {
const data = await this.accountsService.createManualAccount(userId, payload);
return ok(data);
@ -37,4 +39,14 @@ export class AccountsController {
const data = await this.accountsService.refreshBalances(userId);
return ok(data);
}
@Patch(":id/ownership")
async updateOwnership(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: UpdateAccountOwnershipDto,
) {
const data = await this.accountsService.updateOwnership(userId, id, payload);
return ok(data);
}
}

View File

@ -1,10 +1,12 @@
import { Module } from "@nestjs/common";
import { PlaidModule } from "../plaid/plaid.module";
import { StripeModule } from "../stripe/stripe.module";
import { TellerModule } from "../teller/teller.module";
import { AccountsController } from "./accounts.controller";
import { AccountsService } from "./accounts.service";
@Module({
imports: [PlaidModule],
imports: [PlaidModule, TellerModule, StripeModule],
controllers: [AccountsController],
providers: [AccountsService]
})

View File

@ -1,18 +1,26 @@
import { Injectable } from "@nestjs/common";
import { BadRequestException, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service";
import { TellerService } from "../teller/teller.service";
import { OpaqueIdService } from "../common/opaque-id.service";
import { PlanLimitsService } from "../stripe/plan-limits.service";
import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto";
const MAX_PAGE_SIZE = 100;
const UI_PAGE_SIZE_LIMIT = 25;
@Injectable()
export class AccountsService {
constructor(
private readonly prisma: PrismaService,
private readonly plaidService: PlaidService,
private readonly tellerService: TellerService,
private readonly opaqueIds: OpaqueIdService,
private readonly planLimits: PlanLimitsService,
) {}
async list(userId: string, page = 1, limit = 20) {
const take = Math.min(limit, MAX_PAGE_SIZE);
const requestedLimit = Number.isFinite(limit) && limit ? limit : 20;
const take = Math.min(Math.max(requestedLimit, 1), UI_PAGE_SIZE_LIMIT);
const skip = (page - 1) * take;
const [accounts, total] = await Promise.all([
this.prisma.account.findMany({
@ -29,6 +37,17 @@ export class AccountsService {
availableBalance: true,
isoCurrencyCode: true,
lastBalanceSync: true,
lastTransactionSync: true,
lastSyncAttemptAt: true,
syncStatus: true,
lastSyncError: true,
syncConsecutiveFailures: true,
plaidWebhookCode: true,
plaidWebhookAt: true,
tellerAccountId: true,
householdId: true,
ownerUserId: true,
ownershipType: true,
isActive: true,
createdAt: true,
// Intentionally omit plaidAccessToken — never expose the encrypted token
@ -36,27 +55,62 @@ export class AccountsService {
}),
this.prisma.account.count({ where: { userId, isActive: true } }),
]);
return { accounts, total, page, limit: take };
return {
accounts: accounts.map((account) => ({
id: this.opaqueIds.encode("account", userId, account.id),
institutionName: account.institutionName,
accountType: account.accountType,
mask: account.mask,
currentBalance: account.currentBalance,
availableBalance: account.availableBalance,
isoCurrencyCode: account.isoCurrencyCode,
lastBalanceSync: account.lastBalanceSync,
lastTransactionSync: account.lastTransactionSync,
lastSyncAttemptAt: account.lastSyncAttemptAt,
syncStatus: account.syncStatus,
lastSyncError: account.lastSyncError,
syncConsecutiveFailures: account.syncConsecutiveFailures,
plaidWebhookCode: account.plaidWebhookCode,
plaidWebhookAt: account.plaidWebhookAt,
tellerConnected: Boolean(account.tellerAccountId),
householdId: account.householdId,
ownerUserId: account.ownerUserId,
ownershipType: account.ownershipType,
isActive: account.isActive,
createdAt: account.createdAt,
})),
total,
page,
limit: take,
};
}
async createLinkToken(userId: string) {
await this.planLimits.assertCanAddAccounts(userId, 1);
return this.plaidService.createLinkToken(userId);
}
async refreshBalances(userId: string) {
return this.plaidService.syncBalancesForUser(userId);
const [plaid, teller] = await Promise.all([
this.plaidService.syncBalancesForUser(userId),
this.tellerService.syncBalancesForUser(userId),
]);
return { updated: plaid.updated + teller.updated, plaid, teller };
}
async createManualAccount(
userId: string,
payload: { institutionName: string; accountType: string; mask?: string },
) {
return this.prisma.account.create({
await this.planLimits.assertCanAddAccounts(userId, 1);
const account = await this.prisma.account.create({
data: {
userId,
institutionName: payload.institutionName,
accountType: payload.accountType,
mask: payload.mask ?? null,
ownerUserId: userId,
ownershipType: "mine",
isActive: true,
},
select: {
@ -64,9 +118,91 @@ export class AccountsService {
institutionName: true,
accountType: true,
mask: true,
ownerUserId: true,
ownershipType: true,
isActive: true,
createdAt: true,
},
});
return {
...account,
id: this.opaqueIds.encode("account", userId, account.id),
};
}
async updateOwnership(userId: string, accountHandle: string, payload: UpdateAccountOwnershipDto) {
const accountId = this.opaqueIds.decode("account", userId, accountHandle);
const account = await this.prisma.account.findFirst({
where: { id: accountId, userId, isActive: true },
});
if (!account) throw new BadRequestException("Account not found.");
let householdId: string | null = null;
let ownerUserId: string | null = userId;
if (payload.ownershipType === "mine") {
householdId = null;
ownerUserId = userId;
} else {
if (!payload.householdId) {
throw new BadRequestException("Household is required for shared account ownership.");
}
const requesterMembership = await this.prisma.householdMember.findFirst({
where: { householdId: payload.householdId, userId, status: "active" },
});
if (!requesterMembership) throw new BadRequestException("Household not found.");
householdId = payload.householdId;
if (payload.ownershipType === "joint") {
ownerUserId = null;
} else {
if (!payload.ownerUserId || payload.ownerUserId === userId) {
throw new BadRequestException("Owner user must be another active household member for 'theirs' accounts.");
}
const ownerMembership = await this.prisma.householdMember.findFirst({
where: { householdId, userId: payload.ownerUserId, status: "active" },
});
if (!ownerMembership) throw new BadRequestException("Owner must be an active household member.");
ownerUserId = payload.ownerUserId;
}
}
const updated = await this.prisma.account.update({
where: { id: accountId },
data: {
ownershipType: payload.ownershipType,
householdId,
ownerUserId,
},
select: {
id: true,
institutionName: true,
accountType: true,
mask: true,
householdId: true,
ownerUserId: true,
ownershipType: true,
isActive: true,
createdAt: true,
},
});
await this.prisma.auditLog.create({
data: {
userId,
action: "account.ownership.update",
metadata: {
accountId,
householdId,
ownerUserId,
ownershipType: payload.ownershipType,
},
},
});
return {
...updated,
id: this.opaqueIds.encode("account", userId, updated.id),
};
}
}

View File

@ -0,0 +1,16 @@
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
export class CreateManualAccountDto {
@IsString()
@MaxLength(120)
institutionName!: string;
@IsString()
@IsIn(["checking", "savings", "credit", "investment", "loan", "cash", "other"])
accountType!: string;
@IsOptional()
@IsString()
@MaxLength(12)
mask?: string;
}

View File

@ -0,0 +1,17 @@
import { IsIn, IsOptional, IsString } from "class-validator";
export const ACCOUNT_OWNERSHIP_TYPES = ["mine", "theirs", "joint"] as const;
export type AccountOwnershipType = typeof ACCOUNT_OWNERSHIP_TYPES[number];
export class UpdateAccountOwnershipDto {
@IsIn(ACCOUNT_OWNERSHIP_TYPES)
ownershipType!: AccountOwnershipType;
@IsOptional()
@IsString()
householdId?: string;
@IsOptional()
@IsString()
ownerUserId?: string;
}

View File

@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ok } from "../common/response";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
import { AdminService } from "./admin.service";
@Roles("admin")
@UseGuards(RolesGuard)
@Controller("admin")
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get("users")
async users() {
return ok(await this.adminService.listUsers());
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
@Module({
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

View File

@ -0,0 +1,25 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class AdminService {
constructor(private readonly prisma: PrismaService) {}
async listUsers() {
return this.prisma.user.findMany({
orderBy: { createdAt: "desc" },
take: 100,
select: {
id: true,
email: true,
fullName: true,
role: true,
emailVerified: true,
twoFactorEnabled: true,
createdAt: true,
updatedAt: true,
subscription: { select: { plan: true } },
},
});
}
}

View File

@ -1,7 +1,7 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
import { APP_GUARD } from "@nestjs/core";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { envValidationSchema } from "./config/env.validation";
import { CommonModule } from "./common/common.module";
@ -11,6 +11,7 @@ import { SupabaseModule } from "./supabase/supabase.module";
import { EmailModule } from "./email/email.module";
import { AuthModule } from "./auth/auth.module";
import { PlaidModule } from "./plaid/plaid.module";
import { TellerModule } from "./teller/teller.module";
import { TaxModule } from "./tax/tax.module";
import { TransactionsModule } from "./transactions/transactions.module";
import { AccountsModule } from "./accounts/accounts.module";
@ -19,8 +20,19 @@ import { ExportsModule } from "./exports/exports.module";
import { StripeModule } from "./stripe/stripe.module";
import { TwoFactorModule } from "./auth/twofa/two-factor.module";
import { GoogleModule } from "./google/google.module";
import { AbuseModule } from "./abuse/abuse.module";
import { PublicApiModule } from "./public-api/public-api.module";
import { AdminModule } from "./admin/admin.module";
import { HouseholdsModule } from "./households/households.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { BillPayModule } from "./bill-pay/bill-pay.module";
import { CreditScoreModule } from "./credit-score/credit-score.module";
import { ViewModule } from "./view/view.module";
import { ComplianceModule } from "./compliance/compliance.module";
import { PlanningModule } from "./planning/planning.module";
import { LoggerModule } from "nestjs-pino";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.interceptor";
@Module({
imports: [
@ -57,10 +69,12 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
StorageModule,
SupabaseModule,
EmailModule,
AbuseModule,
// ─── Feature modules ─────────────────────────────────────────────────────
AuthModule,
PlaidModule,
TellerModule,
TaxModule,
TransactionsModule,
AccountsModule,
@ -69,12 +83,23 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
StripeModule,
TwoFactorModule,
GoogleModule,
PublicApiModule,
AdminModule,
HouseholdsModule,
NotificationsModule,
BillPayModule,
CreditScoreModule,
ViewModule,
ComplianceModule,
PlanningModule,
],
providers: [
// Apply rate limiting globally
{ provide: APP_GUARD, useClass: ThrottlerGuard },
// Apply JWT auth globally (routes decorated with @Public() are exempt)
{ provide: APP_GUARD, useClass: JwtAuthGuard },
// Treat browser/API clients as untrusted presentation clients by default.
{ provide: APP_INTERCEPTOR, useClass: BrowserUntrustedInterceptor },
],
})
export class AppModule {}

View File

@ -1,30 +1,34 @@
import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common";
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req } from "@nestjs/common";
import { Request } from "express";
import { ok } from "../common/response";
import { requestContextFrom } from "../abuse/abuse.types";
import { AuthService } from "./auth.service";
import { SocialAuthService } from "./social-auth.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
@Controller("auth")
@UseGuards(JwtAuthGuard)
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly socialAuthService: SocialAuthService,
) {}
@Public()
@Post("register")
async register(@Body() payload: RegisterDto) {
return ok(await this.authService.register(payload));
async register(@Body() payload: RegisterDto, @Req() req: Request) {
return ok(await this.authService.register(payload, requestContextFrom(req)));
}
@Public()
@Post("login")
async login(@Body() payload: LoginDto) {
return ok(await this.authService.login(payload));
async login(@Body() payload: LoginDto, @Req() req: Request) {
return ok(await this.authService.login(payload, requestContextFrom(req)));
}
@Public()
@ -35,8 +39,8 @@ export class AuthController {
@Public()
@Post("refresh")
async refresh(@Body("refreshToken") refreshToken: string) {
return ok(await this.authService.refreshAccessToken(refreshToken));
async refresh(@Body("refreshToken") refreshToken: string, @Req() req: Request) {
return ok(await this.authService.refreshAccessToken(refreshToken, requestContextFrom(req)));
}
@Post("logout")
@ -56,13 +60,46 @@ export class AuthController {
return ok(await this.authService.resetPassword(payload));
}
@Public()
@Get("social/:provider/url")
async socialAuthUrl(@Param("provider") provider: string, @Query("next") next: string) {
return ok(this.socialAuthService.getAuthorizationUrl(provider, next));
}
@Public()
@Post("social/:provider/callback")
async socialCallback(
@Param("provider") provider: string,
@Body("provider") bodyProvider: string | undefined,
@Body("code") code: string,
@Body("state") state: string,
@Req() req: Request,
) {
return ok(await this.socialAuthService.completeLogin(bodyProvider ?? provider, code, state, requestContextFrom(req)));
}
@Get("me")
async me(@CurrentUser() userId: string) {
return ok(await this.authService.getProfile(userId));
}
@Get("me/privacy-summary")
async privacySummary(@CurrentUser() userId: string) {
return ok(await this.authService.getPrivacySummary(userId));
}
@Get("me/data-export")
async dataExport(@CurrentUser() userId: string) {
return ok(await this.authService.exportPersonalData(userId));
}
@Patch("profile")
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
return ok(await this.authService.updateProfile(userId, payload));
}
@Delete("me")
async deleteMe(@CurrentUser() userId: string) {
return ok(await this.authService.deleteAccount(userId));
}
}

View File

@ -1,18 +1,23 @@
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { SocialAuthService } from "./social-auth.service";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
@Module({
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: "15m" },
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>("JWT_SECRET"),
signOptions: { expiresIn: `${config.get<number>("JWT_ACCESS_TTL_SECONDS", 60)}s` },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
providers: [AuthService, SocialAuthService, JwtAuthGuard],
exports: [AuthService, JwtModule, JwtAuthGuard],
})
export class AuthModule {}

View File

@ -2,6 +2,7 @@ import {
BadRequestException,
Injectable,
NotFoundException,
Optional,
UnauthorizedException,
} from "@nestjs/common";
import * as crypto from "crypto";
@ -15,10 +16,13 @@ import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
import { AbuseService } from "../abuse/abuse.service";
import { RequestContext } from "../abuse/abuse.types";
const VERIFY_TOKEN_TTL_HOURS = 24;
const RESET_TOKEN_TTL_HOURS = 1;
const REFRESH_TOKEN_TTL_DAYS = 30;
const SESSION_TTL_DAYS = 30;
@Injectable()
export class AuthService {
@ -27,9 +31,10 @@ export class AuthService {
private readonly jwtService: JwtService,
private readonly emailService: EmailService,
private readonly encryption: EncryptionService,
@Optional() private readonly abuseService?: AbuseService,
) {}
async register(payload: RegisterDto) {
async register(payload: RegisterDto, context?: RequestContext) {
const email = payload.email.toLowerCase().trim();
const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) throw new BadRequestException("Email already registered.");
@ -47,20 +52,21 @@ export class AuthService {
});
await this.emailService.sendVerificationEmail(email, verifyToken);
const accessToken = this.signAccessToken(user.id);
const refreshToken = await this.createRefreshToken(user.id);
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken,
refreshToken,
sessionNonce,
message: "Registration successful. Please verify your email.",
};
}
async login(payload: LoginDto) {
async login(payload: LoginDto, context?: RequestContext) {
const email = payload.email.toLowerCase().trim();
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
await this.abuseService?.recordLoginFailure(email, user?.id, context);
throw new UnauthorizedException("Invalid credentials.");
}
@ -77,12 +83,12 @@ export class AuthService {
}
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
const accessToken = this.signAccessToken(user.id);
const refreshToken = await this.createRefreshToken(user.id);
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken,
refreshToken,
sessionNonce,
};
}
@ -96,24 +102,47 @@ export class AuthService {
return { message: "Email verified successfully." };
}
async refreshAccessToken(rawRefreshToken: string) {
async refreshAccessToken(rawRefreshToken: string, context?: RequestContext) {
const tokenHash = this.hashToken(rawRefreshToken);
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
if (!record || record.revokedAt || record.expiresAt < new Date()) {
const record = await this.prisma.refreshToken.findUnique({
where: { tokenHash },
include: { session: true },
});
if (!record || record.revokedAt || record.expiresAt < new Date() || !record.session) {
throw new UnauthorizedException("Invalid or expired refresh token.");
}
this.assertSessionMatches(record.session, context);
const sessionNonce = this.createSessionNonce();
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
const accessToken = this.signAccessToken(record.userId);
const refreshToken = await this.createRefreshToken(record.userId);
return { accessToken, refreshToken };
await this.prisma.session.update({
where: { id: record.sessionId! },
data: { lastSeenAt: new Date(), nonceHash: this.hashToken(sessionNonce) },
});
const accessToken = this.signAccessToken(record.userId, record.sessionId!);
const refreshToken = await this.createRefreshToken(record.userId, record.sessionId!);
return { accessToken, refreshToken, sessionNonce };
}
async issueTokensForUser(userId: string, context?: RequestContext) {
const { session, sessionNonce } = await this.createSession(userId, context);
const accessToken = this.signAccessToken(userId, session.id);
const refreshToken = await this.createRefreshToken(userId, session.id);
return { accessToken, refreshToken, sessionNonce };
}
async logout(rawRefreshToken: string) {
const tokenHash = this.hashToken(rawRefreshToken);
const token = await this.prisma.refreshToken.findUnique({ where: { tokenHash }, select: { sessionId: true } });
await this.prisma.refreshToken.updateMany({
where: { tokenHash, revokedAt: null },
data: { revokedAt: new Date() },
});
if (token?.sessionId) {
await this.prisma.session.updateMany({
where: { id: token.sessionId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
return { message: "Logged out." };
}
@ -140,6 +169,10 @@ export class AuthService {
where: { userId: record.userId, revokedAt: null },
data: { revokedAt: new Date() },
});
await this.prisma.session.updateMany({
where: { userId: record.userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return { message: "Password reset successfully. Please log in." };
}
@ -180,19 +213,329 @@ export class AuthService {
};
}
verifyToken(token: string): { sub: string } {
return this.jwtService.verify<{ sub: string }>(token);
async getPrivacySummary(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, emailVerified: true, twoFactorEnabled: true, createdAt: true },
});
if (!user) throw new NotFoundException("User not found.");
const [
accounts,
transactions,
transactionComments,
rules,
taxReturns,
exports,
exportDownloadTokens,
apiKeys,
csvImportMappings,
notifications,
pushSubscriptions,
bills,
billPayees,
billPayments,
creditScores,
householdMemberships,
householdInvites,
householdGoals,
] = await Promise.all([
this.prisma.account.count({ where: { userId } }),
this.prisma.transactionRaw.count({ where: { account: { userId } } }),
this.prisma.transactionComment.count({ where: { userId } }),
this.prisma.rule.count({ where: { userId } }),
this.prisma.taxReturn.count({ where: { userId } }),
this.prisma.exportLog.count({ where: { userId } }),
this.prisma.exportDownloadToken.count({ where: { userId } }),
this.prisma.apiKey.count({ where: { userId } }),
this.prisma.csvImportMapping.count({ where: { userId } }),
this.prisma.notification.count({ where: { userId } }),
this.prisma.pushSubscription.count({ where: { userId } }),
this.prisma.bill.count({ where: { userId } }),
this.prisma.billPayee.count({ where: { userId } }),
this.prisma.billPayment.count({ where: { userId } }),
this.prisma.creditScoreEntry.count({ where: { userId } }),
this.prisma.householdMember.count({ where: { userId } }),
this.prisma.householdInvite.count({ where: { OR: [{ invitedById: userId }, { acceptedById: userId }] } }),
this.prisma.householdGoal.count({ where: { createdByUserId: userId } }),
]);
return {
subject: user,
dataCategories: {
accounts,
transactions,
transactionComments,
rules,
taxReturns,
exports,
exportDownloadTokens,
apiKeys,
csvImportMappings,
notifications,
pushSubscriptions,
bills,
billPayees,
billPayments,
creditScores,
householdMemberships,
householdInvites,
householdGoals,
},
controls: {
exportEndpoint: "/api/auth/me/data-export",
deletionEndpoint: "/api/auth/me",
excludedFromExport: [
"passwordHash",
"twoFactorSecret",
"session and refresh tokens",
"OAuth tokens",
"API key hashes",
"bank access tokens",
"raw bank payloads",
"stable internal account and transaction IDs",
],
},
};
}
private signAccessToken(userId: string): string {
return this.jwtService.sign({ sub: userId });
async exportPersonalData(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
fullName: true,
phone: true,
companyName: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
postalCode: true,
country: true,
role: true,
emailVerified: true,
twoFactorEnabled: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) throw new NotFoundException("User not found.");
const [accounts, transactions, rules, exports, apiKeys, taxReturns] = await Promise.all([
this.prisma.account.findMany({
where: { userId },
select: {
institutionName: true,
accountType: true,
mask: true,
ownershipType: true,
currentBalance: true,
availableBalance: true,
isoCurrencyCode: true,
syncStatus: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
}),
this.prisma.transactionRaw.findMany({
where: { account: { userId } },
select: {
date: true,
amount: true,
description: true,
source: true,
ingestedAt: true,
derived: {
select: {
userCategory: true,
userNotes: true,
attribution: true,
splitMode: true,
splitMinePercent: true,
splitYoursPercent: true,
isHidden: true,
modifiedAt: true,
},
},
},
orderBy: { date: "desc" },
}),
this.prisma.rule.findMany({
where: { userId },
select: { name: true, priority: true, conditions: true, actions: true, isActive: true, createdAt: true },
orderBy: { priority: "asc" },
}),
this.prisma.exportLog.findMany({
where: { userId },
select: { format: true, destination: true, filters: true, rowCount: true, fileName: true, mimeType: true, fileHash: true, metadata: true, createdAt: true },
orderBy: { createdAt: "desc" },
}),
this.prisma.apiKey.findMany({
where: { userId },
select: { name: true, prefix: true, scopes: true, lastUsedAt: true, revokedAt: true, expiresAt: true, createdAt: true },
orderBy: { createdAt: "desc" },
}),
this.prisma.taxReturn.findMany({
where: { userId },
select: { taxYear: true, filingType: true, jurisdictions: true, status: true, summary: true, createdAt: true, updatedAt: true },
orderBy: { createdAt: "desc" },
}),
]);
return {
generatedAt: new Date().toISOString(),
subject: user,
minimizationNotes: [
"Secrets, raw bank payloads, access tokens, token hashes, and stable internal account/transaction IDs are excluded.",
],
data: { accounts, transactions, rules, exports, apiKeys, taxReturns },
};
}
private async createRefreshToken(userId: string): Promise<string> {
async deleteAccount(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!user) throw new NotFoundException("User not found.");
await this.prisma.$transaction(async (tx) => {
const [accounts, rawTransactions, rules, taxReturns, bills, createdHouseholds] = await Promise.all([
tx.account.findMany({ where: { userId }, select: { id: true } }),
tx.transactionRaw.findMany({ where: { account: { userId } }, select: { id: true } }),
tx.rule.findMany({ where: { userId }, select: { id: true } }),
tx.taxReturn.findMany({ where: { userId }, select: { id: true } }),
tx.bill.findMany({ where: { userId }, select: { id: true } }),
tx.household.findMany({ where: { createdByUserId: userId }, select: { id: true } }),
]);
const accountIds = accounts.map((account) => account.id);
const transactionIds = rawTransactions.map((transaction) => transaction.id);
const ruleIds = rules.map((rule) => rule.id);
const taxReturnIds = taxReturns.map((taxReturn) => taxReturn.id);
const billIds = bills.map((bill) => bill.id);
const createdHouseholdIds = createdHouseholds.map((household) => household.id);
await tx.taxDocument.deleteMany({ where: { taxReturnId: { in: taxReturnIds } } });
await tx.ruleExecution.deleteMany({
where: {
OR: [
{ ruleId: { in: ruleIds } },
{ transactionId: { in: transactionIds } },
],
},
});
await tx.transactionComment.deleteMany({
where: { OR: [{ userId }, { rawTransactionId: { in: transactionIds } }] },
});
await tx.transactionDerived.deleteMany({ where: { rawTransactionId: { in: transactionIds } } });
await tx.transactionRaw.deleteMany({ where: { id: { in: transactionIds } } });
await tx.account.deleteMany({ where: { id: { in: accountIds } } });
await tx.rule.deleteMany({ where: { userId } });
await tx.exportDownloadToken.deleteMany({ where: { userId } });
await tx.exportLog.deleteMany({ where: { userId } });
await tx.auditLog.deleteMany({ where: { userId } });
await tx.abuseEvent.deleteMany({ where: { userId } });
await tx.apiKey.deleteMany({ where: { userId } });
await tx.csvImportMapping.deleteMany({ where: { userId } });
await tx.googleConnection.deleteMany({ where: { userId } });
await tx.notificationPreference.deleteMany({ where: { userId } });
await tx.notification.deleteMany({ where: { userId } });
await tx.pushSubscription.deleteMany({ where: { userId } });
await tx.billPayment.deleteMany({
where: { OR: [{ userId }, { billId: { in: billIds } }] },
});
await tx.bill.deleteMany({ where: { userId } });
await tx.billPayee.deleteMany({ where: { userId } });
await tx.creditScoreEntry.deleteMany({ where: { userId } });
await tx.householdInvite.deleteMany({
where: {
OR: [
{ invitedById: userId },
{ acceptedById: userId },
{ householdId: { in: createdHouseholdIds } },
],
},
});
await tx.householdGoal.deleteMany({
where: {
OR: [
{ createdByUserId: userId },
{ householdId: { in: createdHouseholdIds } },
],
},
});
await tx.householdMember.deleteMany({
where: {
OR: [
{ userId },
{ householdId: { in: createdHouseholdIds } },
],
},
});
await tx.household.deleteMany({ where: { id: { in: createdHouseholdIds } } });
await tx.emailVerificationToken.deleteMany({ where: { userId } });
await tx.passwordResetToken.deleteMany({ where: { userId } });
await tx.refreshToken.deleteMany({ where: { userId } });
await tx.session.deleteMany({ where: { userId } });
await tx.socialAccount.deleteMany({ where: { userId } });
await tx.subscription.deleteMany({ where: { userId } });
await tx.taxReturn.deleteMany({ where: { userId } });
await tx.user.delete({ where: { id: userId } });
});
return { message: "Account and associated personal data deleted." };
}
verifyToken(token: string): { sub: string; sid: string } {
return this.jwtService.verify<{ sub: string; sid: string }>(token);
}
private signAccessToken(userId: string, sessionId: string): string {
return this.jwtService.sign({ sub: userId, sid: sessionId });
}
private async createSession(userId: string, context?: RequestContext) {
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400 * 1000);
const sessionNonce = this.createSessionNonce();
const session = await this.prisma.session.create({
data: {
userId,
ipHash: this.hashBindingValue(context?.ipAddress ?? "unknown-ip"),
userAgentHash: this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"),
nonceHash: this.hashToken(sessionNonce),
expiresAt,
},
});
return { session, sessionNonce };
}
private assertSessionMatches(
session: { revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string; nonceHash?: string | null },
context?: RequestContext,
) {
if (session.revokedAt || session.expiresAt < new Date()) {
throw new UnauthorizedException("Session expired.");
}
const ipHash = this.hashBindingValue(context?.ipAddress ?? "unknown-ip");
const userAgentHash = this.hashBindingValue(context?.userAgent ?? "unknown-user-agent");
if (session.ipHash !== ipHash || session.userAgentHash !== userAgentHash) {
throw new UnauthorizedException("Session binding mismatch.");
}
if (!session.nonceHash || !context?.sessionNonce || session.nonceHash !== this.hashToken(context.sessionNonce)) {
throw new UnauthorizedException("Session nonce mismatch.");
}
}
private createSessionNonce(): string {
return crypto.randomBytes(32).toString("base64url");
}
private async createRefreshToken(userId: string, sessionId: string): Promise<string> {
const raw = crypto.randomBytes(40).toString("hex");
const tokenHash = this.hashToken(raw);
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000);
await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
await this.prisma.refreshToken.create({ data: { userId, sessionId, tokenHash, expiresAt } });
return raw;
}
@ -200,6 +543,10 @@ export class AuthService {
return crypto.createHash("sha256").update(token).digest("hex");
}
private hashBindingValue(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
private hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");

View File

@ -0,0 +1,296 @@
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import * as crypto from "crypto";
import { google } from "googleapis";
import { AuthService } from "./auth.service";
import { PrismaService } from "../prisma/prisma.service";
import { RequestContext } from "../abuse/abuse.types";
type SocialProvider = "google" | "apple";
type SocialProfile = {
provider: SocialProvider;
providerAccountId: string;
email: string;
name?: string;
};
type AppleJwk = {
kid: string;
alg: string;
kty: string;
use: string;
n: string;
e: string;
};
@Injectable()
export class SocialAuthService {
constructor(
private readonly prisma: PrismaService,
private readonly authService: AuthService,
) {}
getAuthorizationUrl(provider: string, next = "/app") {
const normalized = this.normalizeProvider(provider);
const redirectUri = this.getRedirectUri();
const state = this.signState({ provider: normalized, next: this.safeNext(next), exp: Date.now() + 10 * 60 * 1000 });
if (normalized === "google") {
const clientId = process.env.GOOGLE_AUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID;
if (!clientId) throw new BadRequestException("Google social login is not configured.");
const client = new google.auth.OAuth2(clientId, process.env.GOOGLE_AUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET, redirectUri);
return {
authUrl: client.generateAuthUrl({
access_type: "online",
scope: ["openid", "email", "profile"],
prompt: "select_account",
state,
}),
};
}
const clientId = process.env.APPLE_CLIENT_ID;
if (!clientId) throw new BadRequestException("Apple social login is not configured.");
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
response_mode: "query",
scope: "name email",
state,
});
return { authUrl: `https://appleid.apple.com/auth/authorize?${params.toString()}` };
}
async completeLogin(provider: string, code: string, state: string, context?: RequestContext) {
const normalized = this.normalizeProvider(provider);
const parsedState = this.verifyState(state);
if (parsedState.provider !== normalized) {
throw new BadRequestException("OAuth state provider mismatch.");
}
const profile = normalized === "google"
? await this.exchangeGoogleCode(code)
: await this.exchangeAppleCode(code);
const user = await this.upsertSocialUser(profile);
const tokens = await this.authService.issueTokensForUser(user.id, context);
await this.prisma.auditLog.create({
data: {
userId: user.id,
action: `auth.social.${normalized}.login`,
metadata: { provider: normalized, email: profile.email },
},
});
return {
...tokens,
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
next: parsedState.next || "/app",
};
}
private async exchangeGoogleCode(code: string): Promise<SocialProfile> {
const clientId = process.env.GOOGLE_AUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_AUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new BadRequestException("Google social login is not configured.");
const client = new google.auth.OAuth2(clientId, clientSecret, this.getRedirectUri());
const { tokens } = await client.getToken(code);
if (!tokens.id_token) throw new UnauthorizedException("Google did not return an identity token.");
const ticket = await client.verifyIdToken({ idToken: tokens.id_token, audience: clientId });
const payload = ticket.getPayload();
if (!payload?.sub || !payload.email) throw new UnauthorizedException("Google identity token is missing required claims.");
return {
provider: "google",
providerAccountId: payload.sub,
email: payload.email.toLowerCase(),
name: payload.name,
};
}
private async exchangeAppleCode(code: string): Promise<SocialProfile> {
const clientId = process.env.APPLE_CLIENT_ID;
if (!clientId) throw new BadRequestException("Apple social login is not configured.");
const response = await fetch("https://appleid.apple.com/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: this.createAppleClientSecret(),
code,
grant_type: "authorization_code",
redirect_uri: this.getRedirectUri(),
}),
});
const tokenData = await response.json() as { id_token?: string; error?: string };
if (!response.ok || !tokenData.id_token) {
throw new UnauthorizedException(tokenData.error || "Apple did not return an identity token.");
}
const payload = await this.verifyAppleIdToken(tokenData.id_token);
if (!payload.sub || !payload.email) throw new UnauthorizedException("Apple identity token is missing required claims.");
return {
provider: "apple",
providerAccountId: payload.sub,
email: payload.email.toLowerCase(),
};
}
private async upsertSocialUser(profile: SocialProfile) {
const existingAccount = await this.prisma.socialAccount.findUnique({
where: { provider_providerAccountId: { provider: profile.provider, providerAccountId: profile.providerAccountId } },
include: { user: true },
});
if (existingAccount) return existingAccount.user;
const user = await this.prisma.user.upsert({
where: { email: profile.email },
update: {
emailVerified: true,
...(profile.name ? { fullName: profile.name } : {}),
},
create: {
email: profile.email,
passwordHash: this.createUnusablePasswordHash(),
fullName: profile.name,
emailVerified: true,
},
});
await this.prisma.socialAccount.create({
data: {
userId: user.id,
provider: profile.provider,
providerAccountId: profile.providerAccountId,
email: profile.email,
},
});
return user;
}
private normalizeProvider(provider: string): SocialProvider {
if (provider === "google" || provider === "apple") return provider;
throw new BadRequestException("Unsupported social login provider.");
}
private getRedirectUri() {
return process.env.SOCIAL_AUTH_REDIRECT_URI || `${process.env.APP_URL || "http://localhost:3052"}/auth/social/callback`;
}
private safeNext(next: string) {
return next.startsWith("/") && !next.startsWith("//") ? next : "/app";
}
private signState(payload: { provider: SocialProvider; next: string; exp: number }) {
const body = this.base64url(JSON.stringify(payload));
const signature = this.base64url(crypto.createHmac("sha256", this.getStateSecret()).update(body).digest());
return `${body}.${signature}`;
}
private verifyState(state: string): { provider: SocialProvider; next: string; exp: number } {
const [body, signature] = state.split(".");
if (!body || !signature) throw new BadRequestException("Invalid OAuth state.");
const expected = this.base64url(crypto.createHmac("sha256", this.getStateSecret()).update(body).digest());
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (signatureBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
throw new BadRequestException("Invalid OAuth state signature.");
}
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as { provider: SocialProvider; next: string; exp: number };
if (payload.exp < Date.now()) throw new BadRequestException("OAuth state expired.");
return payload;
}
private getStateSecret() {
return process.env.JWT_SECRET || "social_auth_state_secret";
}
private createUnusablePasswordHash() {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.pbkdf2Sync(crypto.randomBytes(32).toString("hex"), salt, 100_000, 64, "sha512").toString("hex");
return `${salt}:${hash}`;
}
private createAppleClientSecret() {
const teamId = process.env.APPLE_TEAM_ID;
const keyId = process.env.APPLE_KEY_ID;
const clientId = process.env.APPLE_CLIENT_ID;
const rawPrivateKey = process.env.APPLE_PRIVATE_KEY?.replace(/\\n/g, "\n");
if (!teamId || !keyId || !clientId || !rawPrivateKey) {
throw new BadRequestException("Apple social login is not configured.");
}
const now = Math.floor(Date.now() / 1000);
const header = this.base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
const payload = this.base64url(JSON.stringify({
iss: teamId,
iat: now,
exp: now + 180 * 24 * 60 * 60,
aud: "https://appleid.apple.com",
sub: clientId,
}));
const data = `${header}.${payload}`;
const derSignature = crypto.sign("sha256", Buffer.from(data), rawPrivateKey);
return `${data}.${this.base64url(this.derToJose(derSignature, 64))}`;
}
private async verifyAppleIdToken(idToken: string): Promise<{ sub?: string; email?: string; aud?: string; iss?: string; exp?: number }> {
const [rawHeader, rawPayload, rawSignature] = idToken.split(".");
if (!rawHeader || !rawPayload || !rawSignature) throw new UnauthorizedException("Invalid Apple identity token.");
const header = JSON.parse(Buffer.from(rawHeader, "base64url").toString("utf8")) as { kid?: string; alg?: string };
const payload = JSON.parse(Buffer.from(rawPayload, "base64url").toString("utf8")) as { sub?: string; email?: string; aud?: string; iss?: string; exp?: number };
const response = await fetch("https://appleid.apple.com/auth/keys");
const data = await response.json() as { keys: AppleJwk[] };
const jwk = data.keys.find((key) => key.kid === header.kid && key.alg === "RS256");
if (!jwk) throw new UnauthorizedException("Apple signing key not found.");
const key = crypto.createPublicKey({ key: jwk, format: "jwk" });
const isValid = crypto.verify(
"RSA-SHA256",
Buffer.from(`${rawHeader}.${rawPayload}`),
key,
Buffer.from(rawSignature, "base64url"),
);
if (!isValid) throw new UnauthorizedException("Invalid Apple identity token signature.");
if (payload.iss !== "https://appleid.apple.com" || payload.aud !== process.env.APPLE_CLIENT_ID || !payload.exp || payload.exp < Math.floor(Date.now() / 1000)) {
throw new UnauthorizedException("Invalid Apple identity token claims.");
}
return payload;
}
private derToJose(signature: Buffer, size: number) {
let offset = 0;
if (signature[offset] !== 0x30) throw new BadRequestException("Invalid Apple signature.");
offset += 1;
const sequenceLength = signature[offset];
offset += 1;
if (sequenceLength + 2 !== signature.length) throw new BadRequestException("Invalid Apple signature.");
const readInteger = () => {
if (signature[offset] !== 0x02) throw new BadRequestException("Invalid Apple signature.");
offset += 1;
const length = signature[offset];
offset += 1;
let value = signature.subarray(offset, offset + length);
offset += length;
while (value.length > 0 && value[0] === 0) value = value.subarray(1);
if (value.length > size / 2) throw new BadRequestException("Invalid Apple signature.");
return Buffer.concat([Buffer.alloc(size / 2 - value.length), value]);
};
return Buffer.concat([readInteger(), readInteger()]);
}
private base64url(value: string | Buffer) {
return Buffer.from(value).toString("base64url");
}
}

View File

@ -0,0 +1,50 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response";
import { BillPayService } from "./bill-pay.service";
import { CreateBillDto, CreateBillPayeeDto, InitiateBillPaymentDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
@Controller("bill-pay")
export class BillPayController {
constructor(private readonly billPayService: BillPayService) {}
@Get("summary")
async summary(@CurrentUser() userId: string) {
return ok(await this.billPayService.summary(userId));
}
@Get("payees")
async payees(@CurrentUser() userId: string) {
return ok(await this.billPayService.listPayees(userId));
}
@Post("payees")
async createPayee(@CurrentUser() userId: string, @Body() body: CreateBillPayeeDto) {
return ok(await this.billPayService.createPayee(userId, body));
}
@Get("bills")
async bills(@CurrentUser() userId: string, @Query("status") status?: string) {
return ok(await this.billPayService.listBills(userId, status));
}
@Post("bills")
async createBill(@CurrentUser() userId: string, @Body() body: CreateBillDto) {
return ok(await this.billPayService.createBill(userId, body));
}
@Patch("bills/:id")
async updateBill(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: UpdateBillDto) {
return ok(await this.billPayService.updateBill(userId, id, body));
}
@Post("bills/:id/pay")
async markPaid(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: MarkBillPaidDto) {
return ok(await this.billPayService.markPaid(userId, id, body));
}
@Post("bills/:id/initiate-payment")
async initiatePayment(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: InitiateBillPaymentDto) {
return ok(await this.billPayService.initiatePayment(userId, id, body));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { NotificationsModule } from "../notifications/notifications.module";
import { BillPayController } from "./bill-pay.controller";
import { BillPayService } from "./bill-pay.service";
@Module({
imports: [NotificationsModule],
controllers: [BillPayController],
providers: [BillPayService],
})
export class BillPayModule {}

View File

@ -0,0 +1,347 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { randomUUID } from "crypto";
import { Prisma } from "@prisma/client";
import { NotificationsService } from "../notifications/notifications.service";
import { PrismaService } from "../prisma/prisma.service";
import { CreateBillDto, CreateBillPayeeDto, InitiateBillPaymentDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
const ACTIVE_STATUSES = ["pending", "scheduled"];
const SUPPORTED_PAYMENT_PROVIDERS = ["sandbox"];
@Injectable()
export class BillPayService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async listPayees(userId: string) {
return this.prisma.billPayee.findMany({
where: { userId },
include: { bills: { orderBy: { dueDate: "asc" }, take: 5 } },
orderBy: { name: "asc" },
});
}
async createPayee(userId: string, dto: CreateBillPayeeDto) {
return this.prisma.billPayee.create({
data: {
userId,
name: dto.name.trim(),
nickname: this.optionalString(dto.nickname),
category: this.optionalString(dto.category),
website: this.optionalString(dto.website),
accountNumberLast4: this.optionalString(dto.accountNumberLast4),
},
});
}
async listBills(userId: string, status?: string) {
const bills = await this.prisma.bill.findMany({
where: {
userId,
...(status && status !== "all" ? { status } : {}),
},
include: {
payee: true,
payments: { orderBy: { paidAt: "desc" } },
},
orderBy: [{ dueDate: "asc" }, { createdAt: "desc" }],
take: 100,
});
return bills.map((bill) => this.withComputedStatus(bill));
}
async createBill(userId: string, dto: CreateBillDto) {
if (dto.payeeId) await this.assertPayee(userId, dto.payeeId);
const dueDate = this.parseDate(dto.dueDate, "due date");
const bill = await this.prisma.bill.create({
data: {
userId,
payeeId: dto.payeeId,
name: dto.name.trim(),
amount: new Prisma.Decimal(dto.amount),
currency: (dto.currency ?? "USD").toUpperCase(),
dueDate,
status: dto.status ?? "pending",
recurrence: dto.recurrence ?? "none",
autopay: dto.autopay ?? false,
reminderDays: dto.reminderDays ?? 3,
notes: this.optionalString(dto.notes),
},
include: { payee: true, payments: true },
});
await this.prisma.auditLog.create({
data: {
userId,
action: "bill_pay.bill_create",
metadata: { billId: bill.id, dueDate: bill.dueDate, amount: bill.amount.toString() },
},
});
await this.notifications.notifyUser(userId, {
type: "bill.created",
severity: this.isDueSoon(bill.dueDate, bill.reminderDays) ? "warning" : "info",
title: `Bill added: ${bill.name}`,
body: `${this.formatMoney(bill.amount, bill.currency)} is due on ${bill.dueDate.toISOString().slice(0, 10)}.`,
metadata: { billId: bill.id },
});
return this.withComputedStatus(bill);
}
async updateBill(userId: string, billId: string, dto: UpdateBillDto) {
const existing = await this.assertBill(userId, billId);
if (dto.payeeId) await this.assertPayee(userId, dto.payeeId);
const bill = await this.prisma.bill.update({
where: { id: existing.id },
data: {
...(dto.payeeId !== undefined ? { payeeId: dto.payeeId } : {}),
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.amount !== undefined ? { amount: new Prisma.Decimal(dto.amount) } : {}),
...(dto.currency !== undefined ? { currency: dto.currency.toUpperCase() } : {}),
...(dto.dueDate !== undefined ? { dueDate: this.parseDate(dto.dueDate, "due date") } : {}),
...(dto.status !== undefined ? { status: dto.status, paidAt: dto.status === "paid" ? existing.paidAt ?? new Date() : existing.paidAt } : {}),
...(dto.recurrence !== undefined ? { recurrence: dto.recurrence } : {}),
...(dto.autopay !== undefined ? { autopay: dto.autopay } : {}),
...(dto.reminderDays !== undefined ? { reminderDays: dto.reminderDays } : {}),
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
},
include: { payee: true, payments: { orderBy: { paidAt: "desc" } } },
});
await this.prisma.auditLog.create({
data: {
userId,
action: "bill_pay.bill_update",
metadata: { billId: bill.id, fields: Object.keys(dto) },
},
});
return this.withComputedStatus(bill);
}
async markPaid(userId: string, billId: string, dto: MarkBillPaidDto) {
const bill = await this.assertBill(userId, billId);
const paidAt = dto.paidAt ? this.parseDate(dto.paidAt, "paid date") : new Date();
const amount = new Prisma.Decimal(dto.amount ?? Number(bill.amount));
const payment = await (this.prisma as any).billPayment.create({
data: {
userId,
billId: bill.id,
amount,
paidAt,
method: dto.method ?? "manual",
confirmationNumber: this.optionalString(dto.confirmationNumber),
notes: this.optionalString(dto.notes),
},
});
const updated = await this.prisma.bill.update({
where: { id: bill.id },
data: { status: "paid", paidAt },
include: { payee: true, payments: { orderBy: { paidAt: "desc" } } },
});
await this.prisma.auditLog.create({
data: {
userId,
action: "bill_pay.bill_paid",
metadata: {
billId: bill.id,
paymentId: payment.id,
amount: amount.toString(),
method: payment.method,
},
},
});
await this.notifications.notifyUser(userId, {
type: "bill.paid",
severity: "info",
title: `Bill paid: ${bill.name}`,
body: `${this.formatMoney(amount, bill.currency)} was recorded as paid.`,
metadata: { billId: bill.id, paymentId: payment.id },
});
return { bill: this.withComputedStatus(updated), payment };
}
async initiatePayment(userId: string, billId: string, dto: InitiateBillPaymentDto) {
const bill = await this.assertBill(userId, billId);
if (!ACTIVE_STATUSES.includes(bill.status)) {
throw new BadRequestException("Only pending or scheduled bills can be initiated.");
}
const provider = (process.env.BILL_PAY_PROVIDER ?? "sandbox").toLowerCase();
if (!SUPPORTED_PAYMENT_PROVIDERS.includes(provider)) {
throw new BadRequestException("Configured bill-payment provider is not supported by this build.");
}
const amount = new Prisma.Decimal(dto.amount ?? Number(bill.amount));
const scheduledFor = dto.scheduledFor ? this.parseDate(dto.scheduledFor, "scheduled date") : new Date();
const method = dto.method ?? "ach";
const providerResult = await this.initiateWithProvider(provider, {
userId,
billId: bill.id,
amount: amount.toNumber(),
currency: bill.currency,
scheduledFor,
method,
fundingAccountRef: this.optionalString(dto.fundingAccountRef),
memo: this.optionalString(dto.memo),
});
const payment = await (this.prisma as any).billPayment.create({
data: {
userId,
billId: bill.id,
amount,
paidAt: scheduledFor,
method,
status: providerResult.status,
provider,
providerPaymentId: providerResult.providerPaymentId,
confirmationNumber: providerResult.confirmationNumber,
notes: this.optionalString(dto.memo),
metadata: providerResult.metadata as Prisma.InputJsonValue,
},
});
const updated = await this.prisma.bill.update({
where: { id: bill.id },
data: {
status: providerResult.status === "completed" ? "paid" : "scheduled",
paidAt: providerResult.status === "completed" ? scheduledFor : bill.paidAt,
},
include: { payee: true, payments: { orderBy: { paidAt: "desc" } } },
});
await this.prisma.auditLog.create({
data: {
userId,
action: "bill_pay.payment_initiate",
metadata: {
billId: bill.id,
paymentId: payment.id,
provider,
providerPaymentId: providerResult.providerPaymentId,
status: providerResult.status,
},
},
});
await this.notifications.notifyUser(userId, {
type: "bill.payment_initiated",
severity: "info",
title: `Payment initiated: ${bill.name}`,
body: `${this.formatMoney(amount, bill.currency)} is ${providerResult.status} through ${provider}.`,
metadata: { billId: bill.id, paymentId: payment.id, provider },
});
return {
bill: this.withComputedStatus(updated),
payment,
provider: providerResult,
};
}
async summary(userId: string) {
const bills = await this.listBills(userId, "all");
const now = new Date();
const next30 = new Date(now);
next30.setDate(next30.getDate() + 30);
const active = bills.filter((bill: any) => ACTIVE_STATUSES.includes(bill.status));
const upcoming = active.filter((bill: any) => new Date(bill.dueDate) <= next30);
const overdue = bills.filter((bill: any) => bill.computedStatus === "overdue");
const totalDueNext30 = upcoming.reduce((sum: Prisma.Decimal, bill: any) => sum.plus(bill.amount), new Prisma.Decimal(0));
return {
activeCount: active.length,
upcomingCount: upcoming.length,
overdueCount: overdue.length,
totalDueNext30: totalDueNext30.toString(),
nextBills: upcoming.slice(0, 5),
overdueBills: overdue.slice(0, 5),
};
}
private async assertPayee(userId: string, payeeId: string) {
const payee = await this.prisma.billPayee.findFirst({ where: { id: payeeId, userId } });
if (!payee) throw new BadRequestException("Bill payee not found.");
return payee;
}
private async assertBill(userId: string, billId: string) {
const bill = await this.prisma.bill.findFirst({ where: { id: billId, userId } });
if (!bill) throw new BadRequestException("Bill not found.");
return bill;
}
private withComputedStatus<T extends { dueDate: Date; status: string }>(bill: T) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const due = new Date(bill.dueDate);
due.setHours(0, 0, 0, 0);
return {
...bill,
computedStatus: ACTIVE_STATUSES.includes(bill.status) && due < today ? "overdue" : bill.status,
};
}
private isDueSoon(dueDate: Date, reminderDays: number) {
const now = new Date();
const threshold = new Date(now);
threshold.setDate(threshold.getDate() + reminderDays);
return dueDate <= threshold;
}
private parseDate(value: string, label: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) throw new BadRequestException(`Invalid ${label}.`);
return date;
}
private optionalString(value?: string | null) {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
private formatMoney(amount: Prisma.Decimal, currency: string) {
return `${currency} ${amount.toFixed(2)}`;
}
private async initiateWithProvider(provider: string, payload: {
userId: string;
billId: string;
amount: number;
currency: string;
scheduledFor: Date;
method: string;
fundingAccountRef?: string | null;
memo?: string | null;
}) {
if (provider !== "sandbox") {
throw new BadRequestException("Bill-payment provider is not available.");
}
const providerPaymentId = `sandbox_${randomUUID().replace(/-/g, "")}`;
return {
providerPaymentId,
confirmationNumber: providerPaymentId.slice(-12).toUpperCase(),
status: payload.scheduledFor.getTime() <= Date.now() ? "completed" : "initiated",
metadata: {
rail: payload.method,
amount: payload.amount,
currency: payload.currency,
scheduledFor: payload.scheduledFor.toISOString(),
fundingAccountRef: payload.fundingAccountRef,
memo: payload.memo,
sandbox: true,
},
};
}
}

155
src/bill-pay/dto.ts Normal file
View File

@ -0,0 +1,155 @@
import { IsBoolean, IsDateString, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min } from "class-validator";
export class CreateBillPayeeDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
nickname?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
website?: string;
@IsOptional()
@IsString()
accountNumberLast4?: string;
}
export class CreateBillDto {
@IsOptional()
@IsString()
payeeId?: string;
@IsString()
name!: string;
@IsNumber()
@Min(0.01)
amount!: number;
@IsOptional()
@IsString()
currency?: string;
@IsDateString()
dueDate!: string;
@IsOptional()
@IsIn(["pending", "scheduled"])
status?: "pending" | "scheduled";
@IsOptional()
@IsIn(["none", "weekly", "monthly", "quarterly", "yearly"])
recurrence?: "none" | "weekly" | "monthly" | "quarterly" | "yearly";
@IsOptional()
@IsBoolean()
autopay?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(30)
reminderDays?: number;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateBillDto {
@IsOptional()
@IsString()
payeeId?: string | null;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsNumber()
@Min(0.01)
amount?: number;
@IsOptional()
@IsString()
currency?: string;
@IsOptional()
@IsDateString()
dueDate?: string;
@IsOptional()
@IsIn(["pending", "scheduled", "paid", "skipped", "cancelled"])
status?: "pending" | "scheduled" | "paid" | "skipped" | "cancelled";
@IsOptional()
@IsIn(["none", "weekly", "monthly", "quarterly", "yearly"])
recurrence?: "none" | "weekly" | "monthly" | "quarterly" | "yearly";
@IsOptional()
@IsBoolean()
autopay?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(30)
reminderDays?: number;
@IsOptional()
@IsString()
notes?: string | null;
}
export class MarkBillPaidDto {
@IsOptional()
@IsNumber()
@Min(0.01)
amount?: number;
@IsOptional()
@IsDateString()
paidAt?: string;
@IsOptional()
@IsIn(["manual", "autopay", "bank_bill_pay", "card", "ach", "check", "cash", "other"])
method?: string;
@IsOptional()
@IsString()
confirmationNumber?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class InitiateBillPaymentDto {
@IsOptional()
@IsNumber()
@Min(0.01)
amount?: number;
@IsOptional()
@IsDateString()
scheduledFor?: string;
@IsOptional()
@IsIn(["ach", "bank_bill_pay", "card"])
method?: string;
@IsOptional()
@IsString()
fundingAccountRef?: string;
@IsOptional()
@IsString()
memo?: string;
}

View File

@ -0,0 +1,23 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable } from "rxjs";
@Injectable()
export class BrowserUntrustedInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const response = context.switchToHttp().getResponse<{
setHeader?: (name: string, value: string) => void;
headersSent?: boolean;
}>();
if (response?.setHeader && !response.headersSent) {
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, private");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
response.setHeader("X-LedgerOne-Browser-Trust", "untrusted");
response.setHeader("X-LedgerOne-Data-Boundary", "server-authoritative");
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
}
return next.handle();
}
}

View File

@ -1,9 +1,12 @@
import { Global, Module } from "@nestjs/common";
import { EncryptionService } from "./encryption.service";
import { OpaqueIdService } from "./opaque-id.service";
import { RawPayloadEncryptionService } from "./raw-payload-encryption.service";
import { ViewRefService } from "./view-ref.service";
@Global()
@Module({
providers: [EncryptionService],
exports: [EncryptionService],
providers: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
exports: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
})
export class CommonModule {}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const ROLES_KEY = "roles";
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -7,6 +7,10 @@ import {
import { Reflector } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import * as crypto from "crypto";
import { AbuseService } from "../../abuse/abuse.service";
import { requestContextFrom } from "../../abuse/abuse.types";
import { PrismaService } from "../../prisma/prisma.service";
export const IS_PUBLIC_KEY = "isPublic";
@ -15,9 +19,11 @@ export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly reflector: Reflector,
private readonly abuseService: AbuseService,
private readonly prisma: PrismaService,
) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
@ -33,12 +39,41 @@ export class JwtAuthGuard implements CanActivate {
}
try {
const payload = this.jwtService.verify<{ sub: string }>(token, {
const payload = this.jwtService.verify<{ sub: string; sid?: string }>(token, {
secret: process.env.JWT_SECRET,
});
(request as Request & { user: { sub: string } }).user = payload;
if (!payload.sid) {
throw new UnauthorizedException("Session-bound token required.");
}
const session = await this.prisma.session.findUnique({ where: { id: payload.sid } });
const requestContext = requestContextFrom(request);
if (
!session ||
session.userId !== payload.sub ||
session.revokedAt ||
session.expiresAt < new Date() ||
session.ipHash !== this.hashBindingValue(requestContext.ipAddress ?? "unknown-ip") ||
session.userAgentHash !== this.hashBindingValue(requestContext.userAgent ?? "unknown-user-agent") ||
!session.nonceHash ||
!requestContext.sessionNonce ||
session.nonceHash !== this.hashBindingValue(requestContext.sessionNonce)
) {
throw new UnauthorizedException("Invalid session binding.");
}
const nextNonce = crypto.randomBytes(32).toString("base64url");
const response = context.switchToHttp().getResponse<{ setHeader(name: string, value: string): void }>();
await this.prisma.session.update({
where: { id: session.id },
data: {
lastSeenAt: new Date(),
nonceHash: this.hashBindingValue(nextNonce),
},
});
response.setHeader("X-LedgerOne-Next-Nonce", nextNonce);
(request as Request & { user: { sub: string; sid: string } }).user = { sub: payload.sub, sid: payload.sid };
return true;
} catch {
await this.abuseService.recordInvalidToken(requestContextFrom(request));
throw new UnauthorizedException("Invalid or expired token.");
}
}
@ -50,4 +85,8 @@ export class JwtAuthGuard implements CanActivate {
}
return null;
}
private hashBindingValue(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
}

View File

@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { PrismaService } from "../../prisma/prisma.service";
import { ROLES_KEY } from "../decorators/roles.decorator";
import { IS_PUBLIC_KEY } from "./jwt-auth.guard";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles?.length) return true;
const request = context.switchToHttp().getRequest<Request & { user?: { sub: string } }>();
const userId = request.user?.sub;
if (!userId) throw new ForbiddenException("Authentication required.");
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (!user || !requiredRoles.includes(user.role)) {
throw new ForbiddenException("Insufficient role.");
}
return true;
}
}

View File

@ -0,0 +1,31 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { EncryptionService } from "./encryption.service";
type OpaqueIdPayload = {
kind: string;
userId: string;
id: string;
};
@Injectable()
export class OpaqueIdService {
constructor(private readonly encryption: EncryptionService) {}
encode(kind: string, userId: string, id: string) {
const encrypted = this.encryption.encrypt(JSON.stringify({ kind, userId, id }));
return Buffer.from(encrypted, "utf8").toString("base64url");
}
decode(kind: string, userId: string, token: string) {
try {
const encrypted = Buffer.from(token, "base64url").toString("utf8");
const payload = JSON.parse(this.encryption.decrypt(encrypted)) as OpaqueIdPayload;
if (payload.kind !== kind || payload.userId !== userId || !payload.id) {
throw new Error("Opaque ID scope mismatch.");
}
return payload.id;
} catch {
throw new BadRequestException("Invalid resource identifier.");
}
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { EncryptionService } from "./encryption.service";
type EncryptedRawPayload = {
encrypted: true;
version: 1;
ciphertext: string;
};
@Injectable()
export class RawPayloadEncryptionService {
constructor(private readonly encryption: EncryptionService) {}
encrypt(payload: unknown): Prisma.InputJsonValue {
return {
encrypted: true,
version: 1,
ciphertext: this.encryption.encrypt(JSON.stringify(payload ?? null)),
} satisfies EncryptedRawPayload as unknown as Prisma.InputJsonValue;
}
decrypt<T = unknown>(payload: unknown): T {
if (!this.isEncryptedPayload(payload)) return payload as T;
return JSON.parse(this.encryption.decrypt(payload.ciphertext)) as T;
}
private isEncryptedPayload(payload: unknown): payload is EncryptedRawPayload {
return Boolean(
payload &&
typeof payload === "object" &&
(payload as { encrypted?: unknown }).encrypted === true &&
(payload as { version?: unknown }).version === 1 &&
typeof (payload as { ciphertext?: unknown }).ciphertext === "string",
);
}
}

View File

@ -12,6 +12,7 @@ import { Request, Response } from "express";
@Catch()
export class SentryExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger("ExceptionFilter");
private readonly sentryEnabled = Boolean(process.env.SENTRY_DSN);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
@ -23,17 +24,35 @@ export class SentryExceptionFilter implements ExceptionFilter {
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// Only report 5xx errors to Sentry
if (status >= 500 && process.env.SENTRY_DSN) {
Sentry.captureException(exception, {
extra: {
url: request.url,
const userId = (request as Request & { user?: { sub: string } }).user?.sub;
// Only report server-side failures to Sentry. Client errors remain local
// responses and do not create noisy operational alerts.
if (status >= 500 && this.sentryEnabled) {
const eventId = Sentry.withScope((scope) => {
if (userId) {
scope.setUser({ id: userId });
}
scope.setTag("http.status_code", String(status));
scope.setContext("request", {
method: request.method,
userId: (request as Request & { user?: { sub: string } }).user?.sub,
},
path: request.path,
query: request.query,
ip: request.ip,
userAgent: request.headers["user-agent"],
});
return Sentry.captureException(exception);
});
this.logger.error(
`Captured server exception in Sentry: ${eventId}`,
exception instanceof Error ? exception.stack : undefined,
);
} else if (status >= 500) {
this.logger.error(exception);
this.logger.error(
"Unhandled server exception",
exception instanceof Error ? exception.stack : String(exception),
);
}
const message =
@ -50,7 +69,7 @@ export class SentryExceptionFilter implements ExceptionFilter {
? message
: (message as { message?: string }).message ?? "Internal server error",
},
meta: { timestamp: new Date().toISOString(), version: "1.0" },
meta: { timestamp: new Date().toISOString(), version: "v1" },
});
}
}

View File

@ -0,0 +1,21 @@
import { Injectable } from "@nestjs/common";
import * as crypto from "crypto";
@Injectable()
export class ViewRefService {
create(userId: string, type: string, id: string) {
const day = new Date().toISOString().slice(0, 10);
const secret = process.env.JWT_SECRET ?? "ledgerone-view-boundary";
return crypto
.createHmac("sha256", secret)
.update(`${day}:${userId}:${type}:${id}`)
.digest("hex")
.slice(0, 24);
}
matches(userId: string, type: string, id: string, candidate: string) {
if (!/^[a-f0-9]{24}$/i.test(candidate)) return false;
const expected = this.create(userId, type, id);
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate.toLowerCase()));
}
}

View File

@ -0,0 +1,19 @@
import { Controller, Get } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ComplianceService } from "./compliance.service";
@Controller("compliance")
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}
@Get("soc2/readiness")
async soc2Readiness(@CurrentUser() userId: string) {
return this.complianceService.getSoc2Readiness(userId);
}
@Get("soc2/operations")
async soc2Operations() {
return this.complianceService.getSoc2Operations();
}
}

View File

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ComplianceController } from "./compliance.controller";
import { ComplianceService } from "./compliance.service";
@Module({
controllers: [ComplianceController],
providers: [ComplianceService],
})
export class ComplianceModule {}

View File

@ -0,0 +1,212 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
export type ControlStatus = "implemented" | "partial" | "external_required";
export interface Soc2Control {
id: string;
trustServiceCriterion: string;
status: ControlStatus;
control: string;
evidence: string[];
}
export interface Soc2ReadinessReport {
framework: "SOC 2";
readinessStatus: "technical_baseline_ready";
certificationStatus: "not_certified";
generatedAt: string;
evidenceSummary: {
auditLogCount: number;
exportLogCount: number;
abuseEventCount: number;
activeSessionCount: number;
};
controls: Soc2Control[];
pendingExternalControls: string[];
note: string;
}
export interface Soc2OperationsReport {
framework: "SOC 2";
certificationStatus: "not_certified";
generatedAt: string;
externalOperations: Array<{
id: string;
area: string;
owner: string;
status: "external_required";
requiredEvidence: string[];
systemSupport: string[];
}>;
note: string;
}
@Injectable()
export class ComplianceService {
constructor(private readonly prisma: PrismaService) {}
async getSoc2Readiness(userId: string): Promise<Soc2ReadinessReport> {
const [auditLogCount, exportLogCount, abuseEventCount, activeSessionCount] = await Promise.all([
this.prisma.auditLog.count({ where: { userId } }),
this.prisma.exportLog.count({ where: { userId } }),
this.prisma.abuseEvent.count({ where: { userId } }),
this.prisma.session.count({ where: { userId, revokedAt: null, expiresAt: { gt: new Date() } } }),
]);
const controls: Soc2Control[] = [
{
id: "CC6.1",
trustServiceCriterion: "Logical access",
status: "implemented",
control: "Authenticated access is bound to a server-side session with IP, user-agent, refresh token, and rotating nonce checks.",
evidence: [
"JwtAuthGuard validates session ID, IP hash, user-agent hash, and per-request nonce hash.",
`${activeSessionCount} active session(s) currently recorded for this user.`,
],
},
{
id: "CC6.7",
trustServiceCriterion: "Data confidentiality",
status: "implemented",
control: "Sensitive tokens and raw transaction payloads are encrypted before storage, and browser APIs return presentation data.",
evidence: [
"Plaid tokens and raw transaction payloads use server-side encryption services.",
"Browser-facing finance routes are served through sanitized view APIs.",
],
},
{
id: "CC7.2",
trustServiceCriterion: "Security monitoring",
status: "implemented",
control: "Abuse and anomaly signals are recorded for risky usage patterns.",
evidence: [`${abuseEventCount} abuse/risk event(s) currently recorded for this user.`],
},
{
id: "CC7.3",
trustServiceCriterion: "Auditability",
status: "implemented",
control: "Security-relevant account, export, and collaboration actions write audit evidence.",
evidence: [
`${auditLogCount} audit event(s) currently recorded for this user.`,
`${exportLogCount} export audit record(s) currently recorded for this user.`,
],
},
{
id: "CC8.1",
trustServiceCriterion: "Change management",
status: "partial",
control: "Repository-level CI and committed implementation history exist, but formal approval and release controls remain operational processes.",
evidence: ["Technical CI coverage can support SOC 2 evidence, but policy attestation is outside the application runtime."],
},
{
id: "P1.1",
trustServiceCriterion: "Privacy",
status: "implemented",
control: "User erasure, privacy inventory, and minimized personal-data export are available through authenticated account privacy flows.",
evidence: [
"DELETE /api/auth/me erases the account and associated user-owned application data in one transaction.",
"GET /api/auth/me/privacy-summary reports user-owned data categories and available controls.",
"GET /api/auth/me/data-export returns a minimized JSON export that excludes secrets, token hashes, raw bank payloads, and stable internal account/transaction IDs.",
"Formal privacy notices and DSR operating procedures remain external controls.",
],
},
];
return {
framework: "SOC 2",
readinessStatus: "technical_baseline_ready",
certificationStatus: "not_certified",
generatedAt: new Date().toISOString(),
evidenceSummary: {
auditLogCount,
exportLogCount,
abuseEventCount,
activeSessionCount,
},
controls,
pendingExternalControls: [
"Independent SOC 2 auditor engagement and examination period",
"Board/management-approved security, access control, incident response, vendor, and change management policies",
"Recurring access reviews with retained evidence",
"Vendor risk reviews for infrastructure, email, payment, and data-provider subprocessors",
"Incident response tabletop or equivalent drill evidence",
"Employee security training and onboarding/offboarding records",
],
note: "This endpoint reports application control evidence only. It does not represent SOC 2 certification or audit opinion.",
};
}
getSoc2Operations(): Soc2OperationsReport {
return {
framework: "SOC 2",
certificationStatus: "not_certified",
generatedAt: new Date().toISOString(),
externalOperations: [
{
id: "SOC2-AUDITOR",
area: "Independent examination",
owner: "Executive sponsor / auditor",
status: "external_required",
requiredEvidence: [
"Signed engagement letter with a CPA firm",
"Defined Type I or Type II examination scope",
"Auditor request list and evidence retention plan",
],
systemSupport: ["GET /api/compliance/soc2/readiness exposes runtime control evidence."],
},
{
id: "SOC2-POLICIES",
area: "Security policies",
owner: "Security / management",
status: "external_required",
requiredEvidence: [
"Approved access control policy",
"Approved change management policy",
"Approved incident response policy",
"Approved vendor risk policy",
],
systemSupport: ["Audit logs, export logs, session records, and abuse events provide technical evidence."],
},
{
id: "SOC2-ACCESS-REVIEWS",
area: "Access reviews",
owner: "Operations",
status: "external_required",
requiredEvidence: [
"Quarterly access review checklist",
"Reviewer sign-off records",
"Remediation tickets for excessive access",
],
systemSupport: ["User/session/audit data can support review evidence, but approvals occur outside the runtime."],
},
{
id: "SOC2-VENDOR-RISK",
area: "Vendor risk",
owner: "Operations / legal",
status: "external_required",
requiredEvidence: [
"Subprocessor inventory",
"Vendor security reviews for infrastructure, email, payments, bank data, and tax providers",
"Executed DPAs where required",
],
systemSupport: ["Provider integration metadata identifies Stripe, Plaid, Teller, Google, SMTP, and export storage dependencies."],
},
{
id: "SOC2-TRAINING-DRILLS",
area: "Training and incident drills",
owner: "People / security",
status: "external_required",
requiredEvidence: [
"Employee security training records",
"Onboarding/offboarding records",
"Incident response tabletop or drill report",
],
systemSupport: ["Application audit records can support incident reconstruction after an event."],
},
],
note: "These operations cannot be completed by code alone. The application can expose evidence, but certification requires external policies, retained records, and an auditor.",
};
}
}

View File

@ -4,6 +4,7 @@ export const envValidationSchema = Joi.object({
DATABASE_URL: Joi.string().required(),
JWT_SECRET: Joi.string().min(32).required(),
JWT_REFRESH_SECRET: Joi.string().min(32).required(),
JWT_ACCESS_TTL_SECONDS: Joi.number().integer().min(30).max(60).default(60),
ENCRYPTION_KEY: Joi.string().length(64).required(),
PLAID_CLIENT_ID: Joi.string().required(),
@ -12,11 +13,26 @@ export const envValidationSchema = Joi.object({
PLAID_PRODUCTS: Joi.string().default("transactions"),
PLAID_COUNTRY_CODES: Joi.string().default("US"),
PLAID_REDIRECT_URI: Joi.string().uri().optional().allow(""),
PLAID_WEBHOOK_URL: Joi.string().uri().optional().allow(""),
PLAID_VERIFY_WEBHOOKS: Joi.boolean().default(true),
TELLER_APPLICATION_ID: Joi.string().optional().allow(""),
TELLER_ENV: Joi.string().valid("sandbox", "development", "production").default("sandbox"),
TELLER_PRODUCTS: Joi.string().default("transactions,balance"),
TELLER_API_BASE_URL: Joi.string().uri().default("https://api.teller.io"),
TELLER_CERT_PATH: Joi.string().optional().allow(""),
TELLER_KEY_PATH: Joi.string().optional().allow(""),
TELLER_CERT_PEM: Joi.string().optional().allow(""),
TELLER_KEY_PEM: Joi.string().optional().allow(""),
SUPABASE_URL: Joi.string().optional().allow(""),
SUPABASE_SERVICE_KEY: Joi.string().optional().allow(""),
SUPABASE_ANON_KEY: Joi.string().optional().allow(""),
EXPORT_OBJECT_STORAGE_DRIVER: Joi.string().valid("local", "supabase").default("local"),
EXPORT_OBJECT_STORAGE_BUCKET: Joi.string().default("ledgerone-exports"),
EXPORT_OBJECT_STORAGE_DIR: Joi.string().default("data/export-objects"),
STRIPE_SECRET_KEY: Joi.string().optional().allow(""),
STRIPE_WEBHOOK_SECRET: Joi.string().optional().allow(""),
STRIPE_PRICE_PRO: Joi.string().optional().allow(""),
@ -27,6 +43,9 @@ export const envValidationSchema = Joi.object({
SMTP_USER: Joi.string().optional().allow(""),
SMTP_PASS: Joi.string().optional().allow(""),
SMTP_FROM: Joi.string().default("noreply@ledgerone.app"),
VAPID_PUBLIC_KEY: Joi.string().optional().allow(""),
VAPID_PRIVATE_KEY: Joi.string().optional().allow(""),
VAPID_SUBJECT: Joi.string().default("mailto:support@ledgerone.app"),
APP_URL: Joi.string().uri().default("http://localhost:3052"),
PORT: Joi.number().default(3051),
@ -35,11 +54,27 @@ export const envValidationSchema = Joi.object({
CORS_ORIGIN: Joi.string().default("http://localhost:3052"),
AUTO_SYNC_ENABLED: Joi.boolean().default(true),
AUTO_SYNC_INTERVAL_MINUTES: Joi.number().default(15),
AUTO_SYNC_INTERVAL_MINUTES: Joi.number().integer().min(1).default(15),
AUTO_SYNC_STALE_MINUTES: Joi.number().integer().min(1).optional(),
AUTO_SYNC_LOOKBACK_DAYS: Joi.number().integer().min(1).max(365).default(7),
AUTO_SYNC_MAX_USERS_PER_RUN: Joi.number().integer().min(1).max(500).default(25),
SENTRY_DSN: Joi.string().optional().allow(""),
SENTRY_DSN: Joi.when("NODE_ENV", {
is: "production",
then: Joi.string().uri().required(),
otherwise: Joi.string().uri().optional().allow(""),
}),
SENTRY_TRACES_SAMPLE_RATE: Joi.number().min(0).max(1).default(0),
GOOGLE_CLIENT_ID: Joi.string().optional().allow(""),
GOOGLE_CLIENT_SECRET: Joi.string().optional().allow(""),
GOOGLE_REDIRECT_URI: Joi.string().uri().optional().allow(""),
SOCIAL_AUTH_REDIRECT_URI: Joi.string().uri().optional().allow(""),
GOOGLE_AUTH_CLIENT_ID: Joi.string().optional().allow(""),
GOOGLE_AUTH_CLIENT_SECRET: Joi.string().optional().allow(""),
APPLE_CLIENT_ID: Joi.string().optional().allow(""),
APPLE_TEAM_ID: Joi.string().optional().allow(""),
APPLE_KEY_ID: Joi.string().optional().allow(""),
APPLE_PRIVATE_KEY: Joi.string().optional().allow(""),
});

View File

@ -0,0 +1,30 @@
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response";
import { CreditScoreService } from "./credit-score.service";
import { CreateCreditScoreEntryDto, PullCreditScoreDto } from "./dto";
@Controller("credit-score")
export class CreditScoreController {
constructor(private readonly creditScoreService: CreditScoreService) {}
@Get("summary")
async summary(@CurrentUser() userId: string) {
return ok(await this.creditScoreService.summary(userId));
}
@Get("entries")
async entries(@CurrentUser() userId: string, @Query("bureau") bureau?: string) {
return ok(await this.creditScoreService.listEntries(userId, bureau));
}
@Post("entries")
async createEntry(@CurrentUser() userId: string, @Body() body: CreateCreditScoreEntryDto) {
return ok(await this.creditScoreService.createEntry(userId, body));
}
@Post("pull")
async pull(@CurrentUser() userId: string, @Body() body: PullCreditScoreDto) {
return ok(await this.creditScoreService.pullScore(userId, body));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { NotificationsModule } from "../notifications/notifications.module";
import { CreditScoreController } from "./credit-score.controller";
import { CreditScoreService } from "./credit-score.service";
@Module({
imports: [NotificationsModule],
controllers: [CreditScoreController],
providers: [CreditScoreService],
})
export class CreditScoreModule {}

View File

@ -0,0 +1,169 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { NotificationsService } from "../notifications/notifications.service";
import { PrismaService } from "../prisma/prisma.service";
import { CreateCreditScoreEntryDto, PullCreditScoreDto } from "./dto";
const SUPPORTED_CREDIT_PROVIDERS = ["sandbox"];
@Injectable()
export class CreditScoreService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async listEntries(userId: string, bureau?: string) {
return this.prisma.creditScoreEntry.findMany({
where: {
userId,
...(bureau && bureau !== "all" ? { bureau } : {}),
},
orderBy: [{ scoreDate: "desc" }, { createdAt: "desc" }],
take: 100,
});
}
async createEntry(userId: string, dto: CreateCreditScoreEntryDto) {
const scoreDate = new Date(dto.scoreDate);
if (Number.isNaN(scoreDate.getTime())) throw new BadRequestException("Invalid score date.");
const previous = await this.prisma.creditScoreEntry.findFirst({
where: {
userId,
bureau: dto.bureau ?? "unknown",
scoreDate: { lt: scoreDate },
},
orderBy: { scoreDate: "desc" },
});
const entry = await this.prisma.creditScoreEntry.create({
data: {
userId,
score: dto.score,
bureau: dto.bureau ?? "unknown",
source: dto.source ?? "manual",
model: dto.model ?? "vantage_score_3",
scoreDate,
factors: (dto.factors ?? {}) as Prisma.InputJsonValue,
metadata: (dto.metadata ?? {}) as Prisma.InputJsonValue,
},
});
const change = previous ? entry.score - previous.score : null;
await this.prisma.auditLog.create({
data: {
userId,
action: "credit_score.entry_create",
metadata: {
entryId: entry.id,
bureau: entry.bureau,
score: entry.score,
scoreDate: entry.scoreDate,
change,
},
},
});
if (change !== null && Math.abs(change) >= 20) {
await this.notifications.notifyUser(userId, {
type: "credit_score.change",
severity: change < 0 ? "warning" : "info",
title: change < 0 ? "Credit score dropped" : "Credit score improved",
body: `${this.label(entry.bureau)} score changed by ${change > 0 ? "+" : ""}${change} points to ${entry.score}.`,
metadata: { entryId: entry.id, bureau: entry.bureau, score: entry.score, change },
});
}
return {
...entry,
change,
};
}
async pullScore(userId: string, dto: PullCreditScoreDto) {
const provider = (dto.provider ?? process.env.CREDIT_SCORE_PROVIDER ?? "sandbox").toLowerCase();
if (!SUPPORTED_CREDIT_PROVIDERS.includes(provider)) {
throw new BadRequestException("Configured credit-score provider is not supported by this build.");
}
const pulled = await this.pullFromProvider(provider, userId, dto.bureau ?? "experian");
return this.createEntry(userId, {
score: pulled.score,
bureau: pulled.bureau,
source: "provider",
model: pulled.model,
scoreDate: pulled.scoreDate,
factors: pulled.factors,
metadata: {
provider,
providerPullId: pulled.providerPullId,
consent: dto.consent ?? {},
sandbox: provider === "sandbox",
},
});
}
async summary(userId: string) {
const entries = await this.listEntries(userId, "all");
const latestByBureau = new Map<string, any>();
for (const entry of entries) {
if (!latestByBureau.has(entry.bureau)) latestByBureau.set(entry.bureau, entry);
}
const latest = entries[0] ?? null;
const previous = latest
? entries.find((entry) => entry.id !== latest.id && entry.bureau === latest.bureau) ?? null
: null;
const change = latest && previous ? latest.score - previous.score : null;
return {
latest,
previous,
change,
averageScore: entries.length
? Math.round(entries.reduce((sum, entry) => sum + entry.score, 0) / entries.length)
: null,
entryCount: entries.length,
latestByBureau: Array.from(latestByBureau.values()),
trend: entries
.slice()
.reverse()
.map((entry) => ({
id: entry.id,
score: entry.score,
bureau: entry.bureau,
scoreDate: entry.scoreDate,
})),
};
}
private label(bureau: string) {
if (bureau === "experian") return "Experian";
if (bureau === "equifax") return "Equifax";
if (bureau === "transunion") return "TransUnion";
return "Credit";
}
private async pullFromProvider(provider: string, userId: string, bureau: "experian" | "equifax" | "transunion") {
if (provider !== "sandbox") {
throw new BadRequestException("Credit-score provider is not available.");
}
const seed = Array.from(`${userId}:${bureau}:${new Date().toISOString().slice(0, 10)}`)
.reduce((sum, char) => sum + char.charCodeAt(0), 0);
const score = 660 + (seed % 90);
return {
score,
bureau,
model: "vantage_score_3",
scoreDate: new Date().toISOString(),
providerPullId: `sandbox_credit_${seed}_${Date.now()}`,
factors: {
paymentHistory: "good",
utilization: seed % 3 === 0 ? "moderate" : "low",
accountAge: "established",
inquiries: seed % 2 === 0 ? "low" : "moderate",
},
};
}
}

45
src/credit-score/dto.ts Normal file
View File

@ -0,0 +1,45 @@
import { IsDateString, IsIn, IsInt, IsObject, IsOptional, IsString, Max, Min } from "class-validator";
export class CreateCreditScoreEntryDto {
@IsInt()
@Min(300)
@Max(850)
score!: number;
@IsOptional()
@IsIn(["experian", "equifax", "transunion", "unknown"])
bureau?: "experian" | "equifax" | "transunion" | "unknown";
@IsOptional()
@IsIn(["manual", "import", "provider"])
source?: "manual" | "import" | "provider";
@IsOptional()
@IsString()
model?: string;
@IsDateString()
scoreDate!: string;
@IsOptional()
@IsObject()
factors?: Record<string, unknown>;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}
export class PullCreditScoreDto {
@IsOptional()
@IsIn(["experian", "equifax", "transunion"])
bureau?: "experian" | "equifax" | "transunion";
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsObject()
consent?: Record<string, unknown>;
}

View File

@ -77,4 +77,51 @@ export class EmailService {
this.logger.error(`Failed to send password reset email to ${email}`, err);
}
}
async sendHouseholdInviteEmail(email: string, householdName: string, inviterName: string, token: string): Promise<void> {
const url = `${this.appUrl}/settings/households/invite?token=${token}`;
try {
const info = await this.transporter.sendMail({
from: this.from,
to: email,
subject: `You're invited to join ${householdName} on LedgerOne`,
html: `
<h2>Join ${householdName}</h2>
<p>${inviterName} invited you to collaborate in a LedgerOne household.</p>
<p><a href="${url}" style="background:#316263;color:#B6FF3B;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Accept Invite</a></p>
<p>Or copy this link: ${url}</p>
<p>This invitation expires in 7 days.</p>
`,
});
if (!process.env.SMTP_HOST) {
this.logger.log(`[DEV] Household invite email for ${email}: ${url}`);
this.logger.debug(JSON.stringify(info));
}
} catch (err) {
this.logger.error(`Failed to send household invite email to ${email}`, err);
}
}
async sendNotificationEmail(email: string, title: string, body: string, actionUrl?: string, severity = "info"): Promise<void> {
const url = actionUrl ?? `${this.appUrl}/notifications`;
try {
const info = await this.transporter.sendMail({
from: this.from,
to: email,
subject: `[LedgerOne ${severity.toUpperCase()}] ${title}`,
html: `
<h2>${title}</h2>
<p>${body}</p>
<p><a href="${url}" style="background:#316263;color:#B6FF3B;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Open LedgerOne</a></p>
<p>Or copy this link: ${url}</p>
`,
});
if (!process.env.SMTP_HOST) {
this.logger.log(`[DEV] Notification email for ${email}: ${title}`);
this.logger.debug(JSON.stringify(info));
}
} catch (err) {
this.logger.error(`Failed to send notification email to ${email}`, err);
}
}
}

View File

@ -0,0 +1,101 @@
import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import { promises as fs } from "fs";
import * as path from "path";
import * as crypto from "crypto";
export type ExportObjectInput = {
userId: string;
format: string;
fileName: string;
mimeType: string;
content: string | Buffer;
};
export type StoredExportObject = {
provider: "local" | "supabase";
key: string;
sizeBytes: number;
};
export type LoadedExportObject = {
content: Buffer;
};
@Injectable()
export class ExportObjectStorageService {
private readonly driver = (process.env.EXPORT_OBJECT_STORAGE_DRIVER || "local").toLowerCase();
private readonly bucket = process.env.EXPORT_OBJECT_STORAGE_BUCKET || "ledgerone-exports";
private readonly localRoot = path.resolve(process.cwd(), process.env.EXPORT_OBJECT_STORAGE_DIR || "data/export-objects");
private supabaseClient?: SupabaseClient;
async store(input: ExportObjectInput): Promise<StoredExportObject> {
const content = Buffer.isBuffer(input.content) ? input.content : Buffer.from(input.content, "utf8");
const key = this.createObjectKey(input.userId, input.format, input.fileName);
if (this.driver === "supabase") {
const client = this.getSupabaseClient();
const { error } = await client.storage.from(this.bucket).upload(key, content, {
contentType: input.mimeType,
upsert: false,
});
if (error) {
throw new InternalServerErrorException(`Failed to store export object: ${error.message}`);
}
return { provider: "supabase", key, sizeBytes: content.byteLength };
}
const targetPath = this.localPathForKey(key);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, content);
return { provider: "local", key, sizeBytes: content.byteLength };
}
async load(provider: string | null | undefined, key: string | null | undefined): Promise<LoadedExportObject> {
if (!key) throw new NotFoundException("Stored export object not found.");
if (provider === "supabase") {
const client = this.getSupabaseClient();
const { data, error } = await client.storage.from(this.bucket).download(key);
if (error || !data) {
throw new NotFoundException("Stored export object not found.");
}
return { content: Buffer.from(await data.arrayBuffer()) };
}
try {
return { content: await fs.readFile(this.localPathForKey(key)) };
} catch {
throw new NotFoundException("Stored export object not found.");
}
}
private createObjectKey(userId: string, format: string, fileName: string) {
const now = new Date();
const yyyy = now.getUTCFullYear();
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
const dd = String(now.getUTCDate()).padStart(2, "0");
const safeName = fileName.replace(/[^A-Za-z0-9._-]/g, "_");
return `exports/${userId}/${yyyy}/${mm}/${dd}/${crypto.randomUUID()}-${format}-${safeName}`;
}
private localPathForKey(key: string) {
const normalizedKey = key.replace(/\\/g, "/");
const targetPath = path.resolve(this.localRoot, normalizedKey);
if (!targetPath.startsWith(this.localRoot)) {
throw new InternalServerErrorException("Invalid export object key.");
}
return targetPath;
}
private getSupabaseClient() {
if (this.supabaseClient) return this.supabaseClient;
const url = process.env.SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY;
if (!url || !serviceKey) {
throw new InternalServerErrorException("Supabase export storage is not configured.");
}
this.supabaseClient = createClient(url, serviceKey, { auth: { persistSession: false } });
return this.supabaseClient;
}
}

View File

@ -1,21 +1,55 @@
import { Controller, Get, Post, Query } from "@nestjs/common";
import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import { Request, Response } from "express";
import { ok } from "../common/response";
import { ExportsService } from "./exports.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { requestContextFrom } from "../abuse/abuse.types";
import { RequiredPlan, SubscriptionGuard } from "../stripe/subscription.guard";
import { Public } from "../common/decorators/public.decorator";
@Controller("exports")
@RequiredPlan("pro")
@UseGuards(SubscriptionGuard)
export class ExportsController {
constructor(private readonly exportsService: ExportsService) {}
@Get("csv")
async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
const data = await this.exportsService.exportCsv(userId, query);
async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
const data = await this.exportsService.createSignedDownloadUrl(userId, "csv", query, requestContextFrom(req));
return ok(data);
}
@Get("json")
async exportJson(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
const data = await this.exportsService.createSignedDownloadUrl(userId, "json", query, requestContextFrom(req));
return ok(data);
}
@Get("xlsx")
async exportXlsx(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
const data = await this.exportsService.createSignedDownloadUrl(userId, "xlsx", query, requestContextFrom(req));
return ok(data);
}
@Get("pdf")
async exportPdf(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
const data = await this.exportsService.createSignedDownloadUrl(userId, "pdf", query, requestContextFrom(req));
return ok(data);
}
@Public()
@Get("download/:token")
async downloadSignedExport(@Param("token") token: string, @Req() req: Request, @Res() res: Response) {
const file = await this.exportsService.consumeSignedDownloadUrl(token, requestContextFrom(req));
res.setHeader("Content-Type", file.mimeType);
res.setHeader("Content-Disposition", `attachment; filename="${file.fileName.replace(/"/g, "")}"`);
res.setHeader("Cache-Control", "no-store");
res.send(file.content);
}
@Post("sheets")
async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
const data = await this.exportsService.exportSheets(userId, query);
async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
const data = await this.exportsService.exportSheets(userId, query, requestContextFrom(req));
return ok(data);
}
}

View File

@ -1,9 +1,13 @@
import { Module } from "@nestjs/common";
import { ExportsController } from "./exports.controller";
import { ExportsService } from "./exports.service";
import { ExportObjectStorageService } from "./export-object-storage.service";
import { StripeModule } from "../stripe/stripe.module";
@Module({
imports: [StripeModule],
controllers: [ExportsController],
providers: [ExportsService],
providers: [ExportsService, ExportObjectStorageService],
exports: [ExportsService],
})
export class ExportsModule {}

View File

@ -1,12 +1,32 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { BadRequestException, GoneException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import * as crypto from "crypto";
import { google } from "googleapis";
import { Prisma } from "@prisma/client";
import * as XLSX from "xlsx";
import { PrismaService } from "../prisma/prisma.service";
import { AbuseService } from "../abuse/abuse.service";
import { RequestContext } from "../abuse/abuse.types";
import { ExportObjectStorageService } from "./export-object-storage.service";
type ExportWatermark = {
label: string;
userId: string;
generatedAt: string;
traceId: string;
text: string;
};
@Injectable()
export class ExportsService {
private readonly logger = new Logger(ExportsService.name);
private readonly syncSheetTitle = "LedgerOne Sync";
private readonly downloadTokenTtlMs = 2 * 60 * 1000;
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly abuseService: AbuseService,
private readonly exportObjectStorage: ExportObjectStorageService,
) {}
private toCsv(rows: Array<Record<string, string>>) {
if (!rows.length) return "";
@ -19,6 +39,102 @@ export class ExportsService {
return lines.join("\n");
}
private createWatermark(userId: string): ExportWatermark {
const generatedAt = new Date().toISOString();
const traceId = crypto.randomBytes(12).toString("hex");
const label = "LedgerOne Export Watermark";
return {
label,
userId,
generatedAt,
traceId,
text: `${label} | user=${userId} | generated=${generatedAt} | trace=${traceId}`,
};
}
private applyWatermarkToRows(rows: ReturnType<typeof this.toRows>, watermark: ExportWatermark) {
const watermarkedRows = rows.map((row) => ({ ...row, watermark: watermark.text }));
if (watermarkedRows.length) return watermarkedRows;
return [{
id: "",
date: "",
description: "",
amount: "",
category: "",
notes: "",
attribution: "",
splitMode: "",
splitMinePercent: "",
splitYoursPercent: "",
splitMineAmount: "",
splitYoursAmount: "",
hidden: "",
source: "",
watermark: watermark.text,
}];
}
private escapePdfText(value: string) {
return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
}
private buildPdf(rows: Array<Record<string, string>>, watermark: ExportWatermark) {
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"];
const lines = [
"LedgerOne Export",
`Generated ${new Date().toISOString()}`,
`Rows ${rows.length}`,
watermark.text,
"",
headers.join(" | "),
...rows.map((row) => headers.map((header) => row[header] ?? "").join(" | ")),
].map((line) => line.length > 118 ? `${line.slice(0, 115)}...` : line);
const pageHeight = 792;
const margin = 36;
const lineHeight = 11;
const fontSize = 8;
const linesPerPage = Math.floor((pageHeight - margin * 2) / lineHeight);
const pages: string[] = [];
for (let index = 0; index < lines.length; index += linesPerPage) {
const pageLines = lines.slice(index, index + linesPerPage);
const commands = ["BT", `/F1 ${fontSize} Tf`];
pageLines.forEach((line, lineIndex) => {
const y = pageHeight - margin - lineIndex * lineHeight;
commands.push(`1 0 0 1 ${margin} ${y} Tm (${this.escapePdfText(line)}) Tj`);
});
commands.push(`1 0 0 1 ${margin} ${margin - 14} Tm (${this.escapePdfText(watermark.text)}) Tj`);
commands.push("ET");
pages.push(commands.join("\n"));
}
const objects: string[] = [];
const pageObjectIds = pages.map((_, index) => 4 + index * 2);
objects[0] = "<< /Type /Catalog /Pages 2 0 R >>";
objects[1] = `<< /Type /Pages /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pages.length} >>`;
objects[2] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
pages.forEach((content, index) => {
const pageObjectId = 4 + index * 2;
const contentObjectId = pageObjectId + 1;
objects[pageObjectId - 1] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentObjectId} 0 R >>`;
objects[contentObjectId - 1] = `<< /Length ${Buffer.byteLength(content, "binary")} >>\nstream\n${content}\nendstream`;
});
let pdf = "%PDF-1.4\n";
const offsets = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "binary"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
const xrefOffset = Buffer.byteLength(pdf, "binary");
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let index = 1; index < offsets.length; index += 1) {
pdf += `${String(offsets[index]).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
return Buffer.from(pdf, "binary");
}
private async getTransactions(
userId: string,
filters: Record<string, string>,
@ -50,7 +166,14 @@ export class ExportsService {
}
return this.prisma.transactionRaw.findMany({
where,
include: { derived: true },
include: {
derived: true,
account: {
select: {
ownershipType: true,
},
},
},
orderBy: { date: "desc" },
take: limit,
});
@ -64,30 +187,356 @@ export class ExportsService {
amount: Number(tx.amount).toFixed(2),
category: tx.derived?.userCategory ?? "",
notes: tx.derived?.userNotes ?? "",
attribution: tx.derived?.attribution ?? this.defaultAttributionForAccount(tx.account?.ownershipType),
splitMode: tx.derived?.splitMode ?? "none",
splitMinePercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"),
splitYoursPercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"),
splitMineAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"),
splitYoursAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"),
hidden: tx.derived?.isHidden ? "true" : "false",
source: tx.source,
}));
}
async exportCsv(userId: string, filters: Record<string, string> = {}) {
const transactions = await this.getTransactions(userId, filters);
const rows = this.toRows(transactions);
const csv = this.toCsv(rows);
await this.prisma.exportLog.create({
data: { userId, filters, rowCount: rows.length },
});
return { status: "ready", csv, rowCount: rows.length };
private defaultAttributionForAccount(ownershipType?: string | null) {
if (ownershipType === "joint") return "ours";
if (ownershipType === "theirs") return "yours";
return "mine";
}
async exportSheets(userId: string, filters: Record<string, string> = {}) {
// Get the user's Google connection
private splitPercent(mode: string | null | undefined, value: unknown, side: "mine" | "yours") {
if (!mode || mode === "none") return side === "mine" ? "100.00" : "0.00";
if (mode === "equal") return "50.00";
return Number(value ?? 0).toFixed(2);
}
private splitAmount(amount: number, mode: string | null | undefined, value: unknown, side: "mine" | "yours") {
const percent = Number(this.splitPercent(mode, value, side));
return (amount * (percent / 100)).toFixed(2);
}
private hashContent(content: string | Buffer) {
return crypto.createHash("sha256").update(content).digest("hex");
}
private hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
private async createExportLog(
userId: string,
filters: Record<string, string>,
rowCount: number,
audit: {
format: string;
destination?: string;
fileName?: string;
mimeType?: string;
fileContent?: string | Buffer;
metadata?: Record<string, unknown>;
},
context?: RequestContext,
) {
await this.prisma.exportLog.create({
data: {
userId,
format: audit.format,
destination: audit.destination ?? "download",
filters,
rowCount,
fileName: audit.fileName,
mimeType: audit.mimeType,
fileHash: audit.fileContent ? this.hashContent(audit.fileContent) : undefined,
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
metadata: (audit.metadata ?? {}) as Prisma.InputJsonValue,
},
});
}
private async buildCsvFile(userId: string, filters: Record<string, string> = {}) {
const transactions = await this.getTransactions(userId, filters);
const rows = this.toRows(transactions);
const watermark = this.createWatermark(userId);
const csv = this.toCsv(this.applyWatermarkToRows(rows, watermark));
const fileName = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
return {
content: csv,
fileName,
mimeType: "text/csv",
rowCount: rows.length,
watermark,
};
}
async exportCsv(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
const file = await this.buildCsvFile(userId, filters);
await this.createExportLog(userId, filters, file.rowCount, {
format: "csv",
fileName: file.fileName,
mimeType: file.mimeType,
fileContent: file.content,
metadata: { watermark: file.watermark },
}, context);
await this.abuseService.recordExportActivity(userId, file.rowCount, filters, context);
return { status: "ready", csv: file.content, fileName: file.fileName, rowCount: file.rowCount };
}
private async buildJsonFile(userId: string, filters: Record<string, string> = {}) {
const transactions = await this.getTransactions(userId, filters);
const rows = this.toRows(transactions);
const watermark = this.createWatermark(userId);
const payload = {
exportedAt: new Date().toISOString(),
rowCount: rows.length,
filters,
watermark,
transactions: rows,
};
const buffer = Buffer.from(JSON.stringify(payload, null, 2), "utf8");
return {
content: buffer,
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.json`,
mimeType: "application/json",
rowCount: rows.length,
watermark,
};
}
async exportJson(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
const file = await this.buildJsonFile(userId, filters);
await this.createExportLog(userId, filters, file.rowCount, {
format: "json",
fileName: file.fileName,
mimeType: file.mimeType,
fileContent: file.content,
metadata: { watermark: file.watermark },
}, context);
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "json" }, context);
return {
status: "ready",
fileName: file.fileName,
mimeType: file.mimeType,
base64: file.content.toString("base64"),
rowCount: file.rowCount,
};
}
private async buildXlsxFile(userId: string, filters: Record<string, string> = {}) {
const transactions = await this.getTransactions(userId, filters);
const rows = this.toRows(transactions);
const watermark = this.createWatermark(userId);
const watermarkedRows = this.applyWatermarkToRows(rows, watermark);
const headers = this.getHeaders(watermarkedRows);
const worksheet = XLSX.utils.json_to_sheet(watermarkedRows, { header: headers });
const watermarkWorksheet = XLSX.utils.json_to_sheet([{
label: watermark.label,
userId: watermark.userId,
generatedAt: watermark.generatedAt,
traceId: watermark.traceId,
watermark: watermark.text,
}]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "LedgerOne Export");
XLSX.utils.book_append_sheet(workbook, watermarkWorksheet, "Watermark");
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
return {
content: buffer,
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.xlsx`,
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
rowCount: rows.length,
watermark,
};
}
async exportXlsx(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
const file = await this.buildXlsxFile(userId, filters);
await this.createExportLog(userId, filters, file.rowCount, {
format: "xlsx",
fileName: file.fileName,
mimeType: file.mimeType,
fileContent: file.content,
metadata: { watermark: file.watermark },
}, context);
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "xlsx" }, context);
return {
status: "ready",
fileName: file.fileName,
mimeType: file.mimeType,
base64: file.content.toString("base64"),
rowCount: file.rowCount,
};
}
private async buildPdfFile(userId: string, filters: Record<string, string> = {}) {
const transactions = await this.getTransactions(userId, filters);
const rows = this.toRows(transactions);
const watermark = this.createWatermark(userId);
const buffer = this.buildPdf(rows, watermark);
return {
content: buffer,
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.pdf`,
mimeType: "application/pdf",
rowCount: rows.length,
watermark,
};
}
async exportPdf(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
const file = await this.buildPdfFile(userId, filters);
await this.createExportLog(userId, filters, file.rowCount, {
format: "pdf",
fileName: file.fileName,
mimeType: file.mimeType,
fileContent: file.content,
metadata: { watermark: file.watermark },
}, context);
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "pdf" }, context);
return {
status: "ready",
fileName: file.fileName,
mimeType: file.mimeType,
base64: file.content.toString("base64"),
rowCount: file.rowCount,
};
}
async createSignedDownloadUrl(
userId: string,
format: "csv" | "json" | "xlsx" | "pdf",
filters: Record<string, string> = {},
context?: RequestContext,
) {
const token = crypto.randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + this.downloadTokenTtlMs);
const file = await this.buildDownloadFile(userId, format, filters);
const stored = await this.exportObjectStorage.store({
userId,
format,
fileName: file.fileName,
mimeType: file.mimeType,
content: file.content,
});
const fileHash = this.hashContent(file.content);
await this.prisma.exportDownloadToken.create({
data: {
userId,
tokenHash: this.hashToken(token),
format,
filters,
storageProvider: stored.provider,
storageKey: stored.key,
fileName: file.fileName,
mimeType: file.mimeType,
rowCount: file.rowCount,
fileHash,
expiresAt,
},
});
return {
status: "signed",
downloadUrl: `/api/exports/download/${token}`,
expiresAt: expiresAt.toISOString(),
singleUse: true,
expiresInSeconds: Math.floor(this.downloadTokenTtlMs / 1000),
storageProvider: stored.provider,
};
}
async consumeSignedDownloadUrl(token: string, context?: RequestContext) {
const tokenHash = this.hashToken(token);
const record = await this.prisma.exportDownloadToken.findUnique({ where: { tokenHash } });
if (!record) throw new NotFoundException("Download link not found.");
if (record.usedAt) throw new GoneException("Download link has already been used.");
if (record.expiresAt < new Date()) throw new GoneException("Download link has expired.");
const consumed = await this.prisma.exportDownloadToken.updateMany({
where: { id: record.id, usedAt: null, expiresAt: { gt: new Date() } },
data: { usedAt: new Date() },
});
if (consumed.count !== 1) {
throw new GoneException("Download link is no longer valid.");
}
const filters = record.filters as unknown as Record<string, string>;
const format = record.format as "csv" | "json" | "xlsx" | "pdf";
const storedFile = await this.exportObjectStorage.load(record.storageProvider, record.storageKey);
const file = {
content: storedFile.content,
fileName: record.fileName ?? `ledgerone-export-${new Date().toISOString().slice(0, 10)}.${format}`,
mimeType: record.mimeType ?? this.mimeTypeForFormat(format),
rowCount: record.rowCount ?? 0,
};
await this.createExportLog(record.userId, filters, file.rowCount, {
format,
fileName: file.fileName,
mimeType: file.mimeType,
fileContent: file.content,
metadata: {
signedUrl: true,
tokenId: record.id,
expiresAt: record.expiresAt.toISOString(),
storageProvider: record.storageProvider,
storageKey: record.storageKey,
precomputedFileHash: record.fileHash,
},
}, context);
await this.abuseService.recordExportActivity(record.userId, file.rowCount, { ...filters, format, signedUrl: "true" }, context);
return file;
}
private async buildDownloadFile(userId: string, format: "csv" | "json" | "xlsx" | "pdf", filters: Record<string, string>) {
if (format === "csv") return this.buildCsvFile(userId, filters);
if (format === "json") return this.buildJsonFile(userId, filters);
if (format === "xlsx") return this.buildXlsxFile(userId, filters);
if (format === "pdf") return this.buildPdfFile(userId, filters);
throw new BadRequestException("Unsupported export format.");
}
private mimeTypeForFormat(format: "csv" | "json" | "xlsx" | "pdf") {
if (format === "csv") return "text/csv";
if (format === "json") return "application/json";
if (format === "xlsx") return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
if (format === "pdf") return "application/pdf";
return "application/octet-stream";
}
private getHeaders(rows: ReturnType<typeof this.toRows>) {
return rows.length
? Object.keys(rows[0])
: ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"];
}
private toSheetValues(rows: ReturnType<typeof this.toRows>) {
const headers = this.getHeaders(rows);
return [
headers,
...rows.map((row) => headers.map((header) => row[header as keyof typeof row] ?? "")),
];
}
private async createGoogleSheetsClient(userId: string, failWhenDisconnected: boolean) {
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
if (!gc || !gc.isConnected) {
throw new BadRequestException(
"Google account not connected. Please connect via /api/google/connect.",
);
if (failWhenDisconnected) {
throw new BadRequestException(
"Google account not connected. Please connect via /api/google/connect.",
);
}
return null;
}
const oauth2Client = new google.auth.OAuth2(
@ -99,7 +548,6 @@ export class ExportsService {
refresh_token: gc.refreshToken,
});
// Refresh the access token if needed
const { credentials } = await oauth2Client.refreshAccessToken();
await this.prisma.googleConnection.update({
where: { userId },
@ -110,39 +558,76 @@ export class ExportsService {
});
oauth2Client.setCredentials(credentials);
const sheets = google.sheets({ version: "v4", auth: oauth2Client });
const transactions = await this.getTransactions(userId, filters);
const rows = this.toRows(transactions);
return {
gc,
sheets: google.sheets({ version: "v4", auth: oauth2Client }),
};
}
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`;
let spreadsheetId = gc.spreadsheetId;
if (!spreadsheetId) {
// Create a new spreadsheet
const spreadsheet = await sheets.spreadsheets.create({
requestBody: { properties: { title: "LedgerOne" } },
});
spreadsheetId = spreadsheet.data.spreadsheetId!;
await this.prisma.googleConnection.update({
where: { userId },
data: { spreadsheetId },
});
private async ensureSpreadsheet(
userId: string,
sheets: ReturnType<typeof google.sheets>,
spreadsheetId?: string | null,
) {
if (spreadsheetId) {
await this.markDriveMirror(userId, spreadsheetId, "ready");
return spreadsheetId;
}
// Add a new sheet tab
await sheets.spreadsheets.batchUpdate({
spreadsheetId,
requestBody: {
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
const spreadsheet = await sheets.spreadsheets.create({
requestBody: { properties: { title: "LedgerOne User-Owned Ledger" } },
});
const createdSpreadsheetId = spreadsheet.data.spreadsheetId!;
await this.markDriveMirror(userId, createdSpreadsheetId, "ready");
return createdSpreadsheetId;
}
private async markDriveMirror(userId: string, spreadsheetId: string, status: string, syncedAt?: Date) {
await this.prisma.googleConnection.update({
where: { userId },
data: {
spreadsheetId,
driveMirrorEnabled: true,
driveMirrorStatus: status,
driveMirrorSpreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
...(syncedAt ? { driveMirrorLastSyncedAt: syncedAt, lastSyncedAt: syncedAt } : {}),
},
});
}
// Build values: header + data rows
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"];
const values = [
headers,
...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")),
];
private async addSheetIfMissing(
sheets: ReturnType<typeof google.sheets>,
spreadsheetId: string,
sheetTitle: string,
) {
try {
await sheets.spreadsheets.batchUpdate({
spreadsheetId,
requestBody: {
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
},
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "";
if (!/already exists|duplicate/i.test(message)) {
throw error;
}
}
}
private async writeSheetValues(
sheets: ReturnType<typeof google.sheets>,
spreadsheetId: string,
sheetTitle: string,
values: string[][],
clearFirst = false,
) {
if (clearFirst) {
await sheets.spreadsheets.values.clear({
spreadsheetId,
range: `'${sheetTitle}'!A:Z`,
});
}
await sheets.spreadsheets.values.update({
spreadsheetId,
@ -150,11 +635,34 @@ export class ExportsService {
valueInputOption: "RAW",
requestBody: { values },
});
}
await this.prisma.exportLog.create({
data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length },
async exportSheets(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
const client = await this.createGoogleSheetsClient(userId, true);
const { gc, sheets } = client!;
const transactions = await this.getTransactions(userId, filters);
const rows = this.toRows(transactions);
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`;
const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId);
await sheets.spreadsheets.batchUpdate({
spreadsheetId,
requestBody: {
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
},
});
await this.writeSheetValues(sheets, spreadsheetId, sheetTitle, this.toSheetValues(rows));
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
await this.createExportLog(userId, filters, rows.length, {
format: "google_sheets",
destination: "google_sheets",
metadata: { spreadsheetId, sheetTitle, url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}` },
}, context);
await this.abuseService.recordExportActivity(userId, rows.length, { ...filters, destination: "google_sheets" }, context);
this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`);
return {
@ -165,4 +673,38 @@ export class ExportsService {
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
};
}
async syncGoogleSheets(userId: string, reason = "transaction_change") {
const client = await this.createGoogleSheetsClient(userId, false);
if (!client) {
return { status: "skipped", reason: "not_connected" };
}
const { gc, sheets } = client;
const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId);
const transactions = await this.getTransactions(userId, {}, 5000);
const rows = this.toRows(transactions);
await this.addSheetIfMissing(sheets, spreadsheetId, this.syncSheetTitle);
await this.writeSheetValues(
sheets,
spreadsheetId,
this.syncSheetTitle,
this.toSheetValues(rows),
true,
);
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
this.logger.log(`Synced ${rows.length} rows to Google Sheets for user ${userId}`);
return {
status: "synced",
reason,
rowCount: rows.length,
spreadsheetId,
sheetTitle: this.syncSheetTitle,
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
};
}
}

View File

@ -30,4 +30,10 @@ export class GoogleController {
status(@CurrentUser() userId: string) {
return this.googleService.getStatus(userId);
}
@Post("data-system-mode")
@HttpCode(200)
dataSystemMode(@CurrentUser() userId: string, @Body() body: { mode: "backend_db" | "google_sheets_mirror" }) {
return this.googleService.updateDataSystemMode(userId, body.mode);
}
}

View File

@ -1,6 +1,5 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { google } from "googleapis";
import { Credentials } from "google-auth-library";
import { PrismaService } from "../prisma/prisma.service";
const SCOPES = [
@ -31,19 +30,18 @@ export class GoogleService {
const authUrl = client.generateAuthUrl({
access_type: "offline",
scope: SCOPES,
prompt: "consent",
state: userId,
prompt: "consent", // always request a refresh_token
state: userId, // passed back in callback to identify the user
});
return { authUrl };
}
async exchangeCode(userId: string, code: string) {
const client = this.createClient();
let tokens: Credentials;
let tokens: import("google-auth-library").Credentials;
try {
const { tokens: t } = await client.getToken(code);
tokens = t;
const result = await client.getToken(code);
tokens = result.tokens;
} catch {
throw new BadRequestException("Invalid or expired authorization code.");
}
@ -54,12 +52,13 @@ export class GoogleService {
);
}
// Fetch the Google account email
client.setCredentials(tokens);
const oauth2 = google.oauth2({ version: "v2", auth: client });
const { data } = await oauth2.userinfo.get();
const googleEmail = data.email ?? "";
await this.prisma.googleConnection.upsert({
await (this.prisma as any).googleConnection.upsert({
where: { userId },
update: {
googleEmail,
@ -67,7 +66,12 @@ export class GoogleService {
accessToken: tokens.access_token ?? null,
isConnected: true,
connectedAt: new Date(),
spreadsheetId: null,
spreadsheetId: null, // reset so a new spreadsheet is created on next export
driveMirrorEnabled: false,
driveMirrorStatus: "pending_first_sync",
driveMirrorSpreadsheetUrl: null,
driveMirrorLastSyncedAt: null,
dataSystemMode: "backend_db",
},
create: {
userId,
@ -75,6 +79,9 @@ export class GoogleService {
refreshToken: tokens.refresh_token,
accessToken: tokens.access_token ?? null,
isConnected: true,
driveMirrorEnabled: false,
driveMirrorStatus: "pending_first_sync",
dataSystemMode: "backend_db",
},
});
@ -88,8 +95,55 @@ export class GoogleService {
}
async getStatus(userId: string) {
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
const gc = await (this.prisma as any).googleConnection.findUnique({ where: { userId } });
if (!gc || !gc.isConnected) return { connected: false };
return { connected: true, googleEmail: gc.googleEmail, connectedAt: gc.connectedAt };
return {
connected: true,
googleEmail: gc.googleEmail,
connectedAt: gc.connectedAt,
driveMirror: {
enabled: gc.driveMirrorEnabled,
status: gc.driveMirrorStatus,
spreadsheetId: gc.spreadsheetId,
url: gc.driveMirrorSpreadsheetUrl,
lastSyncedAt: gc.driveMirrorLastSyncedAt ?? gc.lastSyncedAt,
ownership: "user_google_drive",
},
dataSystem: {
mode: gc.dataSystemMode,
operationalSystemOfRecord: "ledgerone_backend_db",
userOwnedMirror: gc.dataSystemMode === "google_sheets_mirror",
note: gc.dataSystemMode === "google_sheets_mirror"
? "LedgerOne writes a best-effort user-owned Google Sheets mirror while the application database remains the operational store."
: "LedgerOne uses the application database as the operational store. Google Sheets can be enabled as a user-owned mirror.",
},
};
}
}
async updateDataSystemMode(userId: string, mode: "backend_db" | "google_sheets_mirror") {
if (!["backend_db", "google_sheets_mirror"].includes(mode)) {
throw new BadRequestException("Unsupported data system mode.");
}
const connection = await (this.prisma as any).googleConnection.findUnique({ where: { userId } });
if (!connection || !connection.isConnected) {
throw new BadRequestException("Google account must be connected before enabling Sheets-first mirror mode.");
}
const updated = await (this.prisma as any).googleConnection.update({
where: { userId },
data: {
dataSystemMode: mode,
driveMirrorEnabled: mode === "google_sheets_mirror" ? true : connection.driveMirrorEnabled,
driveMirrorStatus: mode === "google_sheets_mirror" && !connection.spreadsheetId ? "pending_first_sync" : connection.driveMirrorStatus,
},
});
return {
mode: updated.dataSystemMode,
driveMirrorEnabled: updated.driveMirrorEnabled,
status: updated.driveMirrorStatus,
operationalSystemOfRecord: "ledgerone_backend_db",
userOwnedMirror: updated.dataSystemMode === "google_sheets_mirror",
};
}
}

View File

@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class AcceptHouseholdInviteDto {
@IsString()
@MinLength(32)
token!: string;
}

View File

@ -0,0 +1,20 @@
export class CreateAccountantTaskDto {
title!: string;
description?: string;
taskType?: string;
assignedToUserId?: string;
priority?: string;
dueDate?: string;
metadata?: Record<string, unknown>;
}
export class UpdateAccountantTaskDto {
title?: string;
description?: string | null;
taskType?: string;
assignedToUserId?: string | null;
priority?: string;
dueDate?: string | null;
status?: string;
metadata?: Record<string, unknown>;
}

View File

@ -0,0 +1,38 @@
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
export class CreateHouseholdGoalDto {
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsNumber()
@Min(0.01)
targetAmount!: number;
@IsOptional()
@IsNumber()
@Min(0)
currentAmount?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
targetDate?: string;
@IsOptional()
@IsIn(["low", "medium", "high"])
priority?: "low" | "medium" | "high";
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@ -0,0 +1,11 @@
import { IsEmail, IsIn, IsOptional } from "class-validator";
import { HOUSEHOLD_ROLES, HouseholdRole } from "./update-household-member.dto";
export class CreateHouseholdInviteDto {
@IsEmail()
email!: string;
@IsOptional()
@IsIn(HOUSEHOLD_ROLES)
role?: HouseholdRole;
}

View File

@ -0,0 +1,12 @@
import { IsObject, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
export class CreateHouseholdDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@ -0,0 +1,20 @@
export type DebtPayoffStrategy = "avalanche" | "snowball" | "custom";
export type DebtPayoffSplitMode = "equal" | "income_weighted" | "custom";
export class DebtPayoffItemDto {
name!: string;
balance!: number;
annualPercentageRate?: number;
minimumPayment!: number;
priority?: number;
}
export class DebtPayoffPlannerDto {
debts!: DebtPayoffItemDto[];
monthlyExtraPayment?: number;
strategy?: DebtPayoffStrategy;
splitMode?: DebtPayoffSplitMode;
mineMonthlyIncome?: number;
yoursMonthlyIncome?: number;
customMinePercent?: number;
}

View File

@ -0,0 +1,9 @@
export type FairSplitMode = "equal" | "income_weighted" | "custom";
export class FairSplitCalculatorDto {
expenseAmount!: number;
mineMonthlyIncome?: number;
yoursMonthlyIncome?: number;
splitMode?: FairSplitMode;
customMinePercent?: number;
}

View File

@ -0,0 +1,24 @@
export type FutureScenarioType = "goal" | "net_worth" | "income_change" | "expense_change";
export class FuturePlanningEventDto {
month!: number;
label!: string;
amount!: number;
}
export class FuturePlanningScenarioDto {
name!: string;
type?: FutureScenarioType;
startingBalance!: number;
monthlyContribution!: number;
monthlyIncome?: number;
monthlyExpenses?: number;
targetAmount?: number;
horizonMonths?: number;
annualGrowthRate?: number;
events?: FuturePlanningEventDto[];
}
export class FuturePlanningScenariosDto {
scenarios!: FuturePlanningScenarioDto[];
}

View File

@ -0,0 +1,44 @@
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
export class UpdateHouseholdGoalDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
@IsOptional()
@IsNumber()
@Min(0.01)
targetAmount?: number;
@IsOptional()
@IsNumber()
@Min(0)
currentAmount?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
targetDate?: string | null;
@IsOptional()
@IsIn(["low", "medium", "high"])
priority?: "low" | "medium" | "high";
@IsOptional()
@IsIn(["active", "paused", "completed", "archived"])
status?: "active" | "paused" | "completed" | "archived";
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@ -0,0 +1,17 @@
import { IsIn, IsOptional } from "class-validator";
export const HOUSEHOLD_ROLES = ["owner", "admin", "member", "viewer", "accountant", "advisor"] as const;
export const HOUSEHOLD_MEMBER_STATUSES = ["active", "inactive", "removed"] as const;
export type HouseholdRole = typeof HOUSEHOLD_ROLES[number];
export type HouseholdMemberStatus = typeof HOUSEHOLD_MEMBER_STATUSES[number];
export class UpdateHouseholdMemberDto {
@IsOptional()
@IsIn(HOUSEHOLD_ROLES)
role?: HouseholdRole;
@IsOptional()
@IsIn(HOUSEHOLD_MEMBER_STATUSES)
status?: HouseholdMemberStatus;
}

View File

@ -0,0 +1,5 @@
export class UpdateHouseholdPrivacyDto {
enabled!: boolean;
hideIndividualBalances?: boolean;
hideIndividualTransactions?: boolean;
}

View File

@ -0,0 +1,163 @@
import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
import { ok } from "../common/response";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateAccountantTaskDto, UpdateAccountantTaskDto } from "./dto/accountant-task.dto";
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
import { CreateHouseholdDto } from "./dto/create-household.dto";
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
import { DebtPayoffPlannerDto } from "./dto/debt-payoff-planner.dto";
import { FairSplitCalculatorDto } from "./dto/fair-split-calculator.dto";
import { FuturePlanningScenariosDto } from "./dto/future-planning-scenario.dto";
import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.dto";
import { HouseholdsService } from "./households.service";
@Controller("households")
export class HouseholdsController {
constructor(private readonly householdsService: HouseholdsService) {}
@Get()
async list(@CurrentUser() userId: string) {
return ok(await this.householdsService.listForUser(userId));
}
@Post()
async create(@CurrentUser() userId: string, @Body() payload: CreateHouseholdDto) {
return ok(await this.householdsService.create(userId, payload));
}
@Get(":id/dashboard")
async dashboard(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.getDashboard(userId, id));
}
@Get(":id")
async get(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.getForUser(userId, id));
}
@Patch(":id/privacy")
async updatePrivacy(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: UpdateHouseholdPrivacyDto,
) {
return ok(await this.householdsService.updatePrivacyMode(userId, id, payload));
}
@Get(":id/members")
async members(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.listMembers(userId, id));
}
@Post(":id/fair-split")
async fairSplit(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: FairSplitCalculatorDto,
) {
return ok(await this.householdsService.calculateFairSplit(userId, id, payload));
}
@Get(":id/money-date-prompts")
async moneyDatePrompts(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.listMoneyDatePrompts(userId, id));
}
@Post(":id/debt-payoff")
async debtPayoff(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: DebtPayoffPlannerDto,
) {
return ok(await this.householdsService.calculateDebtPayoff(userId, id, payload));
}
@Post(":id/future-scenarios")
async futureScenarios(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: FuturePlanningScenariosDto,
) {
return ok(await this.householdsService.calculateFutureScenarios(userId, id, payload));
}
@Get(":id/accountant-tasks")
async accountantTasks(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.listAccountantTasks(userId, id));
}
@Post(":id/accountant-tasks")
async createAccountantTask(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: CreateAccountantTaskDto,
) {
return ok(await this.householdsService.createAccountantTask(userId, id, payload));
}
@Patch(":id/accountant-tasks/:taskId")
async updateAccountantTask(
@CurrentUser() userId: string,
@Param("id") id: string,
@Param("taskId") taskId: string,
@Body() payload: UpdateAccountantTaskDto,
) {
return ok(await this.householdsService.updateAccountantTask(userId, id, taskId, payload));
}
@Get(":id/goals")
async goals(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.listGoals(userId, id));
}
@Post(":id/goals")
async createGoal(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: CreateHouseholdGoalDto,
) {
return ok(await this.householdsService.createGoal(userId, id, payload));
}
@Patch(":id/goals/:goalId")
async updateGoal(
@CurrentUser() userId: string,
@Param("id") id: string,
@Param("goalId") goalId: string,
@Body() payload: UpdateHouseholdGoalDto,
) {
return ok(await this.householdsService.updateGoal(userId, id, goalId, payload));
}
@Patch(":id/members/:memberId")
async updateMember(
@CurrentUser() userId: string,
@Param("id") id: string,
@Param("memberId") memberId: string,
@Body() payload: UpdateHouseholdMemberDto,
) {
return ok(await this.householdsService.updateMember(userId, id, memberId, payload));
}
@Get(":id/invites")
async invites(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.listInvites(userId, id));
}
@Post(":id/invites")
async invite(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: CreateHouseholdInviteDto,
) {
return ok(await this.householdsService.invite(userId, id, payload));
}
@Post("invites/accept")
async acceptInvite(@CurrentUser() userId: string, @Body() payload: AcceptHouseholdInviteDto) {
return ok(await this.householdsService.acceptInvite(userId, payload));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module";
import { HouseholdsController } from "./households.controller";
import { HouseholdsService } from "./households.service";
@Module({
imports: [PrismaModule],
controllers: [HouseholdsController],
providers: [HouseholdsService],
})
export class HouseholdsModule {}

File diff suppressed because it is too large Load Diff

View File

@ -9,11 +9,14 @@ import { AppModule } from "./app.module";
import { SentryExceptionFilter } from "./common/sentry.filter";
async function bootstrap() {
const isProduction = process.env.NODE_ENV === "production";
// ─── Sentry initialization (before app creation) ──────────────────────────
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV ?? "development",
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0),
});
}
@ -23,15 +26,20 @@ async function bootstrap() {
});
// ─── Security headers ─────────────────────────────────────────────────────
const scriptSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
const styleSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
app.use(
helmet({
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Swagger UI needs inline scripts
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc,
styleSrc,
imgSrc: ["'self'", "data:", "https:"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
},
}),

View File

@ -0,0 +1,54 @@
import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response";
import { SavePushSubscriptionDto, UpdateNotificationPreferencesDto } from "./notifications.dto";
import { NotificationsService } from "./notifications.service";
@Controller("notifications")
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
async list(@CurrentUser() userId: string, @Query("unreadOnly") unreadOnly?: string) {
return ok(await this.notificationsService.list(userId, unreadOnly === "true"));
}
@Get("preferences")
async preferences(@CurrentUser() userId: string) {
return ok(await this.notificationsService.getPreferences(userId));
}
@Patch("preferences")
async updatePreferences(@CurrentUser() userId: string, @Body() body: UpdateNotificationPreferencesDto) {
return ok(await this.notificationsService.updatePreferences(userId, body));
}
@Get("vapid-public-key")
vapidPublicKey() {
return ok(this.notificationsService.getVapidStatus());
}
@Post("push-subscriptions")
async savePushSubscription(
@CurrentUser() userId: string,
@Body() body: SavePushSubscriptionDto,
@Headers("user-agent") userAgent?: string,
) {
return ok(await this.notificationsService.savePushSubscription(userId, body, userAgent));
}
@Patch(":id/read")
async markRead(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.notificationsService.markRead(userId, id));
}
@Post("read-all")
async markAllRead(@CurrentUser() userId: string) {
return ok(await this.notificationsService.markAllRead(userId));
}
@Post("test")
async test(@CurrentUser() userId: string) {
return ok(await this.notificationsService.sendTestNotification(userId));
}
}

View File

@ -0,0 +1,33 @@
import { IsBoolean, IsIn, IsOptional, IsString, IsUrl, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
export class UpdateNotificationPreferencesDto {
@IsOptional()
@IsBoolean()
emailEnabled?: boolean;
@IsOptional()
@IsBoolean()
pushEnabled?: boolean;
@IsOptional()
@IsIn(["info", "warning", "critical"])
minSeverity?: "info" | "warning" | "critical";
}
class PushKeysDto {
@IsString()
p256dh!: string;
@IsString()
auth!: string;
}
export class SavePushSubscriptionDto {
@IsUrl({ require_tld: false })
endpoint!: string;
@ValidateNested()
@Type(() => PushKeysDto)
keys!: PushKeysDto;
}

View File

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { NotificationsController } from "./notifications.controller";
import { NotificationsService } from "./notifications.service";
@Module({
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@ -0,0 +1,241 @@
import { Injectable, Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import * as webPush from "web-push";
import { EmailService } from "../email/email.service";
import { PrismaService } from "../prisma/prisma.service";
import { SavePushSubscriptionDto, UpdateNotificationPreferencesDto } from "./notifications.dto";
type NotificationSeverity = "info" | "warning" | "critical";
type NotifyUserInput = {
type: string;
severity?: NotificationSeverity;
title: string;
body: string;
metadata?: Record<string, unknown>;
};
const SEVERITY_RANK: Record<NotificationSeverity, number> = {
info: 1,
warning: 2,
critical: 3,
};
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private readonly pushEnabled: boolean;
private readonly appUrl = process.env.APP_URL ?? "http://localhost:3052";
constructor(
private readonly prisma: PrismaService,
private readonly emailService: EmailService,
) {
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT ?? "mailto:support@ledgerone.app";
this.pushEnabled = Boolean(publicKey && privateKey);
if (this.pushEnabled) {
webPush.setVapidDetails(subject, publicKey as string, privateKey as string);
}
}
getVapidStatus() {
return {
enabled: this.pushEnabled,
publicKey: this.pushEnabled ? process.env.VAPID_PUBLIC_KEY : null,
};
}
async list(userId: string, unreadOnly = false) {
return this.prisma.notification.findMany({
where: {
userId,
...(unreadOnly ? { readAt: null } : {}),
},
orderBy: { createdAt: "desc" },
take: 50,
});
}
async getPreferences(userId: string) {
const existing = await this.prisma.notificationPreference.findUnique({ where: { userId } });
if (existing) return existing;
return this.prisma.notificationPreference.create({
data: {
userId,
emailEnabled: true,
pushEnabled: false,
minSeverity: "info",
},
});
}
async updatePreferences(userId: string, dto: UpdateNotificationPreferencesDto) {
return this.prisma.notificationPreference.upsert({
where: { userId },
create: {
userId,
emailEnabled: dto.emailEnabled ?? true,
pushEnabled: dto.pushEnabled ?? false,
minSeverity: dto.minSeverity ?? "info",
},
update: {
...(dto.emailEnabled !== undefined ? { emailEnabled: dto.emailEnabled } : {}),
...(dto.pushEnabled !== undefined ? { pushEnabled: dto.pushEnabled } : {}),
...(dto.minSeverity ? { minSeverity: dto.minSeverity } : {}),
},
});
}
async savePushSubscription(userId: string, dto: SavePushSubscriptionDto, userAgent?: string) {
const subscription = await this.prisma.pushSubscription.upsert({
where: { endpoint: dto.endpoint },
create: {
userId,
endpoint: dto.endpoint,
p256dh: dto.keys.p256dh,
auth: dto.keys.auth,
userAgent,
},
update: {
userId,
p256dh: dto.keys.p256dh,
auth: dto.keys.auth,
userAgent,
revokedAt: null,
},
});
await this.updatePreferences(userId, { pushEnabled: true });
return subscription;
}
async markRead(userId: string, notificationId: string) {
return this.prisma.notification.updateMany({
where: { id: notificationId, userId },
data: { readAt: new Date() },
});
}
async markAllRead(userId: string) {
return this.prisma.notification.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() },
});
}
async sendTestNotification(userId: string) {
return this.notifyUser(userId, {
type: "notification.test",
severity: "info",
title: "LedgerOne notification test",
body: "SMTP and push notification delivery are configured for your account.",
metadata: { source: "settings_test" },
});
}
async notifyUser(userId: string, input: NotifyUserInput) {
const severity = input.severity ?? "info";
const channels = ["in_app"];
const notification = await this.prisma.notification.create({
data: {
userId,
type: input.type,
severity,
title: input.title,
body: input.body,
metadata: (input.metadata ?? {}) as Prisma.InputJsonValue,
channels: [...channels],
},
});
const [preferences, user] = await Promise.all([
this.getPreferences(userId),
this.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }),
]);
if (this.shouldSend(preferences.minSeverity as NotificationSeverity, severity)) {
if (preferences.emailEnabled && user?.email) {
await this.emailService.sendNotificationEmail(
user.email,
input.title,
input.body,
`${this.appUrl}/notifications`,
severity,
);
channels.push("email");
}
if (preferences.pushEnabled && this.pushEnabled) {
const sent = await this.sendPushNotifications(userId, notification.id, input, severity);
if (sent > 0) channels.push("push");
}
}
return this.prisma.notification.update({
where: { id: notification.id },
data: { channels },
});
}
private shouldSend(minSeverity: NotificationSeverity, severity: NotificationSeverity) {
return SEVERITY_RANK[severity] >= SEVERITY_RANK[minSeverity ?? "info"];
}
private async sendPushNotifications(
userId: string,
notificationId: string,
input: NotifyUserInput,
severity: NotificationSeverity,
) {
const subscriptions = await this.prisma.pushSubscription.findMany({
where: { userId, revokedAt: null },
});
let sent = 0;
const payload = JSON.stringify({
title: input.title,
body: input.body,
url: "/notifications",
notificationId,
type: input.type,
severity,
});
for (const subscription of subscriptions) {
try {
await webPush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
payload,
);
sent += 1;
await this.prisma.pushSubscription.update({
where: { id: subscription.id },
data: { lastUsedAt: new Date() },
});
} catch (err) {
const statusCode = typeof err === "object" && err && "statusCode" in err
? Number((err as { statusCode?: number }).statusCode)
: 0;
if (statusCode === 404 || statusCode === 410) {
await this.prisma.pushSubscription.update({
where: { id: subscription.id },
data: { revokedAt: new Date() },
});
} else {
this.logger.warn(`Push notification failed for subscription ${subscription.id}`);
}
}
}
return sent;
}
}

View File

@ -1,11 +1,21 @@
import { Body, Controller, Post } from "@nestjs/common";
import { BadRequestException, Body, Controller, Headers, Post, Req } from "@nestjs/common";
import { Request } from "express";
import { ok } from "../common/response";
import { PlaidService } from "./plaid.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
import { OpaqueIdService } from "../common/opaque-id.service";
import { ViewRefService } from "../common/view-ref.service";
import { PrismaService } from "../prisma/prisma.service";
@Controller("plaid")
export class PlaidController {
constructor(private readonly plaidService: PlaidService) {}
constructor(
private readonly plaidService: PlaidService,
private readonly opaqueIds: OpaqueIdService,
private readonly viewRefs: ViewRefService,
private readonly prisma: PrismaService,
) {}
@Post("link-token")
async createLinkToken(@CurrentUser() userId: string) {
@ -21,4 +31,49 @@ export class PlaidController {
const data = await this.plaidService.exchangePublicTokenForUser(userId, payload.publicToken);
return ok(data);
}
@Post("update-link-token")
async createUpdateLinkToken(
@CurrentUser() userId: string,
@Body() payload: { accountId: string },
) {
const accountId = await this.resolveAccountHandle(userId, payload.accountId);
const data = await this.plaidService.createUpdateModeLinkToken(userId, accountId);
return ok(data);
}
@Post("repair-complete")
async repairComplete(
@CurrentUser() userId: string,
@Body() payload: { accountId: string },
) {
const accountId = await this.resolveAccountHandle(userId, payload.accountId);
const data = await this.plaidService.markItemRepairComplete(userId, accountId);
return ok(data);
}
@Public()
@Post("webhook")
async webhook(
@Body() payload: Record<string, unknown>,
@Headers("plaid-verification") verification: string | undefined,
@Req() request: Request & { rawBody?: Buffer },
) {
const data = await this.plaidService.handleWebhook(payload, verification, request.rawBody);
return ok(data);
}
private async resolveAccountHandle(userId: string, handle: string) {
try {
return this.opaqueIds.decode("account", userId, handle);
} catch {
const accounts = await this.prisma.account.findMany({
where: { userId, isActive: true },
select: { id: true },
});
const match = accounts.find((account) => this.viewRefs.matches(userId, "account", account.id, handle));
if (!match) throw new BadRequestException("Invalid resource identifier.");
return match.id;
}
}
}

View File

@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { StripeModule } from "../stripe/stripe.module";
import { PlaidController } from "./plaid.controller";
import { PlaidService } from "./plaid.service";
@Module({
imports: [StripeModule],
controllers: [PlaidController],
providers: [PlaidService],
exports: [PlaidService]

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import {
Configuration,
CountryCode,
@ -10,14 +10,20 @@ import * as crypto from "crypto";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { EncryptionService } from "../common/encryption.service";
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
import { PlanLimitsService } from "../stripe/plan-limits.service";
@Injectable()
export class PlaidService {
private readonly logger = new Logger(PlaidService.name);
private readonly client: PlaidApi;
private readonly webhookKeys = new Map<string, JsonWebKey>();
constructor(
private readonly prisma: PrismaService,
private readonly encryption: EncryptionService,
private readonly planLimits: PlanLimitsService,
private readonly rawPayloads: RawPayloadEncryptionService,
) {
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
const clientId = this.requireEnv("PLAID_CLIENT_ID");
@ -55,6 +61,7 @@ export class PlaidService {
country_codes: countryCodes,
language: "en",
redirect_uri: redirectUri || undefined,
webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined,
});
return {
@ -86,6 +93,14 @@ export class PlaidService {
const institutionName = institutionId
? await this.getInstitutionName(institutionId)
: "Plaid institution";
const incomingAccountIds = accountsResponse.data.accounts.map((account) => account.account_id);
const existingAccounts = await this.prisma.account.findMany({
where: { userId, plaidAccountId: { in: incomingAccountIds } },
select: { plaidAccountId: true },
});
const existingIds = new Set(existingAccounts.map((account) => account.plaidAccountId));
const newAccountCount = incomingAccountIds.filter((id) => !existingIds.has(id)).length;
await this.planLimits.assertCanAddAccounts(userId, newAccountCount);
for (const account of accountsResponse.data.accounts) {
await this.prisma.account.upsert({
@ -162,7 +177,7 @@ export class PlaidService {
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
});
// Build map: raw decrypted token → plaidAccountIds
// Build map: raw decrypted token → Plaid account ids for one item.
const tokenMap = new Map<string, string[]>();
for (const account of accounts) {
if (!account.plaidAccessToken || !account.plaidAccountId) continue;
@ -172,48 +187,370 @@ export class PlaidService {
tokenMap.set(raw, list);
}
let created = 0;
for (const [rawToken] of tokenMap) {
const response = await this.client.transactionsGet({
access_token: rawToken,
start_date: startDate,
end_date: endDate,
options: { count: 500, offset: 0 },
});
const now = new Date();
await this.prisma.account.updateMany({
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
data: { syncStatus: "syncing", lastSyncAttemptAt: now, lastSyncError: null },
});
for (const tx of response.data.transactions) {
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
if (!account) continue;
const rawPayload = tx as unknown as Prisma.InputJsonValue;
await this.prisma.transactionRaw.upsert({
where: { bankTransactionId: tx.transaction_id },
update: {
accountId: account.id,
date: new Date(tx.date),
amount: tx.amount,
description: tx.name ?? "Plaid transaction",
rawPayload,
source: "plaid",
ingestedAt: new Date(),
},
create: {
accountId: account.id,
bankTransactionId: tx.transaction_id,
date: new Date(tx.date),
amount: tx.amount,
description: tx.name ?? "Plaid transaction",
rawPayload,
ingestedAt: new Date(),
source: "plaid",
let created = 0;
for (const [rawToken, plaidAccountIds] of tokenMap) {
try {
const response = await this.client.transactionsGet({
access_token: rawToken,
start_date: startDate,
end_date: endDate,
options: { count: 500, offset: 0 },
});
for (const tx of response.data.transactions) {
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
if (!account) continue;
const rawPayload = this.rawPayloads.encrypt(tx);
await this.prisma.transactionRaw.upsert({
where: { bankTransactionId: tx.transaction_id },
update: {
accountId: account.id,
date: new Date(tx.date),
amount: tx.amount,
description: tx.name ?? "Plaid transaction",
rawPayload,
source: "plaid",
ingestedAt: new Date(),
},
create: {
accountId: account.id,
bankTransactionId: tx.transaction_id,
date: new Date(tx.date),
amount: tx.amount,
description: tx.name ?? "Plaid transaction",
rawPayload,
ingestedAt: new Date(),
source: "plaid",
},
});
created += 1;
}
await this.prisma.account.updateMany({
where: { userId, plaidAccountId: { in: plaidAccountIds } },
data: {
syncStatus: "idle",
lastTransactionSync: new Date(),
lastSyncError: null,
syncConsecutiveFailures: 0,
},
});
} catch (error: unknown) {
const err = error as { response?: { data?: { error_message?: string; error_code?: string } }; message?: string };
const message = err.response?.data?.error_message ?? err.message ?? "Plaid transaction sync failed.";
await this.prisma.account.updateMany({
where: { userId, plaidAccountId: { in: plaidAccountIds } },
data: {
syncStatus: "error",
lastSyncError: message.slice(0, 500),
syncConsecutiveFailures: { increment: 1 },
},
});
created += 1;
}
}
return { created };
}
async createUpdateModeLinkToken(userId: string, accountId: string) {
const account = await this.prisma.account.findFirst({
where: {
id: accountId,
userId,
plaidAccessToken: { not: null },
plaidItemId: { not: null },
isActive: true,
},
select: {
plaidAccessToken: true,
},
});
if (!account?.plaidAccessToken) {
throw new BadRequestException("Plaid account not found for update mode.");
}
const rawAccessToken = this.encryption.decrypt(account.plaidAccessToken);
try {
const response = await this.client.linkTokenCreate({
user: { client_user_id: userId },
client_name: "LedgerOne",
country_codes: this.getCountryCodes(),
language: "en",
access_token: rawAccessToken,
redirect_uri: process.env.PLAID_REDIRECT_URI?.trim() || undefined,
webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined,
});
return {
linkToken: response.data.link_token,
expiration: response.data.expiration,
};
} catch (error: unknown) {
const err = error as { response?: { data?: { error_message?: string } } };
const message =
err.response?.data?.error_message ?? "Plaid update-mode link token request failed.";
throw new BadRequestException(message);
}
}
async markItemRepairComplete(userId: string, accountId: string) {
const account = await this.prisma.account.findFirst({
where: {
id: accountId,
userId,
plaidItemId: { not: null },
isActive: true,
},
select: {
plaidItemId: true,
},
});
if (!account?.plaidItemId) {
throw new BadRequestException("Plaid account not found for repair completion.");
}
const updated = await this.prisma.account.updateMany({
where: {
userId,
plaidItemId: account.plaidItemId,
},
data: {
syncStatus: "idle",
lastSyncError: null,
syncConsecutiveFailures: 0,
plaidWebhookCode: "UPDATE_MODE_COMPLETED",
plaidWebhookAt: new Date(),
},
});
return { updated: updated.count };
}
async handleWebhook(payload: PlaidWebhookPayload, verificationHeader?: string, rawBody?: Buffer) {
if (this.shouldVerifyWebhooks()) {
const valid = await this.verifyWebhook(verificationHeader, rawBody);
if (!valid) {
throw new BadRequestException("Invalid Plaid webhook signature.");
}
}
const webhookType = payload.webhook_type ?? "UNKNOWN";
const webhookCode = payload.webhook_code ?? "UNKNOWN";
const itemId = payload.item_id ?? null;
const event = await this.prisma.plaidWebhookEvent.create({
data: {
itemId,
webhookType,
webhookCode,
payload: payload as Prisma.InputJsonValue,
},
});
try {
const result = await this.processWebhook(payload);
await this.prisma.plaidWebhookEvent.update({
where: { id: event.id },
data: {
status: "processed",
processedAt: new Date(),
},
});
return { received: true, processed: true, webhookType, webhookCode, ...result };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Plaid webhook processing failed.";
this.logger.error(`Plaid webhook ${webhookType}:${webhookCode} failed: ${message}`);
await this.prisma.plaidWebhookEvent.update({
where: { id: event.id },
data: {
status: "error",
error: message.slice(0, 500),
processedAt: new Date(),
},
});
throw error;
}
}
private async processWebhook(payload: PlaidWebhookPayload) {
if (!payload.item_id) {
return { skipped: true, reason: "missing_item_id" };
}
const accounts = await this.prisma.account.findMany({
where: { plaidItemId: payload.item_id },
select: { userId: true },
distinct: ["userId"],
});
const userIds = accounts.map((account) => account.userId);
if (userIds.length === 0) {
return { skipped: true, reason: "item_not_found" };
}
const webhookType = payload.webhook_type ?? "UNKNOWN";
const webhookCode = payload.webhook_code ?? "UNKNOWN";
await this.prisma.account.updateMany({
where: { plaidItemId: payload.item_id },
data: {
plaidWebhookCode: webhookCode,
plaidWebhookAt: new Date(),
},
});
if (
webhookType === "TRANSACTIONS" &&
["SYNC_UPDATES_AVAILABLE", "INITIAL_UPDATE", "HISTORICAL_UPDATE", "DEFAULT_UPDATE"].includes(webhookCode)
) {
const days = Number(process.env.PLAID_WEBHOOK_LOOKBACK_DAYS ?? process.env.AUTO_SYNC_LOOKBACK_DAYS ?? 30);
const endDate = new Date().toISOString().slice(0, 10);
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
let created = 0;
for (const userId of userIds) {
const result = await this.syncTransactionsForUser(userId, startDate, endDate);
created += result.created;
}
return { usersSynced: userIds.length, created };
}
if (webhookType === "ITEM") {
return this.handleItemWebhook(payload, userIds);
}
return { skipped: true, reason: "unsupported_webhook" };
}
private async handleItemWebhook(payload: PlaidWebhookPayload, userIds: string[]) {
if (!payload.item_id) return { skipped: true, reason: "missing_item_id" };
const webhookCode = payload.webhook_code ?? "UNKNOWN";
const errorCode = payload.error?.error_code ?? webhookCode;
const errorMessage = payload.error?.error_message ?? webhookCode;
if (webhookCode === "ERROR" || errorCode === "ITEM_LOGIN_REQUIRED") {
await this.prisma.account.updateMany({
where: { plaidItemId: payload.item_id },
data: {
syncStatus: "needs_reauth",
lastSyncError: errorMessage.slice(0, 500),
syncConsecutiveFailures: { increment: 1 },
},
});
return { usersMarked: userIds.length, status: "needs_reauth" };
}
if (["PENDING_EXPIRATION", "PENDING_DISCONNECT", "NEW_ACCOUNTS_AVAILABLE"].includes(webhookCode)) {
await this.prisma.account.updateMany({
where: { plaidItemId: payload.item_id },
data: {
syncStatus: "attention_required",
lastSyncError: webhookCode,
},
});
return { usersMarked: userIds.length, status: "attention_required" };
}
if (["USER_PERMISSION_REVOKED", "USER_ACCOUNT_REVOKED"].includes(webhookCode)) {
await this.prisma.account.updateMany({
where: { plaidItemId: payload.item_id },
data: {
isActive: false,
syncStatus: "revoked",
lastSyncError: webhookCode,
},
});
return { usersMarked: userIds.length, status: "revoked" };
}
if (webhookCode === "LOGIN_REPAIRED") {
await this.prisma.account.updateMany({
where: { plaidItemId: payload.item_id },
data: {
syncStatus: "idle",
lastSyncError: null,
syncConsecutiveFailures: 0,
},
});
return { usersMarked: userIds.length, status: "repaired" };
}
return { skipped: true, reason: "unsupported_item_webhook" };
}
private shouldVerifyWebhooks() {
if (process.env.NODE_ENV === "test") return false;
return process.env.PLAID_VERIFY_WEBHOOKS !== "false";
}
private async verifyWebhook(verificationHeader?: string, rawBody?: Buffer) {
if (!verificationHeader || !rawBody) return false;
const parts = verificationHeader.split(".");
if (parts.length !== 3) return false;
const header = this.decodeJwtPart<{ alg?: string; kid?: string }>(parts[0]);
if (header.alg !== "ES256" || !header.kid) return false;
const key = await this.getWebhookKey(header.kid);
const publicKey = crypto.createPublicKey({ key, format: "jwk" } as crypto.JsonWebKeyInput);
const verifier = crypto.createVerify("SHA256");
verifier.update(`${parts[0]}.${parts[1]}`);
verifier.end();
const signature = this.ecJoseSignatureToDer(Buffer.from(parts[2], "base64url"));
if (!verifier.verify(publicKey, signature)) return false;
const claims = this.decodeJwtPart<{ iat?: number; request_body_sha256?: string }>(parts[1]);
if (!claims.iat || Math.abs(Date.now() / 1000 - claims.iat) > 300) return false;
if (!claims.request_body_sha256) return false;
const actualHash = crypto.createHash("sha256").update(rawBody).digest("hex");
return this.timingSafeEqual(actualHash, claims.request_body_sha256);
}
private async getWebhookKey(keyId: string) {
const cached = this.webhookKeys.get(keyId);
if (cached) return cached;
const response = await (this.client as unknown as {
webhookVerificationKeyGet(request: { key_id: string }): Promise<{ data: { key: JsonWebKey } }>;
}).webhookVerificationKeyGet({ key_id: keyId });
this.webhookKeys.set(keyId, response.data.key);
return response.data.key;
}
private decodeJwtPart<T>(part: string): T {
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T;
}
private timingSafeEqual(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
private ecJoseSignatureToDer(signature: Buffer) {
if (signature.length !== 64) return signature;
const r = this.derInteger(signature.subarray(0, 32));
const s = this.derInteger(signature.subarray(32));
const length = r.length + s.length;
return Buffer.concat([Buffer.from([0x30, length]), r, s]);
}
private derInteger(bytes: Buffer) {
let value = bytes;
while (value.length > 1 && value[0] === 0) {
value = value.subarray(1);
}
if (value[0] & 0x80) {
value = Buffer.concat([Buffer.from([0]), value]);
}
return Buffer.concat([Buffer.from([0x02, value.length]), value]);
}
private requireEnv(name: string) {
const value = process.env[name];
if (!value) {
@ -222,6 +559,13 @@ export class PlaidService {
return value;
}
private getCountryCodes() {
return (process.env.PLAID_COUNTRY_CODES ?? "US")
.split(",")
.map((item) => item.trim())
.filter(Boolean) as CountryCode[];
}
private async getInstitutionName(institutionId: string) {
try {
const response = await this.client.institutionsGetById({
@ -234,3 +578,15 @@ export class PlaidService {
}
}
}
type PlaidWebhookPayload = {
webhook_type?: string;
webhook_code?: string;
item_id?: string;
environment?: string;
error?: {
error_code?: string;
error_message?: string;
};
[key: string]: unknown;
};

188
src/planning/dto/index.ts Normal file
View File

@ -0,0 +1,188 @@
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
export class CreateHouseholdBudgetDto {
@IsString()
householdId!: string;
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(80)
category?: string;
@IsOptional()
@IsIn(["weekly", "monthly", "quarterly", "annual"])
period?: string;
@IsNumber()
@Min(0.01)
limitAmount!: number;
@IsOptional()
@IsNumber()
@Min(0)
spentAmount?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}
export class UpdateHouseholdBudgetDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(80)
category?: string;
@IsOptional()
@IsIn(["weekly", "monthly", "quarterly", "annual"])
period?: string;
@IsOptional()
@IsNumber()
@Min(0.01)
limitAmount?: number;
@IsOptional()
@IsNumber()
@Min(0)
spentAmount?: number;
@IsOptional()
@IsIn(["active", "paused", "archived"])
status?: string;
}
export class CreatePersonalGoalDto {
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsNumber()
@Min(0.01)
targetAmount!: number;
@IsOptional()
@IsNumber()
@Min(0)
currentAmount?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
targetDate?: string;
@IsOptional()
@IsIn(["low", "medium", "high"])
priority?: string;
@IsOptional()
@IsObject()
automation?: Record<string, unknown>;
}
export class UpdatePersonalGoalDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsNumber()
@Min(0)
currentAmount?: number;
@IsOptional()
@IsIn(["active", "paused", "completed", "archived"])
status?: string;
@IsOptional()
@IsObject()
automation?: Record<string, unknown>;
}
export class CreateInvestmentHoldingDto {
@IsOptional()
@IsString()
accountId?: string;
@IsString()
@MaxLength(20)
symbol!: string;
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsIn(["stock", "etf", "mutual_fund", "bond", "crypto", "cash", "other"])
assetClass?: string;
@IsNumber()
@Min(0)
quantity!: number;
@IsNumber()
@Min(0)
price!: number;
@IsOptional()
@IsNumber()
@Min(0)
costBasis?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
asOfDate?: string;
}
export class CreateNetWorthSnapshotDto {
@IsOptional()
@IsDateString()
snapshotDate?: string;
@IsOptional()
@IsNumber()
@Min(0)
assets?: number;
@IsOptional()
@IsNumber()
@Min(0)
liabilities?: number;
}

View File

@ -0,0 +1,77 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response";
import {
CreateHouseholdBudgetDto,
CreateInvestmentHoldingDto,
CreateNetWorthSnapshotDto,
CreatePersonalGoalDto,
UpdateHouseholdBudgetDto,
UpdatePersonalGoalDto,
} from "./dto";
import { PlanningService } from "./planning.service";
@Controller("planning")
export class PlanningController {
constructor(private readonly planningService: PlanningService) {}
@Get("budgets")
async budgets(@CurrentUser() userId: string, @Query("householdId") householdId?: string) {
return ok(await this.planningService.listBudgets(userId, householdId));
}
@Post("budgets")
async createBudget(@CurrentUser() userId: string, @Body() body: CreateHouseholdBudgetDto) {
return ok(await this.planningService.createBudget(userId, body));
}
@Patch("budgets/:id")
async updateBudget(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: UpdateHouseholdBudgetDto) {
return ok(await this.planningService.updateBudget(userId, id, body));
}
@Get("goals")
async goals(@CurrentUser() userId: string) {
return ok(await this.planningService.listPersonalGoals(userId));
}
@Post("goals")
async createGoal(@CurrentUser() userId: string, @Body() body: CreatePersonalGoalDto) {
return ok(await this.planningService.createPersonalGoal(userId, body));
}
@Patch("goals/:id")
async updateGoal(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: UpdatePersonalGoalDto) {
return ok(await this.planningService.updatePersonalGoal(userId, id, body));
}
@Get("investments")
async investments(@CurrentUser() userId: string) {
return ok(await this.planningService.listInvestments(userId));
}
@Post("investments")
async createInvestment(@CurrentUser() userId: string, @Body() body: CreateInvestmentHoldingDto) {
return ok(await this.planningService.createInvestment(userId, body));
}
@Get("net-worth")
async netWorth(@CurrentUser() userId: string) {
return ok(await this.planningService.netWorthSummary(userId));
}
@Post("net-worth/snapshots")
async createNetWorthSnapshot(@CurrentUser() userId: string, @Body() body: CreateNetWorthSnapshotDto) {
return ok(await this.planningService.createNetWorthSnapshot(userId, body));
}
@Get("recurring")
async recurring(@CurrentUser() userId: string) {
return ok(await this.planningService.listRecurring(userId));
}
@Post("recurring/detect")
async detectRecurring(@CurrentUser() userId: string) {
return ok(await this.planningService.detectRecurring(userId));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { NotificationsModule } from "../notifications/notifications.module";
import { PlanningController } from "./planning.controller";
import { PlanningService } from "./planning.service";
@Module({
imports: [NotificationsModule],
controllers: [PlanningController],
providers: [PlanningService],
})
export class PlanningModule {}

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