Compare commits
No commits in common. "2d829ee9692df294135df4bf629d0ae7dfc52827" and "4a37d5befbe00d06a3897bc9ebd7e5b28a04fb5a" have entirely different histories.
2d829ee969
...
4a37d5befb
70
.env.example
70
.env.example
@ -1,71 +1,11 @@
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/ledgerone
|
||||
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
|
||||
|
||||
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
|
||||
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: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=
|
||||
PLAID_REDIRECT_URI=http://localhost:3001/app/connect
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,3 @@ node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
data/export-objects/
|
||||
|
||||
@ -16,7 +16,7 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
RUN npm ci --omit=dev --legacy-peer-deps
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
341
package-lock.json
generated
341
package-lock.json
generated
@ -21,7 +21,6 @@
|
||||
"@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",
|
||||
@ -41,9 +40,7 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"speakeasy": "^2.0.0",
|
||||
"stripe": "^20.4.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5"
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
@ -59,7 +56,6 @@
|
||||
"prisma": "^5.18.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@ -820,30 +816,6 @@
|
||||
"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",
|
||||
@ -2946,34 +2918,6 @@
|
||||
"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",
|
||||
@ -3372,15 +3316,6 @@
|
||||
"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",
|
||||
@ -3616,28 +3551,6 @@
|
||||
"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",
|
||||
@ -3778,13 +3691,6 @@
|
||||
"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",
|
||||
@ -3811,18 +3717,6 @@
|
||||
"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",
|
||||
@ -4057,12 +3951,6 @@
|
||||
"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",
|
||||
@ -4313,19 +4201,6 @@
|
||||
],
|
||||
"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",
|
||||
@ -4540,15 +4415,6 @@
|
||||
"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",
|
||||
@ -4754,18 +4620,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@ -4788,13 +4642,6 @@
|
||||
"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",
|
||||
@ -4956,16 +4803,6 @@
|
||||
"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",
|
||||
@ -5669,15 +5506,6 @@
|
||||
"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",
|
||||
@ -6149,15 +5977,6 @@
|
||||
"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",
|
||||
@ -7852,12 +7671,6 @@
|
||||
"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",
|
||||
@ -9490,18 +9303,6 @@
|
||||
"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",
|
||||
@ -10121,50 +9922,6 @@
|
||||
"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",
|
||||
@ -10383,13 +10140,6 @@
|
||||
"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",
|
||||
@ -10457,46 +10207,6 @@
|
||||
"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",
|
||||
@ -10610,24 +10320,6 @@
|
||||
"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",
|
||||
@ -10715,27 +10407,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@ -10791,16 +10462,6 @@
|
||||
"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",
|
||||
|
||||
@ -25,7 +25,6 @@
|
||||
"@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",
|
||||
@ -45,9 +44,7 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"speakeasy": "^2.0.0",
|
||||
"stripe": "^20.4.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5"
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
@ -63,7 +60,6 @@
|
||||
"prisma": "^5.18.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,299 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,12 +0,0 @@
|
||||
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");
|
||||
@ -1,20 +0,0 @@
|
||||
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;
|
||||
@ -1,9 +0,0 @@
|
||||
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");
|
||||
@ -1,5 +0,0 @@
|
||||
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);
|
||||
@ -1,22 +0,0 @@
|
||||
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;
|
||||
@ -1 +0,0 @@
|
||||
ALTER TABLE "User" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user';
|
||||
@ -1,32 +0,0 @@
|
||||
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;
|
||||
@ -1,25 +0,0 @@
|
||||
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;
|
||||
@ -1,10 +0,0 @@
|
||||
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;
|
||||
@ -1 +0,0 @@
|
||||
ALTER TABLE "TransactionDerived" ADD COLUMN "attribution" TEXT NOT NULL DEFAULT 'mine';
|
||||
@ -1,3 +0,0 @@
|
||||
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);
|
||||
@ -1,25 +0,0 @@
|
||||
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;
|
||||
@ -1,52 +0,0 @@
|
||||
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;
|
||||
@ -1,63 +0,0 @@
|
||||
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;
|
||||
@ -1,20 +0,0 @@
|
||||
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;
|
||||
@ -1 +0,0 @@
|
||||
ALTER TABLE "Session" ADD COLUMN "nonceHash" TEXT NOT NULL DEFAULT '';
|
||||
@ -1,16 +0,0 @@
|
||||
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;
|
||||
@ -1,107 +0,0 @@
|
||||
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;
|
||||
@ -1,27 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,7 +0,0 @@
|
||||
-- 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");
|
||||
@ -1,15 +0,0 @@
|
||||
-- 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");
|
||||
@ -1,2 +0,0 @@
|
||||
-- Track user-selected data ownership/system-of-record posture.
|
||||
ALTER TABLE "GoogleConnection" ADD COLUMN "dataSystemMode" TEXT NOT NULL DEFAULT 'backend_db';
|
||||
@ -1,10 +0,0 @@
|
||||
-- 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);
|
||||
@ -20,7 +20,6 @@ model User {
|
||||
state String?
|
||||
postalCode String?
|
||||
country String?
|
||||
role String @default("user")
|
||||
emailVerified Boolean @default(false)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorSecret String?
|
||||
@ -30,468 +29,34 @@ 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 {
|
||||
@ -508,7 +73,6 @@ model TransactionRaw {
|
||||
account Account @relation(fields: [accountId], references: [id])
|
||||
derived TransactionDerived?
|
||||
ruleExecutions RuleExecution[]
|
||||
comments TransactionComment[]
|
||||
}
|
||||
|
||||
model TransactionDerived {
|
||||
@ -516,10 +80,6 @@ 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
|
||||
@ -527,37 +87,6 @@ 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
|
||||
@ -584,46 +113,13 @@ model RuleExecution {
|
||||
}
|
||||
|
||||
model ExportLog {
|
||||
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])
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([format, createdAt])
|
||||
}
|
||||
|
||||
model ExportDownloadToken {
|
||||
id String @id @default(uuid())
|
||||
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())
|
||||
rowCount Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([expiresAt])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
@ -636,23 +132,6 @@ 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
|
||||
@ -660,11 +139,6 @@ 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?
|
||||
@ -674,24 +148,6 @@ 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
|
||||
@ -713,36 +169,15 @@ 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 {
|
||||
@ -759,19 +194,6 @@ 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
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
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 {}
|
||||
@ -1,187 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -1,9 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
||||
import { Body, Controller, Get, 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 {
|
||||
@ -28,7 +26,7 @@ export class AccountsController {
|
||||
@Post("manual")
|
||||
async manual(
|
||||
@CurrentUser() userId: string,
|
||||
@Body() payload: CreateManualAccountDto,
|
||||
@Body() payload: { institutionName: string; accountType: string; mask?: string },
|
||||
) {
|
||||
const data = await this.accountsService.createManualAccount(userId, payload);
|
||||
return ok(data);
|
||||
@ -39,14 +37,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
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, TellerModule, StripeModule],
|
||||
imports: [PlaidModule],
|
||||
controllers: [AccountsController],
|
||||
providers: [AccountsService]
|
||||
})
|
||||
|
||||
@ -1,26 +1,18 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { 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 UI_PAGE_SIZE_LIMIT = 25;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
@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 requestedLimit = Number.isFinite(limit) && limit ? limit : 20;
|
||||
const take = Math.min(Math.max(requestedLimit, 1), UI_PAGE_SIZE_LIMIT);
|
||||
const take = Math.min(limit, MAX_PAGE_SIZE);
|
||||
const skip = (page - 1) * take;
|
||||
const [accounts, total] = await Promise.all([
|
||||
this.prisma.account.findMany({
|
||||
@ -37,17 +29,6 @@ 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
|
||||
@ -55,62 +36,27 @@ export class AccountsService {
|
||||
}),
|
||||
this.prisma.account.count({ where: { userId, isActive: true } }),
|
||||
]);
|
||||
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,
|
||||
};
|
||||
return { accounts, total, page, limit: take };
|
||||
}
|
||||
|
||||
async createLinkToken(userId: string) {
|
||||
await this.planLimits.assertCanAddAccounts(userId, 1);
|
||||
return this.plaidService.createLinkToken(userId);
|
||||
}
|
||||
|
||||
async refreshBalances(userId: string) {
|
||||
const [plaid, teller] = await Promise.all([
|
||||
this.plaidService.syncBalancesForUser(userId),
|
||||
this.tellerService.syncBalancesForUser(userId),
|
||||
]);
|
||||
return { updated: plaid.updated + teller.updated, plaid, teller };
|
||||
return this.plaidService.syncBalancesForUser(userId);
|
||||
}
|
||||
|
||||
async createManualAccount(
|
||||
userId: string,
|
||||
payload: { institutionName: string; accountType: string; mask?: string },
|
||||
) {
|
||||
await this.planLimits.assertCanAddAccounts(userId, 1);
|
||||
const account = await this.prisma.account.create({
|
||||
return this.prisma.account.create({
|
||||
data: {
|
||||
userId,
|
||||
institutionName: payload.institutionName,
|
||||
accountType: payload.accountType,
|
||||
mask: payload.mask ?? null,
|
||||
ownerUserId: userId,
|
||||
ownershipType: "mine",
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
@ -118,91 +64,9 @@ 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@ -1,25 +0,0 @@
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
|
||||
import { envValidationSchema } from "./config/env.validation";
|
||||
import { CommonModule } from "./common/common.module";
|
||||
@ -11,7 +11,6 @@ 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";
|
||||
@ -20,19 +19,8 @@ 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: [
|
||||
@ -69,12 +57,10 @@ import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.intercep
|
||||
StorageModule,
|
||||
SupabaseModule,
|
||||
EmailModule,
|
||||
AbuseModule,
|
||||
|
||||
// ─── Feature modules ─────────────────────────────────────────────────────
|
||||
AuthModule,
|
||||
PlaidModule,
|
||||
TellerModule,
|
||||
TaxModule,
|
||||
TransactionsModule,
|
||||
AccountsModule,
|
||||
@ -83,23 +69,12 @@ import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.intercep
|
||||
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 {}
|
||||
|
||||
@ -1,34 +1,30 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common";
|
||||
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,
|
||||
private readonly socialAuthService: SocialAuthService,
|
||||
) {}
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post("register")
|
||||
async register(@Body() payload: RegisterDto, @Req() req: Request) {
|
||||
return ok(await this.authService.register(payload, requestContextFrom(req)));
|
||||
async register(@Body() payload: RegisterDto) {
|
||||
return ok(await this.authService.register(payload));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("login")
|
||||
async login(@Body() payload: LoginDto, @Req() req: Request) {
|
||||
return ok(await this.authService.login(payload, requestContextFrom(req)));
|
||||
async login(@Body() payload: LoginDto) {
|
||||
return ok(await this.authService.login(payload));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -39,8 +35,8 @@ export class AuthController {
|
||||
|
||||
@Public()
|
||||
@Post("refresh")
|
||||
async refresh(@Body("refreshToken") refreshToken: string, @Req() req: Request) {
|
||||
return ok(await this.authService.refreshAccessToken(refreshToken, requestContextFrom(req)));
|
||||
async refresh(@Body("refreshToken") refreshToken: string) {
|
||||
return ok(await this.authService.refreshAccessToken(refreshToken));
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@ -60,46 +56,13 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,23 +1,18 @@
|
||||
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.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>("JWT_SECRET"),
|
||||
signOptions: { expiresIn: `${config.get<number>("JWT_ACCESS_TTL_SECONDS", 60)}s` },
|
||||
}),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: "15m" },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SocialAuthService, JwtAuthGuard],
|
||||
providers: [AuthService, JwtAuthGuard],
|
||||
exports: [AuthService, JwtModule, JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -2,7 +2,6 @@ import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
@ -16,13 +15,10 @@ 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 {
|
||||
@ -31,10 +27,9 @@ export class AuthService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly encryption: EncryptionService,
|
||||
@Optional() private readonly abuseService?: AbuseService,
|
||||
) {}
|
||||
|
||||
async register(payload: RegisterDto, context?: RequestContext) {
|
||||
async register(payload: RegisterDto) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existing) throw new BadRequestException("Email already registered.");
|
||||
@ -52,21 +47,20 @@ export class AuthService {
|
||||
});
|
||||
await this.emailService.sendVerificationEmail(email, verifyToken);
|
||||
|
||||
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
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, context?: RequestContext) {
|
||||
async login(payload: LoginDto) {
|
||||
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.");
|
||||
}
|
||||
|
||||
@ -83,12 +77,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
|
||||
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionNonce,
|
||||
};
|
||||
}
|
||||
|
||||
@ -102,47 +96,24 @@ export class AuthService {
|
||||
return { message: "Email verified successfully." };
|
||||
}
|
||||
|
||||
async refreshAccessToken(rawRefreshToken: string, context?: RequestContext) {
|
||||
async refreshAccessToken(rawRefreshToken: string) {
|
||||
const tokenHash = this.hashToken(rawRefreshToken);
|
||||
const record = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { session: true },
|
||||
});
|
||||
if (!record || record.revokedAt || record.expiresAt < new Date() || !record.session) {
|
||||
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
|
||||
if (!record || record.revokedAt || record.expiresAt < new Date()) {
|
||||
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() } });
|
||||
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 };
|
||||
const accessToken = this.signAccessToken(record.userId);
|
||||
const refreshToken = await this.createRefreshToken(record.userId);
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
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." };
|
||||
}
|
||||
|
||||
@ -169,10 +140,6 @@ 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." };
|
||||
}
|
||||
|
||||
@ -213,329 +180,19 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
],
|
||||
},
|
||||
};
|
||||
verifyToken(token: string): { sub: string } {
|
||||
return this.jwtService.verify<{ sub: string }>(token);
|
||||
}
|
||||
|
||||
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 signAccessToken(userId: string): string {
|
||||
return this.jwtService.sign({ sub: userId });
|
||||
}
|
||||
|
||||
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> {
|
||||
private async createRefreshToken(userId: 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, sessionId, tokenHash, expiresAt } });
|
||||
await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
|
||||
return raw;
|
||||
}
|
||||
|
||||
@ -543,10 +200,6 @@ 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");
|
||||
|
||||
@ -1,296 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
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 {}
|
||||
@ -1,347 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,155 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,9 @@
|
||||
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, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
|
||||
exports: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const ROLES_KEY = "roles";
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@ -7,10 +7,6 @@ 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";
|
||||
|
||||
@ -19,11 +15,9 @@ export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly reflector: Reflector,
|
||||
private readonly abuseService: AbuseService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
@ -39,41 +33,12 @@ export class JwtAuthGuard implements CanActivate {
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string; sid?: string }>(token, {
|
||||
const payload = this.jwtService.verify<{ sub: string }>(token, {
|
||||
secret: process.env.JWT_SECRET,
|
||||
});
|
||||
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 };
|
||||
(request as Request & { user: { sub: string } }).user = payload;
|
||||
return true;
|
||||
} catch {
|
||||
await this.abuseService.recordInvalidToken(requestContextFrom(request));
|
||||
throw new UnauthorizedException("Invalid or expired token.");
|
||||
}
|
||||
}
|
||||
@ -85,8 +50,4 @@ export class JwtAuthGuard implements CanActivate {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private hashBindingValue(value: string): string {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,6 @@ 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();
|
||||
@ -24,35 +23,17 @@ export class SentryExceptionFilter implements ExceptionFilter {
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
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", {
|
||||
// Only report 5xx errors to Sentry
|
||||
if (status >= 500 && process.env.SENTRY_DSN) {
|
||||
Sentry.captureException(exception, {
|
||||
extra: {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
query: request.query,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers["user-agent"],
|
||||
});
|
||||
return Sentry.captureException(exception);
|
||||
userId: (request as Request & { user?: { sub: string } }).user?.sub,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.error(
|
||||
`Captured server exception in Sentry: ${eventId}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
} else if (status >= 500) {
|
||||
this.logger.error(
|
||||
"Unhandled server exception",
|
||||
exception instanceof Error ? exception.stack : String(exception),
|
||||
);
|
||||
this.logger.error(exception);
|
||||
}
|
||||
|
||||
const message =
|
||||
@ -69,7 +50,7 @@ export class SentryExceptionFilter implements ExceptionFilter {
|
||||
? message
|
||||
: (message as { message?: string }).message ?? "Internal server error",
|
||||
},
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
meta: { timestamp: new Date().toISOString(), version: "1.0" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { ComplianceController } from "./compliance.controller";
|
||||
import { ComplianceService } from "./compliance.service";
|
||||
|
||||
@Module({
|
||||
controllers: [ComplianceController],
|
||||
providers: [ComplianceService],
|
||||
})
|
||||
export class ComplianceModule {}
|
||||
@ -1,212 +0,0 @@
|
||||
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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@ 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(),
|
||||
@ -13,26 +12,11 @@ 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(""),
|
||||
@ -43,9 +27,6 @@ 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),
|
||||
@ -54,27 +35,11 @@ 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().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),
|
||||
AUTO_SYNC_INTERVAL_MINUTES: Joi.number().default(15),
|
||||
|
||||
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),
|
||||
SENTRY_DSN: Joi.string().optional().allow(""),
|
||||
|
||||
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(""),
|
||||
});
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
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 {}
|
||||
@ -1,169 +0,0 @@
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@ -77,51 +77,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,55 +1,21 @@
|
||||
import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { Controller, Get, Post, Query } from "@nestjs/common";
|
||||
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>, @Req() req: Request) {
|
||||
const data = await this.exportsService.createSignedDownloadUrl(userId, "csv", query, requestContextFrom(req));
|
||||
async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
|
||||
const data = await this.exportsService.exportCsv(userId, query);
|
||||
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>, @Req() req: Request) {
|
||||
const data = await this.exportsService.exportSheets(userId, query, requestContextFrom(req));
|
||||
async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
|
||||
const data = await this.exportsService.exportSheets(userId, query);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
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, ExportObjectStorageService],
|
||||
exports: [ExportsService],
|
||||
providers: [ExportsService],
|
||||
})
|
||||
export class ExportsModule {}
|
||||
|
||||
@ -1,32 +1,12 @@
|
||||
import { BadRequestException, GoneException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
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,
|
||||
private readonly abuseService: AbuseService,
|
||||
private readonly exportObjectStorage: ExportObjectStorageService,
|
||||
) {}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private toCsv(rows: Array<Record<string, string>>) {
|
||||
if (!rows.length) return "";
|
||||
@ -39,102 +19,6 @@ 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>,
|
||||
@ -166,14 +50,7 @@ export class ExportsService {
|
||||
}
|
||||
return this.prisma.transactionRaw.findMany({
|
||||
where,
|
||||
include: {
|
||||
derived: true,
|
||||
account: {
|
||||
select: {
|
||||
ownershipType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { derived: true },
|
||||
orderBy: { date: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
@ -187,356 +64,30 @@ 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,
|
||||
}));
|
||||
}
|
||||
|
||||
private defaultAttributionForAccount(ownershipType?: string | null) {
|
||||
if (ownershipType === "joint") return "ours";
|
||||
if (ownershipType === "theirs") return "yours";
|
||||
return "mine";
|
||||
}
|
||||
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);
|
||||
|
||||
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,
|
||||
},
|
||||
data: { userId, filters, rowCount: rows.length },
|
||||
});
|
||||
|
||||
return {
|
||||
status: "signed",
|
||||
downloadUrl: `/api/exports/download/${token}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
singleUse: true,
|
||||
expiresInSeconds: Math.floor(this.downloadTokenTtlMs / 1000),
|
||||
storageProvider: stored.provider,
|
||||
};
|
||||
return { status: "ready", csv, rowCount: rows.length };
|
||||
}
|
||||
|
||||
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) {
|
||||
async exportSheets(userId: string, filters: Record<string, string> = {}) {
|
||||
// Get the user's Google connection
|
||||
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
||||
if (!gc || !gc.isConnected) {
|
||||
if (failWhenDisconnected) {
|
||||
throw new BadRequestException(
|
||||
"Google account not connected. Please connect via /api/google/connect.",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
throw new BadRequestException(
|
||||
"Google account not connected. Please connect via /api/google/connect.",
|
||||
);
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
@ -548,6 +99,7 @@ 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 },
|
||||
@ -558,94 +110,26 @@ export class ExportsService {
|
||||
});
|
||||
oauth2Client.setCredentials(credentials);
|
||||
|
||||
return {
|
||||
gc,
|
||||
sheets: google.sheets({ version: "v4", auth: oauth2Client }),
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureSpreadsheet(
|
||||
userId: string,
|
||||
sheets: ReturnType<typeof google.sheets>,
|
||||
spreadsheetId?: string | null,
|
||||
) {
|
||||
if (spreadsheetId) {
|
||||
await this.markDriveMirror(userId, spreadsheetId, "ready");
|
||||
return spreadsheetId;
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
range: `'${sheetTitle}'!A1`,
|
||||
valueInputOption: "RAW",
|
||||
requestBody: { values },
|
||||
});
|
||||
}
|
||||
|
||||
async exportSheets(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
||||
const client = await this.createGoogleSheetsClient(userId, true);
|
||||
const { gc, sheets } = client!;
|
||||
const sheets = google.sheets({ version: "v4", auth: oauth2Client });
|
||||
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);
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
// Add a new sheet tab
|
||||
await sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId,
|
||||
requestBody: {
|
||||
@ -653,15 +137,23 @@ export class ExportsService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.writeSheetValues(sheets, spreadsheetId, sheetTitle, this.toSheetValues(rows));
|
||||
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
|
||||
// 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] ?? "")),
|
||||
];
|
||||
|
||||
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);
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: `'${sheetTitle}'!A1`,
|
||||
valueInputOption: "RAW",
|
||||
requestBody: { values },
|
||||
});
|
||||
|
||||
await this.prisma.exportLog.create({
|
||||
data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length },
|
||||
});
|
||||
|
||||
this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`);
|
||||
|
||||
@ -673,38 +165,4 @@ 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,10 +30,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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 = [
|
||||
@ -30,18 +31,19 @@ export class GoogleService {
|
||||
const authUrl = client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: SCOPES,
|
||||
prompt: "consent", // always request a refresh_token
|
||||
state: userId, // passed back in callback to identify the user
|
||||
prompt: "consent",
|
||||
state: userId,
|
||||
});
|
||||
return { authUrl };
|
||||
}
|
||||
|
||||
async exchangeCode(userId: string, code: string) {
|
||||
const client = this.createClient();
|
||||
let tokens: import("google-auth-library").Credentials;
|
||||
let tokens: Credentials;
|
||||
|
||||
try {
|
||||
const result = await client.getToken(code);
|
||||
tokens = result.tokens;
|
||||
const { tokens: t } = await client.getToken(code);
|
||||
tokens = t;
|
||||
} catch {
|
||||
throw new BadRequestException("Invalid or expired authorization code.");
|
||||
}
|
||||
@ -52,13 +54,12 @@ 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 as any).googleConnection.upsert({
|
||||
await this.prisma.googleConnection.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
googleEmail,
|
||||
@ -66,12 +67,7 @@ export class GoogleService {
|
||||
accessToken: tokens.access_token ?? null,
|
||||
isConnected: true,
|
||||
connectedAt: new Date(),
|
||||
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",
|
||||
spreadsheetId: null,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
@ -79,9 +75,6 @@ export class GoogleService {
|
||||
refreshToken: tokens.refresh_token,
|
||||
accessToken: tokens.access_token ?? null,
|
||||
isConnected: true,
|
||||
driveMirrorEnabled: false,
|
||||
driveMirrorStatus: "pending_first_sync",
|
||||
dataSystemMode: "backend_db",
|
||||
},
|
||||
});
|
||||
|
||||
@ -95,55 +88,8 @@ export class GoogleService {
|
||||
}
|
||||
|
||||
async getStatus(userId: string) {
|
||||
const gc = await (this.prisma as any).googleConnection.findUnique({ where: { userId } });
|
||||
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
||||
if (!gc || !gc.isConnected) return { connected: false };
|
||||
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.",
|
||||
},
|
||||
};
|
||||
return { connected: true, googleEmail: gc.googleEmail, connectedAt: gc.connectedAt };
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class AcceptHouseholdInviteDto {
|
||||
@IsString()
|
||||
@MinLength(32)
|
||||
token!: string;
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
export type FairSplitMode = "equal" | "income_weighted" | "custom";
|
||||
|
||||
export class FairSplitCalculatorDto {
|
||||
expenseAmount!: number;
|
||||
mineMonthlyIncome?: number;
|
||||
yoursMonthlyIncome?: number;
|
||||
splitMode?: FairSplitMode;
|
||||
customMinePercent?: number;
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
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[];
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export class UpdateHouseholdPrivacyDto {
|
||||
enabled!: boolean;
|
||||
hideIndividualBalances?: boolean;
|
||||
hideIndividualTransactions?: boolean;
|
||||
}
|
||||
@ -1,163 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
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
12
src/main.ts
12
src/main.ts
@ -9,14 +9,11 @@ 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),
|
||||
});
|
||||
}
|
||||
|
||||
@ -26,20 +23,15 @@ 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,
|
||||
styleSrc,
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"], // Swagger UI needs inline scripts
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
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 {}
|
||||
@ -1,241 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,11 @@
|
||||
import { BadRequestException, Body, Controller, Headers, Post, Req } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
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,
|
||||
private readonly opaqueIds: OpaqueIdService,
|
||||
private readonly viewRefs: ViewRefService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
constructor(private readonly plaidService: PlaidService) {}
|
||||
|
||||
@Post("link-token")
|
||||
async createLinkToken(@CurrentUser() userId: string) {
|
||||
@ -31,49 +21,4 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
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]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import {
|
||||
Configuration,
|
||||
CountryCode,
|
||||
@ -10,20 +10,14 @@ 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");
|
||||
@ -61,7 +55,6 @@ export class PlaidService {
|
||||
country_codes: countryCodes,
|
||||
language: "en",
|
||||
redirect_uri: redirectUri || undefined,
|
||||
webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@ -93,14 +86,6 @@ 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({
|
||||
@ -177,7 +162,7 @@ export class PlaidService {
|
||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
||||
});
|
||||
|
||||
// Build map: raw decrypted token → Plaid account ids for one item.
|
||||
// Build map: raw decrypted token → plaidAccountIds
|
||||
const tokenMap = new Map<string, string[]>();
|
||||
for (const account of accounts) {
|
||||
if (!account.plaidAccessToken || !account.plaidAccountId) continue;
|
||||
@ -187,370 +172,48 @@ export class PlaidService {
|
||||
tokenMap.set(raw, list);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.prisma.account.updateMany({
|
||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
||||
data: { syncStatus: "syncing", lastSyncAttemptAt: now, lastSyncError: null },
|
||||
});
|
||||
|
||||
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 [rawToken] of tokenMap) {
|
||||
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 },
|
||||
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",
|
||||
},
|
||||
});
|
||||
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) {
|
||||
@ -559,13 +222,6 @@ 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({
|
||||
@ -578,15 +234,3 @@ 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;
|
||||
};
|
||||
|
||||
@ -1,188 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
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
Loading…
x
Reference in New Issue
Block a user