feat: implement LedgerOne backend backlog features
This commit is contained in:
parent
bfe489d920
commit
ade95e6fdf
67
.env.example
67
.env.example
@ -1,11 +1,68 @@
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/ledgerone
|
||||
JWT_SECRET=change_me
|
||||
SUPABASE_URL=http://127.0.0.1:54321
|
||||
SUPABASE_SERVICE_KEY=your_service_role_key
|
||||
SUPABASE_ANON_KEY=your_anon_key
|
||||
JWT_SECRET=replace_with_32_plus_char_secret
|
||||
JWT_REFRESH_SECRET=replace_with_32_plus_char_refresh_secret
|
||||
JWT_ACCESS_TTL_SECONDS=60
|
||||
ENCRYPTION_KEY=replace_with_64_hex_chars_for_aes_256_gcm
|
||||
|
||||
PLAID_CLIENT_ID=your_client_id
|
||||
PLAID_SECRET=your_sandbox_secret
|
||||
PLAID_ENV=sandbox
|
||||
PLAID_PRODUCTS=transactions
|
||||
PLAID_COUNTRY_CODES=US
|
||||
PLAID_REDIRECT_URI=http://localhost:3001/app/connect
|
||||
PLAID_REDIRECT_URI=http://localhost:3052/app/connect
|
||||
PLAID_WEBHOOK_URL=http://localhost:3051/api/plaid/webhook
|
||||
PLAID_VERIFY_WEBHOOKS=true
|
||||
|
||||
TELLER_APPLICATION_ID=
|
||||
TELLER_ENV=sandbox
|
||||
TELLER_PRODUCTS=transactions,balance
|
||||
TELLER_API_BASE_URL=https://api.teller.io
|
||||
TELLER_CERT_PATH=
|
||||
TELLER_KEY_PATH=
|
||||
TELLER_CERT_PEM=
|
||||
TELLER_KEY_PEM=
|
||||
|
||||
SUPABASE_URL=http://127.0.0.1:54321
|
||||
SUPABASE_SERVICE_KEY=your_service_role_key
|
||||
SUPABASE_ANON_KEY=your_anon_key
|
||||
|
||||
EXPORT_OBJECT_STORAGE_DRIVER=local
|
||||
EXPORT_OBJECT_STORAGE_BUCKET=ledgerone-exports
|
||||
EXPORT_OBJECT_STORAGE_DIR=data/export-objects
|
||||
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_PRO=
|
||||
STRIPE_PRICE_ELITE=
|
||||
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=noreply@ledgerone.app
|
||||
|
||||
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=
|
||||
|
||||
@ -16,7 +16,7 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev --legacy-peer-deps
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
257
package-lock.json
generated
257
package-lock.json
generated
@ -40,7 +40,8 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"speakeasy": "^2.0.0",
|
||||
"stripe": "^20.4.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
@ -56,6 +57,7 @@
|
||||
"prisma": "^5.18.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@ -816,6 +818,30 @@
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/otel": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz",
|
||||
@ -2918,6 +2944,34 @@
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@ -3551,6 +3605,28 @@
|
||||
"acorn": "^8"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@ -3691,6 +3767,13 @@
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@ -4201,6 +4284,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@ -4415,6 +4511,15 @@
|
||||
"node": ">= 0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/collect-v8-coverage": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
||||
@ -4620,6 +4725,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/create-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||
@ -4642,6 +4759,13 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -4803,6 +4927,16 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-sequences": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||
@ -5506,6 +5640,15 @@
|
||||
"integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@ -9303,6 +9446,18 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-utils": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||
@ -9922,6 +10077,50 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
||||
@ -10140,6 +10339,13 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
@ -10320,6 +10526,24 @@
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
@ -10407,6 +10631,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
@ -10462,6 +10707,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@ -44,7 +44,8 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"speakeasy": "^2.0.0",
|
||||
"stripe": "^20.4.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
@ -60,6 +61,7 @@
|
||||
"prisma": "^5.18.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
ALTER TABLE "ExportLog"
|
||||
ADD COLUMN "format" TEXT NOT NULL DEFAULT 'csv',
|
||||
ADD COLUMN "destination" TEXT NOT NULL DEFAULT 'download',
|
||||
ADD COLUMN "fileName" TEXT,
|
||||
ADD COLUMN "mimeType" TEXT,
|
||||
ADD COLUMN "fileHash" TEXT,
|
||||
ADD COLUMN "ipAddress" TEXT,
|
||||
ADD COLUMN "userAgent" TEXT,
|
||||
ADD COLUMN "metadata" JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
CREATE INDEX "ExportLog_userId_createdAt_idx" ON "ExportLog"("userId", "createdAt");
|
||||
CREATE INDEX "ExportLog_format_createdAt_idx" ON "ExportLog"("format", "createdAt");
|
||||
@ -0,0 +1,20 @@
|
||||
CREATE TABLE "ExportDownloadToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"format" TEXT NOT NULL,
|
||||
"filters" JSONB NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ExportDownloadToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ExportDownloadToken_tokenHash_key" ON "ExportDownloadToken"("tokenHash");
|
||||
CREATE INDEX "ExportDownloadToken_userId_createdAt_idx" ON "ExportDownloadToken"("userId", "createdAt");
|
||||
CREATE INDEX "ExportDownloadToken_expiresAt_idx" ON "ExportDownloadToken"("expiresAt");
|
||||
|
||||
ALTER TABLE "ExportDownloadToken"
|
||||
ADD CONSTRAINT "ExportDownloadToken_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,9 @@
|
||||
ALTER TABLE "ExportDownloadToken"
|
||||
ADD COLUMN "storageProvider" TEXT,
|
||||
ADD COLUMN "storageKey" TEXT,
|
||||
ADD COLUMN "fileName" TEXT,
|
||||
ADD COLUMN "mimeType" TEXT,
|
||||
ADD COLUMN "rowCount" INTEGER,
|
||||
ADD COLUMN "fileHash" TEXT;
|
||||
|
||||
CREATE INDEX "ExportDownloadToken_storageKey_idx" ON "ExportDownloadToken"("storageKey");
|
||||
@ -0,0 +1,5 @@
|
||||
ALTER TABLE "GoogleConnection"
|
||||
ADD COLUMN "driveMirrorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "driveMirrorStatus" TEXT NOT NULL DEFAULT 'not_started',
|
||||
ADD COLUMN "driveMirrorSpreadsheetUrl" TEXT,
|
||||
ADD COLUMN "driveMirrorLastSyncedAt" TIMESTAMP(3);
|
||||
@ -0,0 +1,22 @@
|
||||
CREATE TABLE "ApiKey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"prefix" TEXT NOT NULL,
|
||||
"keyHash" TEXT NOT NULL,
|
||||
"scopes" TEXT[] NOT NULL DEFAULT ARRAY['transactions:read']::TEXT[],
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
|
||||
CREATE INDEX "ApiKey_userId_createdAt_idx" ON "ApiKey"("userId", "createdAt");
|
||||
CREATE INDEX "ApiKey_prefix_idx" ON "ApiKey"("prefix");
|
||||
|
||||
ALTER TABLE "ApiKey"
|
||||
ADD CONSTRAINT "ApiKey_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE "User" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user';
|
||||
32
prisma/migrations/20260716000700_households/migration.sql
Normal file
32
prisma/migrations/20260716000700_households/migration.sql
Normal file
@ -0,0 +1,32 @@
|
||||
CREATE TABLE "Household" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdByUserId" TEXT NOT NULL,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Household_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "HouseholdMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"householdId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'member',
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HouseholdMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "Household_createdByUserId_createdAt_idx" ON "Household"("createdByUserId", "createdAt");
|
||||
CREATE UNIQUE INDEX "HouseholdMember_householdId_userId_key" ON "HouseholdMember"("householdId", "userId");
|
||||
CREATE INDEX "HouseholdMember_userId_status_idx" ON "HouseholdMember"("userId", "status");
|
||||
CREATE INDEX "HouseholdMember_householdId_role_idx" ON "HouseholdMember"("householdId", "role");
|
||||
|
||||
ALTER TABLE "Household" ADD CONSTRAINT "Household_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "HouseholdMember" ADD CONSTRAINT "HouseholdMember_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "HouseholdMember" ADD CONSTRAINT "HouseholdMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,25 @@
|
||||
CREATE TABLE "HouseholdInvite" (
|
||||
"id" TEXT NOT NULL,
|
||||
"householdId" TEXT NOT NULL,
|
||||
"invitedById" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'member',
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"acceptedAt" TIMESTAMP(3),
|
||||
"acceptedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HouseholdInvite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "HouseholdInvite_tokenHash_key" ON "HouseholdInvite"("tokenHash");
|
||||
CREATE INDEX "HouseholdInvite_householdId_status_idx" ON "HouseholdInvite"("householdId", "status");
|
||||
CREATE INDEX "HouseholdInvite_email_status_idx" ON "HouseholdInvite"("email", "status");
|
||||
CREATE INDEX "HouseholdInvite_expiresAt_idx" ON "HouseholdInvite"("expiresAt");
|
||||
|
||||
ALTER TABLE "HouseholdInvite" ADD CONSTRAINT "HouseholdInvite_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "HouseholdInvite" ADD CONSTRAINT "HouseholdInvite_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "HouseholdInvite" ADD CONSTRAINT "HouseholdInvite_acceptedById_fkey" FOREIGN KEY ("acceptedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,10 @@
|
||||
ALTER TABLE "Account"
|
||||
ADD COLUMN "householdId" TEXT,
|
||||
ADD COLUMN "ownerUserId" TEXT,
|
||||
ADD COLUMN "ownershipType" TEXT NOT NULL DEFAULT 'mine';
|
||||
|
||||
CREATE INDEX "Account_householdId_ownershipType_idx" ON "Account"("householdId", "ownershipType");
|
||||
CREATE INDEX "Account_ownerUserId_idx" ON "Account"("ownerUserId");
|
||||
|
||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE "TransactionDerived" ADD COLUMN "attribution" TEXT NOT NULL DEFAULT 'mine';
|
||||
@ -0,0 +1,3 @@
|
||||
ALTER TABLE "TransactionDerived" ADD COLUMN "splitMode" TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE "TransactionDerived" ADD COLUMN "splitMinePercent" DECIMAL(5, 2);
|
||||
ALTER TABLE "TransactionDerived" ADD COLUMN "splitYoursPercent" DECIMAL(5, 2);
|
||||
@ -20,6 +20,7 @@ model User {
|
||||
state String?
|
||||
postalCode String?
|
||||
country String?
|
||||
role String @default("user")
|
||||
emailVerified Boolean @default(false)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorSecret String?
|
||||
@ -29,34 +30,152 @@ 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")
|
||||
ownedAccounts Account[] @relation("AccountOwnerUser")
|
||||
}
|
||||
|
||||
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[]
|
||||
|
||||
@@index([createdByUserId, createdAt])
|
||||
}
|
||||
|
||||
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[]
|
||||
|
||||
@@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 {
|
||||
@ -80,6 +199,10 @@ model TransactionDerived {
|
||||
rawTransactionId String @unique
|
||||
userCategory String?
|
||||
userNotes String?
|
||||
attribution String @default("mine")
|
||||
splitMode String @default("none")
|
||||
splitMinePercent Decimal?
|
||||
splitYoursPercent Decimal?
|
||||
isHidden Boolean @default(false)
|
||||
modifiedAt DateTime @default(now())
|
||||
modifiedBy String
|
||||
@ -87,6 +210,22 @@ model TransactionDerived {
|
||||
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id])
|
||||
}
|
||||
|
||||
model CsvImportMapping {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
headerSignature String
|
||||
name String?
|
||||
mapping Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastUsedAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, headerSignature])
|
||||
@@index([userId, lastUsedAt])
|
||||
}
|
||||
|
||||
model Rule {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
@ -113,13 +252,46 @@ model RuleExecution {
|
||||
}
|
||||
|
||||
model ExportLog {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
filters Json
|
||||
rowCount Int
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
format String @default("csv")
|
||||
destination String @default("download")
|
||||
filters Json
|
||||
rowCount Int
|
||||
fileName String?
|
||||
mimeType String?
|
||||
fileHash String?
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([format, createdAt])
|
||||
}
|
||||
|
||||
model ExportDownloadToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
format String
|
||||
filters Json
|
||||
storageProvider String?
|
||||
storageKey String?
|
||||
fileName String?
|
||||
mimeType String?
|
||||
rowCount Int?
|
||||
fileHash String?
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
@ -132,6 +304,23 @@ model AuditLog {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model AbuseEvent {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
eventType String
|
||||
riskPoints Int
|
||||
severity String
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([eventType, createdAt])
|
||||
}
|
||||
|
||||
model GoogleConnection {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
@ -139,6 +328,10 @@ model GoogleConnection {
|
||||
refreshToken String
|
||||
accessToken String?
|
||||
spreadsheetId String?
|
||||
driveMirrorEnabled Boolean @default(false)
|
||||
driveMirrorStatus String @default("not_started")
|
||||
driveMirrorSpreadsheetUrl String?
|
||||
driveMirrorLastSyncedAt DateTime?
|
||||
isConnected Boolean @default(true)
|
||||
connectedAt DateTime @default(now())
|
||||
lastSyncedAt DateTime?
|
||||
@ -148,6 +341,24 @@ model GoogleConnection {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
name String
|
||||
prefix String
|
||||
keyHash String @unique
|
||||
scopes String[] @default(["transactions:read"])
|
||||
lastUsedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([prefix])
|
||||
}
|
||||
|
||||
model EmailVerificationToken {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
@ -169,15 +380,35 @@ model PasswordResetToken {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
ipHash String
|
||||
userAgentHash String
|
||||
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 {
|
||||
|
||||
14
src/abuse/abuse.controller.ts
Normal file
14
src/abuse/abuse.controller.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { AbuseService } from "./abuse.service";
|
||||
|
||||
@Controller("security")
|
||||
export class AbuseController {
|
||||
constructor(private readonly abuseService: AbuseService) {}
|
||||
|
||||
@Get("risk")
|
||||
async risk(@CurrentUser() userId: string) {
|
||||
return ok(await this.abuseService.getRiskProfile(userId));
|
||||
}
|
||||
}
|
||||
11
src/abuse/abuse.module.ts
Normal file
11
src/abuse/abuse.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { AbuseController } from "./abuse.controller";
|
||||
import { AbuseService } from "./abuse.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [AbuseController],
|
||||
providers: [AbuseService],
|
||||
exports: [AbuseService],
|
||||
})
|
||||
export class AbuseModule {}
|
||||
125
src/abuse/abuse.service.ts
Normal file
125
src/abuse/abuse.service.ts
Normal file
@ -0,0 +1,125 @@
|
||||
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;
|
||||
|
||||
@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 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");
|
||||
}
|
||||
}
|
||||
13
src/abuse/abuse.types.ts
Normal file
13
src/abuse/abuse.types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type RequestContext = {
|
||||
ipAddress?: string;
|
||||
userAgent?: 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,
|
||||
};
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { AccountsService } from "./accounts.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto";
|
||||
|
||||
@Controller("accounts")
|
||||
export class AccountsController {
|
||||
@ -37,4 +38,14 @@ export class AccountsController {
|
||||
const data = await this.accountsService.refreshBalances(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Patch(":id/ownership")
|
||||
async updateOwnership(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: UpdateAccountOwnershipDto,
|
||||
) {
|
||||
const data = await this.accountsService.updateOwnership(userId, id, payload);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PlaidModule } from "../plaid/plaid.module";
|
||||
import { StripeModule } from "../stripe/stripe.module";
|
||||
import { TellerModule } from "../teller/teller.module";
|
||||
import { AccountsController } from "./accounts.controller";
|
||||
import { AccountsService } from "./accounts.service";
|
||||
|
||||
@Module({
|
||||
imports: [PlaidModule],
|
||||
imports: [PlaidModule, TellerModule, StripeModule],
|
||||
controllers: [AccountsController],
|
||||
providers: [AccountsService]
|
||||
})
|
||||
|
||||
@ -1,18 +1,26 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PlaidService } from "../plaid/plaid.service";
|
||||
import { TellerService } from "../teller/teller.service";
|
||||
import { OpaqueIdService } from "../common/opaque-id.service";
|
||||
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
||||
import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto";
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
const UI_PAGE_SIZE_LIMIT = 25;
|
||||
|
||||
@Injectable()
|
||||
export class AccountsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly plaidService: PlaidService,
|
||||
private readonly tellerService: TellerService,
|
||||
private readonly opaqueIds: OpaqueIdService,
|
||||
private readonly planLimits: PlanLimitsService,
|
||||
) {}
|
||||
|
||||
async list(userId: string, page = 1, limit = 20) {
|
||||
const take = Math.min(limit, MAX_PAGE_SIZE);
|
||||
const requestedLimit = Number.isFinite(limit) && limit ? limit : 20;
|
||||
const take = Math.min(Math.max(requestedLimit, 1), UI_PAGE_SIZE_LIMIT);
|
||||
const skip = (page - 1) * take;
|
||||
const [accounts, total] = await Promise.all([
|
||||
this.prisma.account.findMany({
|
||||
@ -29,6 +37,17 @@ export class AccountsService {
|
||||
availableBalance: true,
|
||||
isoCurrencyCode: true,
|
||||
lastBalanceSync: true,
|
||||
lastTransactionSync: true,
|
||||
lastSyncAttemptAt: true,
|
||||
syncStatus: true,
|
||||
lastSyncError: true,
|
||||
syncConsecutiveFailures: true,
|
||||
plaidWebhookCode: true,
|
||||
plaidWebhookAt: true,
|
||||
tellerAccountId: true,
|
||||
householdId: true,
|
||||
ownerUserId: true,
|
||||
ownershipType: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
// Intentionally omit plaidAccessToken — never expose the encrypted token
|
||||
@ -36,27 +55,62 @@ export class AccountsService {
|
||||
}),
|
||||
this.prisma.account.count({ where: { userId, isActive: true } }),
|
||||
]);
|
||||
return { accounts, total, page, limit: take };
|
||||
return {
|
||||
accounts: accounts.map((account) => ({
|
||||
id: this.opaqueIds.encode("account", userId, account.id),
|
||||
institutionName: account.institutionName,
|
||||
accountType: account.accountType,
|
||||
mask: account.mask,
|
||||
currentBalance: account.currentBalance,
|
||||
availableBalance: account.availableBalance,
|
||||
isoCurrencyCode: account.isoCurrencyCode,
|
||||
lastBalanceSync: account.lastBalanceSync,
|
||||
lastTransactionSync: account.lastTransactionSync,
|
||||
lastSyncAttemptAt: account.lastSyncAttemptAt,
|
||||
syncStatus: account.syncStatus,
|
||||
lastSyncError: account.lastSyncError,
|
||||
syncConsecutiveFailures: account.syncConsecutiveFailures,
|
||||
plaidWebhookCode: account.plaidWebhookCode,
|
||||
plaidWebhookAt: account.plaidWebhookAt,
|
||||
tellerConnected: Boolean(account.tellerAccountId),
|
||||
householdId: account.householdId,
|
||||
ownerUserId: account.ownerUserId,
|
||||
ownershipType: account.ownershipType,
|
||||
isActive: account.isActive,
|
||||
createdAt: account.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
};
|
||||
}
|
||||
|
||||
async createLinkToken(userId: string) {
|
||||
await this.planLimits.assertCanAddAccounts(userId, 1);
|
||||
return this.plaidService.createLinkToken(userId);
|
||||
}
|
||||
|
||||
async refreshBalances(userId: string) {
|
||||
return this.plaidService.syncBalancesForUser(userId);
|
||||
const [plaid, teller] = await Promise.all([
|
||||
this.plaidService.syncBalancesForUser(userId),
|
||||
this.tellerService.syncBalancesForUser(userId),
|
||||
]);
|
||||
return { updated: plaid.updated + teller.updated, plaid, teller };
|
||||
}
|
||||
|
||||
async createManualAccount(
|
||||
userId: string,
|
||||
payload: { institutionName: string; accountType: string; mask?: string },
|
||||
) {
|
||||
return this.prisma.account.create({
|
||||
await this.planLimits.assertCanAddAccounts(userId, 1);
|
||||
const account = await this.prisma.account.create({
|
||||
data: {
|
||||
userId,
|
||||
institutionName: payload.institutionName,
|
||||
accountType: payload.accountType,
|
||||
mask: payload.mask ?? null,
|
||||
ownerUserId: userId,
|
||||
ownershipType: "mine",
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
@ -64,9 +118,91 @@ export class AccountsService {
|
||||
institutionName: true,
|
||||
accountType: true,
|
||||
mask: true,
|
||||
ownerUserId: true,
|
||||
ownershipType: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...account,
|
||||
id: this.opaqueIds.encode("account", userId, account.id),
|
||||
};
|
||||
}
|
||||
|
||||
async updateOwnership(userId: string, accountHandle: string, payload: UpdateAccountOwnershipDto) {
|
||||
const accountId = this.opaqueIds.decode("account", userId, accountHandle);
|
||||
const account = await this.prisma.account.findFirst({
|
||||
where: { id: accountId, userId, isActive: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException("Account not found.");
|
||||
|
||||
let householdId: string | null = null;
|
||||
let ownerUserId: string | null = userId;
|
||||
|
||||
if (payload.ownershipType === "mine") {
|
||||
householdId = null;
|
||||
ownerUserId = userId;
|
||||
} else {
|
||||
if (!payload.householdId) {
|
||||
throw new BadRequestException("Household is required for shared account ownership.");
|
||||
}
|
||||
const requesterMembership = await this.prisma.householdMember.findFirst({
|
||||
where: { householdId: payload.householdId, userId, status: "active" },
|
||||
});
|
||||
if (!requesterMembership) throw new BadRequestException("Household not found.");
|
||||
householdId = payload.householdId;
|
||||
|
||||
if (payload.ownershipType === "joint") {
|
||||
ownerUserId = null;
|
||||
} else {
|
||||
if (!payload.ownerUserId || payload.ownerUserId === userId) {
|
||||
throw new BadRequestException("Owner user must be another active household member for 'theirs' accounts.");
|
||||
}
|
||||
const ownerMembership = await this.prisma.householdMember.findFirst({
|
||||
where: { householdId, userId: payload.ownerUserId, status: "active" },
|
||||
});
|
||||
if (!ownerMembership) throw new BadRequestException("Owner must be an active household member.");
|
||||
ownerUserId = payload.ownerUserId;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.account.update({
|
||||
where: { id: accountId },
|
||||
data: {
|
||||
ownershipType: payload.ownershipType,
|
||||
householdId,
|
||||
ownerUserId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
institutionName: true,
|
||||
accountType: true,
|
||||
mask: true,
|
||||
householdId: true,
|
||||
ownerUserId: true,
|
||||
ownershipType: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "account.ownership.update",
|
||||
metadata: {
|
||||
accountId,
|
||||
householdId,
|
||||
ownerUserId,
|
||||
ownershipType: payload.ownershipType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...updated,
|
||||
id: this.opaqueIds.encode("account", userId, updated.id),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
17
src/accounts/dto/update-account-ownership.dto.ts
Normal file
17
src/accounts/dto/update-account-ownership.dto.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export const ACCOUNT_OWNERSHIP_TYPES = ["mine", "theirs", "joint"] as const;
|
||||
export type AccountOwnershipType = typeof ACCOUNT_OWNERSHIP_TYPES[number];
|
||||
|
||||
export class UpdateAccountOwnershipDto {
|
||||
@IsIn(ACCOUNT_OWNERSHIP_TYPES)
|
||||
ownershipType!: AccountOwnershipType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
householdId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerUserId?: string;
|
||||
}
|
||||
17
src/admin/admin.controller.ts
Normal file
17
src/admin/admin.controller.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
@Roles("admin")
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller("admin")
|
||||
export class AdminController {
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@Get("users")
|
||||
async users() {
|
||||
return ok(await this.adminService.listUsers());
|
||||
}
|
||||
}
|
||||
9
src/admin/admin.module.ts
Normal file
9
src/admin/admin.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
25
src/admin/admin.service.ts
Normal file
25
src/admin/admin.service.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listUsers() {
|
||||
return this.prisma.user.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
fullName: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
subscription: { select: { plan: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import { SupabaseModule } from "./supabase/supabase.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { PlaidModule } from "./plaid/plaid.module";
|
||||
import { TellerModule } from "./teller/teller.module";
|
||||
import { TaxModule } from "./tax/tax.module";
|
||||
import { TransactionsModule } from "./transactions/transactions.module";
|
||||
import { AccountsModule } from "./accounts/accounts.module";
|
||||
@ -19,6 +20,10 @@ 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 { LoggerModule } from "nestjs-pino";
|
||||
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
|
||||
|
||||
@ -57,10 +62,12 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
|
||||
StorageModule,
|
||||
SupabaseModule,
|
||||
EmailModule,
|
||||
AbuseModule,
|
||||
|
||||
// ─── Feature modules ─────────────────────────────────────────────────────
|
||||
AuthModule,
|
||||
PlaidModule,
|
||||
TellerModule,
|
||||
TaxModule,
|
||||
TransactionsModule,
|
||||
AccountsModule,
|
||||
@ -69,6 +76,9 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
|
||||
StripeModule,
|
||||
TwoFactorModule,
|
||||
GoogleModule,
|
||||
PublicApiModule,
|
||||
AdminModule,
|
||||
HouseholdsModule,
|
||||
],
|
||||
providers: [
|
||||
// Apply rate limiting globally
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { ok } from "../common/response";
|
||||
import { requestContextFrom } from "../abuse/abuse.types";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { SocialAuthService } from "./social-auth.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
@ -13,18 +16,21 @@ import { Public } from "../common/decorators/public.decorator";
|
||||
@Controller("auth")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly socialAuthService: SocialAuthService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post("register")
|
||||
async register(@Body() payload: RegisterDto) {
|
||||
return ok(await this.authService.register(payload));
|
||||
async register(@Body() payload: RegisterDto, @Req() req: Request) {
|
||||
return ok(await this.authService.register(payload, requestContextFrom(req)));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("login")
|
||||
async login(@Body() payload: LoginDto) {
|
||||
return ok(await this.authService.login(payload));
|
||||
async login(@Body() payload: LoginDto, @Req() req: Request) {
|
||||
return ok(await this.authService.login(payload, requestContextFrom(req)));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -35,8 +41,8 @@ export class AuthController {
|
||||
|
||||
@Public()
|
||||
@Post("refresh")
|
||||
async refresh(@Body("refreshToken") refreshToken: string) {
|
||||
return ok(await this.authService.refreshAccessToken(refreshToken));
|
||||
async refresh(@Body("refreshToken") refreshToken: string, @Req() req: Request) {
|
||||
return ok(await this.authService.refreshAccessToken(refreshToken, requestContextFrom(req)));
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@ -56,6 +62,24 @@ 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));
|
||||
@ -65,4 +89,9 @@ export class AuthController {
|
||||
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,18 +1,23 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { SocialAuthService } from "./social-auth.service";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: "15m" },
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>("JWT_SECRET"),
|
||||
signOptions: { expiresIn: `${config.get<number>("JWT_ACCESS_TTL_SECONDS", 60)}s` },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtAuthGuard],
|
||||
providers: [AuthService, SocialAuthService, JwtAuthGuard],
|
||||
exports: [AuthService, JwtModule, JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
@ -15,10 +16,13 @@ import { RegisterDto } from "./dto/register.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||
import { AbuseService } from "../abuse/abuse.service";
|
||||
import { RequestContext } from "../abuse/abuse.types";
|
||||
|
||||
const VERIFY_TOKEN_TTL_HOURS = 24;
|
||||
const RESET_TOKEN_TTL_HOURS = 1;
|
||||
const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||
const SESSION_TTL_DAYS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@ -27,9 +31,10 @@ export class AuthService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly encryption: EncryptionService,
|
||||
@Optional() private readonly abuseService?: AbuseService,
|
||||
) {}
|
||||
|
||||
async register(payload: RegisterDto) {
|
||||
async register(payload: RegisterDto, context?: RequestContext) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existing) throw new BadRequestException("Email already registered.");
|
||||
@ -47,8 +52,7 @@ export class AuthService {
|
||||
});
|
||||
await this.emailService.sendVerificationEmail(email, verifyToken);
|
||||
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
@ -57,10 +61,11 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async login(payload: LoginDto) {
|
||||
async login(payload: LoginDto, context?: RequestContext) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
|
||||
await this.abuseService?.recordLoginFailure(email, user?.id, context);
|
||||
throw new UnauthorizedException("Invalid credentials.");
|
||||
}
|
||||
|
||||
@ -77,8 +82,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
@ -96,24 +100,46 @@ export class AuthService {
|
||||
return { message: "Email verified successfully." };
|
||||
}
|
||||
|
||||
async refreshAccessToken(rawRefreshToken: string) {
|
||||
async refreshAccessToken(rawRefreshToken: string, context?: RequestContext) {
|
||||
const tokenHash = this.hashToken(rawRefreshToken);
|
||||
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
|
||||
if (!record || record.revokedAt || record.expiresAt < new Date()) {
|
||||
const record = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { session: true },
|
||||
});
|
||||
if (!record || record.revokedAt || record.expiresAt < new Date() || !record.session) {
|
||||
throw new UnauthorizedException("Invalid or expired refresh token.");
|
||||
}
|
||||
this.assertSessionMatches(record.session, context);
|
||||
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
|
||||
const accessToken = this.signAccessToken(record.userId);
|
||||
const refreshToken = await this.createRefreshToken(record.userId);
|
||||
await this.prisma.session.update({
|
||||
where: { id: record.sessionId! },
|
||||
data: { lastSeenAt: new Date() },
|
||||
});
|
||||
const accessToken = this.signAccessToken(record.userId, record.sessionId!);
|
||||
const refreshToken = await this.createRefreshToken(record.userId, record.sessionId!);
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async issueTokensForUser(userId: string, context?: RequestContext) {
|
||||
const session = await this.createSession(userId, context);
|
||||
const accessToken = this.signAccessToken(userId, session.id);
|
||||
const refreshToken = await this.createRefreshToken(userId, session.id);
|
||||
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." };
|
||||
}
|
||||
|
||||
@ -140,6 +166,10 @@ export class AuthService {
|
||||
where: { userId: record.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.prisma.session.updateMany({
|
||||
where: { userId: record.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { message: "Password reset successfully. Please log in." };
|
||||
}
|
||||
|
||||
@ -180,19 +210,92 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
verifyToken(token: string): { sub: string } {
|
||||
return this.jwtService.verify<{ sub: string }>(token);
|
||||
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] = 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 } }),
|
||||
]);
|
||||
|
||||
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);
|
||||
|
||||
await tx.taxDocument.deleteMany({ where: { taxReturnId: { in: taxReturnIds } } });
|
||||
await tx.ruleExecution.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ruleId: { in: ruleIds } },
|
||||
{ transactionId: { 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.exportLog.deleteMany({ where: { userId } });
|
||||
await tx.auditLog.deleteMany({ where: { userId } });
|
||||
await tx.abuseEvent.deleteMany({ where: { userId } });
|
||||
await tx.googleConnection.deleteMany({ where: { userId } });
|
||||
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." };
|
||||
}
|
||||
|
||||
private signAccessToken(userId: string): string {
|
||||
return this.jwtService.sign({ sub: userId });
|
||||
verifyToken(token: string): { sub: string; sid: string } {
|
||||
return this.jwtService.verify<{ sub: string; sid: string }>(token);
|
||||
}
|
||||
|
||||
private async createRefreshToken(userId: string): Promise<string> {
|
||||
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);
|
||||
return this.prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
ipHash: this.hashBindingValue(context?.ipAddress ?? "unknown-ip"),
|
||||
userAgentHash: this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private assertSessionMatches(
|
||||
session: { revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string },
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
private async createRefreshToken(userId: string, sessionId: string): Promise<string> {
|
||||
const raw = crypto.randomBytes(40).toString("hex");
|
||||
const tokenHash = this.hashToken(raw);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000);
|
||||
await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
|
||||
await this.prisma.refreshToken.create({ data: { userId, sessionId, tokenHash, expiresAt } });
|
||||
return raw;
|
||||
}
|
||||
|
||||
@ -200,6 +303,10 @@ export class AuthService {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
private hashBindingValue(value: string): string {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
private hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
||||
|
||||
296
src/auth/social-auth.service.ts
Normal file
296
src/auth/social-auth.service.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { google } from "googleapis";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { RequestContext } from "../abuse/abuse.types";
|
||||
|
||||
type SocialProvider = "google" | "apple";
|
||||
|
||||
type SocialProfile = {
|
||||
provider: SocialProvider;
|
||||
providerAccountId: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type AppleJwk = {
|
||||
kid: string;
|
||||
alg: string;
|
||||
kty: string;
|
||||
use: string;
|
||||
n: string;
|
||||
e: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SocialAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
getAuthorizationUrl(provider: string, next = "/app") {
|
||||
const normalized = this.normalizeProvider(provider);
|
||||
const redirectUri = this.getRedirectUri();
|
||||
const state = this.signState({ provider: normalized, next: this.safeNext(next), exp: Date.now() + 10 * 60 * 1000 });
|
||||
|
||||
if (normalized === "google") {
|
||||
const clientId = process.env.GOOGLE_AUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID;
|
||||
if (!clientId) throw new BadRequestException("Google social login is not configured.");
|
||||
const client = new google.auth.OAuth2(clientId, process.env.GOOGLE_AUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET, redirectUri);
|
||||
return {
|
||||
authUrl: client.generateAuthUrl({
|
||||
access_type: "online",
|
||||
scope: ["openid", "email", "profile"],
|
||||
prompt: "select_account",
|
||||
state,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const clientId = process.env.APPLE_CLIENT_ID;
|
||||
if (!clientId) throw new BadRequestException("Apple social login is not configured.");
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
response_mode: "query",
|
||||
scope: "name email",
|
||||
state,
|
||||
});
|
||||
return { authUrl: `https://appleid.apple.com/auth/authorize?${params.toString()}` };
|
||||
}
|
||||
|
||||
async completeLogin(provider: string, code: string, state: string, context?: RequestContext) {
|
||||
const normalized = this.normalizeProvider(provider);
|
||||
const parsedState = this.verifyState(state);
|
||||
if (parsedState.provider !== normalized) {
|
||||
throw new BadRequestException("OAuth state provider mismatch.");
|
||||
}
|
||||
|
||||
const profile = normalized === "google"
|
||||
? await this.exchangeGoogleCode(code)
|
||||
: await this.exchangeAppleCode(code);
|
||||
|
||||
const user = await this.upsertSocialUser(profile);
|
||||
const tokens = await this.authService.issueTokensForUser(user.id, context);
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: `auth.social.${normalized}.login`,
|
||||
metadata: { provider: normalized, email: profile.email },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
next: parsedState.next || "/app",
|
||||
};
|
||||
}
|
||||
|
||||
private async exchangeGoogleCode(code: string): Promise<SocialProfile> {
|
||||
const clientId = process.env.GOOGLE_AUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID;
|
||||
const clientSecret = process.env.GOOGLE_AUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) throw new BadRequestException("Google social login is not configured.");
|
||||
|
||||
const client = new google.auth.OAuth2(clientId, clientSecret, this.getRedirectUri());
|
||||
const { tokens } = await client.getToken(code);
|
||||
if (!tokens.id_token) throw new UnauthorizedException("Google did not return an identity token.");
|
||||
|
||||
const ticket = await client.verifyIdToken({ idToken: tokens.id_token, audience: clientId });
|
||||
const payload = ticket.getPayload();
|
||||
if (!payload?.sub || !payload.email) throw new UnauthorizedException("Google identity token is missing required claims.");
|
||||
|
||||
return {
|
||||
provider: "google",
|
||||
providerAccountId: payload.sub,
|
||||
email: payload.email.toLowerCase(),
|
||||
name: payload.name,
|
||||
};
|
||||
}
|
||||
|
||||
private async exchangeAppleCode(code: string): Promise<SocialProfile> {
|
||||
const clientId = process.env.APPLE_CLIENT_ID;
|
||||
if (!clientId) throw new BadRequestException("Apple social login is not configured.");
|
||||
|
||||
const response = await fetch("https://appleid.apple.com/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: this.createAppleClientSecret(),
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = await response.json() as { id_token?: string; error?: string };
|
||||
if (!response.ok || !tokenData.id_token) {
|
||||
throw new UnauthorizedException(tokenData.error || "Apple did not return an identity token.");
|
||||
}
|
||||
|
||||
const payload = await this.verifyAppleIdToken(tokenData.id_token);
|
||||
if (!payload.sub || !payload.email) throw new UnauthorizedException("Apple identity token is missing required claims.");
|
||||
|
||||
return {
|
||||
provider: "apple",
|
||||
providerAccountId: payload.sub,
|
||||
email: payload.email.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
private async upsertSocialUser(profile: SocialProfile) {
|
||||
const existingAccount = await this.prisma.socialAccount.findUnique({
|
||||
where: { provider_providerAccountId: { provider: profile.provider, providerAccountId: profile.providerAccountId } },
|
||||
include: { user: true },
|
||||
});
|
||||
if (existingAccount) return existingAccount.user;
|
||||
|
||||
const user = await this.prisma.user.upsert({
|
||||
where: { email: profile.email },
|
||||
update: {
|
||||
emailVerified: true,
|
||||
...(profile.name ? { fullName: profile.name } : {}),
|
||||
},
|
||||
create: {
|
||||
email: profile.email,
|
||||
passwordHash: this.createUnusablePasswordHash(),
|
||||
fullName: profile.name,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.socialAccount.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
provider: profile.provider,
|
||||
providerAccountId: profile.providerAccountId,
|
||||
email: profile.email,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private normalizeProvider(provider: string): SocialProvider {
|
||||
if (provider === "google" || provider === "apple") return provider;
|
||||
throw new BadRequestException("Unsupported social login provider.");
|
||||
}
|
||||
|
||||
private getRedirectUri() {
|
||||
return process.env.SOCIAL_AUTH_REDIRECT_URI || `${process.env.APP_URL || "http://localhost:3052"}/auth/social/callback`;
|
||||
}
|
||||
|
||||
private safeNext(next: string) {
|
||||
return next.startsWith("/") && !next.startsWith("//") ? next : "/app";
|
||||
}
|
||||
|
||||
private signState(payload: { provider: SocialProvider; next: string; exp: number }) {
|
||||
const body = this.base64url(JSON.stringify(payload));
|
||||
const signature = this.base64url(crypto.createHmac("sha256", this.getStateSecret()).update(body).digest());
|
||||
return `${body}.${signature}`;
|
||||
}
|
||||
|
||||
private verifyState(state: string): { provider: SocialProvider; next: string; exp: number } {
|
||||
const [body, signature] = state.split(".");
|
||||
if (!body || !signature) throw new BadRequestException("Invalid OAuth state.");
|
||||
const expected = this.base64url(crypto.createHmac("sha256", this.getStateSecret()).update(body).digest());
|
||||
const signatureBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (signatureBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
|
||||
throw new BadRequestException("Invalid OAuth state signature.");
|
||||
}
|
||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as { provider: SocialProvider; next: string; exp: number };
|
||||
if (payload.exp < Date.now()) throw new BadRequestException("OAuth state expired.");
|
||||
return payload;
|
||||
}
|
||||
|
||||
private getStateSecret() {
|
||||
return process.env.JWT_SECRET || "social_auth_state_secret";
|
||||
}
|
||||
|
||||
private createUnusablePasswordHash() {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.pbkdf2Sync(crypto.randomBytes(32).toString("hex"), salt, 100_000, 64, "sha512").toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
private createAppleClientSecret() {
|
||||
const teamId = process.env.APPLE_TEAM_ID;
|
||||
const keyId = process.env.APPLE_KEY_ID;
|
||||
const clientId = process.env.APPLE_CLIENT_ID;
|
||||
const rawPrivateKey = process.env.APPLE_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
||||
if (!teamId || !keyId || !clientId || !rawPrivateKey) {
|
||||
throw new BadRequestException("Apple social login is not configured.");
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = this.base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
|
||||
const payload = this.base64url(JSON.stringify({
|
||||
iss: teamId,
|
||||
iat: now,
|
||||
exp: now + 180 * 24 * 60 * 60,
|
||||
aud: "https://appleid.apple.com",
|
||||
sub: clientId,
|
||||
}));
|
||||
const data = `${header}.${payload}`;
|
||||
const derSignature = crypto.sign("sha256", Buffer.from(data), rawPrivateKey);
|
||||
return `${data}.${this.base64url(this.derToJose(derSignature, 64))}`;
|
||||
}
|
||||
|
||||
private async verifyAppleIdToken(idToken: string): Promise<{ sub?: string; email?: string; aud?: string; iss?: string; exp?: number }> {
|
||||
const [rawHeader, rawPayload, rawSignature] = idToken.split(".");
|
||||
if (!rawHeader || !rawPayload || !rawSignature) throw new UnauthorizedException("Invalid Apple identity token.");
|
||||
const header = JSON.parse(Buffer.from(rawHeader, "base64url").toString("utf8")) as { kid?: string; alg?: string };
|
||||
const payload = JSON.parse(Buffer.from(rawPayload, "base64url").toString("utf8")) as { sub?: string; email?: string; aud?: string; iss?: string; exp?: number };
|
||||
|
||||
const response = await fetch("https://appleid.apple.com/auth/keys");
|
||||
const data = await response.json() as { keys: AppleJwk[] };
|
||||
const jwk = data.keys.find((key) => key.kid === header.kid && key.alg === "RS256");
|
||||
if (!jwk) throw new UnauthorizedException("Apple signing key not found.");
|
||||
|
||||
const key = crypto.createPublicKey({ key: jwk, format: "jwk" });
|
||||
const isValid = crypto.verify(
|
||||
"RSA-SHA256",
|
||||
Buffer.from(`${rawHeader}.${rawPayload}`),
|
||||
key,
|
||||
Buffer.from(rawSignature, "base64url"),
|
||||
);
|
||||
if (!isValid) throw new UnauthorizedException("Invalid Apple identity token signature.");
|
||||
if (payload.iss !== "https://appleid.apple.com" || payload.aud !== process.env.APPLE_CLIENT_ID || !payload.exp || payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
throw new UnauthorizedException("Invalid Apple identity token claims.");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private derToJose(signature: Buffer, size: number) {
|
||||
let offset = 0;
|
||||
if (signature[offset] !== 0x30) throw new BadRequestException("Invalid Apple signature.");
|
||||
offset += 1;
|
||||
|
||||
const sequenceLength = signature[offset];
|
||||
offset += 1;
|
||||
if (sequenceLength + 2 !== signature.length) throw new BadRequestException("Invalid Apple signature.");
|
||||
|
||||
const readInteger = () => {
|
||||
if (signature[offset] !== 0x02) throw new BadRequestException("Invalid Apple signature.");
|
||||
offset += 1;
|
||||
const length = signature[offset];
|
||||
offset += 1;
|
||||
let value = signature.subarray(offset, offset + length);
|
||||
offset += length;
|
||||
while (value.length > 0 && value[0] === 0) value = value.subarray(1);
|
||||
if (value.length > size / 2) throw new BadRequestException("Invalid Apple signature.");
|
||||
return Buffer.concat([Buffer.alloc(size / 2 - value.length), value]);
|
||||
};
|
||||
|
||||
return Buffer.concat([readInteger(), readInteger()]);
|
||||
}
|
||||
|
||||
private base64url(value: string | Buffer) {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { EncryptionService } from "./encryption.service";
|
||||
import { OpaqueIdService } from "./opaque-id.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService],
|
||||
providers: [EncryptionService, OpaqueIdService],
|
||||
exports: [EncryptionService, OpaqueIdService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
4
src/common/decorators/roles.decorator.ts
Normal file
4
src/common/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const ROLES_KEY = "roles";
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@ -7,6 +7,10 @@ import {
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Request } from "express";
|
||||
import * as crypto from "crypto";
|
||||
import { AbuseService } from "../../abuse/abuse.service";
|
||||
import { requestContextFrom } from "../../abuse/abuse.types";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
export const IS_PUBLIC_KEY = "isPublic";
|
||||
|
||||
@ -15,9 +19,11 @@ export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly reflector: Reflector,
|
||||
private readonly abuseService: AbuseService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
@ -33,12 +39,32 @@ export class JwtAuthGuard implements CanActivate {
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string }>(token, {
|
||||
const payload = this.jwtService.verify<{ sub: string; sid?: string }>(token, {
|
||||
secret: process.env.JWT_SECRET,
|
||||
});
|
||||
(request as Request & { user: { sub: string } }).user = payload;
|
||||
if (!payload.sid) {
|
||||
throw new UnauthorizedException("Session-bound token required.");
|
||||
}
|
||||
const session = await this.prisma.session.findUnique({ where: { id: payload.sid } });
|
||||
const context = requestContextFrom(request);
|
||||
if (
|
||||
!session ||
|
||||
session.userId !== payload.sub ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt < new Date() ||
|
||||
session.ipHash !== this.hashBindingValue(context.ipAddress ?? "unknown-ip") ||
|
||||
session.userAgentHash !== this.hashBindingValue(context.userAgent ?? "unknown-user-agent")
|
||||
) {
|
||||
throw new UnauthorizedException("Invalid session binding.");
|
||||
}
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { lastSeenAt: new Date() },
|
||||
});
|
||||
(request as Request & { user: { sub: string; sid: string } }).user = { sub: payload.sub, sid: payload.sid };
|
||||
return true;
|
||||
} catch {
|
||||
await this.abuseService.recordInvalidToken(requestContextFrom(request));
|
||||
throw new UnauthorizedException("Invalid or expired token.");
|
||||
}
|
||||
}
|
||||
@ -50,4 +76,8 @@ export class JwtAuthGuard implements CanActivate {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private hashBindingValue(value: string): string {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
}
|
||||
|
||||
46
src/common/guards/roles.guard.ts
Normal file
46
src/common/guards/roles.guard.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { ROLES_KEY } from "../decorators/roles.decorator";
|
||||
import { IS_PUBLIC_KEY } from "./jwt-auth.guard";
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles?.length) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request & { user?: { sub: string } }>();
|
||||
const userId = request.user?.sub;
|
||||
if (!userId) throw new ForbiddenException("Authentication required.");
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true },
|
||||
});
|
||||
if (!user || !requiredRoles.includes(user.role)) {
|
||||
throw new ForbiddenException("Insufficient role.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
31
src/common/opaque-id.service.ts
Normal file
31
src/common/opaque-id.service.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { EncryptionService } from "./encryption.service";
|
||||
|
||||
type OpaqueIdPayload = {
|
||||
kind: string;
|
||||
userId: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OpaqueIdService {
|
||||
constructor(private readonly encryption: EncryptionService) {}
|
||||
|
||||
encode(kind: string, userId: string, id: string) {
|
||||
const encrypted = this.encryption.encrypt(JSON.stringify({ kind, userId, id }));
|
||||
return Buffer.from(encrypted, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
decode(kind: string, userId: string, token: string) {
|
||||
try {
|
||||
const encrypted = Buffer.from(token, "base64url").toString("utf8");
|
||||
const payload = JSON.parse(this.encryption.decrypt(encrypted)) as OpaqueIdPayload;
|
||||
if (payload.kind !== kind || payload.userId !== userId || !payload.id) {
|
||||
throw new Error("Opaque ID scope mismatch.");
|
||||
}
|
||||
return payload.id;
|
||||
} catch {
|
||||
throw new BadRequestException("Invalid resource identifier.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { Request, Response } from "express";
|
||||
@Catch()
|
||||
export class SentryExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger("ExceptionFilter");
|
||||
private readonly sentryEnabled = Boolean(process.env.SENTRY_DSN);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
@ -23,17 +24,35 @@ export class SentryExceptionFilter implements ExceptionFilter {
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// Only report 5xx errors to Sentry
|
||||
if (status >= 500 && process.env.SENTRY_DSN) {
|
||||
Sentry.captureException(exception, {
|
||||
extra: {
|
||||
url: request.url,
|
||||
const userId = (request as Request & { user?: { sub: string } }).user?.sub;
|
||||
|
||||
// Only report server-side failures to Sentry. Client errors remain local
|
||||
// responses and do not create noisy operational alerts.
|
||||
if (status >= 500 && this.sentryEnabled) {
|
||||
const eventId = Sentry.withScope((scope) => {
|
||||
if (userId) {
|
||||
scope.setUser({ id: userId });
|
||||
}
|
||||
scope.setTag("http.status_code", String(status));
|
||||
scope.setContext("request", {
|
||||
method: request.method,
|
||||
userId: (request as Request & { user?: { sub: string } }).user?.sub,
|
||||
},
|
||||
path: request.path,
|
||||
query: request.query,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers["user-agent"],
|
||||
});
|
||||
return Sentry.captureException(exception);
|
||||
});
|
||||
|
||||
this.logger.error(
|
||||
`Captured server exception in Sentry: ${eventId}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
} else if (status >= 500) {
|
||||
this.logger.error(exception);
|
||||
this.logger.error(
|
||||
"Unhandled server exception",
|
||||
exception instanceof Error ? exception.stack : String(exception),
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
@ -50,7 +69,7 @@ export class SentryExceptionFilter implements ExceptionFilter {
|
||||
? message
|
||||
: (message as { message?: string }).message ?? "Internal server error",
|
||||
},
|
||||
meta: { timestamp: new Date().toISOString(), version: "1.0" },
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ export const envValidationSchema = Joi.object({
|
||||
DATABASE_URL: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().min(32).required(),
|
||||
JWT_REFRESH_SECRET: Joi.string().min(32).required(),
|
||||
JWT_ACCESS_TTL_SECONDS: Joi.number().integer().min(30).max(60).default(60),
|
||||
ENCRYPTION_KEY: Joi.string().length(64).required(),
|
||||
|
||||
PLAID_CLIENT_ID: Joi.string().required(),
|
||||
@ -12,11 +13,26 @@ export const envValidationSchema = Joi.object({
|
||||
PLAID_PRODUCTS: Joi.string().default("transactions"),
|
||||
PLAID_COUNTRY_CODES: Joi.string().default("US"),
|
||||
PLAID_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
||||
PLAID_WEBHOOK_URL: Joi.string().uri().optional().allow(""),
|
||||
PLAID_VERIFY_WEBHOOKS: Joi.boolean().default(true),
|
||||
|
||||
TELLER_APPLICATION_ID: Joi.string().optional().allow(""),
|
||||
TELLER_ENV: Joi.string().valid("sandbox", "development", "production").default("sandbox"),
|
||||
TELLER_PRODUCTS: Joi.string().default("transactions,balance"),
|
||||
TELLER_API_BASE_URL: Joi.string().uri().default("https://api.teller.io"),
|
||||
TELLER_CERT_PATH: Joi.string().optional().allow(""),
|
||||
TELLER_KEY_PATH: Joi.string().optional().allow(""),
|
||||
TELLER_CERT_PEM: Joi.string().optional().allow(""),
|
||||
TELLER_KEY_PEM: Joi.string().optional().allow(""),
|
||||
|
||||
SUPABASE_URL: Joi.string().optional().allow(""),
|
||||
SUPABASE_SERVICE_KEY: Joi.string().optional().allow(""),
|
||||
SUPABASE_ANON_KEY: Joi.string().optional().allow(""),
|
||||
|
||||
EXPORT_OBJECT_STORAGE_DRIVER: Joi.string().valid("local", "supabase").default("local"),
|
||||
EXPORT_OBJECT_STORAGE_BUCKET: Joi.string().default("ledgerone-exports"),
|
||||
EXPORT_OBJECT_STORAGE_DIR: Joi.string().default("data/export-objects"),
|
||||
|
||||
STRIPE_SECRET_KEY: Joi.string().optional().allow(""),
|
||||
STRIPE_WEBHOOK_SECRET: Joi.string().optional().allow(""),
|
||||
STRIPE_PRICE_PRO: Joi.string().optional().allow(""),
|
||||
@ -35,11 +51,27 @@ export const envValidationSchema = Joi.object({
|
||||
CORS_ORIGIN: Joi.string().default("http://localhost:3052"),
|
||||
|
||||
AUTO_SYNC_ENABLED: Joi.boolean().default(true),
|
||||
AUTO_SYNC_INTERVAL_MINUTES: Joi.number().default(15),
|
||||
AUTO_SYNC_INTERVAL_MINUTES: Joi.number().integer().min(1).default(15),
|
||||
AUTO_SYNC_STALE_MINUTES: Joi.number().integer().min(1).optional(),
|
||||
AUTO_SYNC_LOOKBACK_DAYS: Joi.number().integer().min(1).max(365).default(7),
|
||||
AUTO_SYNC_MAX_USERS_PER_RUN: Joi.number().integer().min(1).max(500).default(25),
|
||||
|
||||
SENTRY_DSN: Joi.string().optional().allow(""),
|
||||
SENTRY_DSN: Joi.when("NODE_ENV", {
|
||||
is: "production",
|
||||
then: Joi.string().uri().required(),
|
||||
otherwise: Joi.string().uri().optional().allow(""),
|
||||
}),
|
||||
SENTRY_TRACES_SAMPLE_RATE: Joi.number().min(0).max(1).default(0),
|
||||
|
||||
GOOGLE_CLIENT_ID: Joi.string().optional().allow(""),
|
||||
GOOGLE_CLIENT_SECRET: Joi.string().optional().allow(""),
|
||||
GOOGLE_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
||||
|
||||
SOCIAL_AUTH_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
||||
GOOGLE_AUTH_CLIENT_ID: Joi.string().optional().allow(""),
|
||||
GOOGLE_AUTH_CLIENT_SECRET: Joi.string().optional().allow(""),
|
||||
APPLE_CLIENT_ID: Joi.string().optional().allow(""),
|
||||
APPLE_TEAM_ID: Joi.string().optional().allow(""),
|
||||
APPLE_KEY_ID: Joi.string().optional().allow(""),
|
||||
APPLE_PRIVATE_KEY: Joi.string().optional().allow(""),
|
||||
});
|
||||
|
||||
@ -77,4 +77,28 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
src/exports/export-object-storage.service.ts
Normal file
101
src/exports/export-object-storage.service.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
import { promises as fs } from "fs";
|
||||
import * as path from "path";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
export type ExportObjectInput = {
|
||||
userId: string;
|
||||
format: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
content: string | Buffer;
|
||||
};
|
||||
|
||||
export type StoredExportObject = {
|
||||
provider: "local" | "supabase";
|
||||
key: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type LoadedExportObject = {
|
||||
content: Buffer;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ExportObjectStorageService {
|
||||
private readonly driver = (process.env.EXPORT_OBJECT_STORAGE_DRIVER || "local").toLowerCase();
|
||||
private readonly bucket = process.env.EXPORT_OBJECT_STORAGE_BUCKET || "ledgerone-exports";
|
||||
private readonly localRoot = path.resolve(process.cwd(), process.env.EXPORT_OBJECT_STORAGE_DIR || "data/export-objects");
|
||||
private supabaseClient?: SupabaseClient;
|
||||
|
||||
async store(input: ExportObjectInput): Promise<StoredExportObject> {
|
||||
const content = Buffer.isBuffer(input.content) ? input.content : Buffer.from(input.content, "utf8");
|
||||
const key = this.createObjectKey(input.userId, input.format, input.fileName);
|
||||
|
||||
if (this.driver === "supabase") {
|
||||
const client = this.getSupabaseClient();
|
||||
const { error } = await client.storage.from(this.bucket).upload(key, content, {
|
||||
contentType: input.mimeType,
|
||||
upsert: false,
|
||||
});
|
||||
if (error) {
|
||||
throw new InternalServerErrorException(`Failed to store export object: ${error.message}`);
|
||||
}
|
||||
return { provider: "supabase", key, sizeBytes: content.byteLength };
|
||||
}
|
||||
|
||||
const targetPath = this.localPathForKey(key);
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, content);
|
||||
return { provider: "local", key, sizeBytes: content.byteLength };
|
||||
}
|
||||
|
||||
async load(provider: string | null | undefined, key: string | null | undefined): Promise<LoadedExportObject> {
|
||||
if (!key) throw new NotFoundException("Stored export object not found.");
|
||||
|
||||
if (provider === "supabase") {
|
||||
const client = this.getSupabaseClient();
|
||||
const { data, error } = await client.storage.from(this.bucket).download(key);
|
||||
if (error || !data) {
|
||||
throw new NotFoundException("Stored export object not found.");
|
||||
}
|
||||
return { content: Buffer.from(await data.arrayBuffer()) };
|
||||
}
|
||||
|
||||
try {
|
||||
return { content: await fs.readFile(this.localPathForKey(key)) };
|
||||
} catch {
|
||||
throw new NotFoundException("Stored export object not found.");
|
||||
}
|
||||
}
|
||||
|
||||
private createObjectKey(userId: string, format: string, fileName: string) {
|
||||
const now = new Date();
|
||||
const yyyy = now.getUTCFullYear();
|
||||
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(now.getUTCDate()).padStart(2, "0");
|
||||
const safeName = fileName.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
return `exports/${userId}/${yyyy}/${mm}/${dd}/${crypto.randomUUID()}-${format}-${safeName}`;
|
||||
}
|
||||
|
||||
private localPathForKey(key: string) {
|
||||
const normalizedKey = key.replace(/\\/g, "/");
|
||||
const targetPath = path.resolve(this.localRoot, normalizedKey);
|
||||
if (!targetPath.startsWith(this.localRoot)) {
|
||||
throw new InternalServerErrorException("Invalid export object key.");
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
private getSupabaseClient() {
|
||||
if (this.supabaseClient) return this.supabaseClient;
|
||||
const url = process.env.SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY;
|
||||
if (!url || !serviceKey) {
|
||||
throw new InternalServerErrorException("Supabase export storage is not configured.");
|
||||
}
|
||||
this.supabaseClient = createClient(url, serviceKey, { auth: { persistSession: false } });
|
||||
return this.supabaseClient;
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,55 @@
|
||||
import { Controller, Get, Post, Query } from "@nestjs/common";
|
||||
import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { ok } from "../common/response";
|
||||
import { ExportsService } from "./exports.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { requestContextFrom } from "../abuse/abuse.types";
|
||||
import { RequiredPlan, SubscriptionGuard } from "../stripe/subscription.guard";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
|
||||
@Controller("exports")
|
||||
@RequiredPlan("pro")
|
||||
@UseGuards(SubscriptionGuard)
|
||||
export class ExportsController {
|
||||
constructor(private readonly exportsService: ExportsService) {}
|
||||
|
||||
@Get("csv")
|
||||
async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
|
||||
const data = await this.exportsService.exportCsv(userId, query);
|
||||
async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
||||
const data = await this.exportsService.createSignedDownloadUrl(userId, "csv", query, requestContextFrom(req));
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("json")
|
||||
async exportJson(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
||||
const data = await this.exportsService.createSignedDownloadUrl(userId, "json", query, requestContextFrom(req));
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("xlsx")
|
||||
async exportXlsx(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
||||
const data = await this.exportsService.createSignedDownloadUrl(userId, "xlsx", query, requestContextFrom(req));
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("pdf")
|
||||
async exportPdf(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
||||
const data = await this.exportsService.createSignedDownloadUrl(userId, "pdf", query, requestContextFrom(req));
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get("download/:token")
|
||||
async downloadSignedExport(@Param("token") token: string, @Req() req: Request, @Res() res: Response) {
|
||||
const file = await this.exportsService.consumeSignedDownloadUrl(token, requestContextFrom(req));
|
||||
res.setHeader("Content-Type", file.mimeType);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${file.fileName.replace(/"/g, "")}"`);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.send(file.content);
|
||||
}
|
||||
|
||||
@Post("sheets")
|
||||
async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
|
||||
const data = await this.exportsService.exportSheets(userId, query);
|
||||
async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
||||
const data = await this.exportsService.exportSheets(userId, query, requestContextFrom(req));
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ExportsController } from "./exports.controller";
|
||||
import { ExportsService } from "./exports.service";
|
||||
import { ExportObjectStorageService } from "./export-object-storage.service";
|
||||
import { StripeModule } from "../stripe/stripe.module";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule],
|
||||
controllers: [ExportsController],
|
||||
providers: [ExportsService],
|
||||
providers: [ExportsService, ExportObjectStorageService],
|
||||
exports: [ExportsService],
|
||||
})
|
||||
export class ExportsModule {}
|
||||
|
||||
@ -1,12 +1,32 @@
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, GoneException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { google } from "googleapis";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import * as XLSX from "xlsx";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { AbuseService } from "../abuse/abuse.service";
|
||||
import { RequestContext } from "../abuse/abuse.types";
|
||||
import { ExportObjectStorageService } from "./export-object-storage.service";
|
||||
|
||||
type ExportWatermark = {
|
||||
label: string;
|
||||
userId: string;
|
||||
generatedAt: string;
|
||||
traceId: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ExportsService {
|
||||
private readonly logger = new Logger(ExportsService.name);
|
||||
private readonly syncSheetTitle = "LedgerOne Sync";
|
||||
private readonly downloadTokenTtlMs = 2 * 60 * 1000;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly abuseService: AbuseService,
|
||||
private readonly exportObjectStorage: ExportObjectStorageService,
|
||||
) {}
|
||||
|
||||
private toCsv(rows: Array<Record<string, string>>) {
|
||||
if (!rows.length) return "";
|
||||
@ -19,6 +39,102 @@ export class ExportsService {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private createWatermark(userId: string): ExportWatermark {
|
||||
const generatedAt = new Date().toISOString();
|
||||
const traceId = crypto.randomBytes(12).toString("hex");
|
||||
const label = "LedgerOne Export Watermark";
|
||||
return {
|
||||
label,
|
||||
userId,
|
||||
generatedAt,
|
||||
traceId,
|
||||
text: `${label} | user=${userId} | generated=${generatedAt} | trace=${traceId}`,
|
||||
};
|
||||
}
|
||||
|
||||
private applyWatermarkToRows(rows: ReturnType<typeof this.toRows>, watermark: ExportWatermark) {
|
||||
const watermarkedRows = rows.map((row) => ({ ...row, watermark: watermark.text }));
|
||||
if (watermarkedRows.length) return watermarkedRows;
|
||||
return [{
|
||||
id: "",
|
||||
date: "",
|
||||
description: "",
|
||||
amount: "",
|
||||
category: "",
|
||||
notes: "",
|
||||
attribution: "",
|
||||
splitMode: "",
|
||||
splitMinePercent: "",
|
||||
splitYoursPercent: "",
|
||||
splitMineAmount: "",
|
||||
splitYoursAmount: "",
|
||||
hidden: "",
|
||||
source: "",
|
||||
watermark: watermark.text,
|
||||
}];
|
||||
}
|
||||
|
||||
private escapePdfText(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
||||
}
|
||||
|
||||
private buildPdf(rows: Array<Record<string, string>>, watermark: ExportWatermark) {
|
||||
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"];
|
||||
const lines = [
|
||||
"LedgerOne Export",
|
||||
`Generated ${new Date().toISOString()}`,
|
||||
`Rows ${rows.length}`,
|
||||
watermark.text,
|
||||
"",
|
||||
headers.join(" | "),
|
||||
...rows.map((row) => headers.map((header) => row[header] ?? "").join(" | ")),
|
||||
].map((line) => line.length > 118 ? `${line.slice(0, 115)}...` : line);
|
||||
|
||||
const pageHeight = 792;
|
||||
const margin = 36;
|
||||
const lineHeight = 11;
|
||||
const fontSize = 8;
|
||||
const linesPerPage = Math.floor((pageHeight - margin * 2) / lineHeight);
|
||||
const pages: string[] = [];
|
||||
for (let index = 0; index < lines.length; index += linesPerPage) {
|
||||
const pageLines = lines.slice(index, index + linesPerPage);
|
||||
const commands = ["BT", `/F1 ${fontSize} Tf`];
|
||||
pageLines.forEach((line, lineIndex) => {
|
||||
const y = pageHeight - margin - lineIndex * lineHeight;
|
||||
commands.push(`1 0 0 1 ${margin} ${y} Tm (${this.escapePdfText(line)}) Tj`);
|
||||
});
|
||||
commands.push(`1 0 0 1 ${margin} ${margin - 14} Tm (${this.escapePdfText(watermark.text)}) Tj`);
|
||||
commands.push("ET");
|
||||
pages.push(commands.join("\n"));
|
||||
}
|
||||
|
||||
const objects: string[] = [];
|
||||
const pageObjectIds = pages.map((_, index) => 4 + index * 2);
|
||||
objects[0] = "<< /Type /Catalog /Pages 2 0 R >>";
|
||||
objects[1] = `<< /Type /Pages /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pages.length} >>`;
|
||||
objects[2] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
|
||||
pages.forEach((content, index) => {
|
||||
const pageObjectId = 4 + index * 2;
|
||||
const contentObjectId = pageObjectId + 1;
|
||||
objects[pageObjectId - 1] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentObjectId} 0 R >>`;
|
||||
objects[contentObjectId - 1] = `<< /Length ${Buffer.byteLength(content, "binary")} >>\nstream\n${content}\nendstream`;
|
||||
});
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets = [0];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, "binary"));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
const xrefOffset = Buffer.byteLength(pdf, "binary");
|
||||
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let index = 1; index < offsets.length; index += 1) {
|
||||
pdf += `${String(offsets[index]).padStart(10, "0")} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||
return Buffer.from(pdf, "binary");
|
||||
}
|
||||
|
||||
private async getTransactions(
|
||||
userId: string,
|
||||
filters: Record<string, string>,
|
||||
@ -50,7 +166,14 @@ export class ExportsService {
|
||||
}
|
||||
return this.prisma.transactionRaw.findMany({
|
||||
where,
|
||||
include: { derived: true },
|
||||
include: {
|
||||
derived: true,
|
||||
account: {
|
||||
select: {
|
||||
ownershipType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
@ -64,30 +187,356 @@ export class ExportsService {
|
||||
amount: Number(tx.amount).toFixed(2),
|
||||
category: tx.derived?.userCategory ?? "",
|
||||
notes: tx.derived?.userNotes ?? "",
|
||||
attribution: tx.derived?.attribution ?? this.defaultAttributionForAccount(tx.account?.ownershipType),
|
||||
splitMode: tx.derived?.splitMode ?? "none",
|
||||
splitMinePercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"),
|
||||
splitYoursPercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"),
|
||||
splitMineAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"),
|
||||
splitYoursAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"),
|
||||
hidden: tx.derived?.isHidden ? "true" : "false",
|
||||
source: tx.source,
|
||||
}));
|
||||
}
|
||||
|
||||
async exportCsv(userId: string, filters: Record<string, string> = {}) {
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
const csv = this.toCsv(rows);
|
||||
|
||||
await this.prisma.exportLog.create({
|
||||
data: { userId, filters, rowCount: rows.length },
|
||||
});
|
||||
|
||||
return { status: "ready", csv, rowCount: rows.length };
|
||||
private defaultAttributionForAccount(ownershipType?: string | null) {
|
||||
if (ownershipType === "joint") return "ours";
|
||||
if (ownershipType === "theirs") return "yours";
|
||||
return "mine";
|
||||
}
|
||||
|
||||
async exportSheets(userId: string, filters: Record<string, string> = {}) {
|
||||
// Get the user's Google connection
|
||||
private splitPercent(mode: string | null | undefined, value: unknown, side: "mine" | "yours") {
|
||||
if (!mode || mode === "none") return side === "mine" ? "100.00" : "0.00";
|
||||
if (mode === "equal") return "50.00";
|
||||
return Number(value ?? 0).toFixed(2);
|
||||
}
|
||||
|
||||
private splitAmount(amount: number, mode: string | null | undefined, value: unknown, side: "mine" | "yours") {
|
||||
const percent = Number(this.splitPercent(mode, value, side));
|
||||
return (amount * (percent / 100)).toFixed(2);
|
||||
}
|
||||
|
||||
private hashContent(content: string | Buffer) {
|
||||
return crypto.createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
private hashToken(token: string) {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
private async createExportLog(
|
||||
userId: string,
|
||||
filters: Record<string, string>,
|
||||
rowCount: number,
|
||||
audit: {
|
||||
format: string;
|
||||
destination?: string;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
fileContent?: string | Buffer;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
context?: RequestContext,
|
||||
) {
|
||||
await this.prisma.exportLog.create({
|
||||
data: {
|
||||
userId,
|
||||
format: audit.format,
|
||||
destination: audit.destination ?? "download",
|
||||
filters,
|
||||
rowCount,
|
||||
fileName: audit.fileName,
|
||||
mimeType: audit.mimeType,
|
||||
fileHash: audit.fileContent ? this.hashContent(audit.fileContent) : undefined,
|
||||
ipAddress: context?.ipAddress,
|
||||
userAgent: context?.userAgent,
|
||||
metadata: (audit.metadata ?? {}) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async buildCsvFile(userId: string, filters: Record<string, string> = {}) {
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
const watermark = this.createWatermark(userId);
|
||||
const csv = this.toCsv(this.applyWatermarkToRows(rows, watermark));
|
||||
const fileName = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
|
||||
return {
|
||||
content: csv,
|
||||
fileName,
|
||||
mimeType: "text/csv",
|
||||
rowCount: rows.length,
|
||||
watermark,
|
||||
};
|
||||
}
|
||||
|
||||
async exportCsv(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
||||
const file = await this.buildCsvFile(userId, filters);
|
||||
|
||||
await this.createExportLog(userId, filters, file.rowCount, {
|
||||
format: "csv",
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
fileContent: file.content,
|
||||
metadata: { watermark: file.watermark },
|
||||
}, context);
|
||||
await this.abuseService.recordExportActivity(userId, file.rowCount, filters, context);
|
||||
|
||||
return { status: "ready", csv: file.content, fileName: file.fileName, rowCount: file.rowCount };
|
||||
}
|
||||
|
||||
private async buildJsonFile(userId: string, filters: Record<string, string> = {}) {
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
const watermark = this.createWatermark(userId);
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
rowCount: rows.length,
|
||||
filters,
|
||||
watermark,
|
||||
transactions: rows,
|
||||
};
|
||||
const buffer = Buffer.from(JSON.stringify(payload, null, 2), "utf8");
|
||||
return {
|
||||
content: buffer,
|
||||
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
mimeType: "application/json",
|
||||
rowCount: rows.length,
|
||||
watermark,
|
||||
};
|
||||
}
|
||||
|
||||
async exportJson(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
||||
const file = await this.buildJsonFile(userId, filters);
|
||||
|
||||
await this.createExportLog(userId, filters, file.rowCount, {
|
||||
format: "json",
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
fileContent: file.content,
|
||||
metadata: { watermark: file.watermark },
|
||||
}, context);
|
||||
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "json" }, context);
|
||||
|
||||
return {
|
||||
status: "ready",
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
base64: file.content.toString("base64"),
|
||||
rowCount: file.rowCount,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildXlsxFile(userId: string, filters: Record<string, string> = {}) {
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
const watermark = this.createWatermark(userId);
|
||||
const watermarkedRows = this.applyWatermarkToRows(rows, watermark);
|
||||
const headers = this.getHeaders(watermarkedRows);
|
||||
const worksheet = XLSX.utils.json_to_sheet(watermarkedRows, { header: headers });
|
||||
const watermarkWorksheet = XLSX.utils.json_to_sheet([{
|
||||
label: watermark.label,
|
||||
userId: watermark.userId,
|
||||
generatedAt: watermark.generatedAt,
|
||||
traceId: watermark.traceId,
|
||||
watermark: watermark.text,
|
||||
}]);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "LedgerOne Export");
|
||||
XLSX.utils.book_append_sheet(workbook, watermarkWorksheet, "Watermark");
|
||||
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||
return {
|
||||
content: buffer,
|
||||
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
rowCount: rows.length,
|
||||
watermark,
|
||||
};
|
||||
}
|
||||
|
||||
async exportXlsx(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
||||
const file = await this.buildXlsxFile(userId, filters);
|
||||
|
||||
await this.createExportLog(userId, filters, file.rowCount, {
|
||||
format: "xlsx",
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
fileContent: file.content,
|
||||
metadata: { watermark: file.watermark },
|
||||
}, context);
|
||||
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "xlsx" }, context);
|
||||
|
||||
return {
|
||||
status: "ready",
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
base64: file.content.toString("base64"),
|
||||
rowCount: file.rowCount,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildPdfFile(userId: string, filters: Record<string, string> = {}) {
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
const watermark = this.createWatermark(userId);
|
||||
const buffer = this.buildPdf(rows, watermark);
|
||||
return {
|
||||
content: buffer,
|
||||
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
rowCount: rows.length,
|
||||
watermark,
|
||||
};
|
||||
}
|
||||
|
||||
async exportPdf(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
||||
const file = await this.buildPdfFile(userId, filters);
|
||||
|
||||
await this.createExportLog(userId, filters, file.rowCount, {
|
||||
format: "pdf",
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
fileContent: file.content,
|
||||
metadata: { watermark: file.watermark },
|
||||
}, context);
|
||||
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "pdf" }, context);
|
||||
|
||||
return {
|
||||
status: "ready",
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
base64: file.content.toString("base64"),
|
||||
rowCount: file.rowCount,
|
||||
};
|
||||
}
|
||||
|
||||
async createSignedDownloadUrl(
|
||||
userId: string,
|
||||
format: "csv" | "json" | "xlsx" | "pdf",
|
||||
filters: Record<string, string> = {},
|
||||
context?: RequestContext,
|
||||
) {
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + this.downloadTokenTtlMs);
|
||||
const file = await this.buildDownloadFile(userId, format, filters);
|
||||
const stored = await this.exportObjectStorage.store({
|
||||
userId,
|
||||
format,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
content: file.content,
|
||||
});
|
||||
const fileHash = this.hashContent(file.content);
|
||||
|
||||
await this.prisma.exportDownloadToken.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: this.hashToken(token),
|
||||
format,
|
||||
filters,
|
||||
storageProvider: stored.provider,
|
||||
storageKey: stored.key,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
rowCount: file.rowCount,
|
||||
fileHash,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: "signed",
|
||||
downloadUrl: `/api/exports/download/${token}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
singleUse: true,
|
||||
expiresInSeconds: Math.floor(this.downloadTokenTtlMs / 1000),
|
||||
storageProvider: stored.provider,
|
||||
};
|
||||
}
|
||||
|
||||
async consumeSignedDownloadUrl(token: string, context?: RequestContext) {
|
||||
const tokenHash = this.hashToken(token);
|
||||
const record = await this.prisma.exportDownloadToken.findUnique({ where: { tokenHash } });
|
||||
if (!record) throw new NotFoundException("Download link not found.");
|
||||
if (record.usedAt) throw new GoneException("Download link has already been used.");
|
||||
if (record.expiresAt < new Date()) throw new GoneException("Download link has expired.");
|
||||
|
||||
const consumed = await this.prisma.exportDownloadToken.updateMany({
|
||||
where: { id: record.id, usedAt: null, expiresAt: { gt: new Date() } },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (consumed.count !== 1) {
|
||||
throw new GoneException("Download link is no longer valid.");
|
||||
}
|
||||
|
||||
const filters = record.filters as unknown as Record<string, string>;
|
||||
const format = record.format as "csv" | "json" | "xlsx" | "pdf";
|
||||
const storedFile = await this.exportObjectStorage.load(record.storageProvider, record.storageKey);
|
||||
const file = {
|
||||
content: storedFile.content,
|
||||
fileName: record.fileName ?? `ledgerone-export-${new Date().toISOString().slice(0, 10)}.${format}`,
|
||||
mimeType: record.mimeType ?? this.mimeTypeForFormat(format),
|
||||
rowCount: record.rowCount ?? 0,
|
||||
};
|
||||
|
||||
await this.createExportLog(record.userId, filters, file.rowCount, {
|
||||
format,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
fileContent: file.content,
|
||||
metadata: {
|
||||
signedUrl: true,
|
||||
tokenId: record.id,
|
||||
expiresAt: record.expiresAt.toISOString(),
|
||||
storageProvider: record.storageProvider,
|
||||
storageKey: record.storageKey,
|
||||
precomputedFileHash: record.fileHash,
|
||||
},
|
||||
}, context);
|
||||
await this.abuseService.recordExportActivity(record.userId, file.rowCount, { ...filters, format, signedUrl: "true" }, context);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private async buildDownloadFile(userId: string, format: "csv" | "json" | "xlsx" | "pdf", filters: Record<string, string>) {
|
||||
if (format === "csv") return this.buildCsvFile(userId, filters);
|
||||
if (format === "json") return this.buildJsonFile(userId, filters);
|
||||
if (format === "xlsx") return this.buildXlsxFile(userId, filters);
|
||||
if (format === "pdf") return this.buildPdfFile(userId, filters);
|
||||
throw new BadRequestException("Unsupported export format.");
|
||||
}
|
||||
|
||||
private mimeTypeForFormat(format: "csv" | "json" | "xlsx" | "pdf") {
|
||||
if (format === "csv") return "text/csv";
|
||||
if (format === "json") return "application/json";
|
||||
if (format === "xlsx") return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
if (format === "pdf") return "application/pdf";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private getHeaders(rows: ReturnType<typeof this.toRows>) {
|
||||
return rows.length
|
||||
? Object.keys(rows[0])
|
||||
: ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"];
|
||||
}
|
||||
|
||||
private toSheetValues(rows: ReturnType<typeof this.toRows>) {
|
||||
const headers = this.getHeaders(rows);
|
||||
return [
|
||||
headers,
|
||||
...rows.map((row) => headers.map((header) => row[header as keyof typeof row] ?? "")),
|
||||
];
|
||||
}
|
||||
|
||||
private async createGoogleSheetsClient(userId: string, failWhenDisconnected: boolean) {
|
||||
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
||||
if (!gc || !gc.isConnected) {
|
||||
throw new BadRequestException(
|
||||
"Google account not connected. Please connect via /api/google/connect.",
|
||||
);
|
||||
if (failWhenDisconnected) {
|
||||
throw new BadRequestException(
|
||||
"Google account not connected. Please connect via /api/google/connect.",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
@ -99,7 +548,6 @@ export class ExportsService {
|
||||
refresh_token: gc.refreshToken,
|
||||
});
|
||||
|
||||
// Refresh the access token if needed
|
||||
const { credentials } = await oauth2Client.refreshAccessToken();
|
||||
await this.prisma.googleConnection.update({
|
||||
where: { userId },
|
||||
@ -110,39 +558,76 @@ export class ExportsService {
|
||||
});
|
||||
oauth2Client.setCredentials(credentials);
|
||||
|
||||
const sheets = google.sheets({ version: "v4", auth: oauth2Client });
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
return {
|
||||
gc,
|
||||
sheets: google.sheets({ version: "v4", auth: oauth2Client }),
|
||||
};
|
||||
}
|
||||
|
||||
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`;
|
||||
|
||||
let spreadsheetId = gc.spreadsheetId;
|
||||
if (!spreadsheetId) {
|
||||
// Create a new spreadsheet
|
||||
const spreadsheet = await sheets.spreadsheets.create({
|
||||
requestBody: { properties: { title: "LedgerOne" } },
|
||||
});
|
||||
spreadsheetId = spreadsheet.data.spreadsheetId!;
|
||||
await this.prisma.googleConnection.update({
|
||||
where: { userId },
|
||||
data: { spreadsheetId },
|
||||
});
|
||||
private async ensureSpreadsheet(
|
||||
userId: string,
|
||||
sheets: ReturnType<typeof google.sheets>,
|
||||
spreadsheetId?: string | null,
|
||||
) {
|
||||
if (spreadsheetId) {
|
||||
await this.markDriveMirror(userId, spreadsheetId, "ready");
|
||||
return spreadsheetId;
|
||||
}
|
||||
|
||||
// Add a new sheet tab
|
||||
await sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
|
||||
const spreadsheet = await sheets.spreadsheets.create({
|
||||
requestBody: { properties: { title: "LedgerOne User-Owned Ledger" } },
|
||||
});
|
||||
const createdSpreadsheetId = spreadsheet.data.spreadsheetId!;
|
||||
await this.markDriveMirror(userId, createdSpreadsheetId, "ready");
|
||||
return createdSpreadsheetId;
|
||||
}
|
||||
|
||||
private async markDriveMirror(userId: string, spreadsheetId: string, status: string, syncedAt?: Date) {
|
||||
await this.prisma.googleConnection.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
spreadsheetId,
|
||||
driveMirrorEnabled: true,
|
||||
driveMirrorStatus: status,
|
||||
driveMirrorSpreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
||||
...(syncedAt ? { driveMirrorLastSyncedAt: syncedAt, lastSyncedAt: syncedAt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Build values: header + data rows
|
||||
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"];
|
||||
const values = [
|
||||
headers,
|
||||
...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")),
|
||||
];
|
||||
private async addSheetIfMissing(
|
||||
sheets: ReturnType<typeof google.sheets>,
|
||||
spreadsheetId: string,
|
||||
sheetTitle: string,
|
||||
) {
|
||||
try {
|
||||
await sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (!/already exists|duplicate/i.test(message)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async writeSheetValues(
|
||||
sheets: ReturnType<typeof google.sheets>,
|
||||
spreadsheetId: string,
|
||||
sheetTitle: string,
|
||||
values: string[][],
|
||||
clearFirst = false,
|
||||
) {
|
||||
if (clearFirst) {
|
||||
await sheets.spreadsheets.values.clear({
|
||||
spreadsheetId,
|
||||
range: `'${sheetTitle}'!A:Z`,
|
||||
});
|
||||
}
|
||||
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
@ -150,11 +635,34 @@ export class ExportsService {
|
||||
valueInputOption: "RAW",
|
||||
requestBody: { values },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.exportLog.create({
|
||||
data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length },
|
||||
async exportSheets(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
||||
const client = await this.createGoogleSheetsClient(userId, true);
|
||||
const { gc, sheets } = client!;
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
|
||||
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`;
|
||||
const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId);
|
||||
|
||||
await sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
|
||||
},
|
||||
});
|
||||
|
||||
await this.writeSheetValues(sheets, spreadsheetId, sheetTitle, this.toSheetValues(rows));
|
||||
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
|
||||
|
||||
await this.createExportLog(userId, filters, rows.length, {
|
||||
format: "google_sheets",
|
||||
destination: "google_sheets",
|
||||
metadata: { spreadsheetId, sheetTitle, url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}` },
|
||||
}, context);
|
||||
await this.abuseService.recordExportActivity(userId, rows.length, { ...filters, destination: "google_sheets" }, context);
|
||||
|
||||
this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`);
|
||||
|
||||
return {
|
||||
@ -165,4 +673,38 @@ export class ExportsService {
|
||||
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
||||
};
|
||||
}
|
||||
|
||||
async syncGoogleSheets(userId: string, reason = "transaction_change") {
|
||||
const client = await this.createGoogleSheetsClient(userId, false);
|
||||
if (!client) {
|
||||
return { status: "skipped", reason: "not_connected" };
|
||||
}
|
||||
|
||||
const { gc, sheets } = client;
|
||||
const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId);
|
||||
const transactions = await this.getTransactions(userId, {}, 5000);
|
||||
const rows = this.toRows(transactions);
|
||||
|
||||
await this.addSheetIfMissing(sheets, spreadsheetId, this.syncSheetTitle);
|
||||
await this.writeSheetValues(
|
||||
sheets,
|
||||
spreadsheetId,
|
||||
this.syncSheetTitle,
|
||||
this.toSheetValues(rows),
|
||||
true,
|
||||
);
|
||||
|
||||
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
|
||||
|
||||
this.logger.log(`Synced ${rows.length} rows to Google Sheets for user ${userId}`);
|
||||
|
||||
return {
|
||||
status: "synced",
|
||||
reason,
|
||||
rowCount: rows.length,
|
||||
spreadsheetId,
|
||||
sheetTitle: this.syncSheetTitle,
|
||||
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +67,10 @@ export class GoogleService {
|
||||
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,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
@ -74,6 +78,8 @@ export class GoogleService {
|
||||
refreshToken: tokens.refresh_token,
|
||||
accessToken: tokens.access_token ?? null,
|
||||
isConnected: true,
|
||||
driveMirrorEnabled: false,
|
||||
driveMirrorStatus: "pending_first_sync",
|
||||
},
|
||||
});
|
||||
|
||||
@ -89,6 +95,18 @@ export class GoogleService {
|
||||
async getStatus(userId: string) {
|
||||
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 };
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
7
src/households/dto/accept-household-invite.dto.ts
Normal file
7
src/households/dto/accept-household-invite.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class AcceptHouseholdInviteDto {
|
||||
@IsString()
|
||||
@MinLength(32)
|
||||
token!: string;
|
||||
}
|
||||
11
src/households/dto/create-household-invite.dto.ts
Normal file
11
src/households/dto/create-household-invite.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsEmail, IsIn, IsOptional } from "class-validator";
|
||||
import { HOUSEHOLD_ROLES, HouseholdRole } from "./update-household-member.dto";
|
||||
|
||||
export class CreateHouseholdInviteDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(HOUSEHOLD_ROLES)
|
||||
role?: HouseholdRole;
|
||||
}
|
||||
12
src/households/dto/create-household.dto.ts
Normal file
12
src/households/dto/create-household.dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { IsObject, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class CreateHouseholdDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
17
src/households/dto/update-household-member.dto.ts
Normal file
17
src/households/dto/update-household-member.dto.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { IsIn, IsOptional } from "class-validator";
|
||||
|
||||
export const HOUSEHOLD_ROLES = ["owner", "admin", "member", "viewer"] 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;
|
||||
}
|
||||
67
src/households/households.controller.ts
Normal file
67
src/households/households.controller.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
||||
import { CreateHouseholdDto } from "./dto/create-household.dto";
|
||||
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
|
||||
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.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));
|
||||
}
|
||||
|
||||
@Get(":id/members")
|
||||
async members(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
return ok(await this.householdsService.listMembers(userId, id));
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
11
src/households/households.module.ts
Normal file
11
src/households/households.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { HouseholdsController } from "./households.controller";
|
||||
import { HouseholdsService } from "./households.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [HouseholdsController],
|
||||
providers: [HouseholdsService],
|
||||
})
|
||||
export class HouseholdsModule {}
|
||||
535
src/households/households.service.ts
Normal file
535
src/households/households.service.ts
Normal file
@ -0,0 +1,535 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
||||
import { CreateHouseholdDto } from "./dto/create-household.dto";
|
||||
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
|
||||
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
|
||||
|
||||
@Injectable()
|
||||
export class HouseholdsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async listForUser(userId: string) {
|
||||
return this.prisma.household.findMany({
|
||||
where: {
|
||||
members: {
|
||||
some: { userId, status: "active" },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async create(userId: string, payload: CreateHouseholdDto) {
|
||||
const name = payload.name.trim();
|
||||
if (!name) throw new BadRequestException("Household name is required.");
|
||||
|
||||
const household = await this.prisma.household.create({
|
||||
data: {
|
||||
name,
|
||||
createdByUserId: userId,
|
||||
metadata: (payload.metadata ?? {}) as Prisma.InputJsonValue,
|
||||
members: {
|
||||
create: {
|
||||
userId,
|
||||
role: "owner",
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "household.create",
|
||||
metadata: {
|
||||
householdId: household.id,
|
||||
role: "owner",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return household;
|
||||
}
|
||||
|
||||
async getForUser(userId: string, householdId: string) {
|
||||
await this.requireActiveMember(userId, householdId);
|
||||
|
||||
return this.prisma.household.findFirst({
|
||||
where: { id: householdId },
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getDashboard(userId: string, householdId: string) {
|
||||
await this.requireActiveMember(userId, householdId);
|
||||
|
||||
const household = await this.prisma.household.findFirst({
|
||||
where: { id: householdId },
|
||||
include: {
|
||||
members: {
|
||||
where: { status: "active" },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!household) throw new BadRequestException("Household not found.");
|
||||
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: { householdId, isActive: true },
|
||||
select: {
|
||||
institutionName: true,
|
||||
accountType: true,
|
||||
mask: true,
|
||||
currentBalance: true,
|
||||
availableBalance: true,
|
||||
isoCurrencyCode: true,
|
||||
ownerUserId: true,
|
||||
ownershipType: true,
|
||||
lastBalanceSync: true,
|
||||
syncStatus: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const cashflowStart = new Date(now.getFullYear(), now.getMonth() - 5, 1);
|
||||
const [recentRows, cashflowRows] = await Promise.all([
|
||||
this.prisma.transactionRaw.findMany({
|
||||
where: {
|
||||
account: { householdId, isActive: true },
|
||||
},
|
||||
include: {
|
||||
derived: true,
|
||||
account: {
|
||||
select: {
|
||||
institutionName: true,
|
||||
mask: true,
|
||||
ownerUserId: true,
|
||||
ownershipType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "desc" },
|
||||
take: 25,
|
||||
}),
|
||||
this.prisma.transactionRaw.findMany({
|
||||
where: {
|
||||
date: { gte: cashflowStart, lte: now },
|
||||
account: { householdId, isActive: true },
|
||||
},
|
||||
include: {
|
||||
derived: true,
|
||||
account: {
|
||||
select: {
|
||||
ownershipType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const activeTransactions = cashflowRows.filter((row: any) => !row.derived?.isHidden);
|
||||
const balanceByOwnership = this.buildOwnershipBreakdown(accounts);
|
||||
const cashflow = this.buildCashflow(activeTransactions, cashflowStart, now);
|
||||
|
||||
return {
|
||||
household: {
|
||||
id: household.id,
|
||||
name: household.name,
|
||||
createdAt: household.createdAt,
|
||||
updatedAt: household.updatedAt,
|
||||
},
|
||||
members: household.members.map((member: any) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt,
|
||||
user: member.user,
|
||||
})),
|
||||
summary: {
|
||||
memberCount: household.members.length,
|
||||
accountCount: accounts.length,
|
||||
totalBalance: this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.currentBalance), 0)),
|
||||
availableBalance: this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.availableBalance), 0)),
|
||||
monthlyIncome: cashflow.currentMonth.income,
|
||||
monthlyExpenses: cashflow.currentMonth.expenses,
|
||||
monthlyNet: cashflow.currentMonth.net,
|
||||
},
|
||||
ownershipBreakdown: balanceByOwnership,
|
||||
accounts: accounts.map((account: any, index: number) => ({
|
||||
displayId: `household_account_${index + 1}`,
|
||||
institutionName: account.institutionName,
|
||||
accountType: account.accountType,
|
||||
mask: account.mask,
|
||||
currentBalance: this.roundCurrency(this.toNumber(account.currentBalance)),
|
||||
availableBalance: this.roundCurrency(this.toNumber(account.availableBalance)),
|
||||
isoCurrencyCode: account.isoCurrencyCode ?? "USD",
|
||||
ownerUserId: account.ownerUserId,
|
||||
ownershipType: account.ownershipType,
|
||||
lastBalanceSync: account.lastBalanceSync,
|
||||
syncStatus: account.syncStatus,
|
||||
})),
|
||||
cashflow: cashflow.months,
|
||||
recentTransactions: recentRows
|
||||
.filter((row: any) => !row.derived?.isHidden)
|
||||
.slice(0, 10)
|
||||
.map((row: any) => ({
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
amount: this.roundCurrency(this.toNumber(row.amount)),
|
||||
source: row.source,
|
||||
category: row.derived?.userCategory ?? "Uncategorized",
|
||||
attribution: row.derived?.attribution ?? this.defaultAttributionForOwnership(row.account?.ownershipType),
|
||||
split: this.resolveSplit(row.derived, this.toNumber(row.amount)),
|
||||
account: {
|
||||
institutionName: row.account?.institutionName,
|
||||
mask: row.account?.mask,
|
||||
ownerUserId: row.account?.ownerUserId,
|
||||
ownershipType: row.account?.ownershipType,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listMembers(userId: string, householdId: string) {
|
||||
await this.requireActiveMember(userId, householdId);
|
||||
return this.prisma.householdMember.findMany({
|
||||
where: { householdId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ role: "asc" }, { joinedAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async updateMember(userId: string, householdId: string, memberId: string, payload: UpdateHouseholdMemberDto) {
|
||||
if (payload.role === undefined && payload.status === undefined) {
|
||||
throw new BadRequestException("Role or status is required.");
|
||||
}
|
||||
|
||||
await this.requireManager(userId, householdId);
|
||||
const target = await this.prisma.householdMember.findFirst({
|
||||
where: { id: memberId, householdId },
|
||||
});
|
||||
if (!target) throw new BadRequestException("Household member not found.");
|
||||
|
||||
if (target.role === "owner" && (payload.role && payload.role !== "owner" || payload.status && payload.status !== "active")) {
|
||||
const ownerCount = await this.prisma.householdMember.count({
|
||||
where: { householdId, role: "owner", status: "active" },
|
||||
});
|
||||
if (ownerCount <= 1) {
|
||||
throw new BadRequestException("A household must keep at least one active owner.");
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.householdMember.update({
|
||||
where: { id: memberId },
|
||||
data: {
|
||||
...(payload.role !== undefined && { role: payload.role }),
|
||||
...(payload.status !== undefined && { status: payload.status }),
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "household.member.update",
|
||||
metadata: {
|
||||
householdId,
|
||||
memberId,
|
||||
role: updated.role,
|
||||
status: updated.status,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async listInvites(userId: string, householdId: string) {
|
||||
await this.requireManager(userId, householdId);
|
||||
return this.prisma.householdInvite.findMany({
|
||||
where: { householdId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
acceptedAt: true,
|
||||
acceptedById: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async invite(userId: string, householdId: string, payload: CreateHouseholdInviteDto) {
|
||||
await this.requireManager(userId, householdId);
|
||||
const household = await this.prisma.household.findFirst({ where: { id: householdId } });
|
||||
if (!household) throw new BadRequestException("Household not found.");
|
||||
const inviter = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true, fullName: true } });
|
||||
const email = payload.email.trim().toLowerCase();
|
||||
const role = payload.role ?? "member";
|
||||
if (role === "owner") {
|
||||
throw new BadRequestException("Invite partners as admin, member, or viewer. Promote owners after acceptance.");
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
const tokenHash = this.hashInviteToken(token);
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const invite = await this.prisma.householdInvite.create({
|
||||
data: {
|
||||
householdId,
|
||||
invitedById: userId,
|
||||
email,
|
||||
role,
|
||||
tokenHash,
|
||||
status: "pending",
|
||||
expiresAt,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.emailService.sendHouseholdInviteEmail(
|
||||
email,
|
||||
household.name,
|
||||
inviter?.fullName ?? inviter?.email ?? "A LedgerOne user",
|
||||
token,
|
||||
);
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "household.invite.create",
|
||||
metadata: {
|
||||
householdId,
|
||||
inviteId: invite.id,
|
||||
email,
|
||||
role,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return invite;
|
||||
}
|
||||
|
||||
async acceptInvite(userId: string, payload: AcceptHouseholdInviteDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
|
||||
if (!user) throw new BadRequestException("User not found.");
|
||||
const tokenHash = this.hashInviteToken(payload.token);
|
||||
const invite = await this.prisma.householdInvite.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { household: true },
|
||||
});
|
||||
if (!invite || invite.status !== "pending") throw new BadRequestException("Invite is invalid or already used.");
|
||||
if (invite.expiresAt < new Date()) throw new BadRequestException("Invite has expired.");
|
||||
if (invite.email.toLowerCase() !== user.email.toLowerCase()) {
|
||||
throw new ForbiddenException("This invite was sent to a different email address.");
|
||||
}
|
||||
|
||||
const member = await this.prisma.householdMember.upsert({
|
||||
where: { householdId_userId: { householdId: invite.householdId, userId } },
|
||||
create: {
|
||||
householdId: invite.householdId,
|
||||
userId,
|
||||
role: invite.role,
|
||||
status: "active",
|
||||
},
|
||||
update: {
|
||||
role: invite.role,
|
||||
status: "active",
|
||||
joinedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.householdInvite.update({
|
||||
where: { id: invite.id },
|
||||
data: {
|
||||
status: "accepted",
|
||||
acceptedAt: new Date(),
|
||||
acceptedById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "household.invite.accept",
|
||||
metadata: {
|
||||
householdId: invite.householdId,
|
||||
inviteId: invite.id,
|
||||
role: invite.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
household: invite.household,
|
||||
member,
|
||||
};
|
||||
}
|
||||
|
||||
private async requireActiveMember(userId: string, householdId: string) {
|
||||
const membership = await this.prisma.householdMember.findFirst({
|
||||
where: { householdId, userId, status: "active" },
|
||||
});
|
||||
if (!membership) throw new BadRequestException("Household not found.");
|
||||
return membership;
|
||||
}
|
||||
|
||||
private async requireManager(userId: string, householdId: string) {
|
||||
const membership = await this.requireActiveMember(userId, householdId);
|
||||
if (!["owner", "admin"].includes(membership.role)) {
|
||||
throw new ForbiddenException("Only household owners and admins can manage members.");
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
|
||||
private hashInviteToken(token: string) {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
private buildOwnershipBreakdown(accounts: any[]) {
|
||||
const initial = {
|
||||
mine: { accountCount: 0, balance: 0 },
|
||||
theirs: { accountCount: 0, balance: 0 },
|
||||
joint: { accountCount: 0, balance: 0 },
|
||||
};
|
||||
|
||||
for (const account of accounts) {
|
||||
const key = (["mine", "theirs", "joint"].includes(account.ownershipType) ? account.ownershipType : "mine") as "mine" | "theirs" | "joint";
|
||||
initial[key].accountCount += 1;
|
||||
initial[key].balance += this.toNumber(account.currentBalance);
|
||||
}
|
||||
|
||||
return {
|
||||
mine: { ...initial.mine, balance: this.roundCurrency(initial.mine.balance) },
|
||||
theirs: { ...initial.theirs, balance: this.roundCurrency(initial.theirs.balance) },
|
||||
joint: { ...initial.joint, balance: this.roundCurrency(initial.joint.balance) },
|
||||
};
|
||||
}
|
||||
|
||||
private buildCashflow(rows: any[], start: Date, end: Date) {
|
||||
const buckets = new Map<string, { month: string; income: number; expenses: number; net: number; transactionCount: number }>();
|
||||
for (let date = new Date(start.getFullYear(), start.getMonth(), 1); date <= end; date.setMonth(date.getMonth() + 1)) {
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
buckets.set(key, { month: key, income: 0, expenses: 0, net: 0, transactionCount: 0 });
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const date = new Date(row.date);
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket) continue;
|
||||
const amount = this.toNumber(row.amount);
|
||||
if (amount < 0) bucket.income += Math.abs(amount);
|
||||
else bucket.expenses += amount;
|
||||
bucket.transactionCount += 1;
|
||||
bucket.net = bucket.income - bucket.expenses;
|
||||
}
|
||||
|
||||
const months = Array.from(buckets.values()).map((bucket) => ({
|
||||
...bucket,
|
||||
income: this.roundCurrency(bucket.income),
|
||||
expenses: this.roundCurrency(bucket.expenses),
|
||||
net: this.roundCurrency(bucket.net),
|
||||
}));
|
||||
const currentKey = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, "0")}`;
|
||||
const currentMonth = months.find((bucket) => bucket.month === currentKey) ?? { month: currentKey, income: 0, expenses: 0, net: 0, transactionCount: 0 };
|
||||
return { months, currentMonth };
|
||||
}
|
||||
|
||||
private toNumber(value: unknown) {
|
||||
if (value === null || value === undefined) return 0;
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
private roundCurrency(value: number) {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
private defaultAttributionForOwnership(ownershipType?: string | null) {
|
||||
if (ownershipType === "joint") return "ours";
|
||||
if (ownershipType === "theirs") return "yours";
|
||||
return "mine";
|
||||
}
|
||||
|
||||
private resolveSplit(
|
||||
derived: { splitMode?: string | null; splitMinePercent?: unknown; splitYoursPercent?: unknown } | null | undefined,
|
||||
amount: number,
|
||||
) {
|
||||
const mode = derived?.splitMode && ["none", "equal", "custom"].includes(derived.splitMode) ? derived.splitMode : "none";
|
||||
const minePercent = mode === "none" ? 100 : this.toNumber(derived?.splitMinePercent ?? (mode === "equal" ? 50 : 0));
|
||||
const yoursPercent = mode === "none" ? 0 : this.toNumber(derived?.splitYoursPercent ?? (mode === "equal" ? 50 : 0));
|
||||
return {
|
||||
mode,
|
||||
minePercent: this.roundCurrency(minePercent),
|
||||
yoursPercent: this.roundCurrency(yoursPercent),
|
||||
mineAmount: this.roundCurrency(amount * (minePercent / 100)),
|
||||
yoursAmount: this.roundCurrency(amount * (yoursPercent / 100)),
|
||||
};
|
||||
}
|
||||
}
|
||||
12
src/main.ts
12
src/main.ts
@ -9,11 +9,14 @@ import { AppModule } from "./app.module";
|
||||
import { SentryExceptionFilter } from "./common/sentry.filter";
|
||||
|
||||
async function bootstrap() {
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
// ─── Sentry initialization (before app creation) ──────────────────────────
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV ?? "development",
|
||||
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
@ -23,15 +26,20 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// ─── Security headers ─────────────────────────────────────────────────────
|
||||
const scriptSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
|
||||
const styleSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
|
||||
app.use(
|
||||
helmet({
|
||||
crossOriginEmbedderPolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"], // Swagger UI needs inline scripts
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc,
|
||||
styleSrc,
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { Body, Controller, Headers, Post, Req } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { ok } from "../common/response";
|
||||
import { PlaidService } from "./plaid.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { OpaqueIdService } from "../common/opaque-id.service";
|
||||
|
||||
@Controller("plaid")
|
||||
export class PlaidController {
|
||||
constructor(private readonly plaidService: PlaidService) {}
|
||||
constructor(
|
||||
private readonly plaidService: PlaidService,
|
||||
private readonly opaqueIds: OpaqueIdService,
|
||||
) {}
|
||||
|
||||
@Post("link-token")
|
||||
async createLinkToken(@CurrentUser() userId: string) {
|
||||
@ -21,4 +27,35 @@ 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 = this.opaqueIds.decode("account", 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 = this.opaqueIds.decode("account", 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { StripeModule } from "../stripe/stripe.module";
|
||||
import { PlaidController } from "./plaid.controller";
|
||||
import { PlaidService } from "./plaid.service";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule],
|
||||
controllers: [PlaidController],
|
||||
providers: [PlaidService],
|
||||
exports: [PlaidService]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
Configuration,
|
||||
CountryCode,
|
||||
@ -10,14 +10,18 @@ import * as crypto from "crypto";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EncryptionService } from "../common/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,
|
||||
) {
|
||||
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
|
||||
const clientId = this.requireEnv("PLAID_CLIENT_ID");
|
||||
@ -55,6 +59,7 @@ export class PlaidService {
|
||||
country_codes: countryCodes,
|
||||
language: "en",
|
||||
redirect_uri: redirectUri || undefined,
|
||||
webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@ -86,6 +91,14 @@ export class PlaidService {
|
||||
const institutionName = institutionId
|
||||
? await this.getInstitutionName(institutionId)
|
||||
: "Plaid institution";
|
||||
const incomingAccountIds = accountsResponse.data.accounts.map((account) => account.account_id);
|
||||
const existingAccounts = await this.prisma.account.findMany({
|
||||
where: { userId, plaidAccountId: { in: incomingAccountIds } },
|
||||
select: { plaidAccountId: true },
|
||||
});
|
||||
const existingIds = new Set(existingAccounts.map((account) => account.plaidAccountId));
|
||||
const newAccountCount = incomingAccountIds.filter((id) => !existingIds.has(id)).length;
|
||||
await this.planLimits.assertCanAddAccounts(userId, newAccountCount);
|
||||
|
||||
for (const account of accountsResponse.data.accounts) {
|
||||
await this.prisma.account.upsert({
|
||||
@ -162,7 +175,7 @@ export class PlaidService {
|
||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
||||
});
|
||||
|
||||
// Build map: raw decrypted token → plaidAccountIds
|
||||
// Build map: raw decrypted token → Plaid account ids for one item.
|
||||
const tokenMap = new Map<string, string[]>();
|
||||
for (const account of accounts) {
|
||||
if (!account.plaidAccessToken || !account.plaidAccountId) continue;
|
||||
@ -172,48 +185,370 @@ export class PlaidService {
|
||||
tokenMap.set(raw, list);
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
for (const [rawToken] of tokenMap) {
|
||||
const response = await this.client.transactionsGet({
|
||||
access_token: rawToken,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
options: { count: 500, offset: 0 },
|
||||
});
|
||||
const now = new Date();
|
||||
await this.prisma.account.updateMany({
|
||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
||||
data: { syncStatus: "syncing", lastSyncAttemptAt: now, lastSyncError: null },
|
||||
});
|
||||
|
||||
for (const tx of response.data.transactions) {
|
||||
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
|
||||
if (!account) continue;
|
||||
const rawPayload = tx as unknown as Prisma.InputJsonValue;
|
||||
await this.prisma.transactionRaw.upsert({
|
||||
where: { bankTransactionId: tx.transaction_id },
|
||||
update: {
|
||||
accountId: account.id,
|
||||
date: new Date(tx.date),
|
||||
amount: tx.amount,
|
||||
description: tx.name ?? "Plaid transaction",
|
||||
rawPayload,
|
||||
source: "plaid",
|
||||
ingestedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
accountId: account.id,
|
||||
bankTransactionId: tx.transaction_id,
|
||||
date: new Date(tx.date),
|
||||
amount: tx.amount,
|
||||
description: tx.name ?? "Plaid transaction",
|
||||
rawPayload,
|
||||
ingestedAt: new Date(),
|
||||
source: "plaid",
|
||||
let created = 0;
|
||||
for (const [rawToken, plaidAccountIds] of tokenMap) {
|
||||
try {
|
||||
const response = await this.client.transactionsGet({
|
||||
access_token: rawToken,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
options: { count: 500, offset: 0 },
|
||||
});
|
||||
|
||||
for (const tx of response.data.transactions) {
|
||||
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
|
||||
if (!account) continue;
|
||||
const rawPayload = 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;
|
||||
}
|
||||
|
||||
await this.prisma.account.updateMany({
|
||||
where: { userId, plaidAccountId: { in: plaidAccountIds } },
|
||||
data: {
|
||||
syncStatus: "idle",
|
||||
lastTransactionSync: new Date(),
|
||||
lastSyncError: null,
|
||||
syncConsecutiveFailures: 0,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error_message?: string; error_code?: string } }; message?: string };
|
||||
const message = err.response?.data?.error_message ?? err.message ?? "Plaid transaction sync failed.";
|
||||
await this.prisma.account.updateMany({
|
||||
where: { userId, plaidAccountId: { in: plaidAccountIds } },
|
||||
data: {
|
||||
syncStatus: "error",
|
||||
lastSyncError: message.slice(0, 500),
|
||||
syncConsecutiveFailures: { increment: 1 },
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { created };
|
||||
}
|
||||
|
||||
async createUpdateModeLinkToken(userId: string, accountId: string) {
|
||||
const account = await this.prisma.account.findFirst({
|
||||
where: {
|
||||
id: accountId,
|
||||
userId,
|
||||
plaidAccessToken: { not: null },
|
||||
plaidItemId: { not: null },
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
plaidAccessToken: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!account?.plaidAccessToken) {
|
||||
throw new BadRequestException("Plaid account not found for update mode.");
|
||||
}
|
||||
|
||||
const rawAccessToken = this.encryption.decrypt(account.plaidAccessToken);
|
||||
try {
|
||||
const response = await this.client.linkTokenCreate({
|
||||
user: { client_user_id: userId },
|
||||
client_name: "LedgerOne",
|
||||
country_codes: this.getCountryCodes(),
|
||||
language: "en",
|
||||
access_token: rawAccessToken,
|
||||
redirect_uri: process.env.PLAID_REDIRECT_URI?.trim() || undefined,
|
||||
webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
linkToken: response.data.link_token,
|
||||
expiration: response.data.expiration,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error_message?: string } } };
|
||||
const message =
|
||||
err.response?.data?.error_message ?? "Plaid update-mode link token request failed.";
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
|
||||
async markItemRepairComplete(userId: string, accountId: string) {
|
||||
const account = await this.prisma.account.findFirst({
|
||||
where: {
|
||||
id: accountId,
|
||||
userId,
|
||||
plaidItemId: { not: null },
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
plaidItemId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!account?.plaidItemId) {
|
||||
throw new BadRequestException("Plaid account not found for repair completion.");
|
||||
}
|
||||
|
||||
const updated = await this.prisma.account.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
plaidItemId: account.plaidItemId,
|
||||
},
|
||||
data: {
|
||||
syncStatus: "idle",
|
||||
lastSyncError: null,
|
||||
syncConsecutiveFailures: 0,
|
||||
plaidWebhookCode: "UPDATE_MODE_COMPLETED",
|
||||
plaidWebhookAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { updated: updated.count };
|
||||
}
|
||||
|
||||
async handleWebhook(payload: PlaidWebhookPayload, verificationHeader?: string, rawBody?: Buffer) {
|
||||
if (this.shouldVerifyWebhooks()) {
|
||||
const valid = await this.verifyWebhook(verificationHeader, rawBody);
|
||||
if (!valid) {
|
||||
throw new BadRequestException("Invalid Plaid webhook signature.");
|
||||
}
|
||||
}
|
||||
|
||||
const webhookType = payload.webhook_type ?? "UNKNOWN";
|
||||
const webhookCode = payload.webhook_code ?? "UNKNOWN";
|
||||
const itemId = payload.item_id ?? null;
|
||||
const event = await this.prisma.plaidWebhookEvent.create({
|
||||
data: {
|
||||
itemId,
|
||||
webhookType,
|
||||
webhookCode,
|
||||
payload: payload as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.processWebhook(payload);
|
||||
await this.prisma.plaidWebhookEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
status: "processed",
|
||||
processedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { received: true, processed: true, webhookType, webhookCode, ...result };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Plaid webhook processing failed.";
|
||||
this.logger.error(`Plaid webhook ${webhookType}:${webhookCode} failed: ${message}`);
|
||||
await this.prisma.plaidWebhookEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
status: "error",
|
||||
error: message.slice(0, 500),
|
||||
processedAt: new Date(),
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async processWebhook(payload: PlaidWebhookPayload) {
|
||||
if (!payload.item_id) {
|
||||
return { skipped: true, reason: "missing_item_id" };
|
||||
}
|
||||
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: { plaidItemId: payload.item_id },
|
||||
select: { userId: true },
|
||||
distinct: ["userId"],
|
||||
});
|
||||
const userIds = accounts.map((account) => account.userId);
|
||||
if (userIds.length === 0) {
|
||||
return { skipped: true, reason: "item_not_found" };
|
||||
}
|
||||
|
||||
const webhookType = payload.webhook_type ?? "UNKNOWN";
|
||||
const webhookCode = payload.webhook_code ?? "UNKNOWN";
|
||||
await this.prisma.account.updateMany({
|
||||
where: { plaidItemId: payload.item_id },
|
||||
data: {
|
||||
plaidWebhookCode: webhookCode,
|
||||
plaidWebhookAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
webhookType === "TRANSACTIONS" &&
|
||||
["SYNC_UPDATES_AVAILABLE", "INITIAL_UPDATE", "HISTORICAL_UPDATE", "DEFAULT_UPDATE"].includes(webhookCode)
|
||||
) {
|
||||
const days = Number(process.env.PLAID_WEBHOOK_LOOKBACK_DAYS ?? process.env.AUTO_SYNC_LOOKBACK_DAYS ?? 30);
|
||||
const endDate = new Date().toISOString().slice(0, 10);
|
||||
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
let created = 0;
|
||||
for (const userId of userIds) {
|
||||
const result = await this.syncTransactionsForUser(userId, startDate, endDate);
|
||||
created += result.created;
|
||||
}
|
||||
return { usersSynced: userIds.length, created };
|
||||
}
|
||||
|
||||
if (webhookType === "ITEM") {
|
||||
return this.handleItemWebhook(payload, userIds);
|
||||
}
|
||||
|
||||
return { skipped: true, reason: "unsupported_webhook" };
|
||||
}
|
||||
|
||||
private async handleItemWebhook(payload: PlaidWebhookPayload, userIds: string[]) {
|
||||
if (!payload.item_id) return { skipped: true, reason: "missing_item_id" };
|
||||
const webhookCode = payload.webhook_code ?? "UNKNOWN";
|
||||
const errorCode = payload.error?.error_code ?? webhookCode;
|
||||
const errorMessage = payload.error?.error_message ?? webhookCode;
|
||||
|
||||
if (webhookCode === "ERROR" || errorCode === "ITEM_LOGIN_REQUIRED") {
|
||||
await this.prisma.account.updateMany({
|
||||
where: { plaidItemId: payload.item_id },
|
||||
data: {
|
||||
syncStatus: "needs_reauth",
|
||||
lastSyncError: errorMessage.slice(0, 500),
|
||||
syncConsecutiveFailures: { increment: 1 },
|
||||
},
|
||||
});
|
||||
return { usersMarked: userIds.length, status: "needs_reauth" };
|
||||
}
|
||||
|
||||
if (["PENDING_EXPIRATION", "PENDING_DISCONNECT", "NEW_ACCOUNTS_AVAILABLE"].includes(webhookCode)) {
|
||||
await this.prisma.account.updateMany({
|
||||
where: { plaidItemId: payload.item_id },
|
||||
data: {
|
||||
syncStatus: "attention_required",
|
||||
lastSyncError: webhookCode,
|
||||
},
|
||||
});
|
||||
return { usersMarked: userIds.length, status: "attention_required" };
|
||||
}
|
||||
|
||||
if (["USER_PERMISSION_REVOKED", "USER_ACCOUNT_REVOKED"].includes(webhookCode)) {
|
||||
await this.prisma.account.updateMany({
|
||||
where: { plaidItemId: payload.item_id },
|
||||
data: {
|
||||
isActive: false,
|
||||
syncStatus: "revoked",
|
||||
lastSyncError: webhookCode,
|
||||
},
|
||||
});
|
||||
return { usersMarked: userIds.length, status: "revoked" };
|
||||
}
|
||||
|
||||
if (webhookCode === "LOGIN_REPAIRED") {
|
||||
await this.prisma.account.updateMany({
|
||||
where: { plaidItemId: payload.item_id },
|
||||
data: {
|
||||
syncStatus: "idle",
|
||||
lastSyncError: null,
|
||||
syncConsecutiveFailures: 0,
|
||||
},
|
||||
});
|
||||
return { usersMarked: userIds.length, status: "repaired" };
|
||||
}
|
||||
|
||||
return { skipped: true, reason: "unsupported_item_webhook" };
|
||||
}
|
||||
|
||||
private shouldVerifyWebhooks() {
|
||||
if (process.env.NODE_ENV === "test") return false;
|
||||
return process.env.PLAID_VERIFY_WEBHOOKS !== "false";
|
||||
}
|
||||
|
||||
private async verifyWebhook(verificationHeader?: string, rawBody?: Buffer) {
|
||||
if (!verificationHeader || !rawBody) return false;
|
||||
const parts = verificationHeader.split(".");
|
||||
if (parts.length !== 3) return false;
|
||||
|
||||
const header = this.decodeJwtPart<{ alg?: string; kid?: string }>(parts[0]);
|
||||
if (header.alg !== "ES256" || !header.kid) return false;
|
||||
|
||||
const key = await this.getWebhookKey(header.kid);
|
||||
const publicKey = crypto.createPublicKey({ key, format: "jwk" } as crypto.JsonWebKeyInput);
|
||||
const verifier = crypto.createVerify("SHA256");
|
||||
verifier.update(`${parts[0]}.${parts[1]}`);
|
||||
verifier.end();
|
||||
|
||||
const signature = this.ecJoseSignatureToDer(Buffer.from(parts[2], "base64url"));
|
||||
if (!verifier.verify(publicKey, signature)) return false;
|
||||
|
||||
const claims = this.decodeJwtPart<{ iat?: number; request_body_sha256?: string }>(parts[1]);
|
||||
if (!claims.iat || Math.abs(Date.now() / 1000 - claims.iat) > 300) return false;
|
||||
if (!claims.request_body_sha256) return false;
|
||||
|
||||
const actualHash = crypto.createHash("sha256").update(rawBody).digest("hex");
|
||||
return this.timingSafeEqual(actualHash, claims.request_body_sha256);
|
||||
}
|
||||
|
||||
private async getWebhookKey(keyId: string) {
|
||||
const cached = this.webhookKeys.get(keyId);
|
||||
if (cached) return cached;
|
||||
const response = await (this.client as unknown as {
|
||||
webhookVerificationKeyGet(request: { key_id: string }): Promise<{ data: { key: JsonWebKey } }>;
|
||||
}).webhookVerificationKeyGet({ key_id: keyId });
|
||||
this.webhookKeys.set(keyId, response.data.key);
|
||||
return response.data.key;
|
||||
}
|
||||
|
||||
private decodeJwtPart<T>(part: string): T {
|
||||
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T;
|
||||
}
|
||||
|
||||
private timingSafeEqual(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
private ecJoseSignatureToDer(signature: Buffer) {
|
||||
if (signature.length !== 64) return signature;
|
||||
const r = this.derInteger(signature.subarray(0, 32));
|
||||
const s = this.derInteger(signature.subarray(32));
|
||||
const length = r.length + s.length;
|
||||
return Buffer.concat([Buffer.from([0x30, length]), r, s]);
|
||||
}
|
||||
|
||||
private derInteger(bytes: Buffer) {
|
||||
let value = bytes;
|
||||
while (value.length > 1 && value[0] === 0) {
|
||||
value = value.subarray(1);
|
||||
}
|
||||
if (value[0] & 0x80) {
|
||||
value = Buffer.concat([Buffer.from([0]), value]);
|
||||
}
|
||||
return Buffer.concat([Buffer.from([0x02, value.length]), value]);
|
||||
}
|
||||
|
||||
private requireEnv(name: string) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
@ -222,6 +557,13 @@ export class PlaidService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private getCountryCodes() {
|
||||
return (process.env.PLAID_COUNTRY_CODES ?? "US")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) as CountryCode[];
|
||||
}
|
||||
|
||||
private async getInstitutionName(institutionId: string) {
|
||||
try {
|
||||
const response = await this.client.institutionsGetById({
|
||||
@ -234,3 +576,15 @@ export class PlaidService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PlaidWebhookPayload = {
|
||||
webhook_type?: string;
|
||||
webhook_code?: string;
|
||||
item_id?: string;
|
||||
environment?: string;
|
||||
error?: {
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
27
src/public-api/api-key.controller.ts
Normal file
27
src/public-api/api-key.controller.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { RequiredPlan, SubscriptionGuard } from "../stripe/subscription.guard";
|
||||
import { ApiKeyService } from "./api-key.service";
|
||||
|
||||
@RequiredPlan("pro")
|
||||
@UseGuards(SubscriptionGuard)
|
||||
@Controller("api-keys")
|
||||
export class ApiKeyController {
|
||||
constructor(private readonly apiKeyService: ApiKeyService) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() userId: string) {
|
||||
return ok(await this.apiKeyService.listKeys(userId));
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() userId: string, @Body() body: { name?: string; scopes?: string[]; expiresAt?: string }) {
|
||||
return ok(await this.apiKeyService.createKey(userId, body));
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
async revoke(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
return ok(await this.apiKeyService.revokeKey(userId, id));
|
||||
}
|
||||
}
|
||||
23
src/public-api/api-key.guard.ts
Normal file
23
src/public-api/api-key.guard.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { ApiKeyService } from "./api-key.service";
|
||||
|
||||
export type ApiKeyRequest = Request & {
|
||||
apiKeyAuth?: { userId: string; keyId: string; scopes: string[] };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyGuard implements CanActivate {
|
||||
constructor(private readonly apiKeyService: ApiKeyService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<ApiKeyRequest>();
|
||||
const rawKey = request.header("x-ledgerone-api-key") ?? request.header("x-api-key");
|
||||
if (!rawKey) {
|
||||
await this.apiKeyService.authenticate("");
|
||||
return false;
|
||||
}
|
||||
request.apiKeyAuth = await this.apiKeyService.authenticate(rawKey);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
97
src/public-api/api-key.service.ts
Normal file
97
src/public-api/api-key.service.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
const DEFAULT_SCOPES = ["transactions:read"];
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private hashKey(key: string) {
|
||||
return crypto.createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
async createKey(userId: string, payload: { name?: string; scopes?: string[]; expiresAt?: string }) {
|
||||
const scopes = payload.scopes?.length ? payload.scopes : DEFAULT_SCOPES;
|
||||
if (scopes.some((scope) => !DEFAULT_SCOPES.includes(scope))) {
|
||||
throw new BadRequestException("Unsupported API key scope.");
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(32).toString("base64url");
|
||||
const key = `l1_${secret}`;
|
||||
const prefix = key.slice(0, 10);
|
||||
const expiresAt = payload.expiresAt ? new Date(payload.expiresAt) : undefined;
|
||||
if (expiresAt && Number.isNaN(expiresAt.getTime())) {
|
||||
throw new BadRequestException("expiresAt must be a valid ISO date.");
|
||||
}
|
||||
|
||||
const record = await this.prisma.apiKey.create({
|
||||
data: {
|
||||
userId,
|
||||
name: payload.name?.trim() || "Public API key",
|
||||
prefix,
|
||||
keyHash: this.hashKey(key),
|
||||
scopes,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
key,
|
||||
prefix: record.prefix,
|
||||
name: record.name,
|
||||
scopes: record.scopes,
|
||||
expiresAt: record.expiresAt,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async listKeys(userId: string) {
|
||||
const keys = await this.prisma.apiKey.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return {
|
||||
keys: keys.map((key) => ({
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
prefix: key.prefix,
|
||||
scopes: key.scopes,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
revokedAt: key.revokedAt,
|
||||
expiresAt: key.expiresAt,
|
||||
createdAt: key.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async revokeKey(userId: string, keyId: string) {
|
||||
await this.prisma.apiKey.update({
|
||||
where: { id: keyId, userId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { revoked: true };
|
||||
}
|
||||
|
||||
async authenticate(rawKey: string, requiredScope = "transactions:read") {
|
||||
const key = await this.prisma.apiKey.findUnique({
|
||||
where: { keyHash: this.hashKey(rawKey) },
|
||||
});
|
||||
if (
|
||||
!key ||
|
||||
key.revokedAt ||
|
||||
(key.expiresAt && key.expiresAt < new Date()) ||
|
||||
!key.scopes.includes(requiredScope)
|
||||
) {
|
||||
throw new UnauthorizedException("Invalid API key.");
|
||||
}
|
||||
|
||||
await this.prisma.apiKey.update({
|
||||
where: { id: key.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
return { userId: key.userId, keyId: key.id, scopes: key.scopes };
|
||||
}
|
||||
}
|
||||
58
src/public-api/public-api.controller.ts
Normal file
58
src/public-api/public-api.controller.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { TransactionsService } from "../transactions/transactions.service";
|
||||
import { ApiKeyGuard, ApiKeyRequest } from "./api-key.guard";
|
||||
|
||||
@Public()
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@Controller("public/v1")
|
||||
export class PublicApiController {
|
||||
constructor(private readonly transactionsService: TransactionsService) {}
|
||||
|
||||
@Get("transactions")
|
||||
async transactions(
|
||||
@Req() req: ApiKeyRequest,
|
||||
@Query("start_date") startDate?: string,
|
||||
@Query("end_date") endDate?: string,
|
||||
@Query("min_amount") minAmount?: string,
|
||||
@Query("max_amount") maxAmount?: string,
|
||||
@Query("category") category?: string,
|
||||
@Query("source") source?: string,
|
||||
@Query("search") search?: string,
|
||||
@Query("include_hidden") includeHidden?: string,
|
||||
@Query("page") page = 1,
|
||||
@Query("limit") limit = 25,
|
||||
) {
|
||||
const data = await this.transactionsService.list(req.apiKeyAuth!.userId, {
|
||||
startDate,
|
||||
endDate,
|
||||
minAmount,
|
||||
maxAmount,
|
||||
category,
|
||||
source,
|
||||
search,
|
||||
includeHidden,
|
||||
page: +page,
|
||||
limit: +limit,
|
||||
});
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("transactions/summary")
|
||||
async summary(@Req() req: ApiKeyRequest, @Query("start_date") startDate?: string, @Query("end_date") endDate?: string) {
|
||||
const end = endDate ?? new Date().toISOString().slice(0, 10);
|
||||
const start = startDate ?? new Date(new Date().setDate(new Date(end).getDate() - 30)).toISOString().slice(0, 10);
|
||||
return ok(await this.transactionsService.summary(req.apiKeyAuth!.userId, start, end));
|
||||
}
|
||||
|
||||
@Get("transactions/cashflow")
|
||||
async cashflow(@Req() req: ApiKeyRequest, @Query("months") months = 6) {
|
||||
return ok(await this.transactionsService.cashflow(req.apiKeyAuth!.userId, +months));
|
||||
}
|
||||
|
||||
@Get("transactions/merchants")
|
||||
async merchants(@Req() req: ApiKeyRequest, @Query("limit") limit = 6) {
|
||||
return ok(await this.transactionsService.merchantInsights(req.apiKeyAuth!.userId, +limit));
|
||||
}
|
||||
}
|
||||
15
src/public-api/public-api.module.ts
Normal file
15
src/public-api/public-api.module.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { StripeModule } from "../stripe/stripe.module";
|
||||
import { TransactionsModule } from "../transactions/transactions.module";
|
||||
import { ApiKeyController } from "./api-key.controller";
|
||||
import { ApiKeyGuard } from "./api-key.guard";
|
||||
import { ApiKeyService } from "./api-key.service";
|
||||
import { PublicApiController } from "./public-api.controller";
|
||||
|
||||
@Module({
|
||||
imports: [TransactionsModule, StripeModule],
|
||||
controllers: [ApiKeyController, PublicApiController],
|
||||
providers: [ApiKeyService, ApiKeyGuard],
|
||||
exports: [ApiKeyService],
|
||||
})
|
||||
export class PublicApiModule {}
|
||||
@ -63,21 +63,212 @@ export class RulesService {
|
||||
|
||||
private matchesRule(
|
||||
conditions: Record<string, unknown>,
|
||||
tx: { description: string; amount: number | string },
|
||||
tx: {
|
||||
description: string;
|
||||
amount: number | string;
|
||||
date: Date;
|
||||
source: string;
|
||||
category?: string | null;
|
||||
},
|
||||
): boolean {
|
||||
const textContains = typeof conditions.textContains === "string" ? conditions.textContains : "";
|
||||
const amountGt = typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null;
|
||||
const amountLt = typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null;
|
||||
if (this.hasDslConditions(conditions)) {
|
||||
return this.matchesDsl(conditions, tx);
|
||||
}
|
||||
|
||||
if (textContains && !tx.description.toLowerCase().includes(textContains.toLowerCase())) {
|
||||
const textContains = this.asString(conditions.textContains);
|
||||
const textNotContains = this.asString(conditions.textNotContains);
|
||||
const textEquals = this.asString(conditions.textEquals);
|
||||
const textRegex = this.asString(conditions.textRegex);
|
||||
const sourceEquals = this.asString(conditions.sourceEquals);
|
||||
const categoryEquals = this.asString(conditions.categoryEquals);
|
||||
const amountGt = this.asNumber(conditions.amountGreaterThan);
|
||||
const amountLt = this.asNumber(conditions.amountLessThan);
|
||||
const amountGte = this.asNumber(conditions.amountGreaterThanOrEqual);
|
||||
const amountLte = this.asNumber(conditions.amountLessThanOrEqual);
|
||||
const amountEquals = this.asNumber(conditions.amountEquals);
|
||||
const dateAfter = this.asDate(conditions.dateAfter);
|
||||
const dateBefore = this.asDate(conditions.dateBefore);
|
||||
|
||||
const description = tx.description.toLowerCase();
|
||||
if (textContains && !description.includes(textContains.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (textNotContains && description.includes(textNotContains.toLowerCase())) return false;
|
||||
if (textEquals && description !== textEquals.toLowerCase()) return false;
|
||||
if (textRegex) {
|
||||
try {
|
||||
if (!new RegExp(textRegex, "i").test(tx.description)) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const amount = Number(tx.amount);
|
||||
if (amountGt !== null && amount <= amountGt) return false;
|
||||
if (amountLt !== null && amount >= amountLt) return false;
|
||||
if (amountGte !== null && amount < amountGte) return false;
|
||||
if (amountLte !== null && amount > amountLte) return false;
|
||||
if (amountEquals !== null && amount !== amountEquals) return false;
|
||||
if (sourceEquals && tx.source.toLowerCase() !== sourceEquals.toLowerCase()) return false;
|
||||
if (categoryEquals && (tx.category ?? "").toLowerCase() !== categoryEquals.toLowerCase()) return false;
|
||||
if (dateAfter && tx.date < dateAfter) return false;
|
||||
if (dateBefore && tx.date > dateBefore) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private hasDslConditions(conditions: Record<string, unknown>) {
|
||||
return Array.isArray(conditions.all)
|
||||
|| Array.isArray(conditions.any)
|
||||
|| conditions.not !== undefined
|
||||
|| (typeof conditions.field === "string" && typeof conditions.operator === "string");
|
||||
}
|
||||
|
||||
private matchesDsl(
|
||||
node: unknown,
|
||||
tx: {
|
||||
description: string;
|
||||
amount: number | string;
|
||||
date: Date;
|
||||
source: string;
|
||||
category?: string | null;
|
||||
},
|
||||
): boolean {
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) return true;
|
||||
const rule = node as Record<string, unknown>;
|
||||
|
||||
if (Array.isArray(rule.all)) {
|
||||
return rule.all.every((item) => this.matchesDsl(item, tx));
|
||||
}
|
||||
if (Array.isArray(rule.any)) {
|
||||
return rule.any.some((item) => this.matchesDsl(item, tx));
|
||||
}
|
||||
if (rule.not !== undefined) {
|
||||
return !this.matchesDsl(rule.not, tx);
|
||||
}
|
||||
|
||||
const field = this.asString(rule.field);
|
||||
const operator = this.asString(rule.operator);
|
||||
if (!field || !operator) return true;
|
||||
|
||||
return this.matchesDslExpression(field, operator, rule.value, tx);
|
||||
}
|
||||
|
||||
private matchesDslExpression(
|
||||
field: string,
|
||||
operator: string,
|
||||
value: unknown,
|
||||
tx: {
|
||||
description: string;
|
||||
amount: number | string;
|
||||
date: Date;
|
||||
source: string;
|
||||
category?: string | null;
|
||||
},
|
||||
) {
|
||||
const normalizedField = field.toLowerCase();
|
||||
const normalizedOperator = operator.toLowerCase();
|
||||
|
||||
if (normalizedField === "amount") {
|
||||
const actual = Number(tx.amount);
|
||||
const expected = this.asNumber(value);
|
||||
if (expected === null) return false;
|
||||
if (["gt", "greaterthan", ">"].includes(normalizedOperator)) return actual > expected;
|
||||
if (["gte", "greaterthanorequal", ">="].includes(normalizedOperator)) return actual >= expected;
|
||||
if (["lt", "lessthan", "<"].includes(normalizedOperator)) return actual < expected;
|
||||
if (["lte", "lessthanorequal", "<="].includes(normalizedOperator)) return actual <= expected;
|
||||
if (["eq", "equals", "="].includes(normalizedOperator)) return actual === expected;
|
||||
if (["neq", "notequals", "!="].includes(normalizedOperator)) return actual !== expected;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedField === "date") {
|
||||
const expected = this.asDate(value);
|
||||
if (!expected) return false;
|
||||
if (["after", "gt", ">"].includes(normalizedOperator)) return tx.date > expected;
|
||||
if (["onorafter", "gte", ">="].includes(normalizedOperator)) return tx.date >= expected;
|
||||
if (["before", "lt", "<"].includes(normalizedOperator)) return tx.date < expected;
|
||||
if (["onorbefore", "lte", "<="].includes(normalizedOperator)) return tx.date <= expected;
|
||||
return false;
|
||||
}
|
||||
|
||||
const actual = this.fieldString(normalizedField, tx).toLowerCase();
|
||||
const expected = this.asString(value).toLowerCase();
|
||||
if (!expected && !["isempty", "isnotempty"].includes(normalizedOperator)) return false;
|
||||
|
||||
if (["contains", "includes"].includes(normalizedOperator)) return actual.includes(expected);
|
||||
if (["notcontains", "excludes"].includes(normalizedOperator)) return !actual.includes(expected);
|
||||
if (["eq", "equals", "="].includes(normalizedOperator)) return actual === expected;
|
||||
if (["neq", "notequals", "!="].includes(normalizedOperator)) return actual !== expected;
|
||||
if (normalizedOperator === "startswith") return actual.startsWith(expected);
|
||||
if (normalizedOperator === "endswith") return actual.endsWith(expected);
|
||||
if (["regex", "matches"].includes(normalizedOperator)) {
|
||||
try {
|
||||
return new RegExp(this.asString(value), "i").test(this.fieldString(normalizedField, tx));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (normalizedOperator === "isempty") return actual.length === 0;
|
||||
if (normalizedOperator === "isnotempty") return actual.length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private fieldString(
|
||||
field: string,
|
||||
tx: { description: string; source: string; category?: string | null },
|
||||
) {
|
||||
if (field === "description" || field === "merchant" || field === "text") return tx.description;
|
||||
if (field === "source") return tx.source;
|
||||
if (field === "category") return tx.category ?? "";
|
||||
return "";
|
||||
}
|
||||
|
||||
private asString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
}
|
||||
|
||||
private asNumber(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private asDate(value: unknown) {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
private buildDerivedActions(
|
||||
actions: Record<string, unknown>,
|
||||
current?: { userCategory?: string | null; userNotes?: string | null; isHidden?: boolean | null } | null,
|
||||
) {
|
||||
const clearCategory = actions.clearCategory === true;
|
||||
const setCategory = this.asString(actions.setCategory);
|
||||
const clearNote = actions.clearNote === true;
|
||||
const setNote = this.asString(actions.setNote);
|
||||
const appendNote = this.asString(actions.appendNote);
|
||||
|
||||
let userCategory = current?.userCategory ?? null;
|
||||
if (clearCategory) userCategory = null;
|
||||
if (setCategory) userCategory = setCategory;
|
||||
|
||||
let userNotes = current?.userNotes ?? null;
|
||||
if (clearNote) userNotes = null;
|
||||
if (setNote) userNotes = setNote;
|
||||
if (appendNote) {
|
||||
userNotes = userNotes ? `${userNotes}\n${appendNote}` : appendNote;
|
||||
}
|
||||
|
||||
const isHidden = typeof actions.setHidden === "boolean"
|
||||
? actions.setHidden
|
||||
: current?.isHidden ?? false;
|
||||
|
||||
return { userCategory, userNotes, isHidden };
|
||||
}
|
||||
|
||||
async execute(userId: string, id: string) {
|
||||
const rule = await this.prisma.rule.findFirst({ where: { id, userId } });
|
||||
if (!rule || !rule.isActive) return { id, status: "skipped" };
|
||||
@ -92,22 +283,26 @@ export class RulesService {
|
||||
|
||||
let applied = 0;
|
||||
for (const tx of transactions) {
|
||||
if (!this.matchesRule(conditions, { description: tx.description, amount: Number(tx.amount) })) {
|
||||
if (!this.matchesRule(conditions, {
|
||||
description: tx.description,
|
||||
amount: Number(tx.amount),
|
||||
date: tx.date,
|
||||
source: tx.source,
|
||||
category: tx.derived?.userCategory,
|
||||
})) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.transactionDerived.upsert({
|
||||
where: { rawTransactionId: tx.id },
|
||||
update: {
|
||||
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : tx.derived?.userCategory ?? null,
|
||||
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : tx.derived?.isHidden ?? false,
|
||||
...this.buildDerivedActions(actions, tx.derived),
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "rule",
|
||||
},
|
||||
create: {
|
||||
rawTransactionId: tx.id,
|
||||
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : null,
|
||||
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false,
|
||||
...this.buildDerivedActions(actions, null),
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "rule",
|
||||
},
|
||||
@ -128,33 +323,141 @@ export class RulesService {
|
||||
}
|
||||
|
||||
async suggest(userId: string) {
|
||||
const derived = await this.prisma.transactionDerived.findMany({
|
||||
where: {
|
||||
raw: { account: { userId } },
|
||||
userCategory: { not: null },
|
||||
},
|
||||
include: { raw: { select: { description: true } } },
|
||||
take: 200,
|
||||
});
|
||||
const [derived, transactions] = await Promise.all([
|
||||
this.prisma.transactionDerived.findMany({
|
||||
where: {
|
||||
raw: { account: { userId } },
|
||||
userCategory: { not: null },
|
||||
},
|
||||
include: { raw: { select: { description: true } } },
|
||||
take: 300,
|
||||
}),
|
||||
this.prisma.transactionRaw.findMany({
|
||||
where: { account: { userId } },
|
||||
include: { derived: true },
|
||||
orderBy: { date: "desc" },
|
||||
take: 500,
|
||||
}),
|
||||
]);
|
||||
|
||||
const bucket = new Map<string, { category: string; count: number }>();
|
||||
const suggestions: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
conditions: Record<string, unknown>;
|
||||
actions: Record<string, unknown>;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
matchCount: number;
|
||||
type: string;
|
||||
}> = [];
|
||||
|
||||
const categoryBucket = new Map<string, { category: string; count: number }>();
|
||||
for (const item of derived) {
|
||||
const key = item.raw.description.toLowerCase();
|
||||
const key = this.merchantKey(item.raw.description);
|
||||
const category = item.userCategory ?? "Uncategorized";
|
||||
const entry = bucket.get(key) ?? { category, count: 0 };
|
||||
const entry = categoryBucket.get(key) ?? { category, count: 0 };
|
||||
entry.count += 1;
|
||||
bucket.set(key, entry);
|
||||
categoryBucket.set(key, entry);
|
||||
}
|
||||
|
||||
return Array.from(bucket.entries())
|
||||
for (const [description, value] of Array.from(categoryBucket.entries())
|
||||
.filter(([, value]) => value.count >= 2)
|
||||
.slice(0, 5)
|
||||
.map(([description, value], index) => ({
|
||||
id: `suggestion_${index + 1}`,
|
||||
.slice(0, 5)) {
|
||||
suggestions.push({
|
||||
id: `category_${suggestions.length + 1}`,
|
||||
name: `Auto: ${value.category}`,
|
||||
conditions: { textContains: description },
|
||||
actions: { setCategory: value.category },
|
||||
confidence: Math.min(0.95, 0.5 + value.count * 0.1),
|
||||
}));
|
||||
reason: `${value.count} categorized transactions used the same merchant text.`,
|
||||
matchCount: value.count,
|
||||
type: "category",
|
||||
});
|
||||
}
|
||||
|
||||
const merchantBucket = new Map<string, { count: number; total: number; positive: number; negative: number }>();
|
||||
for (const tx of transactions) {
|
||||
const key = this.merchantKey(tx.description);
|
||||
if (!key || tx.derived?.userCategory) continue;
|
||||
const entry = merchantBucket.get(key) ?? { count: 0, total: 0, positive: 0, negative: 0 };
|
||||
entry.count += 1;
|
||||
entry.total += Number(tx.amount);
|
||||
if (Number(tx.amount) > 0) entry.positive += 1;
|
||||
if (Number(tx.amount) < 0) entry.negative += 1;
|
||||
merchantBucket.set(key, entry);
|
||||
}
|
||||
|
||||
for (const [merchant, value] of Array.from(merchantBucket.entries())
|
||||
.filter(([, value]) => value.count >= 3)
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.slice(0, 5)) {
|
||||
const category = value.negative > value.positive ? "Income" : "Needs review";
|
||||
suggestions.push({
|
||||
id: `merchant_${suggestions.length + 1}`,
|
||||
name: `Review recurring merchant: ${merchant}`,
|
||||
conditions: { textContains: merchant },
|
||||
actions: { setCategory: category, appendNote: "Suggested from repeated uncategorized merchant activity." },
|
||||
confidence: Math.min(0.9, 0.45 + value.count * 0.08),
|
||||
reason: `${value.count} uncategorized transactions share this merchant text.`,
|
||||
matchCount: value.count,
|
||||
type: "merchant-pattern",
|
||||
});
|
||||
}
|
||||
|
||||
const refundCount = transactions.filter((tx) => /refund|reversal|cashback|credit/i.test(tx.description)).length;
|
||||
if (refundCount >= 2) {
|
||||
suggestions.push({
|
||||
id: `refund_${suggestions.length + 1}`,
|
||||
name: "Tag refunds and credits",
|
||||
conditions: { textRegex: "refund|reversal|cashback|credit" },
|
||||
actions: { setCategory: "Refunds", appendNote: "Suggested from refund-like transaction descriptions." },
|
||||
confidence: Math.min(0.88, 0.5 + refundCount * 0.06),
|
||||
reason: `${refundCount} transactions look like refunds, reversals, cashback, or credits.`,
|
||||
matchCount: refundCount,
|
||||
type: "refund-pattern",
|
||||
});
|
||||
}
|
||||
|
||||
const transferCount = transactions.filter((tx) => /transfer|zelle|venmo|cash app|paypal/i.test(tx.description)).length;
|
||||
if (transferCount >= 2) {
|
||||
suggestions.push({
|
||||
id: `transfer_${suggestions.length + 1}`,
|
||||
name: "Tag transfers",
|
||||
conditions: { textRegex: "transfer|zelle|venmo|cash app|paypal" },
|
||||
actions: { setCategory: "Transfers" },
|
||||
confidence: Math.min(0.86, 0.48 + transferCount * 0.06),
|
||||
reason: `${transferCount} transactions look like transfers or peer payments.`,
|
||||
matchCount: transferCount,
|
||||
type: "transfer-pattern",
|
||||
});
|
||||
}
|
||||
|
||||
const feeCount = transactions.filter((tx) => /fee|interest charge|late charge|overdraft/i.test(tx.description)).length;
|
||||
if (feeCount >= 2) {
|
||||
suggestions.push({
|
||||
id: `fee_${suggestions.length + 1}`,
|
||||
name: "Tag bank fees",
|
||||
conditions: { textRegex: "fee|interest charge|late charge|overdraft" },
|
||||
actions: { setCategory: "Fees" },
|
||||
confidence: Math.min(0.9, 0.5 + feeCount * 0.08),
|
||||
reason: `${feeCount} transactions look like fees or charges.`,
|
||||
matchCount: feeCount,
|
||||
type: "fee-pattern",
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
private merchantKey(description: string) {
|
||||
return description
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.replace(/\b\d{2,}\b/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 48);
|
||||
}
|
||||
}
|
||||
|
||||
31
src/stripe/plan-limits.service.ts
Normal file
31
src/stripe/plan-limits.service.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PLAN_LIMITS } from "./stripe.service";
|
||||
|
||||
@Injectable()
|
||||
export class PlanLimitsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getPlan(userId: string) {
|
||||
const subscription = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
return subscription?.plan ?? "free";
|
||||
}
|
||||
|
||||
async assertCanAddAccounts(userId: string, newAccountCount: number) {
|
||||
if (newAccountCount <= 0) return;
|
||||
|
||||
const plan = await this.getPlan(userId);
|
||||
const accountLimit = PLAN_LIMITS[plan]?.accounts ?? PLAN_LIMITS.free.accounts;
|
||||
if (accountLimit < 0) return;
|
||||
|
||||
const currentAccounts = await this.prisma.account.count({
|
||||
where: { userId, isActive: true },
|
||||
});
|
||||
|
||||
if (currentAccounts + newAccountCount > accountLimit) {
|
||||
throw new ForbiddenException(
|
||||
`Your ${plan} plan allows ${accountLimit} active account${accountLimit === 1 ? "" : "s"}. Upgrade to add more accounts.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -28,19 +28,22 @@ export class StripeController {
|
||||
}
|
||||
|
||||
@Post("checkout")
|
||||
async checkout(@CurrentUser() userId: string, @Body("priceId") priceId: string) {
|
||||
async checkout(
|
||||
@CurrentUser() userId: string,
|
||||
@Body() payload: { priceId?: string; plan?: "pro" | "elite"; successUrl?: string; cancelUrl?: string },
|
||||
) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true },
|
||||
});
|
||||
if (!user) return ok({ error: "User not found" });
|
||||
const data = await this.stripeService.createCheckoutSession(userId, user.email, priceId);
|
||||
const data = await this.stripeService.createCheckoutSession(userId, user.email, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("portal")
|
||||
async portal(@CurrentUser() userId: string) {
|
||||
const data = await this.stripeService.createPortalSession(userId);
|
||||
async portal(@CurrentUser() userId: string, @Body("returnUrl") returnUrl?: string) {
|
||||
const data = await this.stripeService.createPortalSession(userId, returnUrl);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PlanLimitsService } from "./plan-limits.service";
|
||||
import { StripeController } from "./stripe.controller";
|
||||
import { StripeService } from "./stripe.service";
|
||||
import { SubscriptionGuard } from "./subscription.guard";
|
||||
|
||||
@Module({
|
||||
controllers: [StripeController],
|
||||
providers: [StripeService, SubscriptionGuard],
|
||||
exports: [StripeService, SubscriptionGuard],
|
||||
providers: [StripeService, SubscriptionGuard, PlanLimitsService],
|
||||
exports: [StripeService, SubscriptionGuard, PlanLimitsService],
|
||||
})
|
||||
export class StripeModule {}
|
||||
|
||||
@ -3,8 +3,8 @@ import Stripe from "stripe";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
export const PLAN_LIMITS: Record<string, { accounts: number; exports: number }> = {
|
||||
free: { accounts: 2, exports: 5 },
|
||||
pro: { accounts: 10, exports: 100 },
|
||||
free: { accounts: 2, exports: 0 },
|
||||
pro: { accounts: 10, exports: -1 },
|
||||
elite: { accounts: -1, exports: -1 }, // -1 = unlimited
|
||||
};
|
||||
|
||||
@ -32,28 +32,33 @@ export class StripeService {
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
async createCheckoutSession(userId: string, email: string, priceId: string) {
|
||||
async createCheckoutSession(
|
||||
userId: string,
|
||||
email: string,
|
||||
payload: { priceId?: string; plan?: "pro" | "elite"; successUrl?: string; cancelUrl?: string },
|
||||
) {
|
||||
const priceId = payload.priceId ?? this.priceIdForPlan(payload.plan);
|
||||
const customerId = await this.getOrCreateCustomer(userId, email);
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ["card"],
|
||||
mode: "subscription",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${process.env.APP_URL}/settings/billing?success=true`,
|
||||
cancel_url: `${process.env.APP_URL}/settings/billing?cancelled=true`,
|
||||
success_url: payload.successUrl ?? `${process.env.APP_URL}/settings/subscription?success=true`,
|
||||
cancel_url: payload.cancelUrl ?? `${process.env.APP_URL}/settings/subscription?cancelled=true`,
|
||||
metadata: { userId },
|
||||
});
|
||||
return { url: session.url };
|
||||
}
|
||||
|
||||
async createPortalSession(userId: string) {
|
||||
async createPortalSession(userId: string, returnUrl?: string) {
|
||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
if (!sub?.stripeCustomerId) {
|
||||
throw new BadRequestException("No Stripe customer found. Please upgrade first.");
|
||||
}
|
||||
const session = await this.stripe.billingPortal.sessions.create({
|
||||
customer: sub.stripeCustomerId,
|
||||
return_url: `${process.env.APP_URL}/settings/billing`,
|
||||
return_url: returnUrl ?? `${process.env.APP_URL}/settings/subscription`,
|
||||
});
|
||||
return { url: session.url };
|
||||
}
|
||||
@ -125,4 +130,10 @@ export class StripeService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private priceIdForPlan(plan?: "pro" | "elite") {
|
||||
if (plan === "pro" && process.env.STRIPE_PRICE_PRO) return process.env.STRIPE_PRICE_PRO;
|
||||
if (plan === "elite" && process.env.STRIPE_PRICE_ELITE) return process.env.STRIPE_PRICE_ELITE;
|
||||
throw new BadRequestException("Missing Stripe price for selected plan.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IS_PUBLIC_KEY } from "../common/guards/jwt-auth.guard";
|
||||
|
||||
export const REQUIRED_PLAN_KEY = "requiredPlan";
|
||||
export const RequiredPlan = (plan: "pro" | "elite") =>
|
||||
@ -23,6 +24,12 @@ export class SubscriptionGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const required = this.reflector.getAllAndOverride<string>(REQUIRED_PLAN_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Param, Patch, Post, Put } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { CreateTaxReturnDto } from "./dto/create-return.dto";
|
||||
import { UpdateTaxReturnDto } from "./dto/update-return.dto";
|
||||
@ -41,9 +41,41 @@ export class TaxController {
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("returns/:id/intake")
|
||||
async getIntake(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
const data = await this.taxService.getIntake(userId, id);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Put("returns/:id/intake")
|
||||
async saveIntake(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: { intake: Record<string, unknown>; submit?: boolean },
|
||||
) {
|
||||
const data = await this.taxService.saveIntake(userId, id, payload.intake, Boolean(payload.submit));
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("returns/:id/export")
|
||||
async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
const data = await this.taxService.exportReturn(userId, id);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("returns/:id/efile")
|
||||
async submitEFile(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: { consentAccepted?: boolean },
|
||||
) {
|
||||
const data = await this.taxService.submitEFile(userId, id, Boolean(payload.consentAccepted));
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("returns/:id/efile")
|
||||
async getEFileStatus(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
const data = await this.taxService.getEFileStatus(userId, id);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import * as crypto from "crypto";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateTaxReturnDto } from "./dto/create-return.dto";
|
||||
import { UpdateTaxReturnDto } from "./dto/update-return.dto";
|
||||
@ -58,6 +59,58 @@ export class TaxService {
|
||||
});
|
||||
}
|
||||
|
||||
async getIntake(userId: string, id: string) {
|
||||
const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id, userId } });
|
||||
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
||||
const summary = this.asRecord(taxReturn.summary);
|
||||
return {
|
||||
taxReturnId: taxReturn.id,
|
||||
status: taxReturn.status,
|
||||
intake: this.asRecord(summary.intake),
|
||||
readiness: this.intakeReadiness(this.asRecord(summary.intake)),
|
||||
};
|
||||
}
|
||||
|
||||
async saveIntake(userId: string, id: string, intake: Record<string, unknown>, submit = false) {
|
||||
const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id, userId } });
|
||||
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
||||
const existingSummary = this.asRecord(taxReturn.summary);
|
||||
const normalizedIntake = this.normalizeIntake(intake);
|
||||
const readiness = this.intakeReadiness(normalizedIntake);
|
||||
const nextStatus = submit && readiness.complete ? "ready" : "draft";
|
||||
|
||||
const updated = await this.prisma.taxReturn.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: nextStatus,
|
||||
summary: {
|
||||
...existingSummary,
|
||||
intake: normalizedIntake,
|
||||
intakeReadiness: readiness,
|
||||
intakeSubmittedAt: submit ? new Date().toISOString() : existingSummary.intakeSubmittedAt ?? null,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: submit ? "tax.intake_submit" : "tax.intake_save",
|
||||
metadata: {
|
||||
taxReturnId: id,
|
||||
complete: readiness.complete,
|
||||
missingFields: readiness.missingFields,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
return: updated,
|
||||
intake: normalizedIntake,
|
||||
readiness,
|
||||
};
|
||||
}
|
||||
|
||||
async exportReturn(userId: string, id: string) {
|
||||
const taxReturn = await this.prisma.taxReturn.findFirst({
|
||||
where: { id, userId },
|
||||
@ -70,6 +123,263 @@ export class TaxService {
|
||||
data: { status: "exported" },
|
||||
});
|
||||
|
||||
return { return: { ...taxReturn, status: "exported" }, documents: taxReturn.documents };
|
||||
const exportedReturn = { ...taxReturn, status: "exported" };
|
||||
const generatedAt = new Date().toISOString();
|
||||
const packageId = `taxpkg_${crypto.randomUUID()}`;
|
||||
const requiredDocuments = this.requiredDocumentsFor(exportedReturn.filingType);
|
||||
const providedDocumentTypes = new Set(taxReturn.documents.map((document) => document.docType));
|
||||
const missingDocuments = requiredDocuments.filter((docType) => !providedDocumentTypes.has(docType));
|
||||
|
||||
const summary = this.asRecord(exportedReturn.summary);
|
||||
const packageBody = {
|
||||
packageId,
|
||||
packageVersion: "2026.1",
|
||||
generatedAt,
|
||||
manifest: {
|
||||
userId,
|
||||
taxReturnId: taxReturn.id,
|
||||
taxYear: taxReturn.taxYear,
|
||||
filingType: taxReturn.filingType,
|
||||
jurisdictions: taxReturn.jurisdictions,
|
||||
status: "exported",
|
||||
documentCount: taxReturn.documents.length,
|
||||
requiredDocuments,
|
||||
missingDocuments,
|
||||
intakeReadiness: this.intakeReadiness(this.asRecord(summary.intake)),
|
||||
readiness: missingDocuments.length === 0 ? "complete" : "needs_documents",
|
||||
},
|
||||
return: exportedReturn,
|
||||
documents: taxReturn.documents.map((document) => ({
|
||||
id: document.id,
|
||||
docType: document.docType,
|
||||
metadata: document.metadata,
|
||||
createdAt: document.createdAt,
|
||||
})),
|
||||
summary,
|
||||
intake: summary.intake ?? {},
|
||||
};
|
||||
const packageHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(JSON.stringify(packageBody))
|
||||
.digest("hex");
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "tax.export_package",
|
||||
metadata: {
|
||||
taxReturnId: taxReturn.id,
|
||||
packageId,
|
||||
packageHash,
|
||||
documentCount: taxReturn.documents.length,
|
||||
missingDocuments,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...packageBody,
|
||||
packageHash,
|
||||
};
|
||||
}
|
||||
|
||||
async submitEFile(userId: string, id: string, consentAccepted: boolean) {
|
||||
if (!consentAccepted) {
|
||||
throw new BadRequestException("E-file consent must be accepted before submission.");
|
||||
}
|
||||
|
||||
const taxReturn = await this.prisma.taxReturn.findFirst({
|
||||
where: { id, userId },
|
||||
include: { documents: true },
|
||||
});
|
||||
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
||||
|
||||
const summary = this.asRecord(taxReturn.summary);
|
||||
const intake = this.asRecord(summary.intake);
|
||||
const intakeReadiness = this.intakeReadiness(intake);
|
||||
const requiredDocuments = this.requiredDocumentsFor(taxReturn.filingType);
|
||||
const providedDocumentTypes = new Set(taxReturn.documents.map((document) => document.docType));
|
||||
const missingDocuments = requiredDocuments.filter((docType) => !providedDocumentTypes.has(docType));
|
||||
if (!intakeReadiness.complete || missingDocuments.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: "Tax return is not ready for e-file submission.",
|
||||
missingFields: intakeReadiness.missingFields,
|
||||
missingDocuments,
|
||||
});
|
||||
}
|
||||
|
||||
const provider = this.resolveEFileProvider();
|
||||
const submittedAt = new Date().toISOString();
|
||||
const submissionPackage = {
|
||||
provider: provider.name,
|
||||
taxReturnId: taxReturn.id,
|
||||
userId,
|
||||
taxYear: taxReturn.taxYear,
|
||||
filingType: taxReturn.filingType,
|
||||
jurisdictions: taxReturn.jurisdictions,
|
||||
documentIds: taxReturn.documents.map((document) => document.id),
|
||||
intake,
|
||||
submittedAt,
|
||||
};
|
||||
const packageHash = crypto.createHash("sha256").update(JSON.stringify(submissionPackage)).digest("hex");
|
||||
const providerResponse = await provider.submit({ ...submissionPackage, packageHash });
|
||||
const eFile = {
|
||||
provider: provider.name,
|
||||
providerMode: provider.mode,
|
||||
submissionId: providerResponse.submissionId,
|
||||
status: providerResponse.status,
|
||||
acknowledgementId: providerResponse.acknowledgementId,
|
||||
submittedAt,
|
||||
lastCheckedAt: submittedAt,
|
||||
packageHash,
|
||||
consentAcceptedAt: submittedAt,
|
||||
};
|
||||
|
||||
const updated = await this.prisma.taxReturn.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "efile_submitted",
|
||||
summary: {
|
||||
...summary,
|
||||
eFile,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "tax.efile_submit",
|
||||
metadata: {
|
||||
taxReturnId: id,
|
||||
provider: provider.name,
|
||||
submissionId: eFile.submissionId,
|
||||
status: eFile.status,
|
||||
packageHash,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
return: updated,
|
||||
eFile,
|
||||
};
|
||||
}
|
||||
|
||||
async getEFileStatus(userId: string, id: string) {
|
||||
const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id, userId } });
|
||||
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
||||
const summary = this.asRecord(taxReturn.summary);
|
||||
const eFile = this.asRecord(summary.eFile);
|
||||
if (!eFile.submissionId) {
|
||||
return {
|
||||
taxReturnId: id,
|
||||
status: "not_submitted",
|
||||
};
|
||||
}
|
||||
|
||||
const provider = this.resolveEFileProvider(String(eFile.provider ?? ""));
|
||||
const checkedAt = new Date().toISOString();
|
||||
const providerStatus = await provider.status(String(eFile.submissionId));
|
||||
const nextEFile = {
|
||||
...eFile,
|
||||
submissionId: String(eFile.submissionId),
|
||||
status: providerStatus.status,
|
||||
acknowledgementId: providerStatus.acknowledgementId ?? eFile.acknowledgementId,
|
||||
lastCheckedAt: checkedAt,
|
||||
};
|
||||
|
||||
await this.prisma.taxReturn.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: providerStatus.status === "accepted" ? "efile_accepted" : taxReturn.status,
|
||||
summary: {
|
||||
...summary,
|
||||
eFile: nextEFile,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "tax.efile_status",
|
||||
metadata: {
|
||||
taxReturnId: id,
|
||||
provider: provider.name,
|
||||
submissionId: nextEFile.submissionId,
|
||||
status: nextEFile.status,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
taxReturnId: id,
|
||||
eFile: nextEFile,
|
||||
};
|
||||
}
|
||||
|
||||
private requiredDocumentsFor(filingType: string) {
|
||||
if (filingType === "business") {
|
||||
return ["income_statement", "balance_sheet", "bank_statements", "entity_information"];
|
||||
}
|
||||
return ["w2_or_1099", "interest_and_dividend_forms", "deduction_support", "identity_information"];
|
||||
}
|
||||
|
||||
private normalizeIntake(intake: Record<string, unknown>) {
|
||||
return {
|
||||
taxpayer: this.asRecord(intake.taxpayer),
|
||||
income: this.asRecord(intake.income),
|
||||
deductions: this.asRecord(intake.deductions),
|
||||
credits: this.asRecord(intake.credits),
|
||||
notes: typeof intake.notes === "string" ? intake.notes : "",
|
||||
};
|
||||
}
|
||||
|
||||
private intakeReadiness(intake: Record<string, unknown>) {
|
||||
const taxpayer = this.asRecord(intake.taxpayer);
|
||||
const income = this.asRecord(intake.income);
|
||||
const missingFields = [
|
||||
["taxpayer.name", taxpayer.name],
|
||||
["taxpayer.filingStatus", taxpayer.filingStatus],
|
||||
["taxpayer.address", taxpayer.address],
|
||||
["income.total", income.total],
|
||||
]
|
||||
.filter(([, value]) => value === undefined || value === null || value === "")
|
||||
.map(([field]) => field as string);
|
||||
return {
|
||||
complete: missingFields.length === 0,
|
||||
missingFields,
|
||||
};
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, any> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, any>
|
||||
: {};
|
||||
}
|
||||
|
||||
private resolveEFileProvider(expectedProvider?: string) {
|
||||
const configured = (process.env.TAX_EFILE_PROVIDER ?? "sandbox").toLowerCase();
|
||||
if (expectedProvider && expectedProvider !== "sandbox") {
|
||||
throw new BadRequestException("Configured e-file provider does not match the stored submission.");
|
||||
}
|
||||
if (configured !== "sandbox" && configured !== "mock") {
|
||||
throw new BadRequestException(`E-file provider '${configured}' is not configured in this deployment.`);
|
||||
}
|
||||
return {
|
||||
name: "sandbox",
|
||||
mode: "test",
|
||||
submit: async (payload: { taxReturnId: string; packageHash: string }) => ({
|
||||
submissionId: `efile_${crypto.createHash("sha256").update(`${payload.taxReturnId}:${payload.packageHash}`).digest("hex").slice(0, 24)}`,
|
||||
status: "submitted",
|
||||
acknowledgementId: `ack_${crypto.randomUUID()}`,
|
||||
}),
|
||||
status: async (submissionId: string) => ({
|
||||
submissionId,
|
||||
status: "accepted",
|
||||
acknowledgementId: `ack_${crypto.createHash("sha256").update(submissionId).digest("hex").slice(0, 24)}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
38
src/teller/teller.controller.ts
Normal file
38
src/teller/teller.controller.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { TellerService } from "./teller.service";
|
||||
|
||||
@Controller("teller")
|
||||
export class TellerController {
|
||||
constructor(private readonly tellerService: TellerService) {}
|
||||
|
||||
@Get("config")
|
||||
config() {
|
||||
return ok(this.tellerService.getConnectConfig());
|
||||
}
|
||||
|
||||
@Post("enrollment")
|
||||
async enrollment(
|
||||
@CurrentUser() userId: string,
|
||||
@Body() payload: TellerEnrollmentPayload,
|
||||
) {
|
||||
const data = await this.tellerService.exchangeEnrollment(userId, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("sync")
|
||||
async sync(@CurrentUser() userId: string) {
|
||||
const data = await this.tellerService.syncTransactionsForUser(userId);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
export type TellerEnrollmentPayload = {
|
||||
accessToken: string;
|
||||
user?: { id?: string };
|
||||
enrollment?: {
|
||||
id?: string;
|
||||
institution?: { name?: string };
|
||||
};
|
||||
};
|
||||
12
src/teller/teller.module.ts
Normal file
12
src/teller/teller.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { StripeModule } from "../stripe/stripe.module";
|
||||
import { TellerController } from "./teller.controller";
|
||||
import { TellerService } from "./teller.service";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule],
|
||||
controllers: [TellerController],
|
||||
providers: [TellerService],
|
||||
exports: [TellerService],
|
||||
})
|
||||
export class TellerModule {}
|
||||
304
src/teller/teller.service.ts
Normal file
304
src/teller/teller.service.ts
Normal file
@ -0,0 +1,304 @@
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import * as fs from "fs";
|
||||
import * as https from "https";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EncryptionService } from "../common/encryption.service";
|
||||
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
||||
import { TellerEnrollmentPayload } from "./teller.controller";
|
||||
|
||||
@Injectable()
|
||||
export class TellerService {
|
||||
private readonly logger = new Logger(TellerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly encryption: EncryptionService,
|
||||
private readonly planLimits: PlanLimitsService,
|
||||
) {}
|
||||
|
||||
getConnectConfig() {
|
||||
const applicationId = process.env.TELLER_APPLICATION_ID;
|
||||
if (!applicationId) {
|
||||
throw new BadRequestException("Teller is not configured.");
|
||||
}
|
||||
return {
|
||||
applicationId,
|
||||
environment: process.env.TELLER_ENV ?? "sandbox",
|
||||
products: this.getProducts(),
|
||||
};
|
||||
}
|
||||
|
||||
async exchangeEnrollment(userId: string, payload: TellerEnrollmentPayload) {
|
||||
if (!payload.accessToken) {
|
||||
throw new BadRequestException("Missing Teller access token.");
|
||||
}
|
||||
|
||||
const accounts = await this.api<TellerAccount[]>("/accounts", payload.accessToken);
|
||||
const encryptedToken = this.encryption.encrypt(payload.accessToken);
|
||||
const incomingAccountIds = accounts.map((account) => account.id);
|
||||
const existingAccounts = await this.prisma.account.findMany({
|
||||
where: { userId, tellerAccountId: { in: incomingAccountIds } },
|
||||
select: { tellerAccountId: true },
|
||||
});
|
||||
const existingIds = new Set(existingAccounts.map((account) => account.tellerAccountId));
|
||||
const newAccountCount = incomingAccountIds.filter((id) => !existingIds.has(id)).length;
|
||||
await this.planLimits.assertCanAddAccounts(userId, newAccountCount);
|
||||
let imported = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
const balances = account.links?.balances
|
||||
? await this.safeBalances(account.id, payload.accessToken)
|
||||
: null;
|
||||
|
||||
await this.prisma.account.upsert({
|
||||
where: { tellerAccountId: account.id },
|
||||
update: {
|
||||
userId,
|
||||
institutionName: account.institution?.name ?? payload.enrollment?.institution?.name ?? "Teller institution",
|
||||
accountType: account.subtype ?? account.type,
|
||||
mask: account.last_four ?? null,
|
||||
tellerAccessToken: encryptedToken,
|
||||
tellerEnrollmentId: account.enrollment_id ?? payload.enrollment?.id ?? null,
|
||||
currentBalance: balances?.ledger ? new Prisma.Decimal(balances.ledger) : null,
|
||||
availableBalance: balances?.available ? new Prisma.Decimal(balances.available) : null,
|
||||
isoCurrencyCode: account.currency ?? null,
|
||||
lastBalanceSync: balances ? new Date() : undefined,
|
||||
syncStatus: account.status === "open" ? "idle" : "attention_required",
|
||||
lastSyncError: account.status === "open" ? null : `Teller account status: ${account.status}`,
|
||||
isActive: account.status !== "closed",
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
institutionName: account.institution?.name ?? payload.enrollment?.institution?.name ?? "Teller institution",
|
||||
accountType: account.subtype ?? account.type,
|
||||
mask: account.last_four ?? null,
|
||||
tellerAccessToken: encryptedToken,
|
||||
tellerEnrollmentId: account.enrollment_id ?? payload.enrollment?.id ?? null,
|
||||
tellerAccountId: account.id,
|
||||
currentBalance: balances?.ledger ? new Prisma.Decimal(balances.ledger) : null,
|
||||
availableBalance: balances?.available ? new Prisma.Decimal(balances.available) : null,
|
||||
isoCurrencyCode: account.currency ?? null,
|
||||
lastBalanceSync: balances ? new Date() : null,
|
||||
syncStatus: account.status === "open" ? "idle" : "attention_required",
|
||||
lastSyncError: account.status === "open" ? null : `Teller account status: ${account.status}`,
|
||||
isActive: account.status !== "closed",
|
||||
},
|
||||
});
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
enrollmentId: payload.enrollment?.id ?? accounts[0]?.enrollment_id ?? null,
|
||||
accountCount: imported,
|
||||
};
|
||||
}
|
||||
|
||||
async syncTransactionsForUser(userId: string) {
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: { userId, tellerAccessToken: { not: null }, tellerAccountId: { not: null }, isActive: true },
|
||||
});
|
||||
|
||||
let created = 0;
|
||||
for (const account of accounts) {
|
||||
if (!account.tellerAccessToken || !account.tellerAccountId) continue;
|
||||
const accessToken = this.encryption.decrypt(account.tellerAccessToken);
|
||||
try {
|
||||
const transactions = await this.api<TellerTransaction[]>(
|
||||
`/accounts/${encodeURIComponent(account.tellerAccountId)}/transactions`,
|
||||
accessToken,
|
||||
);
|
||||
|
||||
for (const tx of transactions) {
|
||||
await this.prisma.transactionRaw.upsert({
|
||||
where: { bankTransactionId: tx.id },
|
||||
update: {
|
||||
accountId: account.id,
|
||||
date: new Date(tx.date),
|
||||
amount: new Prisma.Decimal(tx.amount),
|
||||
description: tx.description ?? "Teller transaction",
|
||||
rawPayload: tx as unknown as Prisma.InputJsonValue,
|
||||
source: "teller",
|
||||
ingestedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
accountId: account.id,
|
||||
bankTransactionId: tx.id,
|
||||
date: new Date(tx.date),
|
||||
amount: new Prisma.Decimal(tx.amount),
|
||||
description: tx.description ?? "Teller transaction",
|
||||
rawPayload: tx as unknown as Prisma.InputJsonValue,
|
||||
source: "teller",
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
|
||||
await this.prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
syncStatus: "idle",
|
||||
lastTransactionSync: new Date(),
|
||||
lastSyncAttemptAt: new Date(),
|
||||
lastSyncError: null,
|
||||
syncConsecutiveFailures: 0,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Teller transaction sync failed.";
|
||||
await this.prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
syncStatus: this.isDisconnectedError(message) ? "needs_reauth" : "error",
|
||||
lastSyncAttemptAt: new Date(),
|
||||
lastSyncError: message.slice(0, 500),
|
||||
syncConsecutiveFailures: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { created };
|
||||
}
|
||||
|
||||
async syncBalancesForUser(userId: string) {
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: { userId, tellerAccessToken: { not: null }, tellerAccountId: { not: null }, isActive: true },
|
||||
});
|
||||
|
||||
let updated = 0;
|
||||
for (const account of accounts) {
|
||||
if (!account.tellerAccessToken || !account.tellerAccountId) continue;
|
||||
const accessToken = this.encryption.decrypt(account.tellerAccessToken);
|
||||
const balances = await this.safeBalances(account.tellerAccountId, accessToken);
|
||||
if (!balances) continue;
|
||||
await this.prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
currentBalance: balances.ledger ? new Prisma.Decimal(balances.ledger) : null,
|
||||
availableBalance: balances.available ? new Prisma.Decimal(balances.available) : null,
|
||||
lastBalanceSync: new Date(),
|
||||
syncStatus: "idle",
|
||||
lastSyncError: null,
|
||||
},
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
return { updated };
|
||||
}
|
||||
|
||||
private async safeBalances(accountId: string, accessToken: string) {
|
||||
try {
|
||||
return await this.api<TellerBalances>(`/accounts/${encodeURIComponent(accountId)}/balances`, accessToken);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Teller balances unavailable for ${accountId}: ${error instanceof Error ? error.message : "unknown error"}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async api<T>(path: string, accessToken: string): Promise<T> {
|
||||
const baseUrl = process.env.TELLER_API_BASE_URL ?? "https://api.teller.io";
|
||||
const url = new URL(path, baseUrl);
|
||||
const auth = Buffer.from(`${accessToken}:`).toString("base64");
|
||||
const agent = this.createAgent();
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const req = https.request(
|
||||
url,
|
||||
{
|
||||
method: "GET",
|
||||
agent,
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
res.on("end", () => {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if ((res.statusCode ?? 500) >= 400) {
|
||||
reject(new Error(this.formatError(res.statusCode ?? 500, raw)));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(raw ? (JSON.parse(raw) as T) : ({} as T));
|
||||
} catch {
|
||||
reject(new Error("Teller returned invalid JSON."));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
private createAgent() {
|
||||
const cert = this.readSecret("TELLER_CERT_PEM", "TELLER_CERT_PATH");
|
||||
const key = this.readSecret("TELLER_KEY_PEM", "TELLER_KEY_PATH");
|
||||
if (!cert && !key) return undefined;
|
||||
if (!cert || !key) {
|
||||
throw new BadRequestException("Both Teller certificate and key are required for mTLS.");
|
||||
}
|
||||
return new https.Agent({ cert, key });
|
||||
}
|
||||
|
||||
private readSecret(valueEnv: string, pathEnv: string) {
|
||||
const inline = process.env[valueEnv]?.replace(/\\n/g, "\n");
|
||||
if (inline) return inline;
|
||||
const filePath = process.env[pathEnv];
|
||||
if (!filePath) return "";
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
private formatError(statusCode: number, raw: string) {
|
||||
try {
|
||||
const payload = JSON.parse(raw) as { error?: { code?: string; message?: string }; code?: string; message?: string };
|
||||
return payload.error?.message ?? payload.message ?? payload.error?.code ?? payload.code ?? `Teller API failed with ${statusCode}.`;
|
||||
} catch {
|
||||
return raw || `Teller API failed with ${statusCode}.`;
|
||||
}
|
||||
}
|
||||
|
||||
private isDisconnectedError(message: string) {
|
||||
return /disconnected|credentials|mfa|required|closed/i.test(message);
|
||||
}
|
||||
|
||||
private getProducts() {
|
||||
return (process.env.TELLER_PRODUCTS ?? "transactions,balance")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
type TellerAccount = {
|
||||
id: string;
|
||||
enrollment_id?: string;
|
||||
institution?: { id?: string; name?: string };
|
||||
type: string;
|
||||
subtype?: string;
|
||||
currency?: string;
|
||||
last_four?: string;
|
||||
status?: string;
|
||||
links?: {
|
||||
balances?: string;
|
||||
transactions?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type TellerBalances = {
|
||||
ledger?: string | null;
|
||||
available?: string | null;
|
||||
};
|
||||
|
||||
type TellerTransaction = {
|
||||
id: string;
|
||||
account_id: string;
|
||||
amount: string;
|
||||
date: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@ -1,10 +1,12 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PlaidService } from "../plaid/plaid.service";
|
||||
|
||||
@Injectable()
|
||||
export class AutoSyncService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(AutoSyncService.name);
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@ -14,17 +16,19 @@ export class AutoSyncService implements OnModuleInit, OnModuleDestroy {
|
||||
onModuleInit() {
|
||||
const enabled = (process.env.AUTO_SYNC_ENABLED ?? "true").toLowerCase() !== "false";
|
||||
if (!enabled) {
|
||||
this.logger.log("Auto-sync disabled.");
|
||||
return;
|
||||
}
|
||||
if (!process.env.PLAID_CLIENT_ID || !process.env.PLAID_SECRET) {
|
||||
this.logger.warn("Auto-sync disabled because Plaid credentials are missing.");
|
||||
return;
|
||||
}
|
||||
const minutes = Number(process.env.AUTO_SYNC_INTERVAL_MINUTES ?? "15");
|
||||
const interval = Number.isNaN(minutes) ? 15 : minutes;
|
||||
const interval = Number.isNaN(minutes) || minutes < 1 ? 15 : minutes;
|
||||
this.intervalId = setInterval(() => {
|
||||
this.run().catch(() => undefined);
|
||||
this.runOnce().catch((error) => this.logger.error(error));
|
||||
}, interval * 60 * 1000);
|
||||
this.run().catch(() => undefined);
|
||||
this.runOnce().catch((error) => this.logger.error(error));
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
@ -34,21 +38,61 @@ export class AutoSyncService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async run() {
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: { plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
||||
select: { userId: true }
|
||||
});
|
||||
const userIds = Array.from(new Set(accounts.map((acct) => acct.userId)));
|
||||
if (!userIds.length) {
|
||||
return;
|
||||
async runOnce() {
|
||||
if (this.running) {
|
||||
this.logger.debug("Auto-sync skipped because a previous run is still active.");
|
||||
return { skipped: true, reason: "already_running" };
|
||||
}
|
||||
const endDate = new Date().toISOString().slice(0, 10);
|
||||
const startDate = new Date(new Date().setDate(new Date().getDate() - 7))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
for (const userId of userIds) {
|
||||
await this.plaidService.syncTransactionsForUser(userId, startDate, endDate);
|
||||
this.running = true;
|
||||
try {
|
||||
return await this.run();
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async run() {
|
||||
const intervalMinutes = Number(process.env.AUTO_SYNC_INTERVAL_MINUTES ?? "15");
|
||||
const staleMinutes = Number(process.env.AUTO_SYNC_STALE_MINUTES ?? intervalMinutes);
|
||||
const maxUsers = Number(process.env.AUTO_SYNC_MAX_USERS_PER_RUN ?? "25");
|
||||
const cutoff = new Date(Date.now() - (Number.isNaN(staleMinutes) ? 15 : staleMinutes) * 60 * 1000);
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
plaidAccessToken: { not: null },
|
||||
plaidAccountId: { not: null },
|
||||
syncStatus: { not: "syncing" },
|
||||
OR: [
|
||||
{ lastTransactionSync: null },
|
||||
{ lastTransactionSync: { lte: cutoff } },
|
||||
{ syncStatus: "error", lastSyncAttemptAt: { lte: cutoff } },
|
||||
],
|
||||
},
|
||||
distinct: ["userId"],
|
||||
orderBy: { lastTransactionSync: "asc" },
|
||||
take: Number.isNaN(maxUsers) ? 25 : Math.max(1, maxUsers),
|
||||
select: { userId: true }
|
||||
});
|
||||
const userIds = accounts.map((acct) => acct.userId);
|
||||
if (!userIds.length) {
|
||||
return { usersScanned: 0, usersSynced: 0, created: 0, failed: 0 };
|
||||
}
|
||||
const endDate = new Date().toISOString().slice(0, 10);
|
||||
const lookbackDays = Number(process.env.AUTO_SYNC_LOOKBACK_DAYS ?? "7");
|
||||
const startDate = new Date(new Date().setDate(new Date().getDate() - (Number.isNaN(lookbackDays) ? 7 : lookbackDays)))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
let created = 0;
|
||||
let failed = 0;
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate);
|
||||
created += result.created;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
this.logger.warn(`Auto-sync failed for user ${userId}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
return { usersScanned: userIds.length, usersSynced: userIds.length - failed, created, failed };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, IsDateString } from "class-validator";
|
||||
import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsDateString, Max, Min } from "class-validator";
|
||||
|
||||
export class CreateManualTransactionDto {
|
||||
@IsOptional()
|
||||
@ -22,6 +22,26 @@ export class CreateManualTransactionDto {
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["mine", "yours", "ours"])
|
||||
attribution?: "mine" | "yours" | "ours";
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["none", "equal", "custom"])
|
||||
splitMode?: "none" | "equal" | "custom";
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
splitMinePercent?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
splitYoursPercent?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hidden?: boolean;
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
export type UpdateDerivedDto = {
|
||||
userCategory?: string;
|
||||
userNotes?: string;
|
||||
attribution?: "mine" | "yours" | "ours";
|
||||
splitMode?: "none" | "equal" | "custom";
|
||||
splitMinePercent?: number;
|
||||
splitYoursPercent?: number;
|
||||
isHidden?: boolean;
|
||||
};
|
||||
|
||||
@ -7,9 +7,10 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ok } from "../common/response";
|
||||
import { UpdateDerivedDto } from "./dto/update-derived.dto";
|
||||
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
|
||||
@ -33,7 +34,7 @@ export class TransactionsController {
|
||||
@Query("search") search?: string,
|
||||
@Query("include_hidden") includeHidden?: string,
|
||||
@Query("page") page = 1,
|
||||
@Query("limit") limit = 50,
|
||||
@Query("limit") limit = 25,
|
||||
) {
|
||||
const data = await this.transactionsService.list(userId, {
|
||||
startDate,
|
||||
@ -56,8 +57,31 @@ export class TransactionsController {
|
||||
async importCsv(
|
||||
@CurrentUser() userId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body("mapping") mapping?: string,
|
||||
) {
|
||||
const data = await this.transactionsService.importCsv(userId, file);
|
||||
const data = await this.transactionsService.importCsv(userId, file, mapping);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("import/preview")
|
||||
@UseInterceptors(FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async previewCsv(
|
||||
@CurrentUser() userId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
const data = await this.transactionsService.previewCsv(userId, file);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("import/batch")
|
||||
@UseInterceptors(FilesInterceptor("files", 20, { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async importCsvBatch(
|
||||
@CurrentUser() userId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Body("mapping") mapping?: string,
|
||||
@Body("mappings") mappings?: string,
|
||||
) {
|
||||
const data = await this.transactionsService.importCsvBatch(userId, files, { mapping, mappings });
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PlaidModule } from "../plaid/plaid.module";
|
||||
import { ExportsModule } from "../exports/exports.module";
|
||||
import { TransactionsController } from "./transactions.controller";
|
||||
import { TransactionsService } from "./transactions.service";
|
||||
import { AutoSyncService } from "./auto-sync.service";
|
||||
|
||||
@Module({
|
||||
imports: [PlaidModule],
|
||||
imports: [PlaidModule, ExportsModule],
|
||||
controllers: [TransactionsController],
|
||||
providers: [TransactionsService, AutoSyncService]
|
||||
providers: [TransactionsService, AutoSyncService],
|
||||
exports: [TransactionsService],
|
||||
})
|
||||
export class TransactionsModule {}
|
||||
|
||||
@ -4,15 +4,29 @@ import { parse } from "csv-parse/sync";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PlaidService } from "../plaid/plaid.service";
|
||||
import { OpaqueIdService } from "../common/opaque-id.service";
|
||||
import { ExportsService } from "../exports/exports.service";
|
||||
import { UpdateDerivedDto } from "./dto/update-derived.dto";
|
||||
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
const UI_PAGE_SIZE_LIMIT = 25;
|
||||
const TRANSACTION_ATTRIBUTIONS = ["mine", "yours", "ours"] as const;
|
||||
type TransactionAttribution = typeof TRANSACTION_ATTRIBUTIONS[number];
|
||||
const TRANSACTION_SPLIT_MODES = ["none", "equal", "custom"] as const;
|
||||
type TransactionSplitMode = typeof TRANSACTION_SPLIT_MODES[number];
|
||||
|
||||
// ─── Bank CSV format auto-detection ──────────────────────────────────────────
|
||||
type ParsedRow = { date: string; description: string; amount: number };
|
||||
type CsvMapping = {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
category?: string;
|
||||
notes?: string;
|
||||
amountMultiplier?: number;
|
||||
};
|
||||
|
||||
function detectAndParse(buffer: Buffer): ParsedRow[] {
|
||||
function parseCsvRecords(buffer: Buffer) {
|
||||
const text = buffer.toString("utf8").trim();
|
||||
const rows: Record<string, string>[] = parse(text, {
|
||||
columns: true,
|
||||
@ -20,6 +34,65 @@ function detectAndParse(buffer: Buffer): ParsedRow[] {
|
||||
trim: true,
|
||||
bom: true,
|
||||
});
|
||||
const headers = rows.length ? Object.keys(rows[0]) : [];
|
||||
return { rows, headers };
|
||||
}
|
||||
|
||||
function headerSignature(headers: string[]) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(headers.map((header) => header.trim().toLowerCase()).join("|"))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function findHeader(headers: string[], pattern: RegExp) {
|
||||
return headers.find((header) => pattern.test(header.trim().toLowerCase()));
|
||||
}
|
||||
|
||||
function inferMapping(headers: string[]): Partial<CsvMapping> {
|
||||
return {
|
||||
date: findHeader(headers, /^(transaction\s*)?date$|posted|posting/) ?? "",
|
||||
description: findHeader(headers, /description|desc|memo|narr|payee|merchant/) ?? "",
|
||||
amount: findHeader(headers, /amount|debit|credit|withdrawal|deposit/) ?? "",
|
||||
category: findHeader(headers, /category/) ?? undefined,
|
||||
notes: findHeader(headers, /note|notes/) ?? undefined,
|
||||
amountMultiplier: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMapping(input: unknown, headers: string[]): CsvMapping {
|
||||
const mapping = typeof input === "string" ? JSON.parse(input) as CsvMapping : input as CsvMapping;
|
||||
const required = ["date", "description", "amount"] as const;
|
||||
for (const key of required) {
|
||||
if (!mapping?.[key] || !headers.includes(mapping[key])) {
|
||||
throw new BadRequestException(`CSV mapping requires a valid ${key} column.`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
date: mapping.date,
|
||||
description: mapping.description,
|
||||
amount: mapping.amount,
|
||||
category: mapping.category && headers.includes(mapping.category) ? mapping.category : undefined,
|
||||
notes: mapping.notes && headers.includes(mapping.notes) ? mapping.notes : undefined,
|
||||
amountMultiplier: mapping.amountMultiplier === -1 ? -1 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWithMapping(buffer: Buffer, mapping: CsvMapping): ParsedRow[] {
|
||||
const { rows, headers } = parseCsvRecords(buffer);
|
||||
const normalized = normalizeMapping(mapping, headers);
|
||||
return rows.map((row) => {
|
||||
const amountRaw = row[normalized.amount] ?? "0";
|
||||
return {
|
||||
date: row[normalized.date],
|
||||
description: row[normalized.description],
|
||||
amount: parseFloat(amountRaw.replace(/[^0-9.-]/g, "")) * (normalized.amountMultiplier ?? 1),
|
||||
};
|
||||
}).filter((row) => row.date && row.description && !Number.isNaN(row.amount));
|
||||
}
|
||||
|
||||
function detectAndParse(buffer: Buffer): ParsedRow[] {
|
||||
const { rows } = parseCsvRecords(buffer);
|
||||
|
||||
if (!rows.length) return [];
|
||||
const headers = Object.keys(rows[0]).map((h) => h.toLowerCase());
|
||||
@ -68,13 +141,29 @@ function detectAndParse(buffer: Buffer): ParsedRow[] {
|
||||
throw new BadRequestException("Unrecognized CSV format. Supported: Chase, Bank of America, Wells Fargo, or generic (date/amount/description columns).");
|
||||
}
|
||||
|
||||
function parseOptionalJson<T>(value: unknown): T | undefined {
|
||||
if (!value) return undefined;
|
||||
if (typeof value !== "string") return value as T;
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransactionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly plaidService: PlaidService,
|
||||
private readonly opaqueIds: OpaqueIdService,
|
||||
private readonly exportsService?: ExportsService,
|
||||
) {}
|
||||
|
||||
private async syncGoogleSheetsBestEffort(userId: string, reason: string) {
|
||||
try {
|
||||
await this.exportsService?.syncGoogleSheets(userId, reason);
|
||||
} catch {
|
||||
// Google Sheets live sync is best-effort and must not block ledger writes.
|
||||
}
|
||||
}
|
||||
|
||||
async list(
|
||||
userId: string,
|
||||
filters: {
|
||||
@ -120,20 +209,29 @@ export class TransactionsService {
|
||||
}
|
||||
|
||||
if (filters.accountId) {
|
||||
where.accountId = filters.accountId;
|
||||
where.accountId = this.opaqueIds.decode("account", userId, filters.accountId);
|
||||
}
|
||||
|
||||
if (filters.includeHidden !== "true") {
|
||||
where.OR = [{ derived: null }, { derived: { isHidden: false } }];
|
||||
}
|
||||
|
||||
const take = Math.min(filters.limit ?? 50, MAX_PAGE_SIZE);
|
||||
const requestedLimit = Number.isFinite(filters.limit) && filters.limit ? filters.limit : UI_PAGE_SIZE_LIMIT;
|
||||
const take = Math.min(Math.max(requestedLimit, 1), UI_PAGE_SIZE_LIMIT);
|
||||
const skip = ((filters.page ?? 1) - 1) * take;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.transactionRaw.findMany({
|
||||
where,
|
||||
include: { derived: true },
|
||||
include: {
|
||||
derived: true,
|
||||
account: {
|
||||
select: {
|
||||
ownershipType: true,
|
||||
ownerUserId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "desc" },
|
||||
take,
|
||||
skip,
|
||||
@ -142,22 +240,24 @@ export class TransactionsService {
|
||||
]);
|
||||
|
||||
const transactions = rows.map((row) => ({
|
||||
id: row.id,
|
||||
id: this.opaqueIds.encode("transaction", userId, row.id),
|
||||
name: row.description,
|
||||
amount: Number(row.amount).toFixed(2),
|
||||
category: row.derived?.userCategory ?? "Uncategorized",
|
||||
note: row.derived?.userNotes ?? "",
|
||||
attribution: this.resolveAttribution(row.derived?.attribution, row.account),
|
||||
split: this.resolveSplit(row.derived, Number(row.amount)),
|
||||
status: row.derived?.modifiedBy ?? "raw",
|
||||
hidden: row.derived?.isHidden ?? false,
|
||||
date: row.date.toISOString().slice(0, 10),
|
||||
source: row.source,
|
||||
accountId: row.accountId,
|
||||
accountId: this.opaqueIds.encode("account", userId, row.accountId),
|
||||
}));
|
||||
|
||||
return { transactions, total, page: filters.page ?? 1, limit: take };
|
||||
}
|
||||
|
||||
async importCsv(userId: string, file: Express.Multer.File) {
|
||||
async previewCsv(userId: string, file: Express.Multer.File) {
|
||||
if (!file?.buffer) {
|
||||
throw new BadRequestException("No file uploaded.");
|
||||
}
|
||||
@ -165,11 +265,51 @@ export class TransactionsService {
|
||||
throw new BadRequestException("File must be a CSV.");
|
||||
}
|
||||
|
||||
const rows = detectAndParse(file.buffer);
|
||||
const { rows, headers } = parseCsvRecords(file.buffer);
|
||||
if (!rows.length) {
|
||||
throw new BadRequestException("CSV file is empty or could not be parsed.");
|
||||
}
|
||||
const signature = headerSignature(headers);
|
||||
const remembered = await this.prisma.csvImportMapping.findUnique({
|
||||
where: { userId_headerSignature: { userId, headerSignature: signature } },
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: file.originalname,
|
||||
headerSignature: signature,
|
||||
headers,
|
||||
sampleRows: rows.slice(0, 5),
|
||||
mapping: remembered?.mapping ?? inferMapping(headers),
|
||||
remembered: Boolean(remembered),
|
||||
};
|
||||
}
|
||||
|
||||
async importCsv(userId: string, file: Express.Multer.File, mapping?: unknown, syncAfter = true) {
|
||||
if (!file?.buffer) {
|
||||
throw new BadRequestException("No file uploaded.");
|
||||
}
|
||||
if (!file.originalname.toLowerCase().endsWith(".csv")) {
|
||||
throw new BadRequestException("File must be a CSV.");
|
||||
}
|
||||
|
||||
const rows = mapping ? parseWithMapping(file.buffer, parseOptionalJson<CsvMapping>(mapping) as CsvMapping) : detectAndParse(file.buffer);
|
||||
if (!rows.length) {
|
||||
throw new BadRequestException("CSV file is empty or could not be parsed.");
|
||||
}
|
||||
|
||||
if (mapping) {
|
||||
const { headers } = parseCsvRecords(file.buffer);
|
||||
await this.rememberCsvMapping(userId, headers, parseOptionalJson<CsvMapping>(mapping), file.originalname);
|
||||
}
|
||||
|
||||
const result = await this.importParsedRows(userId, rows);
|
||||
if (syncAfter && result.imported > 0) {
|
||||
await this.syncGoogleSheetsBestEffort(userId, "csv_import");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async importParsedRows(userId: string, rows: ParsedRow[]) {
|
||||
// Find or create a manual import account for this user
|
||||
let account = await this.prisma.account.findFirst({
|
||||
where: { userId, institutionName: "CSV Import", plaidAccessToken: null },
|
||||
@ -223,9 +363,83 @@ export class TransactionsService {
|
||||
return { imported, skipped, total: rows.length };
|
||||
}
|
||||
|
||||
private async rememberCsvMapping(userId: string, headers: string[], mapping: unknown, name?: string) {
|
||||
const normalized = normalizeMapping(mapping, headers);
|
||||
const signature = headerSignature(headers);
|
||||
await this.prisma.csvImportMapping.upsert({
|
||||
where: { userId_headerSignature: { userId, headerSignature: signature } },
|
||||
update: {
|
||||
name,
|
||||
mapping: normalized as unknown as Prisma.InputJsonValue,
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
headerSignature: signature,
|
||||
name,
|
||||
mapping: normalized as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async importCsvBatch(userId: string, files: Express.Multer.File[] = [], options?: { mapping?: unknown; mappings?: unknown }) {
|
||||
if (!files.length) {
|
||||
throw new BadRequestException("No CSV files uploaded.");
|
||||
}
|
||||
const sharedMapping = parseOptionalJson<CsvMapping>(options?.mapping);
|
||||
const mappingsByFile = parseOptionalJson<Record<string, CsvMapping>>(options?.mappings);
|
||||
|
||||
const results: Array<{
|
||||
fileName: string;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const mapping = mappingsByFile?.[file.originalname] ?? sharedMapping;
|
||||
const result = await this.importCsv(userId, file, mapping, false);
|
||||
results.push({
|
||||
fileName: file.originalname,
|
||||
imported: result.imported,
|
||||
skipped: result.skipped,
|
||||
total: result.total,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "CSV import failed.";
|
||||
results.push({
|
||||
fileName: file?.originalname ?? "unknown.csv",
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
total: 0,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
totalFiles: files.length,
|
||||
processedFiles: results.filter((result) => !result.error).length,
|
||||
failedFiles: results.filter((result) => result.error).length,
|
||||
imported: results.reduce((sum, result) => sum + result.imported, 0),
|
||||
skipped: results.reduce((sum, result) => sum + result.skipped, 0),
|
||||
total: results.reduce((sum, result) => sum + result.total, 0),
|
||||
results,
|
||||
};
|
||||
if (summary.imported > 0) {
|
||||
await this.syncGoogleSheetsBestEffort(userId, "csv_batch_import");
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
async createManualTransaction(userId: string, payload: CreateManualTransactionDto) {
|
||||
const account = payload.accountId
|
||||
? await this.prisma.account.findFirst({ where: { id: payload.accountId, userId } })
|
||||
const accountId = payload.accountId
|
||||
? this.opaqueIds.decode("account", userId, payload.accountId)
|
||||
: undefined;
|
||||
const account = accountId
|
||||
? await this.prisma.account.findFirst({ where: { id: accountId, userId } })
|
||||
: await this.prisma.account.findFirst({ where: { userId } });
|
||||
|
||||
if (!account) {
|
||||
@ -246,12 +460,18 @@ export class TransactionsService {
|
||||
},
|
||||
});
|
||||
|
||||
if (payload.category || payload.note || payload.hidden) {
|
||||
const attribution = this.normalizeAttribution(payload.attribution);
|
||||
const split = this.normalizeSplit(payload);
|
||||
if (payload.category || payload.note || payload.hidden || attribution || split.mode !== "none") {
|
||||
await this.prisma.transactionDerived.create({
|
||||
data: {
|
||||
rawTransactionId: raw.id,
|
||||
userCategory: payload.category ?? null,
|
||||
userNotes: payload.note ?? null,
|
||||
attribution: attribution ?? this.defaultAttributionForAccount(account),
|
||||
splitMode: split.mode,
|
||||
splitMinePercent: split.minePercent,
|
||||
splitYoursPercent: split.yoursPercent,
|
||||
isHidden: payload.hidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "user",
|
||||
@ -259,38 +479,63 @@ export class TransactionsService {
|
||||
});
|
||||
}
|
||||
|
||||
return { id: raw.id };
|
||||
await this.syncGoogleSheetsBestEffort(userId, "manual_transaction");
|
||||
|
||||
return { id: this.opaqueIds.encode("transaction", userId, raw.id) };
|
||||
}
|
||||
|
||||
async updateDerived(userId: string, id: string, payload: UpdateDerivedDto) {
|
||||
const transactionId = this.opaqueIds.decode("transaction", userId, id);
|
||||
const attribution = this.normalizeAttribution(payload.attribution);
|
||||
const split = this.normalizeSplit(payload);
|
||||
// Ensure the transaction belongs to the user
|
||||
const tx = await this.prisma.transactionRaw.findFirst({
|
||||
where: { id, account: { userId } },
|
||||
where: { id: transactionId, account: { userId } },
|
||||
include: {
|
||||
account: {
|
||||
select: {
|
||||
ownershipType: true,
|
||||
ownerUserId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!tx) throw new BadRequestException("Transaction not found.");
|
||||
|
||||
return this.prisma.transactionDerived.upsert({
|
||||
where: { rawTransactionId: id },
|
||||
const derived = await this.prisma.transactionDerived.upsert({
|
||||
where: { rawTransactionId: transactionId },
|
||||
update: {
|
||||
userCategory: payload.userCategory,
|
||||
userNotes: payload.userNotes,
|
||||
attribution: attribution ?? this.defaultAttributionForAccount(tx.account),
|
||||
splitMode: split.mode,
|
||||
splitMinePercent: split.minePercent,
|
||||
splitYoursPercent: split.yoursPercent,
|
||||
isHidden: payload.isHidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "user",
|
||||
},
|
||||
create: {
|
||||
rawTransactionId: id,
|
||||
rawTransactionId: transactionId,
|
||||
userCategory: payload.userCategory,
|
||||
userNotes: payload.userNotes,
|
||||
attribution: attribution ?? this.defaultAttributionForAccount(tx.account),
|
||||
splitMode: split.mode,
|
||||
splitMinePercent: split.minePercent,
|
||||
splitYoursPercent: split.yoursPercent,
|
||||
isHidden: payload.isHidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "user",
|
||||
},
|
||||
});
|
||||
await this.syncGoogleSheetsBestEffort(userId, "transaction_derived_update");
|
||||
return derived;
|
||||
}
|
||||
|
||||
async sync(userId: string, startDate: string, endDate: string) {
|
||||
return this.plaidService.syncTransactionsForUser(userId, startDate, endDate);
|
||||
const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate);
|
||||
await this.syncGoogleSheetsBestEffort(userId, "plaid_sync");
|
||||
return result;
|
||||
}
|
||||
|
||||
async summary(userId: string, startDate: string, endDate: string) {
|
||||
@ -351,7 +596,7 @@ export class TransactionsService {
|
||||
}
|
||||
|
||||
async merchantInsights(userId: string, limit = 6) {
|
||||
const capped = Math.min(limit, MAX_PAGE_SIZE);
|
||||
const capped = Math.min(Math.max(limit, 1), UI_PAGE_SIZE_LIMIT);
|
||||
const rows = await this.prisma.transactionRaw.findMany({
|
||||
where: { account: { userId } },
|
||||
select: { description: true, amount: true },
|
||||
@ -375,4 +620,82 @@ export class TransactionsService {
|
||||
count: value.count,
|
||||
}));
|
||||
}
|
||||
|
||||
private normalizeAttribution(value?: string | null): TransactionAttribution | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if ((TRANSACTION_ATTRIBUTIONS as readonly string[]).includes(value)) {
|
||||
return value as TransactionAttribution;
|
||||
}
|
||||
throw new BadRequestException("Transaction attribution must be mine, yours, or ours.");
|
||||
}
|
||||
|
||||
private resolveAttribution(value: string | null | undefined, account?: { ownershipType?: string | null; ownerUserId?: string | null }) {
|
||||
return this.normalizeAttribution(value) ?? this.defaultAttributionForAccount(account);
|
||||
}
|
||||
|
||||
private defaultAttributionForAccount(account?: { ownershipType?: string | null; ownerUserId?: string | null }): TransactionAttribution {
|
||||
if (account?.ownershipType === "joint") return "ours";
|
||||
if (account?.ownershipType === "theirs") return "yours";
|
||||
return "mine";
|
||||
}
|
||||
|
||||
private normalizeSplit(payload: { splitMode?: string; splitMinePercent?: number; splitYoursPercent?: number }) {
|
||||
const mode = this.normalizeSplitMode(payload.splitMode);
|
||||
if (mode === "none") {
|
||||
return { mode, minePercent: null, yoursPercent: null };
|
||||
}
|
||||
if (mode === "equal") {
|
||||
return { mode, minePercent: 50, yoursPercent: 50 };
|
||||
}
|
||||
|
||||
const minePercent = Number(payload.splitMinePercent);
|
||||
const yoursPercent = Number(payload.splitYoursPercent);
|
||||
if (!Number.isFinite(minePercent) || !Number.isFinite(yoursPercent)) {
|
||||
throw new BadRequestException("Custom split requires mine and yours percentages.");
|
||||
}
|
||||
if (minePercent < 0 || minePercent > 100 || yoursPercent < 0 || yoursPercent > 100) {
|
||||
throw new BadRequestException("Split percentages must be between 0 and 100.");
|
||||
}
|
||||
if (Math.round((minePercent + yoursPercent) * 100) / 100 !== 100) {
|
||||
throw new BadRequestException("Split percentages must total 100.");
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
minePercent: this.roundCurrency(minePercent),
|
||||
yoursPercent: this.roundCurrency(yoursPercent),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSplitMode(value?: string | null): TransactionSplitMode {
|
||||
if (value === undefined || value === null || value === "") return "none";
|
||||
if ((TRANSACTION_SPLIT_MODES as readonly string[]).includes(value)) {
|
||||
return value as TransactionSplitMode;
|
||||
}
|
||||
throw new BadRequestException("Transaction split mode must be none, equal, or custom.");
|
||||
}
|
||||
|
||||
private resolveSplit(
|
||||
derived: { splitMode?: string | null; splitMinePercent?: unknown; splitYoursPercent?: unknown } | null | undefined,
|
||||
amount: number,
|
||||
) {
|
||||
const mode = this.normalizeSplitMode(derived?.splitMode);
|
||||
const minePercent = mode === "none" ? 100 : this.toNumber(derived?.splitMinePercent ?? (mode === "equal" ? 50 : 0));
|
||||
const yoursPercent = mode === "none" ? 0 : this.toNumber(derived?.splitYoursPercent ?? (mode === "equal" ? 50 : 0));
|
||||
return {
|
||||
mode,
|
||||
minePercent: this.roundCurrency(minePercent),
|
||||
yoursPercent: this.roundCurrency(yoursPercent),
|
||||
mineAmount: this.roundCurrency(amount * (minePercent / 100)),
|
||||
yoursAmount: this.roundCurrency(amount * (yoursPercent / 100)),
|
||||
};
|
||||
}
|
||||
|
||||
private toNumber(value: unknown) {
|
||||
if (value === null || value === undefined) return 0;
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
private roundCurrency(value: number) {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
49
test/abuse.service.spec.ts
Normal file
49
test/abuse.service.spec.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { AbuseService } from "../src/abuse/abuse.service";
|
||||
|
||||
const createService = () => {
|
||||
const prisma = {
|
||||
abuseEvent: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
exportLog: {
|
||||
count: jest.fn(),
|
||||
},
|
||||
};
|
||||
return { service: new AbuseService(prisma as any), prisma };
|
||||
};
|
||||
|
||||
describe("AbuseService", () => {
|
||||
it("calculates a capped risk profile from recent events", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.abuseEvent.findMany.mockResolvedValue([
|
||||
{ id: "evt_1", eventType: "EXPORT_LARGE", severity: "high", riskPoints: 80, createdAt: new Date("2026-01-01") },
|
||||
{ id: "evt_2", eventType: "AUTH_LOGIN_FAILURE", severity: "medium", riskPoints: 30, createdAt: new Date("2026-01-01") },
|
||||
]);
|
||||
|
||||
const result = await service.getRiskProfile("user_1");
|
||||
|
||||
expect(result.score).toBe(100);
|
||||
expect(result.level).toBe("high");
|
||||
expect(result.recentEvents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("records large and repeated export risk events", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.exportLog.count.mockResolvedValue(5);
|
||||
prisma.abuseEvent.create.mockResolvedValue({ id: "evt_1" });
|
||||
|
||||
await service.recordExportActivity("user_1", 1000, { category: "Meals" }, {
|
||||
ipAddress: "127.0.0.1",
|
||||
userAgent: "jest",
|
||||
});
|
||||
|
||||
expect(prisma.abuseEvent.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.abuseEvent.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ eventType: "EXPORT_LARGE", riskPoints: 25, severity: "high" }),
|
||||
}));
|
||||
expect(prisma.abuseEvent.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ eventType: "EXPORT_REPEATED", riskPoints: 20, severity: "high" }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
115
test/accounts.service.spec.ts
Normal file
115
test/accounts.service.spec.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { AccountsService } from "../src/accounts/accounts.service";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
|
||||
const createService = () => {
|
||||
const prisma = createPrismaMock();
|
||||
const plaid = { createLinkToken: jest.fn(), syncBalancesForUser: jest.fn() };
|
||||
const teller = { syncBalancesForUser: jest.fn() };
|
||||
const opaqueIds = {
|
||||
encode: jest.fn((_kind: string, _userId: string, id: string) => `opaque_account_${id}`),
|
||||
decode: jest.fn((_kind: string, _userId: string, handle: string) => handle.replace("opaque_account_", "")),
|
||||
};
|
||||
const planLimits = { assertCanAddAccounts: jest.fn() };
|
||||
return {
|
||||
prisma,
|
||||
service: new AccountsService(prisma as any, plaid as any, teller as any, opaqueIds as any, planLimits as any),
|
||||
};
|
||||
};
|
||||
|
||||
describe("AccountsService ownership", () => {
|
||||
it("marks an account as joint for an active household member", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue({ id: "acct_1", userId: "user_1", isActive: true });
|
||||
prisma.householdMember.findFirst.mockResolvedValue({ householdId: "household_1", userId: "user_1", status: "active" });
|
||||
prisma.account.update.mockResolvedValue({
|
||||
id: "acct_1",
|
||||
institutionName: "Bank",
|
||||
accountType: "checking",
|
||||
mask: "1234",
|
||||
householdId: "household_1",
|
||||
ownerUserId: null,
|
||||
ownershipType: "joint",
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
});
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.updateOwnership("user_1", "opaque_account_acct_1", {
|
||||
ownershipType: "joint",
|
||||
householdId: "household_1",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("opaque_account_acct_1");
|
||||
expect(prisma.account.update).toHaveBeenCalledWith({
|
||||
where: { id: "acct_1" },
|
||||
data: {
|
||||
ownershipType: "joint",
|
||||
householdId: "household_1",
|
||||
ownerUserId: null,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "account.ownership.update",
|
||||
metadata: expect.objectContaining({ ownershipType: "joint", householdId: "household_1" }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("marks an account as theirs only when the owner is another household member", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue({ id: "acct_1", userId: "user_1", isActive: true });
|
||||
prisma.householdMember.findFirst
|
||||
.mockResolvedValueOnce({ householdId: "household_1", userId: "user_1", status: "active" })
|
||||
.mockResolvedValueOnce({ householdId: "household_1", userId: "user_2", status: "active" });
|
||||
prisma.account.update.mockResolvedValue({
|
||||
id: "acct_1",
|
||||
householdId: "household_1",
|
||||
ownerUserId: "user_2",
|
||||
ownershipType: "theirs",
|
||||
});
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
await service.updateOwnership("user_1", "opaque_account_acct_1", {
|
||||
ownershipType: "theirs",
|
||||
householdId: "household_1",
|
||||
ownerUserId: "user_2",
|
||||
});
|
||||
|
||||
expect(prisma.account.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ ownershipType: "theirs", ownerUserId: "user_2" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("blocks shared ownership without active household membership", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue({ id: "acct_1", userId: "user_1", isActive: true });
|
||||
prisma.householdMember.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateOwnership("user_1", "opaque_account_acct_1", {
|
||||
ownershipType: "joint",
|
||||
householdId: "household_1",
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.account.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets household ownership when marked mine", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue({ id: "acct_1", userId: "user_1", isActive: true });
|
||||
prisma.account.update.mockResolvedValue({
|
||||
id: "acct_1",
|
||||
householdId: null,
|
||||
ownerUserId: "user_1",
|
||||
ownershipType: "mine",
|
||||
});
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
await service.updateOwnership("user_1", "opaque_account_acct_1", { ownershipType: "mine" });
|
||||
|
||||
expect(prisma.account.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: { ownershipType: "mine", householdId: null, ownerUserId: "user_1" },
|
||||
}));
|
||||
});
|
||||
});
|
||||
76
test/api-key.service.spec.ts
Normal file
76
test/api-key.service.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { ApiKeyService } from "../src/public-api/api-key.service";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
|
||||
describe("ApiKeyService", () => {
|
||||
it("creates a hashed public API key and returns the raw key once", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.apiKey.create.mockImplementation(async ({ data }) => ({
|
||||
id: "key_1",
|
||||
...data,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
}));
|
||||
const service = new ApiKeyService(prisma as any);
|
||||
|
||||
const result = await service.createKey("user_1", { name: "Power tools" });
|
||||
|
||||
expect(result.key).toMatch(/^l1_[A-Za-z0-9_-]+$/);
|
||||
expect(result.prefix).toBe(result.key.slice(0, 10));
|
||||
expect(result.scopes).toEqual(["transactions:read"]);
|
||||
expect(prisma.apiKey.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
name: "Power tools",
|
||||
prefix: result.key.slice(0, 10),
|
||||
keyHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
scopes: ["transactions:read"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported scopes", async () => {
|
||||
const service = new ApiKeyService(createPrismaMock() as any);
|
||||
|
||||
await expect(service.createKey("user_1", { scopes: ["transactions:write"] })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("authenticates a valid API key and updates last used time", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.apiKey.findUnique.mockResolvedValue({
|
||||
id: "key_1",
|
||||
userId: "user_1",
|
||||
scopes: ["transactions:read"],
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
});
|
||||
prisma.apiKey.update.mockResolvedValue({});
|
||||
const service = new ApiKeyService(prisma as any);
|
||||
|
||||
await expect(service.authenticate("l1_secret")).resolves.toEqual({
|
||||
userId: "user_1",
|
||||
keyId: "key_1",
|
||||
scopes: ["transactions:read"],
|
||||
});
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { keyHash: expect.stringMatching(/^[a-f0-9]{64}$/) },
|
||||
});
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "key_1" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects revoked API keys", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.apiKey.findUnique.mockResolvedValue({
|
||||
id: "key_1",
|
||||
userId: "user_1",
|
||||
scopes: ["transactions:read"],
|
||||
revokedAt: new Date(),
|
||||
expiresAt: null,
|
||||
});
|
||||
const service = new ApiKeyService(prisma as any);
|
||||
|
||||
await expect(service.authenticate("l1_secret")).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
143
test/auth.service.spec.ts
Normal file
143
test/auth.service.spec.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { AuthService } from "../src/auth/auth.service";
|
||||
|
||||
const model = (order: string[]) => ({
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(async () => {
|
||||
order.push("user.delete");
|
||||
}),
|
||||
deleteMany: jest.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const createService = () => {
|
||||
const order: string[] = [];
|
||||
const tx = {
|
||||
account: model(order),
|
||||
transactionRaw: model(order),
|
||||
transactionDerived: model(order),
|
||||
rule: model(order),
|
||||
ruleExecution: model(order),
|
||||
exportLog: model(order),
|
||||
auditLog: model(order),
|
||||
googleConnection: model(order),
|
||||
emailVerificationToken: model(order),
|
||||
passwordResetToken: model(order),
|
||||
refreshToken: model(order),
|
||||
session: model(order),
|
||||
socialAccount: model(order),
|
||||
subscription: model(order),
|
||||
abuseEvent: model(order),
|
||||
taxReturn: model(order),
|
||||
taxDocument: model(order),
|
||||
user: model(order),
|
||||
};
|
||||
tx.taxDocument.deleteMany.mockImplementation(async () => {
|
||||
order.push("taxDocument.deleteMany");
|
||||
});
|
||||
tx.ruleExecution.deleteMany.mockImplementation(async () => {
|
||||
order.push("ruleExecution.deleteMany");
|
||||
});
|
||||
tx.transactionDerived.deleteMany.mockImplementation(async () => {
|
||||
order.push("transactionDerived.deleteMany");
|
||||
});
|
||||
tx.transactionRaw.deleteMany.mockImplementation(async () => {
|
||||
order.push("transactionRaw.deleteMany");
|
||||
});
|
||||
tx.account.deleteMany.mockImplementation(async () => {
|
||||
order.push("account.deleteMany");
|
||||
});
|
||||
tx.rule.deleteMany.mockImplementation(async () => {
|
||||
order.push("rule.deleteMany");
|
||||
});
|
||||
|
||||
const prisma = {
|
||||
user: { findUnique: jest.fn() },
|
||||
$transaction: jest.fn((callback: (client: typeof tx) => Promise<void>) => callback(tx)),
|
||||
};
|
||||
const service = new AuthService(
|
||||
prisma as any,
|
||||
{ sign: jest.fn(), verify: jest.fn() } as any,
|
||||
{ sendVerificationEmail: jest.fn(), sendPasswordResetEmail: jest.fn() } as any,
|
||||
{ decrypt: jest.fn() } as any,
|
||||
);
|
||||
return { service, prisma, tx, order };
|
||||
};
|
||||
|
||||
describe("AuthService account deletion", () => {
|
||||
it("deletes dependent personal data before deleting the user", async () => {
|
||||
const { service, prisma, tx, order } = createService();
|
||||
prisma.user.findUnique.mockResolvedValue({ id: "user_1" });
|
||||
tx.account.findMany.mockResolvedValue([{ id: "acct_1" }]);
|
||||
tx.transactionRaw.findMany.mockResolvedValue([{ id: "tx_1" }]);
|
||||
tx.rule.findMany.mockResolvedValue([{ id: "rule_1" }]);
|
||||
tx.taxReturn.findMany.mockResolvedValue([{ id: "return_1" }]);
|
||||
|
||||
await expect(service.deleteAccount("user_1")).resolves.toEqual({
|
||||
message: "Account and associated personal data deleted.",
|
||||
});
|
||||
|
||||
expect(tx.ruleExecution.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ ruleId: { in: ["rule_1"] } },
|
||||
{ transactionId: { in: ["tx_1"] } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(tx.user.delete).toHaveBeenCalledWith({ where: { id: "user_1" } });
|
||||
expect(order.slice(0, 6)).toEqual([
|
||||
"taxDocument.deleteMany",
|
||||
"ruleExecution.deleteMany",
|
||||
"transactionDerived.deleteMany",
|
||||
"transactionRaw.deleteMany",
|
||||
"account.deleteMany",
|
||||
"rule.deleteMany",
|
||||
]);
|
||||
expect(order[order.length - 1]).toBe("user.delete");
|
||||
});
|
||||
|
||||
it("throws when the user no longer exists", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deleteAccount("missing_user")).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthService session-bound tokens", () => {
|
||||
it("creates a server-side session and binds tokens to it", async () => {
|
||||
const { service, prisma } = createService();
|
||||
(prisma as any).session = {
|
||||
create: jest.fn().mockResolvedValue({ id: "session_1" }),
|
||||
};
|
||||
(prisma as any).refreshToken = {
|
||||
create: jest.fn().mockResolvedValue({ id: "refresh_1" }),
|
||||
};
|
||||
const jwt = (service as any).jwtService;
|
||||
jwt.sign.mockReturnValue("access_token");
|
||||
|
||||
const result = await service.issueTokensForUser("user_1", {
|
||||
ipAddress: "127.0.0.1",
|
||||
userAgent: "jest",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ accessToken: "access_token", refreshToken: expect.any(String) });
|
||||
expect((prisma as any).session.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
ipHash: expect.any(String),
|
||||
userAgentHash: expect.any(String),
|
||||
}),
|
||||
});
|
||||
expect(jwt.sign).toHaveBeenCalledWith({ sub: "user_1", sid: "session_1" });
|
||||
expect((prisma as any).refreshToken.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ userId: "user_1", sessionId: "session_1" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
70
test/auto-sync.service.spec.ts
Normal file
70
test/auto-sync.service.spec.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { AutoSyncService } from "../src/transactions/auto-sync.service";
|
||||
|
||||
const createService = () => {
|
||||
const prisma = {
|
||||
account: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
const plaid = {
|
||||
syncTransactionsForUser: jest.fn(),
|
||||
};
|
||||
return { service: new AutoSyncService(prisma as any, plaid as any), prisma, plaid };
|
||||
};
|
||||
|
||||
describe("AutoSyncService", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
AUTO_SYNC_INTERVAL_MINUTES: "15",
|
||||
AUTO_SYNC_STALE_MINUTES: "15",
|
||||
AUTO_SYNC_LOOKBACK_DAYS: "7",
|
||||
AUTO_SYNC_MAX_USERS_PER_RUN: "25",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("syncs only stale Plaid users selected by the query", async () => {
|
||||
const { service, prisma, plaid } = createService();
|
||||
prisma.account.findMany.mockResolvedValue([{ userId: "user_1" }, { userId: "user_2" }]);
|
||||
plaid.syncTransactionsForUser.mockResolvedValue({ created: 3 });
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(prisma.account.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
isActive: true,
|
||||
plaidAccessToken: { not: null },
|
||||
plaidAccountId: { not: null },
|
||||
syncStatus: { not: "syncing" },
|
||||
}),
|
||||
distinct: ["userId"],
|
||||
take: 25,
|
||||
}));
|
||||
expect(plaid.syncTransactionsForUser).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ usersScanned: 2, usersSynced: 2, created: 6, failed: 0 });
|
||||
});
|
||||
|
||||
it("skips overlapping runs", async () => {
|
||||
const { service, prisma, plaid } = createService();
|
||||
prisma.account.findMany.mockResolvedValue([{ userId: "user_1" }]);
|
||||
let release!: () => void;
|
||||
plaid.syncTransactionsForUser.mockReturnValue(new Promise((resolve) => {
|
||||
release = () => resolve({ created: 1 });
|
||||
}));
|
||||
|
||||
const firstRun = service.runOnce();
|
||||
const secondRun = await service.runOnce();
|
||||
release();
|
||||
await firstRun;
|
||||
|
||||
expect(secondRun).toEqual({ skipped: true, reason: "already_running" });
|
||||
expect(plaid.syncTransactionsForUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -1,14 +1,39 @@
|
||||
import { ExportsService } from "../src/exports/exports.service";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
describe("ExportsService", () => {
|
||||
it("returns missing_user when user id is absent", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ExportsService(prisma as any);
|
||||
const abuse = { recordExportActivity: jest.fn() };
|
||||
const createObjectStorage = () => ({
|
||||
store: jest.fn(async (input: { content: string | Buffer }) => ({
|
||||
provider: "local",
|
||||
key: "exports/user_1/test.csv",
|
||||
sizeBytes: Buffer.isBuffer(input.content) ? input.content.byteLength : Buffer.byteLength(input.content),
|
||||
})),
|
||||
load: jest.fn(async () => ({ content: Buffer.from("id,date,description,amount,category,notes,hidden,source\ntx_1,2025-01-10,Lunch,20.00,Meals,Team lunch,false,manual") })),
|
||||
});
|
||||
|
||||
const result = await service.exportCsv(undefined);
|
||||
expect(result.status).toBe("missing_user");
|
||||
expect(prisma.exportLog.create).not.toHaveBeenCalled();
|
||||
beforeEach(() => {
|
||||
abuse.recordExportActivity.mockReset();
|
||||
});
|
||||
|
||||
it("exports an empty csv when the user has no matching rows", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([]);
|
||||
prisma.exportLog.create.mockResolvedValue({ id: "log_1" });
|
||||
const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any);
|
||||
|
||||
const result = await service.exportCsv("user_1");
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.rowCount).toBe(0);
|
||||
expect(result.csv).toContain("id,date,description,amount,category,notes,attribution,splitMode,splitMinePercent,splitYoursPercent,splitMineAmount,splitYoursAmount,hidden,source,watermark");
|
||||
expect(result.csv).toContain("LedgerOne Export Watermark");
|
||||
expect(prisma.exportLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ userId: "user_1", rowCount: 0 })
|
||||
})
|
||||
);
|
||||
expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 0, {}, undefined);
|
||||
});
|
||||
|
||||
it("exports csv with headers and rows", async () => {
|
||||
@ -24,16 +49,326 @@ describe("ExportsService", () => {
|
||||
}
|
||||
]);
|
||||
prisma.exportLog.create.mockResolvedValue({ id: "log_1" });
|
||||
const service = new ExportsService(prisma as any);
|
||||
const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any);
|
||||
|
||||
const result = await service.exportCsv("user_1", { category: "Meals" });
|
||||
const result = await service.exportCsv("user_1", { category: "Meals" }, {
|
||||
ipAddress: "203.0.113.10",
|
||||
userAgent: "jest-agent",
|
||||
});
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.csv).toContain("id,date,description,amount,category,notes,hidden,source");
|
||||
expect(result.csv).toContain("id,date,description,amount,category,notes,attribution,splitMode,splitMinePercent,splitYoursPercent,splitMineAmount,splitYoursAmount,hidden,source,watermark");
|
||||
expect(result.csv).toContain("LedgerOne Export Watermark");
|
||||
expect(prisma.exportLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ userId: "user_1", rowCount: 1 })
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
format: "csv",
|
||||
destination: "download",
|
||||
filters: { category: "Meals" },
|
||||
rowCount: 1,
|
||||
fileName: expect.stringMatching(/ledgerone-export-\d{4}-\d{2}-\d{2}\.csv/),
|
||||
mimeType: "text/csv",
|
||||
fileHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
ipAddress: "203.0.113.10",
|
||||
userAgent: "jest-agent",
|
||||
metadata: { watermark: expect.objectContaining({ label: "LedgerOne Export Watermark", userId: "user_1" }) },
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals" }, {
|
||||
ipAddress: "203.0.113.10",
|
||||
userAgent: "jest-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("exports json as a downloadable base64 transaction file", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_1",
|
||||
date: new Date("2025-01-10"),
|
||||
description: "Lunch",
|
||||
amount: 20,
|
||||
source: "manual",
|
||||
derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false }
|
||||
}
|
||||
]);
|
||||
prisma.exportLog.create.mockResolvedValue({ id: "log_1" });
|
||||
const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any);
|
||||
|
||||
const result = await service.exportJson("user_1", { category: "Meals" });
|
||||
const payload = JSON.parse(Buffer.from(result.base64, "base64").toString("utf8"));
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.fileName).toMatch(/ledgerone-export-\d{4}-\d{2}-\d{2}\.json/);
|
||||
expect(result.mimeType).toBe("application/json");
|
||||
expect(payload.rowCount).toBe(1);
|
||||
expect(payload.filters).toEqual({ category: "Meals" });
|
||||
expect(payload.watermark).toEqual(expect.objectContaining({
|
||||
label: "LedgerOne Export Watermark",
|
||||
userId: "user_1",
|
||||
traceId: expect.any(String),
|
||||
}));
|
||||
expect(payload.transactions[0]).toEqual(expect.objectContaining({
|
||||
id: "tx_1",
|
||||
description: "Lunch",
|
||||
category: "Meals",
|
||||
}));
|
||||
expect(prisma.exportLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
format: "json",
|
||||
destination: "download",
|
||||
rowCount: 1,
|
||||
filters: { category: "Meals" },
|
||||
fileHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "json" }, undefined);
|
||||
});
|
||||
|
||||
it("exports xlsx as a downloadable base64 workbook", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_1",
|
||||
date: new Date("2025-01-10"),
|
||||
description: "Lunch",
|
||||
amount: 20,
|
||||
source: "manual",
|
||||
derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false }
|
||||
}
|
||||
]);
|
||||
prisma.exportLog.create.mockResolvedValue({ id: "log_1" });
|
||||
const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any);
|
||||
|
||||
const result = await service.exportXlsx("user_1", { category: "Meals" });
|
||||
const fileBuffer = Buffer.from(result.base64, "base64");
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.fileName).toMatch(/ledgerone-export-\d{4}-\d{2}-\d{2}\.xlsx/);
|
||||
expect(result.mimeType).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
expect(fileBuffer.subarray(0, 2).toString()).toBe("PK");
|
||||
const workbook = XLSX.read(fileBuffer, { type: "buffer" });
|
||||
expect(workbook.SheetNames).toContain("Watermark");
|
||||
const watermarkRows = XLSX.utils.sheet_to_json(workbook.Sheets.Watermark);
|
||||
expect(watermarkRows[0]).toEqual(expect.objectContaining({
|
||||
label: "LedgerOne Export Watermark",
|
||||
userId: "user_1",
|
||||
}));
|
||||
const exportRows = XLSX.utils.sheet_to_json(workbook.Sheets["LedgerOne Export"]);
|
||||
expect(exportRows[0]).toEqual(expect.objectContaining({
|
||||
watermark: expect.stringContaining("LedgerOne Export Watermark"),
|
||||
}));
|
||||
expect(prisma.exportLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
format: "xlsx",
|
||||
destination: "download",
|
||||
rowCount: 1,
|
||||
filters: { category: "Meals" },
|
||||
fileHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "xlsx" }, undefined);
|
||||
});
|
||||
|
||||
it("exports pdf as a downloadable base64 document", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_1",
|
||||
date: new Date("2025-01-10"),
|
||||
description: "Lunch",
|
||||
amount: 20,
|
||||
source: "manual",
|
||||
derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false }
|
||||
}
|
||||
]);
|
||||
prisma.exportLog.create.mockResolvedValue({ id: "log_1" });
|
||||
const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any);
|
||||
|
||||
const result = await service.exportPdf("user_1", { category: "Meals" });
|
||||
const fileBuffer = Buffer.from(result.base64, "base64");
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.fileName).toMatch(/ledgerone-export-\d{4}-\d{2}-\d{2}\.pdf/);
|
||||
expect(result.mimeType).toBe("application/pdf");
|
||||
expect(fileBuffer.subarray(0, 4).toString()).toBe("%PDF");
|
||||
expect(fileBuffer.toString("binary")).toContain("LedgerOne Export Watermark");
|
||||
expect(prisma.exportLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
format: "pdf",
|
||||
destination: "download",
|
||||
rowCount: 1,
|
||||
filters: { category: "Meals" },
|
||||
fileHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "pdf" }, undefined);
|
||||
});
|
||||
|
||||
it("skips real-time Google Sheets sync when Google is not connected", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.googleConnection.findUnique.mockResolvedValue(null);
|
||||
const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any);
|
||||
|
||||
const result = await service.syncGoogleSheets("user_1");
|
||||
|
||||
expect(result).toEqual({ status: "skipped", reason: "not_connected" });
|
||||
expect(prisma.transactionRaw.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a short-lived single-use signed download URL", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.exportDownloadToken.create.mockResolvedValue({ id: "token_1" });
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_1",
|
||||
date: new Date("2025-01-10"),
|
||||
description: "Lunch",
|
||||
amount: 20,
|
||||
source: "manual",
|
||||
derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false }
|
||||
}
|
||||
]);
|
||||
const objectStorage = createObjectStorage();
|
||||
const service = new ExportsService(prisma as any, abuse as any, objectStorage as any);
|
||||
|
||||
const result = await service.createSignedDownloadUrl("user_1", "csv", { category: "Meals" });
|
||||
|
||||
expect(result.status).toBe("signed");
|
||||
expect(result.singleUse).toBe(true);
|
||||
expect(result.expiresInSeconds).toBe(120);
|
||||
expect(result.downloadUrl).toMatch(/^\/api\/exports\/download\/[A-Za-z0-9_-]+$/);
|
||||
expect(prisma.exportDownloadToken.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
tokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
format: "csv",
|
||||
filters: { category: "Meals" },
|
||||
storageProvider: "local",
|
||||
storageKey: "exports/user_1/test.csv",
|
||||
fileName: expect.stringMatching(/ledgerone-export-\d{4}-\d{2}-\d{2}\.csv/),
|
||||
mimeType: "text/csv",
|
||||
rowCount: 1,
|
||||
fileHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
expiresAt: expect.any(Date),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(objectStorage.store).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: "user_1",
|
||||
format: "csv",
|
||||
mimeType: "text/csv",
|
||||
content: expect.stringContaining("Lunch"),
|
||||
}));
|
||||
expect(objectStorage.store).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: expect.stringContaining("LedgerOne Export Watermark"),
|
||||
}));
|
||||
});
|
||||
|
||||
it("consumes a signed download URL once and records export audit data", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.exportDownloadToken.findUnique.mockResolvedValue({
|
||||
id: "token_1",
|
||||
userId: "user_1",
|
||||
format: "csv",
|
||||
filters: { category: "Meals" },
|
||||
storageProvider: "local",
|
||||
storageKey: "exports/user_1/test.csv",
|
||||
fileName: "ledgerone-export-2025-01-10.csv",
|
||||
mimeType: "text/csv",
|
||||
rowCount: 1,
|
||||
fileHash: "hash_1",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
usedAt: null,
|
||||
});
|
||||
prisma.exportDownloadToken.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_1",
|
||||
date: new Date("2025-01-10"),
|
||||
description: "Lunch",
|
||||
amount: 20,
|
||||
source: "manual",
|
||||
derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false }
|
||||
}
|
||||
]);
|
||||
prisma.exportLog.create.mockResolvedValue({ id: "log_1" });
|
||||
const objectStorage = createObjectStorage();
|
||||
const service = new ExportsService(prisma as any, abuse as any, objectStorage as any);
|
||||
|
||||
const file = await service.consumeSignedDownloadUrl("raw_token", {
|
||||
ipAddress: "203.0.113.10",
|
||||
userAgent: "jest-agent",
|
||||
});
|
||||
|
||||
expect(file.fileName).toBe("ledgerone-export-2025-01-10.csv");
|
||||
expect(file.mimeType).toBe("text/csv");
|
||||
expect(file.rowCount).toBe(1);
|
||||
expect(String(file.content)).toContain("Lunch");
|
||||
expect(objectStorage.load).toHaveBeenCalledWith("local", "exports/user_1/test.csv");
|
||||
expect(prisma.exportDownloadToken.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ id: "token_1", usedAt: null }),
|
||||
data: { usedAt: expect.any(Date) },
|
||||
})
|
||||
);
|
||||
expect(prisma.exportLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
format: "csv",
|
||||
destination: "download",
|
||||
rowCount: 1,
|
||||
ipAddress: "203.0.113.10",
|
||||
userAgent: "jest-agent",
|
||||
metadata: expect.objectContaining({
|
||||
signedUrl: true,
|
||||
tokenId: "token_1",
|
||||
storageProvider: "local",
|
||||
storageKey: "exports/user_1/test.csv",
|
||||
precomputedFileHash: "hash_1",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, {
|
||||
category: "Meals",
|
||||
format: "csv",
|
||||
signedUrl: "true",
|
||||
}, {
|
||||
ipAddress: "203.0.113.10",
|
||||
userAgent: "jest-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a signed download URL that was already used", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.exportDownloadToken.findUnique.mockResolvedValue({
|
||||
id: "token_1",
|
||||
userId: "user_1",
|
||||
format: "csv",
|
||||
filters: {},
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
usedAt: new Date(),
|
||||
});
|
||||
const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any);
|
||||
|
||||
await expect(service.consumeSignedDownloadUrl("raw_token")).rejects.toThrow("already been used");
|
||||
expect(prisma.transactionRaw.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
35
test/google.service.spec.ts
Normal file
35
test/google.service.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { GoogleService } from "../src/google/google.service";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
|
||||
describe("GoogleService", () => {
|
||||
it("reports the durable user-owned Google Drive mirror status", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.googleConnection.findUnique.mockResolvedValue({
|
||||
userId: "user_1",
|
||||
googleEmail: "owner@example.com",
|
||||
connectedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
spreadsheetId: "sheet_123",
|
||||
driveMirrorEnabled: true,
|
||||
driveMirrorStatus: "synced",
|
||||
driveMirrorSpreadsheetUrl: "https://docs.google.com/spreadsheets/d/sheet_123",
|
||||
driveMirrorLastSyncedAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
lastSyncedAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
isConnected: true,
|
||||
});
|
||||
const service = new GoogleService(prisma as any);
|
||||
|
||||
await expect(service.getStatus("user_1")).resolves.toEqual({
|
||||
connected: true,
|
||||
googleEmail: "owner@example.com",
|
||||
connectedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
driveMirror: {
|
||||
enabled: true,
|
||||
status: "synced",
|
||||
spreadsheetId: "sheet_123",
|
||||
url: "https://docs.google.com/spreadsheets/d/sheet_123",
|
||||
lastSyncedAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
ownership: "user_google_drive",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
333
test/households.service.spec.ts
Normal file
333
test/households.service.spec.ts
Normal file
@ -0,0 +1,333 @@
|
||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||
import { HouseholdsService } from "../src/households/households.service";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
|
||||
describe("HouseholdsService", () => {
|
||||
const createService = () => {
|
||||
const prisma = createPrismaMock();
|
||||
const email = { sendHouseholdInviteEmail: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new HouseholdsService(prisma as any, email as any);
|
||||
return { prisma, email, service };
|
||||
};
|
||||
|
||||
it("creates a household with the current user as owner", async () => {
|
||||
const { prisma, service } = createService();
|
||||
const household = {
|
||||
id: "household_1",
|
||||
name: "Mohan Household",
|
||||
createdByUserId: "user_1",
|
||||
metadata: { type: "couple" },
|
||||
members: [{ id: "member_1", userId: "user_1", role: "owner", status: "active" }],
|
||||
};
|
||||
prisma.household.create.mockResolvedValue(household);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.create("user_1", {
|
||||
name: " Mohan Household ",
|
||||
metadata: { type: "couple" },
|
||||
});
|
||||
|
||||
expect(result).toBe(household);
|
||||
expect(prisma.household.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Mohan Household",
|
||||
createdByUserId: "user_1",
|
||||
metadata: { type: "couple" },
|
||||
members: {
|
||||
create: {
|
||||
userId: "user_1",
|
||||
role: "owner",
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
include: expect.any(Object),
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
action: "household.create",
|
||||
metadata: expect.objectContaining({ householdId: "household_1", role: "owner" }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("lists only active household memberships for the current user", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.household.findMany.mockResolvedValue([{ id: "household_1" }]);
|
||||
|
||||
await service.listForUser("user_1");
|
||||
|
||||
expect(prisma.household.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
members: {
|
||||
some: { userId: "user_1", status: "active" },
|
||||
},
|
||||
},
|
||||
include: expect.any(Object),
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks reading households when the user is not an active member", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.householdMember.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getForUser("user_1", "household_1")).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.household.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds a shared household financial dashboard for active members", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "owner", status: "active" });
|
||||
prisma.household.findFirst.mockResolvedValue({
|
||||
id: "household_1",
|
||||
name: "Mohan Household",
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
members: [
|
||||
{
|
||||
id: "member_1",
|
||||
userId: "user_1",
|
||||
role: "owner",
|
||||
joinedAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
user: { id: "user_1", email: "owner@example.com", fullName: "Owner User" },
|
||||
},
|
||||
{
|
||||
id: "member_2",
|
||||
userId: "user_2",
|
||||
role: "member",
|
||||
joinedAt: new Date("2026-07-02T00:00:00.000Z"),
|
||||
user: { id: "user_2", email: "partner@example.com", fullName: "Partner User" },
|
||||
},
|
||||
],
|
||||
});
|
||||
prisma.account.findMany.mockResolvedValue([
|
||||
{
|
||||
institutionName: "Bank A",
|
||||
accountType: "checking",
|
||||
mask: "1111",
|
||||
currentBalance: "1000.25",
|
||||
availableBalance: "900.25",
|
||||
isoCurrencyCode: "USD",
|
||||
ownerUserId: null,
|
||||
ownershipType: "joint",
|
||||
lastBalanceSync: new Date("2026-07-15T00:00:00.000Z"),
|
||||
syncStatus: "idle",
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
institutionName: "Bank B",
|
||||
accountType: "credit",
|
||||
mask: "2222",
|
||||
currentBalance: "-100.25",
|
||||
availableBalance: null,
|
||||
isoCurrencyCode: "USD",
|
||||
ownerUserId: "user_2",
|
||||
ownershipType: "theirs",
|
||||
lastBalanceSync: null,
|
||||
syncStatus: "idle",
|
||||
createdAt: new Date("2026-07-02T00:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
prisma.transactionRaw.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
date: new Date("2026-07-15T00:00:00.000Z"),
|
||||
amount: "42.5",
|
||||
description: "Groceries",
|
||||
source: "plaid",
|
||||
derived: { userCategory: "Food", isHidden: false },
|
||||
account: { institutionName: "Bank A", mask: "1111", ownerUserId: null, ownershipType: "joint" },
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
date: new Date(),
|
||||
amount: "-2500",
|
||||
description: "Payroll",
|
||||
source: "plaid",
|
||||
derived: { userCategory: "Income", isHidden: false },
|
||||
account: { ownershipType: "joint" },
|
||||
},
|
||||
{
|
||||
date: new Date(),
|
||||
amount: "42.5",
|
||||
description: "Groceries",
|
||||
source: "plaid",
|
||||
derived: { userCategory: "Food", isHidden: false },
|
||||
account: { ownershipType: "joint" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getDashboard("user_1", "household_1");
|
||||
|
||||
expect(result.summary.memberCount).toBe(2);
|
||||
expect(result.summary.accountCount).toBe(2);
|
||||
expect(result.summary.totalBalance).toBe(900);
|
||||
expect(result.ownershipBreakdown.joint).toEqual({ accountCount: 1, balance: 1000.25 });
|
||||
expect(result.ownershipBreakdown.theirs).toEqual({ accountCount: 1, balance: -100.25 });
|
||||
expect(result.accounts[0]).not.toHaveProperty("id");
|
||||
expect(result.recentTransactions[0].category).toBe("Food");
|
||||
expect(result.summary.monthlyIncome).toBe(2500);
|
||||
expect(result.summary.monthlyExpenses).toBe(42.5);
|
||||
expect(prisma.transactionRaw.findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows owners to update household member roles", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.householdMember.findFirst
|
||||
.mockResolvedValueOnce({ id: "owner_member", userId: "user_1", role: "owner", status: "active" })
|
||||
.mockResolvedValueOnce({ id: "member_2", userId: "user_2", role: "member", status: "active" });
|
||||
prisma.householdMember.update.mockResolvedValue({
|
||||
id: "member_2",
|
||||
userId: "user_2",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
});
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.updateMember("user_1", "household_1", "member_2", { role: "admin" });
|
||||
|
||||
expect(result.role).toBe("admin");
|
||||
expect(prisma.householdMember.update).toHaveBeenCalledWith({
|
||||
where: { id: "member_2" },
|
||||
data: { role: "admin" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "household.member.update",
|
||||
metadata: expect.objectContaining({
|
||||
householdId: "household_1",
|
||||
memberId: "member_2",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks non-managers from updating member roles", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.householdMember.findFirst.mockResolvedValue({
|
||||
id: "member_1",
|
||||
userId: "user_1",
|
||||
role: "member",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
await expect(service.updateMember("user_1", "household_1", "member_2", { role: "admin" })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.householdMember.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps at least one active household owner", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.householdMember.findFirst
|
||||
.mockResolvedValueOnce({ id: "owner_member", userId: "user_1", role: "owner", status: "active" })
|
||||
.mockResolvedValueOnce({ id: "owner_member", userId: "user_1", role: "owner", status: "active" });
|
||||
prisma.householdMember.count.mockResolvedValue(1);
|
||||
|
||||
await expect(service.updateMember("user_1", "household_1", "owner_member", { role: "member" })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.householdMember.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a partner invite for household managers and sends email", async () => {
|
||||
const { prisma, email, service } = createService();
|
||||
prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" });
|
||||
prisma.household.findFirst.mockResolvedValue({ id: "household_1", name: "Mohan Household" });
|
||||
prisma.user.findUnique.mockResolvedValue({ email: "owner@example.com", fullName: "Owner User" });
|
||||
prisma.householdInvite.create.mockResolvedValue({
|
||||
id: "invite_1",
|
||||
email: "partner@example.com",
|
||||
role: "admin",
|
||||
status: "pending",
|
||||
expiresAt: new Date("2026-07-23T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
});
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.invite("user_1", "household_1", {
|
||||
email: " Partner@Example.com ",
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("invite_1");
|
||||
expect(prisma.householdInvite.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
householdId: "household_1",
|
||||
invitedById: "user_1",
|
||||
email: "partner@example.com",
|
||||
role: "admin",
|
||||
status: "pending",
|
||||
tokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(email.sendHouseholdInviteEmail).toHaveBeenCalledWith(
|
||||
"partner@example.com",
|
||||
"Mohan Household",
|
||||
"Owner User",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "household.invite.create",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a pending invite for the matching email", async () => {
|
||||
const { prisma, service } = createService();
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
prisma.user.findUnique.mockResolvedValue({ email: "partner@example.com" });
|
||||
prisma.householdInvite.findUnique.mockResolvedValue({
|
||||
id: "invite_1",
|
||||
householdId: "household_1",
|
||||
email: "partner@example.com",
|
||||
role: "member",
|
||||
status: "pending",
|
||||
expiresAt,
|
||||
household: { id: "household_1", name: "Mohan Household" },
|
||||
});
|
||||
prisma.householdMember.upsert.mockResolvedValue({
|
||||
id: "member_2",
|
||||
householdId: "household_1",
|
||||
userId: "user_2",
|
||||
role: "member",
|
||||
status: "active",
|
||||
});
|
||||
prisma.householdInvite.update.mockResolvedValue({});
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.acceptInvite("user_2", { token: "x".repeat(40) });
|
||||
|
||||
expect(result.member.id).toBe("member_2");
|
||||
expect(prisma.householdMember.upsert).toHaveBeenCalledWith({
|
||||
where: { householdId_userId: { householdId: "household_1", userId: "user_2" } },
|
||||
create: expect.objectContaining({ role: "member", status: "active" }),
|
||||
update: expect.objectContaining({ role: "member", status: "active" }),
|
||||
include: expect.any(Object),
|
||||
});
|
||||
expect(prisma.householdInvite.update).toHaveBeenCalledWith({
|
||||
where: { id: "invite_1" },
|
||||
data: expect.objectContaining({ status: "accepted", acceptedById: "user_2" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks accepting an invite sent to a different email", async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.user.findUnique.mockResolvedValue({ email: "other@example.com" });
|
||||
prisma.householdInvite.findUnique.mockResolvedValue({
|
||||
id: "invite_1",
|
||||
householdId: "household_1",
|
||||
email: "partner@example.com",
|
||||
role: "member",
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
household: { id: "household_1" },
|
||||
});
|
||||
|
||||
await expect(service.acceptInvite("user_2", { token: "x".repeat(40) })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.householdMember.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
169
test/plaid.webhook.spec.ts
Normal file
169
test/plaid.webhook.spec.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { PlaidService } from "../src/plaid/plaid.service";
|
||||
|
||||
const createService = () => {
|
||||
const prisma = {
|
||||
plaidWebhookEvent: {
|
||||
create: jest.fn().mockResolvedValue({ id: "evt_1" }),
|
||||
update: jest.fn(),
|
||||
},
|
||||
account: {
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
transactionRaw: {
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
};
|
||||
const service = Object.create(PlaidService.prototype) as PlaidService;
|
||||
Object.assign(service as any, {
|
||||
prisma,
|
||||
client: {
|
||||
linkTokenCreate: jest.fn(),
|
||||
transactionsGet: jest.fn(),
|
||||
},
|
||||
encryption: {
|
||||
decrypt: jest.fn((value: string) => value.replace("enc_", "raw_")),
|
||||
},
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
webhookKeys: new Map(),
|
||||
});
|
||||
return { service, prisma, client: (service as any).client };
|
||||
};
|
||||
|
||||
describe("PlaidService webhooks", () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.PLAID_WEBHOOK_LOOKBACK_DAYS = "7";
|
||||
});
|
||||
|
||||
it("records and processes transaction update webhooks", async () => {
|
||||
const { service, prisma, client } = createService();
|
||||
prisma.account.findMany
|
||||
.mockResolvedValueOnce([{ userId: "user_1" }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "acct_1",
|
||||
userId: "user_1",
|
||||
plaidAccessToken: "enc_token",
|
||||
plaidAccountId: "plaid_acct_1",
|
||||
},
|
||||
]);
|
||||
client.transactionsGet.mockResolvedValue({
|
||||
data: {
|
||||
transactions: [
|
||||
{
|
||||
transaction_id: "tx_1",
|
||||
account_id: "plaid_acct_1",
|
||||
date: "2026-07-15",
|
||||
amount: 12.34,
|
||||
name: "Coffee",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.handleWebhook({
|
||||
webhook_type: "TRANSACTIONS",
|
||||
webhook_code: "SYNC_UPDATES_AVAILABLE",
|
||||
item_id: "item_1",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
received: true,
|
||||
processed: true,
|
||||
webhookType: "TRANSACTIONS",
|
||||
webhookCode: "SYNC_UPDATES_AVAILABLE",
|
||||
usersSynced: 1,
|
||||
created: 1,
|
||||
});
|
||||
expect(prisma.plaidWebhookEvent.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
itemId: "item_1",
|
||||
webhookType: "TRANSACTIONS",
|
||||
webhookCode: "SYNC_UPDATES_AVAILABLE",
|
||||
}),
|
||||
});
|
||||
expect(client.transactionsGet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
access_token: "raw_token",
|
||||
}));
|
||||
expect(prisma.transactionRaw.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { bankTransactionId: "tx_1" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("marks Plaid item errors as needing reauth", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.account.findMany.mockResolvedValue([{ userId: "user_1" }]);
|
||||
|
||||
const result = await service.handleWebhook({
|
||||
webhook_type: "ITEM",
|
||||
webhook_code: "ERROR",
|
||||
item_id: "item_1",
|
||||
error: {
|
||||
error_code: "ITEM_LOGIN_REQUIRED",
|
||||
error_message: "User credentials need repair",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
received: true,
|
||||
processed: true,
|
||||
status: "needs_reauth",
|
||||
usersMarked: 1,
|
||||
});
|
||||
expect(prisma.account.updateMany).toHaveBeenCalledWith({
|
||||
where: { plaidItemId: "item_1" },
|
||||
data: expect.objectContaining({
|
||||
syncStatus: "needs_reauth",
|
||||
lastSyncError: "User credentials need repair",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("creates update-mode link tokens for existing Plaid items", async () => {
|
||||
const { service, prisma, client } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue({ plaidAccessToken: "enc_token" });
|
||||
client.linkTokenCreate.mockResolvedValue({
|
||||
data: {
|
||||
link_token: "link-update-token",
|
||||
expiration: "2026-07-15T18:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.createUpdateModeLinkToken("user_1", "acct_1");
|
||||
|
||||
expect(result).toEqual({
|
||||
linkToken: "link-update-token",
|
||||
expiration: "2026-07-15T18:00:00Z",
|
||||
});
|
||||
expect(client.linkTokenCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
access_token: "raw_token",
|
||||
client_name: "LedgerOne",
|
||||
}));
|
||||
});
|
||||
|
||||
it("marks update-mode repair completion across the Plaid item", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue({ plaidItemId: "item_1" });
|
||||
prisma.account.updateMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
const result = await service.markItemRepairComplete("user_1", "acct_1");
|
||||
|
||||
expect(result).toEqual({ updated: 2 });
|
||||
expect(prisma.account.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user_1",
|
||||
plaidItemId: "item_1",
|
||||
},
|
||||
data: expect.objectContaining({
|
||||
syncStatus: "idle",
|
||||
lastSyncError: null,
|
||||
syncConsecutiveFailures: 0,
|
||||
plaidWebhookCode: "UPDATE_MODE_COMPLETED",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
23
test/plan-limits.service.spec.ts
Normal file
23
test/plan-limits.service.spec.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ForbiddenException } from "@nestjs/common";
|
||||
import { PlanLimitsService } from "../src/stripe/plan-limits.service";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
|
||||
describe("PlanLimitsService", () => {
|
||||
it("blocks free users from exceeding the account limit", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.subscription.findUnique.mockResolvedValue({ userId: "user_1", plan: "free" });
|
||||
prisma.account.count.mockResolvedValue(2);
|
||||
const service = new PlanLimitsService(prisma as any);
|
||||
|
||||
await expect(service.assertCanAddAccounts("user_1", 1)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("allows unlimited elite accounts", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.subscription.findUnique.mockResolvedValue({ userId: "user_1", plan: "elite" });
|
||||
const service = new PlanLimitsService(prisma as any);
|
||||
|
||||
await expect(service.assertCanAddAccounts("user_1", 100)).resolves.toBeUndefined();
|
||||
expect(prisma.account.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
9
test/plan-limits.spec.ts
Normal file
9
test/plan-limits.spec.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { PLAN_LIMITS } from "../src/stripe/stripe.service";
|
||||
|
||||
describe("PLAN_LIMITS", () => {
|
||||
it("blocks free exports and treats paid export plans as unlimited", () => {
|
||||
expect(PLAN_LIMITS.free.exports).toBe(0);
|
||||
expect(PLAN_LIMITS.pro.exports).toBe(-1);
|
||||
expect(PLAN_LIMITS.elite.exports).toBe(-1);
|
||||
});
|
||||
});
|
||||
50
test/roles.guard.spec.ts
Normal file
50
test/roles.guard.spec.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { RolesGuard } from "../src/common/guards/roles.guard";
|
||||
import { ROLES_KEY } from "../src/common/decorators/roles.decorator";
|
||||
import { IS_PUBLIC_KEY } from "../src/common/guards/jwt-auth.guard";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
|
||||
const createContext = (userId?: string) =>
|
||||
({
|
||||
getHandler: jest.fn(),
|
||||
getClass: jest.fn(),
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: userId ? { sub: userId } : undefined }),
|
||||
}),
|
||||
}) as unknown as ExecutionContext;
|
||||
|
||||
describe("RolesGuard", () => {
|
||||
it("allows users with the required role", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.user.findUnique.mockResolvedValue({ role: "admin" });
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => key === ROLES_KEY ? ["admin"] : undefined),
|
||||
} as unknown as Reflector;
|
||||
const guard = new RolesGuard(reflector, prisma as any);
|
||||
|
||||
await expect(guard.canActivate(createContext("user_1"))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("blocks users without the required role", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.user.findUnique.mockResolvedValue({ role: "user" });
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => key === ROLES_KEY ? ["admin"] : undefined),
|
||||
} as unknown as Reflector;
|
||||
const guard = new RolesGuard(reflector, prisma as any);
|
||||
|
||||
await expect(guard.canActivate(createContext("user_1"))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("skips checks for public routes", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => key === IS_PUBLIC_KEY ? true : ["admin"]),
|
||||
} as unknown as Reflector;
|
||||
const guard = new RolesGuard(reflector, prisma as any);
|
||||
|
||||
await expect(guard.canActivate(createContext())).resolves.toBe(true);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
213
test/rules.service.spec.ts
Normal file
213
test/rules.service.spec.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { RulesService } from "../src/rules/rules.service";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
|
||||
const createService = () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new RulesService(prisma as any);
|
||||
return { service, prisma };
|
||||
};
|
||||
|
||||
describe("RulesService", () => {
|
||||
it("applies rules with text, amount, source, category, and date conditions", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.rule.findFirst.mockResolvedValue({
|
||||
id: "rule_1",
|
||||
userId: "user_1",
|
||||
isActive: true,
|
||||
conditions: {
|
||||
textContains: "coffee",
|
||||
textNotContains: "refund",
|
||||
amountGreaterThanOrEqual: 4,
|
||||
amountLessThanOrEqual: 10,
|
||||
sourceEquals: "csv",
|
||||
categoryEquals: "Uncategorized",
|
||||
dateAfter: "2026-07-01",
|
||||
dateBefore: "2026-07-31",
|
||||
},
|
||||
actions: { setCategory: "Meals" },
|
||||
});
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_match",
|
||||
description: "Coffee Shop",
|
||||
amount: 4.25,
|
||||
date: new Date("2026-07-15"),
|
||||
source: "csv",
|
||||
derived: { userCategory: "Uncategorized", isHidden: false },
|
||||
},
|
||||
{
|
||||
id: "tx_skip",
|
||||
description: "Coffee refund",
|
||||
amount: 4.25,
|
||||
date: new Date("2026-07-15"),
|
||||
source: "csv",
|
||||
derived: { userCategory: "Uncategorized", isHidden: false },
|
||||
},
|
||||
]);
|
||||
prisma.transactionDerived.upsert.mockResolvedValue({});
|
||||
prisma.ruleExecution.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.execute("user_1", "rule_1");
|
||||
|
||||
expect(result).toEqual({ id: "rule_1", status: "completed", applied: 1 });
|
||||
expect(prisma.transactionDerived.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { rawTransactionId: "tx_match" },
|
||||
update: expect.objectContaining({ userCategory: "Meals" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("supports regex and numeric equality conditions", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.rule.findFirst.mockResolvedValue({
|
||||
id: "rule_regex",
|
||||
userId: "user_1",
|
||||
isActive: true,
|
||||
conditions: {
|
||||
textRegex: "^payroll",
|
||||
amountEquals: "-1200",
|
||||
},
|
||||
actions: { setHidden: true },
|
||||
});
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_payroll",
|
||||
description: "Payroll deposit",
|
||||
amount: -1200,
|
||||
date: new Date("2026-07-15"),
|
||||
source: "plaid",
|
||||
derived: null,
|
||||
},
|
||||
]);
|
||||
prisma.transactionDerived.upsert.mockResolvedValue({});
|
||||
prisma.ruleExecution.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.execute("user_1", "rule_regex");
|
||||
|
||||
expect(result.applied).toBe(1);
|
||||
expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
update: expect.objectContaining({ isHidden: true }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("applies category, note, append-note, clear, and unhide actions", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.rule.findFirst.mockResolvedValue({
|
||||
id: "rule_actions",
|
||||
userId: "user_1",
|
||||
isActive: true,
|
||||
conditions: { textContains: "vendor" },
|
||||
actions: {
|
||||
clearCategory: true,
|
||||
setNote: "Reviewed",
|
||||
appendNote: "Matched by rule",
|
||||
setHidden: false,
|
||||
},
|
||||
});
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_vendor",
|
||||
description: "Vendor payment",
|
||||
amount: 20,
|
||||
date: new Date("2026-07-15"),
|
||||
source: "csv",
|
||||
derived: { userCategory: "Old", userNotes: "Old note", isHidden: true },
|
||||
},
|
||||
]);
|
||||
prisma.transactionDerived.upsert.mockResolvedValue({});
|
||||
prisma.ruleExecution.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.execute("user_1", "rule_actions");
|
||||
|
||||
expect(result.applied).toBe(1);
|
||||
expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
userCategory: null,
|
||||
userNotes: "Reviewed\nMatched by rule",
|
||||
isHidden: false,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("supports nested DSL condition groups with all, any, and not", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.rule.findFirst.mockResolvedValue({
|
||||
id: "rule_dsl",
|
||||
userId: "user_1",
|
||||
isActive: true,
|
||||
conditions: {
|
||||
all: [
|
||||
{ field: "description", operator: "contains", value: "coffee" },
|
||||
{
|
||||
any: [
|
||||
{ field: "amount", operator: ">", value: 5 },
|
||||
{ field: "source", operator: "equals", value: "csv" },
|
||||
],
|
||||
},
|
||||
{
|
||||
not: { field: "category", operator: "equals", value: "Ignore" },
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: { setCategory: "Cafe" },
|
||||
});
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_dsl_match",
|
||||
description: "Coffee Market",
|
||||
amount: 3,
|
||||
date: new Date("2026-07-15"),
|
||||
source: "csv",
|
||||
derived: { userCategory: "Uncategorized", isHidden: false },
|
||||
},
|
||||
{
|
||||
id: "tx_dsl_skip",
|
||||
description: "Coffee Market",
|
||||
amount: 9,
|
||||
date: new Date("2026-07-15"),
|
||||
source: "plaid",
|
||||
derived: { userCategory: "Ignore", isHidden: false },
|
||||
},
|
||||
]);
|
||||
prisma.transactionDerived.upsert.mockResolvedValue({});
|
||||
prisma.ruleExecution.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.execute("user_1", "rule_dsl");
|
||||
|
||||
expect(result.applied).toBe(1);
|
||||
expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { rawTransactionId: "tx_dsl_match" },
|
||||
update: expect.objectContaining({ userCategory: "Cafe" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("suggests category, merchant, refund, transfer, and fee rules from transaction patterns", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.transactionDerived.findMany.mockResolvedValue([
|
||||
{ userCategory: "Meals", raw: { description: "Starbucks 1234" } },
|
||||
{ userCategory: "Meals", raw: { description: "Starbucks 5678" } },
|
||||
]);
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{ description: "Target Store 100", amount: 25, derived: null },
|
||||
{ description: "Target Store 200", amount: 30, derived: null },
|
||||
{ description: "Target Store 300", amount: 35, derived: null },
|
||||
{ description: "Refund Amazon", amount: -12, derived: null },
|
||||
{ description: "Cashback Credit", amount: -3, derived: null },
|
||||
{ description: "Zelle Transfer", amount: 50, derived: null },
|
||||
{ description: "Venmo Transfer", amount: 20, derived: null },
|
||||
{ description: "Monthly Service Fee", amount: 5, derived: null },
|
||||
{ description: "Overdraft Fee", amount: 35, derived: null },
|
||||
]);
|
||||
|
||||
const result = await service.suggest("user_1");
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: "category", actions: expect.objectContaining({ setCategory: "Meals" }) }),
|
||||
expect.objectContaining({ type: "merchant-pattern", matchCount: 3 }),
|
||||
expect.objectContaining({ type: "refund-pattern" }),
|
||||
expect.objectContaining({ type: "transfer-pattern" }),
|
||||
expect.objectContaining({ type: "fee-pattern" }),
|
||||
]));
|
||||
expect(result.every((item) => item.reason && item.confidence > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
49
test/subscription.guard.spec.ts
Normal file
49
test/subscription.guard.spec.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { REQUIRED_PLAN_KEY, SubscriptionGuard } from "../src/stripe/subscription.guard";
|
||||
import { IS_PUBLIC_KEY } from "../src/common/guards/jwt-auth.guard";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
|
||||
const createContext = (userId?: string) => ({
|
||||
getHandler: jest.fn(),
|
||||
getClass: jest.fn(),
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: userId ? { sub: userId } : undefined }),
|
||||
}),
|
||||
}) as unknown as ExecutionContext;
|
||||
|
||||
describe("SubscriptionGuard", () => {
|
||||
it("blocks free users from pro-only exports", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.subscription.findUnique.mockResolvedValue({ userId: "user_1", plan: "free" });
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => key === REQUIRED_PLAN_KEY ? "pro" : undefined),
|
||||
} as unknown as Reflector;
|
||||
const guard = new SubscriptionGuard(reflector, prisma as any);
|
||||
|
||||
await expect(guard.canActivate(createContext("user_1"))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(reflector.getAllAndOverride).toHaveBeenCalledWith(REQUIRED_PLAN_KEY, expect.any(Array));
|
||||
});
|
||||
|
||||
it("allows pro users through pro-only exports", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.subscription.findUnique.mockResolvedValue({ userId: "user_1", plan: "pro" });
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => key === REQUIRED_PLAN_KEY ? "pro" : undefined),
|
||||
} as unknown as Reflector;
|
||||
const guard = new SubscriptionGuard(reflector, prisma as any);
|
||||
|
||||
await expect(guard.canActivate(createContext("user_1"))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("skips subscription checks for public signed download routes", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => key === IS_PUBLIC_KEY ? true : "pro"),
|
||||
} as unknown as Reflector;
|
||||
const guard = new SubscriptionGuard(reflector, prisma as any);
|
||||
|
||||
await expect(guard.canActivate(createContext())).resolves.toBe(true);
|
||||
expect(prisma.subscription.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
182
test/tax.service.spec.ts
Normal file
182
test/tax.service.spec.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { TaxService } from "../src/tax/tax.service";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
|
||||
describe("TaxService", () => {
|
||||
it("saves and submits complete intake as ready", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const createdAt = new Date("2026-07-16T00:00:00.000Z");
|
||||
prisma.taxReturn.findFirst.mockResolvedValue({
|
||||
id: "return_1",
|
||||
userId: "user_1",
|
||||
taxYear: 2026,
|
||||
filingType: "individual",
|
||||
jurisdictions: ["CA"],
|
||||
status: "draft",
|
||||
summary: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
prisma.taxReturn.update.mockResolvedValue({
|
||||
id: "return_1",
|
||||
status: "ready",
|
||||
summary: {},
|
||||
});
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
const service = new TaxService(prisma as any);
|
||||
|
||||
const result = await service.saveIntake("user_1", "return_1", {
|
||||
taxpayer: { name: "Jane Doe", filingStatus: "Single", address: "1 Main St" },
|
||||
income: { total: 120000 },
|
||||
deductions: { standard: true },
|
||||
credits: {},
|
||||
}, true);
|
||||
|
||||
expect(result.readiness.complete).toBe(true);
|
||||
expect(prisma.taxReturn.update).toHaveBeenCalledWith({
|
||||
where: { id: "return_1" },
|
||||
data: expect.objectContaining({
|
||||
status: "ready",
|
||||
summary: expect.objectContaining({
|
||||
intake: expect.any(Object),
|
||||
intakeReadiness: expect.objectContaining({ complete: true }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "tax.intake_submit",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("exports a versioned package with hash, manifest, and audit log", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const createdAt = new Date("2026-07-16T00:00:00.000Z");
|
||||
prisma.taxReturn.findFirst.mockResolvedValue({
|
||||
id: "return_1",
|
||||
userId: "user_1",
|
||||
taxYear: 2026,
|
||||
filingType: "individual",
|
||||
jurisdictions: ["CA"],
|
||||
status: "draft",
|
||||
summary: { grossIncome: 1000 },
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
taxReturnId: "return_1",
|
||||
docType: "w2_or_1099",
|
||||
metadata: { source: "upload" },
|
||||
createdAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
prisma.taxReturn.update.mockResolvedValue({});
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
const service = new TaxService(prisma as any);
|
||||
|
||||
const result = await service.exportReturn("user_1", "return_1");
|
||||
|
||||
expect(result.packageId).toMatch(/^taxpkg_/);
|
||||
expect(result.packageVersion).toBe("2026.1");
|
||||
expect(result.packageHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.manifest).toEqual(expect.objectContaining({
|
||||
taxReturnId: "return_1",
|
||||
taxYear: 2026,
|
||||
filingType: "individual",
|
||||
documentCount: 1,
|
||||
readiness: "needs_documents",
|
||||
}));
|
||||
expect(result.return.status).toBe("exported");
|
||||
expect(result.documents).toHaveLength(1);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
action: "tax.export_package",
|
||||
metadata: expect.objectContaining({
|
||||
taxReturnId: "return_1",
|
||||
packageHash: result.packageHash,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("submits a ready return to the sandbox e-file provider", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const createdAt = new Date("2026-07-16T00:00:00.000Z");
|
||||
prisma.taxReturn.findFirst.mockResolvedValue({
|
||||
id: "return_1",
|
||||
userId: "user_1",
|
||||
taxYear: 2026,
|
||||
filingType: "individual",
|
||||
jurisdictions: ["CA"],
|
||||
status: "ready",
|
||||
summary: {
|
||||
intake: {
|
||||
taxpayer: { name: "Jane Doe", filingStatus: "Single", address: "1 Main St" },
|
||||
income: { total: 120000 },
|
||||
},
|
||||
},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
documents: [
|
||||
{ id: "doc_1", docType: "w2_or_1099", metadata: {}, createdAt },
|
||||
{ id: "doc_2", docType: "interest_and_dividend_forms", metadata: {}, createdAt },
|
||||
{ id: "doc_3", docType: "deduction_support", metadata: {}, createdAt },
|
||||
{ id: "doc_4", docType: "identity_information", metadata: {}, createdAt },
|
||||
],
|
||||
});
|
||||
prisma.taxReturn.update.mockResolvedValue({
|
||||
id: "return_1",
|
||||
status: "efile_submitted",
|
||||
summary: {},
|
||||
});
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
const service = new TaxService(prisma as any);
|
||||
|
||||
const result = await service.submitEFile("user_1", "return_1", true);
|
||||
|
||||
expect(result.eFile.provider).toBe("sandbox");
|
||||
expect(result.eFile.status).toBe("submitted");
|
||||
expect(result.eFile.submissionId).toMatch(/^efile_/);
|
||||
expect(result.eFile.packageHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(prisma.taxReturn.update).toHaveBeenCalledWith({
|
||||
where: { id: "return_1" },
|
||||
data: expect.objectContaining({
|
||||
status: "efile_submitted",
|
||||
summary: expect.objectContaining({
|
||||
eFile: expect.objectContaining({
|
||||
provider: "sandbox",
|
||||
status: "submitted",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "tax.efile_submit",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks e-file submission when intake or documents are incomplete", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.taxReturn.findFirst.mockResolvedValue({
|
||||
id: "return_1",
|
||||
userId: "user_1",
|
||||
taxYear: 2026,
|
||||
filingType: "individual",
|
||||
jurisdictions: ["CA"],
|
||||
status: "draft",
|
||||
summary: { intake: { taxpayer: { name: "Jane Doe" }, income: {} } },
|
||||
documents: [],
|
||||
});
|
||||
const service = new TaxService(prisma as any);
|
||||
|
||||
await expect(service.submitEFile("user_1", "return_1", true)).rejects.toThrow(
|
||||
"Tax return is not ready for e-file submission.",
|
||||
);
|
||||
expect(prisma.taxReturn.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
108
test/teller.service.spec.ts
Normal file
108
test/teller.service.spec.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { TellerService } from "../src/teller/teller.service";
|
||||
|
||||
const createService = () => {
|
||||
const prisma = {
|
||||
account: {
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
transactionRaw: {
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
};
|
||||
const service = Object.create(TellerService.prototype) as TellerService;
|
||||
Object.assign(service as any, {
|
||||
prisma,
|
||||
encryption: {
|
||||
encrypt: jest.fn((value: string) => `enc_${value}`),
|
||||
decrypt: jest.fn((value: string) => value.replace("enc_", "")),
|
||||
},
|
||||
logger: {
|
||||
warn: jest.fn(),
|
||||
},
|
||||
planLimits: {
|
||||
assertCanAddAccounts: jest.fn(),
|
||||
},
|
||||
});
|
||||
return { service, prisma };
|
||||
};
|
||||
|
||||
describe("TellerService", () => {
|
||||
it("imports Teller enrollment accounts with encrypted access tokens", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.account.findMany.mockResolvedValue([]);
|
||||
jest.spyOn(service as any, "api")
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "acc_teller_1",
|
||||
enrollment_id: "enr_1",
|
||||
institution: { name: "Teller Bank" },
|
||||
type: "depository",
|
||||
subtype: "checking",
|
||||
currency: "USD",
|
||||
last_four: "1234",
|
||||
status: "open",
|
||||
links: { balances: "https://api.teller.io/accounts/acc_teller_1/balances" },
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce({
|
||||
ledger: "100.25",
|
||||
available: "95.25",
|
||||
});
|
||||
|
||||
const result = await service.exchangeEnrollment("user_1", {
|
||||
accessToken: "token_1",
|
||||
enrollment: { id: "enr_1", institution: { name: "Teller Bank" } },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ enrollmentId: "enr_1", accountCount: 1 });
|
||||
expect((service as any).planLimits.assertCanAddAccounts).toHaveBeenCalledWith("user_1", 1);
|
||||
expect(prisma.account.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { tellerAccountId: "acc_teller_1" },
|
||||
create: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
tellerAccessToken: "enc_token_1",
|
||||
tellerEnrollmentId: "enr_1",
|
||||
tellerAccountId: "acc_teller_1",
|
||||
institutionName: "Teller Bank",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("syncs Teller transactions into the raw transaction table", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.account.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "acct_1",
|
||||
tellerAccessToken: "enc_token_1",
|
||||
tellerAccountId: "acc_teller_1",
|
||||
},
|
||||
]);
|
||||
prisma.account.update.mockResolvedValue({});
|
||||
jest.spyOn(service as any, "api").mockResolvedValue([
|
||||
{
|
||||
id: "txn_1",
|
||||
account_id: "acc_teller_1",
|
||||
amount: "-12.34",
|
||||
date: "2026-07-15",
|
||||
description: "Coffee",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.syncTransactionsForUser("user_1");
|
||||
|
||||
expect(result).toEqual({ created: 1 });
|
||||
expect(prisma.transactionRaw.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { bankTransactionId: "txn_1" },
|
||||
create: expect.objectContaining({
|
||||
accountId: "acct_1",
|
||||
source: "teller",
|
||||
}),
|
||||
}));
|
||||
expect(prisma.account.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: "acct_1" },
|
||||
data: expect.objectContaining({ syncStatus: "idle" }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@ -8,6 +8,8 @@ describe("TransactionsController", () => {
|
||||
summary: jest.fn().mockResolvedValue({ total: "0.00", count: 0 }),
|
||||
updateDerived: jest.fn().mockResolvedValue({}),
|
||||
createManualTransaction: jest.fn().mockResolvedValue({ id: "tx_1" }),
|
||||
previewCsv: jest.fn().mockResolvedValue({ headers: ["Date", "Description", "Amount"] }),
|
||||
importCsvBatch: jest.fn().mockResolvedValue({ totalFiles: 2, imported: 3 }),
|
||||
cashflow: jest.fn().mockResolvedValue([]),
|
||||
merchantInsights: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
@ -16,49 +18,69 @@ describe("TransactionsController", () => {
|
||||
|
||||
it("lists transactions", async () => {
|
||||
const { controller, service } = createController();
|
||||
const result = await controller.list({ user_id: "user_1" });
|
||||
const result = await controller.list("user_1");
|
||||
expect(service.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user_1" })
|
||||
"user_1",
|
||||
expect.objectContaining({ page: 1, limit: 25 })
|
||||
);
|
||||
expect(result.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("syncs transactions", async () => {
|
||||
const { controller, service } = createController();
|
||||
const result = await controller.sync({ userId: "user_1" });
|
||||
expect(service.sync).toHaveBeenCalled();
|
||||
const result = await controller.sync("user_1", "2025-01-01", "2025-01-31");
|
||||
expect(service.sync).toHaveBeenCalledWith("user_1", "2025-01-01", "2025-01-31");
|
||||
expect(result.data).toEqual({ created: 0 });
|
||||
});
|
||||
|
||||
it("returns summary", async () => {
|
||||
const { controller, service } = createController();
|
||||
const result = await controller.summary({ user_id: "user_1" });
|
||||
expect(service.summary).toHaveBeenCalled();
|
||||
const result = await controller.summary("user_1", "2025-01-01", "2025-01-31");
|
||||
expect(service.summary).toHaveBeenCalledWith("user_1", "2025-01-01", "2025-01-31");
|
||||
expect(result.data.total).toBe("0.00");
|
||||
});
|
||||
|
||||
it("creates manual transaction", async () => {
|
||||
const { controller, service } = createController();
|
||||
const result = await controller.manual({
|
||||
userId: "user_1",
|
||||
const result = await controller.manual("user_1", {
|
||||
date: "2025-01-01",
|
||||
description: "Manual",
|
||||
amount: 10
|
||||
});
|
||||
expect(service.createManualTransaction).toHaveBeenCalledWith("user_1", {
|
||||
date: "2025-01-01",
|
||||
description: "Manual",
|
||||
amount: 10
|
||||
});
|
||||
expect(service.createManualTransaction).toHaveBeenCalled();
|
||||
expect(result.data.id).toBe("tx_1");
|
||||
});
|
||||
|
||||
it("imports a batch of CSV files", async () => {
|
||||
const { controller, service } = createController();
|
||||
const files = [{ originalname: "one.csv" }, { originalname: "two.csv" }] as Express.Multer.File[];
|
||||
const result = await controller.importCsvBatch("user_1", files, "{\"date\":\"Date\"}");
|
||||
expect(service.importCsvBatch).toHaveBeenCalledWith("user_1", files, { mapping: "{\"date\":\"Date\"}", mappings: undefined });
|
||||
expect(result.data).toEqual({ totalFiles: 2, imported: 3 });
|
||||
});
|
||||
|
||||
it("previews a CSV file", async () => {
|
||||
const { controller, service } = createController();
|
||||
const file = { originalname: "preview.csv" } as Express.Multer.File;
|
||||
const result = await controller.previewCsv("user_1", file);
|
||||
expect(service.previewCsv).toHaveBeenCalledWith("user_1", file);
|
||||
expect(result.data.headers).toEqual(["Date", "Description", "Amount"]);
|
||||
});
|
||||
|
||||
it("returns cashflow", async () => {
|
||||
const { controller, service } = createController();
|
||||
const result = await controller.cashflow({ user_id: "user_1", months: "3" });
|
||||
const result = await controller.cashflow("user_1", 3);
|
||||
expect(service.cashflow).toHaveBeenCalledWith("user_1", 3);
|
||||
expect(result.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns merchant insights", async () => {
|
||||
const { controller, service } = createController();
|
||||
const result = await controller.merchants({ user_id: "user_1", limit: "5" });
|
||||
const result = await controller.merchants("user_1", 5);
|
||||
expect(service.merchantInsights).toHaveBeenCalledWith("user_1", 5);
|
||||
expect(result.data).toEqual([]);
|
||||
});
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import { TransactionsService } from "../src/transactions/transactions.service";
|
||||
import { createPrismaMock } from "./utils/mock-prisma";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
const createService = () => {
|
||||
const prisma = createPrismaMock();
|
||||
const plaid = { syncTransactionsForUser: jest.fn() };
|
||||
const service = new TransactionsService(prisma as any, plaid as any);
|
||||
return { service, prisma, plaid };
|
||||
const opaqueIds = {
|
||||
encode: jest.fn((kind: string, _userId: string, id: string) => `opaque_${kind}_${id}`),
|
||||
decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")),
|
||||
};
|
||||
const exportsService = { syncGoogleSheets: jest.fn().mockResolvedValue({ status: "synced" }) };
|
||||
const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, exportsService as any);
|
||||
return { service, prisma, plaid, opaqueIds, exportsService };
|
||||
};
|
||||
|
||||
describe("TransactionsService", () => {
|
||||
@ -34,7 +40,7 @@ describe("TransactionsService", () => {
|
||||
|
||||
const result = await service.cashflow("user_1", 3);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.some((row) => row.month.endsWith("-01"))).toBe(true);
|
||||
expect(result.every((row) => /^\d{4}-\d{2}$/.test(row.month))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns merchant insights sorted by spend", async () => {
|
||||
@ -59,34 +65,221 @@ describe("TransactionsService", () => {
|
||||
prisma.transactionRaw.create.mockResolvedValue({ id: "tx_1" });
|
||||
prisma.transactionDerived.create.mockResolvedValue({ id: "derived_1" });
|
||||
|
||||
const result = await service.createManualTransaction({
|
||||
userId: "user_1",
|
||||
const result = await service.createManualTransaction("user_1", {
|
||||
accountId: "acct_1",
|
||||
date: "2025-01-15",
|
||||
description: "Manual payment",
|
||||
amount: 123.45,
|
||||
category: "Operations",
|
||||
note: "Test note",
|
||||
attribution: "ours",
|
||||
splitMode: "equal",
|
||||
hidden: false
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "tx_1" });
|
||||
expect(result).toEqual({ id: "opaque_transaction_tx_1" });
|
||||
expect(prisma.transactionRaw.create).toHaveBeenCalled();
|
||||
expect(prisma.transactionDerived.create).toHaveBeenCalled();
|
||||
expect(prisma.transactionDerived.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
attribution: "ours",
|
||||
splitMode: "equal",
|
||||
splitMinePercent: 50,
|
||||
splitYoursPercent: 50,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns null when no account is available for manual transaction", async () => {
|
||||
it("returns opaque transaction and account IDs in list responses", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tx_raw_1",
|
||||
accountId: "acct_raw_1",
|
||||
description: "Coffee",
|
||||
amount: 4.25,
|
||||
derived: null,
|
||||
account: { ownershipType: "joint", ownerUserId: null },
|
||||
date: new Date("2026-07-01"),
|
||||
source: "csv",
|
||||
},
|
||||
]);
|
||||
prisma.transactionRaw.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.list("user_1", {});
|
||||
|
||||
expect(result.transactions[0].id).toBe("opaque_transaction_tx_raw_1");
|
||||
expect(result.transactions[0].accountId).toBe("opaque_account_acct_raw_1");
|
||||
expect(result.transactions[0].attribution).toBe("ours");
|
||||
expect(result.transactions[0].split).toEqual({
|
||||
mode: "none",
|
||||
minePercent: 100,
|
||||
yoursPercent: 0,
|
||||
mineAmount: 4.25,
|
||||
yoursAmount: 0,
|
||||
});
|
||||
expect(result.transactions[0].id).not.toBe("tx_raw_1");
|
||||
expect(result.transactions[0].accountId).not.toBe("acct_raw_1");
|
||||
});
|
||||
|
||||
it("updates derived transaction attribution", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.transactionRaw.findFirst.mockResolvedValue({
|
||||
id: "tx_1",
|
||||
account: { ownershipType: "mine", ownerUserId: "user_1" },
|
||||
});
|
||||
prisma.transactionDerived.upsert.mockResolvedValue({
|
||||
rawTransactionId: "tx_1",
|
||||
attribution: "yours",
|
||||
});
|
||||
|
||||
const result = await service.updateDerived("user_1", "opaque_transaction_tx_1", {
|
||||
userCategory: "Dining",
|
||||
userNotes: "Partner paid",
|
||||
attribution: "yours",
|
||||
splitMode: "custom",
|
||||
splitMinePercent: 40,
|
||||
splitYoursPercent: 60,
|
||||
isHidden: false,
|
||||
});
|
||||
|
||||
expect(result.attribution).toBe("yours");
|
||||
expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
update: expect.objectContaining({ attribution: "yours", splitMode: "custom", splitMinePercent: 40, splitYoursPercent: 60 }),
|
||||
create: expect.objectContaining({ attribution: "yours", splitMode: "custom", splitMinePercent: 40, splitYoursPercent: 60 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects custom splits that do not total 100", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.transactionRaw.findFirst.mockResolvedValue({
|
||||
id: "tx_1",
|
||||
account: { ownershipType: "joint", ownerUserId: null },
|
||||
});
|
||||
|
||||
await expect(service.updateDerived("user_1", "opaque_transaction_tx_1", {
|
||||
splitMode: "custom",
|
||||
splitMinePercent: 70,
|
||||
splitYoursPercent: 20,
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.transactionDerived.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps UI transaction list responses at 25 rows", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.transactionRaw.findMany.mockResolvedValue([]);
|
||||
prisma.transactionRaw.count.mockResolvedValue(0);
|
||||
|
||||
const result = await service.list("user_1", { limit: 100 });
|
||||
|
||||
expect(result.limit).toBe(25);
|
||||
expect(prisma.transactionRaw.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
take: 25,
|
||||
}));
|
||||
});
|
||||
|
||||
it("throws when no account is available for manual transaction", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await service.createManualTransaction({
|
||||
userId: "user_1",
|
||||
await expect(service.createManualTransaction("user_1", {
|
||||
date: "2025-01-15",
|
||||
description: "Manual payment",
|
||||
amount: 10
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.transactionRaw.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports multiple CSV files with per-file results", async () => {
|
||||
const { service, prisma, exportsService } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue({ id: "acct_csv", userId: "user_1" });
|
||||
prisma.transactionRaw.upsert.mockResolvedValue({});
|
||||
|
||||
const makeFile = (name: string, body: string) => ({
|
||||
originalname: name,
|
||||
buffer: Buffer.from(body),
|
||||
}) as Express.Multer.File;
|
||||
|
||||
const result = await service.importCsvBatch("user_1", [
|
||||
makeFile("first.csv", "Date,Description,Amount\n2026-07-01,Coffee,4.25\n"),
|
||||
makeFile("second.csv", "Date,Description,Amount\n2026-07-02,Lunch,12.50\n"),
|
||||
]);
|
||||
|
||||
expect(result.totalFiles).toBe(2);
|
||||
expect(result.processedFiles).toBe(2);
|
||||
expect(result.failedFiles).toBe(0);
|
||||
expect(result.imported).toBe(2);
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(prisma.transactionRaw.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(exportsService.syncGoogleSheets).toHaveBeenCalledTimes(1);
|
||||
expect(exportsService.syncGoogleSheets).toHaveBeenCalledWith("user_1", "csv_batch_import");
|
||||
});
|
||||
|
||||
it("previews CSV columns with inferred mapping", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.csvImportMapping.findUnique.mockResolvedValue(null);
|
||||
const file = {
|
||||
originalname: "preview.csv",
|
||||
buffer: Buffer.from("Posted Date,Merchant,Amount\n2026-07-01,Coffee,4.25\n"),
|
||||
} as Express.Multer.File;
|
||||
|
||||
const result = await service.previewCsv("user_1", file);
|
||||
|
||||
expect(result.headers).toEqual(["Posted Date", "Merchant", "Amount"]);
|
||||
expect(result.mapping).toEqual(expect.objectContaining({
|
||||
date: "Posted Date",
|
||||
description: "Merchant",
|
||||
amount: "Amount",
|
||||
}));
|
||||
expect(result.remembered).toBe(false);
|
||||
expect(result.sampleRows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses mapped CSV import and remembers the mapping", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue({ id: "acct_csv", userId: "user_1" });
|
||||
prisma.transactionRaw.upsert.mockResolvedValue({});
|
||||
prisma.csvImportMapping.upsert.mockResolvedValue({});
|
||||
const file = {
|
||||
originalname: "mapped.csv",
|
||||
buffer: Buffer.from("When,Who,Cost\n2026-07-01,Coffee,4.25\n"),
|
||||
} as Express.Multer.File;
|
||||
|
||||
const result = await service.importCsv("user_1", file, {
|
||||
date: "When",
|
||||
description: "Who",
|
||||
amount: "Cost",
|
||||
amountMultiplier: 1,
|
||||
});
|
||||
|
||||
expect(result.imported).toBe(1);
|
||||
expect(prisma.csvImportMapping.upsert).toHaveBeenCalled();
|
||||
expect(prisma.transactionRaw.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
description: "Coffee",
|
||||
amount: 4.25,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps batch import going when one CSV file fails", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.account.findFirst.mockResolvedValue({ id: "acct_csv", userId: "user_1" });
|
||||
prisma.transactionRaw.upsert.mockResolvedValue({});
|
||||
|
||||
const makeFile = (name: string, body: string) => ({
|
||||
originalname: name,
|
||||
buffer: Buffer.from(body),
|
||||
}) as Express.Multer.File;
|
||||
|
||||
const result = await service.importCsvBatch("user_1", [
|
||||
makeFile("good.csv", "Date,Description,Amount\n2026-07-01,Coffee,4.25\n"),
|
||||
makeFile("bad.txt", "not,csv\n1,2\n"),
|
||||
]);
|
||||
|
||||
expect(result.totalFiles).toBe(2);
|
||||
expect(result.processedFiles).toBe(1);
|
||||
expect(result.failedFiles).toBe(1);
|
||||
expect(result.imported).toBe(1);
|
||||
expect(result.results[1].error).toBe("File must be a CSV.");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,20 +1,103 @@
|
||||
/// <reference types="jest" />
|
||||
|
||||
export const createPrismaMock = () => ({
|
||||
account: {
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn()
|
||||
},
|
||||
household: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn()
|
||||
},
|
||||
transactionRaw: {
|
||||
householdMember: {
|
||||
count: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn()
|
||||
},
|
||||
householdInvite: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn()
|
||||
},
|
||||
account: {
|
||||
count: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn()
|
||||
},
|
||||
transactionRaw: {
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
create: jest.fn(),
|
||||
upsert: jest.fn()
|
||||
},
|
||||
transactionDerived: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
upsert: jest.fn()
|
||||
},
|
||||
csvImportMapping: {
|
||||
findUnique: jest.fn(),
|
||||
upsert: jest.fn()
|
||||
},
|
||||
exportLog: {
|
||||
create: jest.fn(),
|
||||
count: jest.fn()
|
||||
},
|
||||
exportDownloadToken: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
updateMany: jest.fn()
|
||||
},
|
||||
googleConnection: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
deleteMany: jest.fn()
|
||||
},
|
||||
apiKey: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn()
|
||||
},
|
||||
subscription: {
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
deleteMany: jest.fn()
|
||||
},
|
||||
abuseEvent: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn()
|
||||
},
|
||||
rule: {
|
||||
count: jest.fn(),
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn()
|
||||
},
|
||||
ruleExecution: {
|
||||
create: jest.fn()
|
||||
},
|
||||
taxReturn: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn()
|
||||
},
|
||||
taxDocument: {
|
||||
create: jest.fn()
|
||||
},
|
||||
auditLog: {
|
||||
create: jest.fn()
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user