feat: implement LedgerOne backend backlog features

This commit is contained in:
MOHAN 2026-07-16 15:07:01 +05:30
parent bfe489d920
commit ade95e6fdf
97 changed files with 7687 additions and 282 deletions

View File

@ -1,11 +1,68 @@
DATABASE_URL=postgresql://user:password@localhost:5432/ledgerone DATABASE_URL=postgresql://user:password@localhost:5432/ledgerone
JWT_SECRET=change_me JWT_SECRET=replace_with_32_plus_char_secret
SUPABASE_URL=http://127.0.0.1:54321 JWT_REFRESH_SECRET=replace_with_32_plus_char_refresh_secret
SUPABASE_SERVICE_KEY=your_service_role_key JWT_ACCESS_TTL_SECONDS=60
SUPABASE_ANON_KEY=your_anon_key ENCRYPTION_KEY=replace_with_64_hex_chars_for_aes_256_gcm
PLAID_CLIENT_ID=your_client_id PLAID_CLIENT_ID=your_client_id
PLAID_SECRET=your_sandbox_secret PLAID_SECRET=your_sandbox_secret
PLAID_ENV=sandbox PLAID_ENV=sandbox
PLAID_PRODUCTS=transactions PLAID_PRODUCTS=transactions
PLAID_COUNTRY_CODES=US 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=

View File

@ -16,7 +16,7 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY package*.json ./ 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/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma

257
package-lock.json generated
View File

@ -40,7 +40,8 @@
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"stripe": "^20.4.0", "stripe": "^20.4.0",
"swagger-ui-express": "^5.0.1" "swagger-ui-express": "^5.0.1",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.3.2", "@nestjs/cli": "^10.3.2",
@ -56,6 +57,7 @@
"prisma": "^5.18.0", "prisma": "^5.18.0",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@ -816,6 +818,30 @@
"node": ">=0.1.90" "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": { "node_modules/@fastify/otel": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz",
@ -2918,6 +2944,34 @@
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -3551,6 +3605,28 @@
"acorn": "^8" "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": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -3691,6 +3767,13 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT" "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": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -4201,6 +4284,19 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -4415,6 +4511,15 @@
"node": ">= 0.12.0" "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": { "node_modules/collect-v8-coverage": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", "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": { "node_modules/create-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "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": "^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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4803,6 +4927,16 @@
"wrappy": "1" "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": { "node_modules/diff-sequences": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@ -5506,6 +5640,15 @@
"integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==",
"license": "MIT" "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": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -9303,6 +9446,18 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/stack-utils": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@ -9922,6 +10077,50 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/tsconfig-paths": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
@ -10140,6 +10339,13 @@
"node": ">= 0.4.0" "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": { "node_modules/v8-to-istanbul": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@ -10320,6 +10526,24 @@
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC" "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": { "node_modules/wordwrap": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@ -10462,6 +10707,16 @@
"node": ">=12" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -44,7 +44,8 @@
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"stripe": "^20.4.0", "stripe": "^20.4.0",
"swagger-ui-express": "^5.0.1" "swagger-ui-express": "^5.0.1",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.3.2", "@nestjs/cli": "^10.3.2",
@ -60,6 +61,7 @@
"prisma": "^5.18.0", "prisma": "^5.18.0",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@ model User {
state String? state String?
postalCode String? postalCode String?
country String? country String?
role String @default("user")
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
twoFactorEnabled Boolean @default(false) twoFactorEnabled Boolean @default(false)
twoFactorSecret String? twoFactorSecret String?
@ -29,34 +30,152 @@ model User {
accounts Account[] accounts Account[]
rules Rule[] rules Rule[]
exports ExportLog[] exports ExportLog[]
exportDownloadTokens ExportDownloadToken[]
auditLogs AuditLog[] auditLogs AuditLog[]
abuseEvents AbuseEvent[]
googleConnection GoogleConnection? googleConnection GoogleConnection?
apiKeys ApiKey[]
emailVerificationToken EmailVerificationToken? emailVerificationToken EmailVerificationToken?
passwordResetTokens PasswordResetToken[] passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
sessions Session[]
socialAccounts SocialAccount[]
subscription Subscription? subscription Subscription?
taxReturns TaxReturn[] 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 { model Account {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
householdId String?
ownerUserId String?
ownershipType String @default("mine")
institutionName String institutionName String
accountType String accountType String
mask String? mask String?
plaidAccessToken String? plaidAccessToken String?
plaidItemId String? plaidItemId String?
plaidAccountId String? @unique plaidAccountId String? @unique
tellerAccessToken String?
tellerEnrollmentId String?
tellerAccountId String? @unique
currentBalance Decimal? currentBalance Decimal?
availableBalance Decimal? availableBalance Decimal?
isoCurrencyCode String? isoCurrencyCode String?
lastBalanceSync DateTime? lastBalanceSync DateTime?
lastTransactionSync DateTime?
lastSyncAttemptAt DateTime?
syncStatus String @default("idle")
lastSyncError String?
syncConsecutiveFailures Int @default(0)
plaidWebhookCode String?
plaidWebhookAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id]) 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[] 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 { model TransactionRaw {
@ -80,6 +199,10 @@ model TransactionDerived {
rawTransactionId String @unique rawTransactionId String @unique
userCategory String? userCategory String?
userNotes String? userNotes String?
attribution String @default("mine")
splitMode String @default("none")
splitMinePercent Decimal?
splitYoursPercent Decimal?
isHidden Boolean @default(false) isHidden Boolean @default(false)
modifiedAt DateTime @default(now()) modifiedAt DateTime @default(now())
modifiedBy String modifiedBy String
@ -87,6 +210,22 @@ model TransactionDerived {
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id]) 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 { model Rule {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
@ -115,11 +254,44 @@ model RuleExecution {
model ExportLog { model ExportLog {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
format String @default("csv")
destination String @default("download")
filters Json filters Json
rowCount Int rowCount Int
fileName String?
mimeType String?
fileHash String?
ipAddress String?
userAgent String?
metadata Json @default("{}")
createdAt DateTime @default(now()) 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 { model AuditLog {
@ -132,6 +304,23 @@ model AuditLog {
user User @relation(fields: [userId], references: [id]) 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 { model GoogleConnection {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique userId String @unique
@ -139,6 +328,10 @@ model GoogleConnection {
refreshToken String refreshToken String
accessToken String? accessToken String?
spreadsheetId String? spreadsheetId String?
driveMirrorEnabled Boolean @default(false)
driveMirrorStatus String @default("not_started")
driveMirrorSpreadsheetUrl String?
driveMirrorLastSyncedAt DateTime?
isConnected Boolean @default(true) isConnected Boolean @default(true)
connectedAt DateTime @default(now()) connectedAt DateTime @default(now())
lastSyncedAt DateTime? lastSyncedAt DateTime?
@ -148,6 +341,24 @@ model GoogleConnection {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) 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 { model EmailVerificationToken {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique userId String @unique
@ -169,15 +380,35 @@ model PasswordResetToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) 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 { model RefreshToken {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
sessionId String?
tokenHash String @unique tokenHash String @unique
expiresAt DateTime expiresAt DateTime
revokedAt DateTime? revokedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
session Session? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId])
} }
model Subscription { model Subscription {

View File

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

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

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

125
src/abuse/abuse.service.ts Normal file
View 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
View 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,
};
}

View File

@ -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 { ok } from "../common/response";
import { AccountsService } from "./accounts.service"; import { AccountsService } from "./accounts.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto";
@Controller("accounts") @Controller("accounts")
export class AccountsController { export class AccountsController {
@ -37,4 +38,14 @@ export class AccountsController {
const data = await this.accountsService.refreshBalances(userId); const data = await this.accountsService.refreshBalances(userId);
return ok(data); return ok(data);
} }
@Patch(":id/ownership")
async updateOwnership(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: UpdateAccountOwnershipDto,
) {
const data = await this.accountsService.updateOwnership(userId, id, payload);
return ok(data);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import { SupabaseModule } from "./supabase/supabase.module";
import { EmailModule } from "./email/email.module"; import { EmailModule } from "./email/email.module";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { PlaidModule } from "./plaid/plaid.module"; import { PlaidModule } from "./plaid/plaid.module";
import { TellerModule } from "./teller/teller.module";
import { TaxModule } from "./tax/tax.module"; import { TaxModule } from "./tax/tax.module";
import { TransactionsModule } from "./transactions/transactions.module"; import { TransactionsModule } from "./transactions/transactions.module";
import { AccountsModule } from "./accounts/accounts.module"; import { AccountsModule } from "./accounts/accounts.module";
@ -19,6 +20,10 @@ import { ExportsModule } from "./exports/exports.module";
import { StripeModule } from "./stripe/stripe.module"; import { StripeModule } from "./stripe/stripe.module";
import { TwoFactorModule } from "./auth/twofa/two-factor.module"; import { TwoFactorModule } from "./auth/twofa/two-factor.module";
import { GoogleModule } from "./google/google.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 { LoggerModule } from "nestjs-pino";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
@ -57,10 +62,12 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
StorageModule, StorageModule,
SupabaseModule, SupabaseModule,
EmailModule, EmailModule,
AbuseModule,
// ─── Feature modules ───────────────────────────────────────────────────── // ─── Feature modules ─────────────────────────────────────────────────────
AuthModule, AuthModule,
PlaidModule, PlaidModule,
TellerModule,
TaxModule, TaxModule,
TransactionsModule, TransactionsModule,
AccountsModule, AccountsModule,
@ -69,6 +76,9 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
StripeModule, StripeModule,
TwoFactorModule, TwoFactorModule,
GoogleModule, GoogleModule,
PublicApiModule,
AdminModule,
HouseholdsModule,
], ],
providers: [ providers: [
// Apply rate limiting globally // Apply rate limiting globally

View File

@ -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 { ok } from "../common/response";
import { requestContextFrom } from "../abuse/abuse.types";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { SocialAuthService } from "./social-auth.service";
import { LoginDto } from "./dto/login.dto"; import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto"; import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto";
@ -13,18 +16,21 @@ import { Public } from "../common/decorators/public.decorator";
@Controller("auth") @Controller("auth")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(
private readonly authService: AuthService,
private readonly socialAuthService: SocialAuthService,
) {}
@Public() @Public()
@Post("register") @Post("register")
async register(@Body() payload: RegisterDto) { async register(@Body() payload: RegisterDto, @Req() req: Request) {
return ok(await this.authService.register(payload)); return ok(await this.authService.register(payload, requestContextFrom(req)));
} }
@Public() @Public()
@Post("login") @Post("login")
async login(@Body() payload: LoginDto) { async login(@Body() payload: LoginDto, @Req() req: Request) {
return ok(await this.authService.login(payload)); return ok(await this.authService.login(payload, requestContextFrom(req)));
} }
@Public() @Public()
@ -35,8 +41,8 @@ export class AuthController {
@Public() @Public()
@Post("refresh") @Post("refresh")
async refresh(@Body("refreshToken") refreshToken: string) { async refresh(@Body("refreshToken") refreshToken: string, @Req() req: Request) {
return ok(await this.authService.refreshAccessToken(refreshToken)); return ok(await this.authService.refreshAccessToken(refreshToken, requestContextFrom(req)));
} }
@Post("logout") @Post("logout")
@ -56,6 +62,24 @@ export class AuthController {
return ok(await this.authService.resetPassword(payload)); 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") @Get("me")
async me(@CurrentUser() userId: string) { async me(@CurrentUser() userId: string) {
return ok(await this.authService.getProfile(userId)); return ok(await this.authService.getProfile(userId));
@ -65,4 +89,9 @@ export class AuthController {
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) { async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
return ok(await this.authService.updateProfile(userId, payload)); return ok(await this.authService.updateProfile(userId, payload));
} }
@Delete("me")
async deleteMe(@CurrentUser() userId: string) {
return ok(await this.authService.deleteAccount(userId));
}
} }

View File

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

View File

@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
Injectable, Injectable,
NotFoundException, NotFoundException,
Optional,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import * as crypto from "crypto"; import * as crypto from "crypto";
@ -15,10 +16,13 @@ import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ForgotPasswordDto } from "./dto/forgot-password.dto"; import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { ResetPasswordDto } from "./dto/reset-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 VERIFY_TOKEN_TTL_HOURS = 24;
const RESET_TOKEN_TTL_HOURS = 1; const RESET_TOKEN_TTL_HOURS = 1;
const REFRESH_TOKEN_TTL_DAYS = 30; const REFRESH_TOKEN_TTL_DAYS = 30;
const SESSION_TTL_DAYS = 30;
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -27,9 +31,10 @@ export class AuthService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly emailService: EmailService, private readonly emailService: EmailService,
private readonly encryption: EncryptionService, 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 email = payload.email.toLowerCase().trim();
const existing = await this.prisma.user.findUnique({ where: { email } }); const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) throw new BadRequestException("Email already registered."); if (existing) throw new BadRequestException("Email already registered.");
@ -47,8 +52,7 @@ export class AuthService {
}); });
await this.emailService.sendVerificationEmail(email, verifyToken); await this.emailService.sendVerificationEmail(email, verifyToken);
const accessToken = this.signAccessToken(user.id); const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context);
const refreshToken = await this.createRefreshToken(user.id);
return { return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken, 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 email = payload.email.toLowerCase().trim();
const user = await this.prisma.user.findUnique({ where: { email } }); const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) { if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
await this.abuseService?.recordLoginFailure(email, user?.id, context);
throw new UnauthorizedException("Invalid credentials."); 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 } } }); await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
const accessToken = this.signAccessToken(user.id); const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context);
const refreshToken = await this.createRefreshToken(user.id);
return { return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken, accessToken,
@ -96,24 +100,46 @@ export class AuthService {
return { message: "Email verified successfully." }; return { message: "Email verified successfully." };
} }
async refreshAccessToken(rawRefreshToken: string) { async refreshAccessToken(rawRefreshToken: string, context?: RequestContext) {
const tokenHash = this.hashToken(rawRefreshToken); const tokenHash = this.hashToken(rawRefreshToken);
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } }); const record = await this.prisma.refreshToken.findUnique({
if (!record || record.revokedAt || record.expiresAt < new Date()) { where: { tokenHash },
include: { session: true },
});
if (!record || record.revokedAt || record.expiresAt < new Date() || !record.session) {
throw new UnauthorizedException("Invalid or expired refresh token."); 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() } }); await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
const accessToken = this.signAccessToken(record.userId); await this.prisma.session.update({
const refreshToken = await this.createRefreshToken(record.userId); 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 }; return { accessToken, refreshToken };
} }
async logout(rawRefreshToken: string) { async logout(rawRefreshToken: string) {
const tokenHash = this.hashToken(rawRefreshToken); const tokenHash = this.hashToken(rawRefreshToken);
const token = await this.prisma.refreshToken.findUnique({ where: { tokenHash }, select: { sessionId: true } });
await this.prisma.refreshToken.updateMany({ await this.prisma.refreshToken.updateMany({
where: { tokenHash, revokedAt: null }, where: { tokenHash, revokedAt: null },
data: { revokedAt: new Date() }, 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." }; return { message: "Logged out." };
} }
@ -140,6 +166,10 @@ export class AuthService {
where: { userId: record.userId, revokedAt: null }, where: { userId: record.userId, revokedAt: null },
data: { revokedAt: new Date() }, 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." }; return { message: "Password reset successfully. Please log in." };
} }
@ -180,19 +210,92 @@ export class AuthService {
}; };
} }
verifyToken(token: string): { sub: string } { async deleteAccount(userId: string) {
return this.jwtService.verify<{ sub: string }>(token); 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 { verifyToken(token: string): { sub: string; sid: string } {
return this.jwtService.sign({ sub: userId }); 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 raw = crypto.randomBytes(40).toString("hex");
const tokenHash = this.hashToken(raw); const tokenHash = this.hashToken(raw);
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000); 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; return raw;
} }
@ -200,6 +303,10 @@ export class AuthService {
return crypto.createHash("sha256").update(token).digest("hex"); 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 { private hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString("hex"); const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");

View File

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

View File

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

View File

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

View File

@ -7,6 +7,10 @@ import {
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { Request } from "express"; 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"; export const IS_PUBLIC_KEY = "isPublic";
@ -15,9 +19,11 @@ export class JwtAuthGuard implements CanActivate {
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly reflector: Reflector, 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, [ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
@ -33,12 +39,32 @@ export class JwtAuthGuard implements CanActivate {
} }
try { try {
const payload = this.jwtService.verify<{ sub: string }>(token, { const payload = this.jwtService.verify<{ sub: string; sid?: string }>(token, {
secret: process.env.JWT_SECRET, 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; return true;
} catch { } catch {
await this.abuseService.recordInvalidToken(requestContextFrom(request));
throw new UnauthorizedException("Invalid or expired token."); throw new UnauthorizedException("Invalid or expired token.");
} }
} }
@ -50,4 +76,8 @@ export class JwtAuthGuard implements CanActivate {
} }
return null; return null;
} }
private hashBindingValue(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
} }

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import { Request, Response } from "express";
@Catch() @Catch()
export class SentryExceptionFilter implements ExceptionFilter { export class SentryExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger("ExceptionFilter"); private readonly logger = new Logger("ExceptionFilter");
private readonly sentryEnabled = Boolean(process.env.SENTRY_DSN);
catch(exception: unknown, host: ArgumentsHost) { catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
@ -23,17 +24,35 @@ export class SentryExceptionFilter implements ExceptionFilter {
? exception.getStatus() ? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR; : HttpStatus.INTERNAL_SERVER_ERROR;
// Only report 5xx errors to Sentry const userId = (request as Request & { user?: { sub: string } }).user?.sub;
if (status >= 500 && process.env.SENTRY_DSN) {
Sentry.captureException(exception, { // Only report server-side failures to Sentry. Client errors remain local
extra: { // responses and do not create noisy operational alerts.
url: request.url, 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, 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) { } else if (status >= 500) {
this.logger.error(exception); this.logger.error(
"Unhandled server exception",
exception instanceof Error ? exception.stack : String(exception),
);
} }
const message = const message =
@ -50,7 +69,7 @@ export class SentryExceptionFilter implements ExceptionFilter {
? message ? message
: (message as { message?: string }).message ?? "Internal server error", : (message as { message?: string }).message ?? "Internal server error",
}, },
meta: { timestamp: new Date().toISOString(), version: "1.0" }, meta: { timestamp: new Date().toISOString(), version: "v1" },
}); });
} }
} }

View File

@ -4,6 +4,7 @@ export const envValidationSchema = Joi.object({
DATABASE_URL: Joi.string().required(), DATABASE_URL: Joi.string().required(),
JWT_SECRET: Joi.string().min(32).required(), JWT_SECRET: Joi.string().min(32).required(),
JWT_REFRESH_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(), ENCRYPTION_KEY: Joi.string().length(64).required(),
PLAID_CLIENT_ID: Joi.string().required(), PLAID_CLIENT_ID: Joi.string().required(),
@ -12,11 +13,26 @@ export const envValidationSchema = Joi.object({
PLAID_PRODUCTS: Joi.string().default("transactions"), PLAID_PRODUCTS: Joi.string().default("transactions"),
PLAID_COUNTRY_CODES: Joi.string().default("US"), PLAID_COUNTRY_CODES: Joi.string().default("US"),
PLAID_REDIRECT_URI: Joi.string().uri().optional().allow(""), 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_URL: Joi.string().optional().allow(""),
SUPABASE_SERVICE_KEY: Joi.string().optional().allow(""), SUPABASE_SERVICE_KEY: Joi.string().optional().allow(""),
SUPABASE_ANON_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_SECRET_KEY: Joi.string().optional().allow(""),
STRIPE_WEBHOOK_SECRET: Joi.string().optional().allow(""), STRIPE_WEBHOOK_SECRET: Joi.string().optional().allow(""),
STRIPE_PRICE_PRO: 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"), CORS_ORIGIN: Joi.string().default("http://localhost:3052"),
AUTO_SYNC_ENABLED: Joi.boolean().default(true), 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_ID: Joi.string().optional().allow(""),
GOOGLE_CLIENT_SECRET: Joi.string().optional().allow(""), GOOGLE_CLIENT_SECRET: Joi.string().optional().allow(""),
GOOGLE_REDIRECT_URI: Joi.string().uri().optional().allow(""), GOOGLE_REDIRECT_URI: Joi.string().uri().optional().allow(""),
SOCIAL_AUTH_REDIRECT_URI: Joi.string().uri().optional().allow(""),
GOOGLE_AUTH_CLIENT_ID: Joi.string().optional().allow(""),
GOOGLE_AUTH_CLIENT_SECRET: Joi.string().optional().allow(""),
APPLE_CLIENT_ID: Joi.string().optional().allow(""),
APPLE_TEAM_ID: Joi.string().optional().allow(""),
APPLE_KEY_ID: Joi.string().optional().allow(""),
APPLE_PRIVATE_KEY: Joi.string().optional().allow(""),
}); });

View File

@ -77,4 +77,28 @@ export class EmailService {
this.logger.error(`Failed to send password reset email to ${email}`, err); 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);
}
}
} }

View File

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

View File

@ -1,21 +1,55 @@
import { Controller, Get, Post, Query } from "@nestjs/common"; import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import { Request, Response } from "express";
import { ok } from "../common/response"; import { ok } from "../common/response";
import { ExportsService } from "./exports.service"; import { ExportsService } from "./exports.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; 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") @Controller("exports")
@RequiredPlan("pro")
@UseGuards(SubscriptionGuard)
export class ExportsController { export class ExportsController {
constructor(private readonly exportsService: ExportsService) {} constructor(private readonly exportsService: ExportsService) {}
@Get("csv") @Get("csv")
async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>) { async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
const data = await this.exportsService.exportCsv(userId, query); const data = await this.exportsService.createSignedDownloadUrl(userId, "csv", query, requestContextFrom(req));
return ok(data); 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") @Post("sheets")
async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>) { async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
const data = await this.exportsService.exportSheets(userId, query); const data = await this.exportsService.exportSheets(userId, query, requestContextFrom(req));
return ok(data); return ok(data);
} }
} }

View File

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

View File

@ -1,12 +1,32 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { BadRequestException, GoneException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import * as crypto from "crypto";
import { google } from "googleapis"; import { google } from "googleapis";
import { Prisma } from "@prisma/client";
import * as XLSX from "xlsx";
import { PrismaService } from "../prisma/prisma.service"; 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() @Injectable()
export class ExportsService { export class ExportsService {
private readonly logger = new Logger(ExportsService.name); 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>>) { private toCsv(rows: Array<Record<string, string>>) {
if (!rows.length) return ""; if (!rows.length) return "";
@ -19,6 +39,102 @@ export class ExportsService {
return lines.join("\n"); 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( private async getTransactions(
userId: string, userId: string,
filters: Record<string, string>, filters: Record<string, string>,
@ -50,7 +166,14 @@ export class ExportsService {
} }
return this.prisma.transactionRaw.findMany({ return this.prisma.transactionRaw.findMany({
where, where,
include: { derived: true }, include: {
derived: true,
account: {
select: {
ownershipType: true,
},
},
},
orderBy: { date: "desc" }, orderBy: { date: "desc" },
take: limit, take: limit,
}); });
@ -64,31 +187,357 @@ export class ExportsService {
amount: Number(tx.amount).toFixed(2), amount: Number(tx.amount).toFixed(2),
category: tx.derived?.userCategory ?? "", category: tx.derived?.userCategory ?? "",
notes: tx.derived?.userNotes ?? "", 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", hidden: tx.derived?.isHidden ? "true" : "false",
source: tx.source, source: tx.source,
})); }));
} }
async exportCsv(userId: string, filters: Record<string, string> = {}) { private defaultAttributionForAccount(ownershipType?: string | null) {
const transactions = await this.getTransactions(userId, filters); if (ownershipType === "joint") return "ours";
const rows = this.toRows(transactions); if (ownershipType === "theirs") return "yours";
const csv = this.toCsv(rows); return "mine";
await this.prisma.exportLog.create({
data: { userId, filters, rowCount: rows.length },
});
return { status: "ready", csv, rowCount: rows.length };
} }
async exportSheets(userId: string, filters: Record<string, string> = {}) { private splitPercent(mode: string | null | undefined, value: unknown, side: "mine" | "yours") {
// Get the user's Google connection 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 } }); const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
if (!gc || !gc.isConnected) { if (!gc || !gc.isConnected) {
if (failWhenDisconnected) {
throw new BadRequestException( throw new BadRequestException(
"Google account not connected. Please connect via /api/google/connect.", "Google account not connected. Please connect via /api/google/connect.",
); );
} }
return null;
}
const oauth2Client = new google.auth.OAuth2( const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_ID,
@ -99,7 +548,6 @@ export class ExportsService {
refresh_token: gc.refreshToken, refresh_token: gc.refreshToken,
}); });
// Refresh the access token if needed
const { credentials } = await oauth2Client.refreshAccessToken(); const { credentials } = await oauth2Client.refreshAccessToken();
await this.prisma.googleConnection.update({ await this.prisma.googleConnection.update({
where: { userId }, where: { userId },
@ -110,39 +558,76 @@ export class ExportsService {
}); });
oauth2Client.setCredentials(credentials); oauth2Client.setCredentials(credentials);
const sheets = google.sheets({ version: "v4", auth: oauth2Client }); return {
const transactions = await this.getTransactions(userId, filters); gc,
const rows = this.toRows(transactions); sheets: google.sheets({ version: "v4", auth: oauth2Client }),
};
}
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`; private async ensureSpreadsheet(
userId: string,
sheets: ReturnType<typeof google.sheets>,
spreadsheetId?: string | null,
) {
if (spreadsheetId) {
await this.markDriveMirror(userId, spreadsheetId, "ready");
return spreadsheetId;
}
let spreadsheetId = gc.spreadsheetId;
if (!spreadsheetId) {
// Create a new spreadsheet
const spreadsheet = await sheets.spreadsheets.create({ const spreadsheet = await sheets.spreadsheets.create({
requestBody: { properties: { title: "LedgerOne" } }, requestBody: { properties: { title: "LedgerOne User-Owned Ledger" } },
}); });
spreadsheetId = spreadsheet.data.spreadsheetId!; 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({ await this.prisma.googleConnection.update({
where: { userId }, where: { userId },
data: { spreadsheetId }, data: {
spreadsheetId,
driveMirrorEnabled: true,
driveMirrorStatus: status,
driveMirrorSpreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
...(syncedAt ? { driveMirrorLastSyncedAt: syncedAt, lastSyncedAt: syncedAt } : {}),
},
}); });
} }
// Add a new sheet tab private async addSheetIfMissing(
sheets: ReturnType<typeof google.sheets>,
spreadsheetId: string,
sheetTitle: string,
) {
try {
await sheets.spreadsheets.batchUpdate({ await sheets.spreadsheets.batchUpdate({
spreadsheetId, spreadsheetId,
requestBody: { requestBody: {
requests: [{ addSheet: { properties: { title: sheetTitle } } }], requests: [{ addSheet: { properties: { title: sheetTitle } } }],
}, },
}); });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "";
if (!/already exists|duplicate/i.test(message)) {
throw error;
}
}
}
// Build values: header + data rows private async writeSheetValues(
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"]; sheets: ReturnType<typeof google.sheets>,
const values = [ spreadsheetId: string,
headers, sheetTitle: string,
...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")), values: string[][],
]; clearFirst = false,
) {
if (clearFirst) {
await sheets.spreadsheets.values.clear({
spreadsheetId,
range: `'${sheetTitle}'!A:Z`,
});
}
await sheets.spreadsheets.values.update({ await sheets.spreadsheets.values.update({
spreadsheetId, spreadsheetId,
@ -150,11 +635,34 @@ export class ExportsService {
valueInputOption: "RAW", valueInputOption: "RAW",
requestBody: { values }, requestBody: { values },
}); });
}
await this.prisma.exportLog.create({ async exportSheets(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length }, 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}`); this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`);
return { return {
@ -165,4 +673,38 @@ export class ExportsService {
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`, url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
}; };
} }
async syncGoogleSheets(userId: string, reason = "transaction_change") {
const client = await this.createGoogleSheetsClient(userId, false);
if (!client) {
return { status: "skipped", reason: "not_connected" };
}
const { gc, sheets } = client;
const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId);
const transactions = await this.getTransactions(userId, {}, 5000);
const rows = this.toRows(transactions);
await this.addSheetIfMissing(sheets, spreadsheetId, this.syncSheetTitle);
await this.writeSheetValues(
sheets,
spreadsheetId,
this.syncSheetTitle,
this.toSheetValues(rows),
true,
);
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
this.logger.log(`Synced ${rows.length} rows to Google Sheets for user ${userId}`);
return {
status: "synced",
reason,
rowCount: rows.length,
spreadsheetId,
sheetTitle: this.syncSheetTitle,
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
};
}
} }

View File

@ -67,6 +67,10 @@ export class GoogleService {
isConnected: true, isConnected: true,
connectedAt: new Date(), connectedAt: new Date(),
spreadsheetId: null, // reset so a new spreadsheet is created on next export spreadsheetId: null, // reset so a new spreadsheet is created on next export
driveMirrorEnabled: false,
driveMirrorStatus: "pending_first_sync",
driveMirrorSpreadsheetUrl: null,
driveMirrorLastSyncedAt: null,
}, },
create: { create: {
userId, userId,
@ -74,6 +78,8 @@ export class GoogleService {
refreshToken: tokens.refresh_token, refreshToken: tokens.refresh_token,
accessToken: tokens.access_token ?? null, accessToken: tokens.access_token ?? null,
isConnected: true, isConnected: true,
driveMirrorEnabled: false,
driveMirrorStatus: "pending_first_sync",
}, },
}); });
@ -89,6 +95,18 @@ export class GoogleService {
async getStatus(userId: string) { async getStatus(userId: string) {
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } }); const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
if (!gc || !gc.isConnected) return { connected: false }; 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",
},
};
} }
} }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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;
}

View 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));
}
}

View File

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

View 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)),
};
}
}

View File

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

View File

@ -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 { ok } from "../common/response";
import { PlaidService } from "./plaid.service"; import { PlaidService } from "./plaid.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
import { OpaqueIdService } from "../common/opaque-id.service";
@Controller("plaid") @Controller("plaid")
export class PlaidController { export class PlaidController {
constructor(private readonly plaidService: PlaidService) {} constructor(
private readonly plaidService: PlaidService,
private readonly opaqueIds: OpaqueIdService,
) {}
@Post("link-token") @Post("link-token")
async createLinkToken(@CurrentUser() userId: string) { async createLinkToken(@CurrentUser() userId: string) {
@ -21,4 +27,35 @@ export class PlaidController {
const data = await this.plaidService.exchangePublicTokenForUser(userId, payload.publicToken); const data = await this.plaidService.exchangePublicTokenForUser(userId, payload.publicToken);
return ok(data); 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);
}
} }

View File

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

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from "@nestjs/common"; import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { import {
Configuration, Configuration,
CountryCode, CountryCode,
@ -10,14 +10,18 @@ import * as crypto from "crypto";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { EncryptionService } from "../common/encryption.service"; import { EncryptionService } from "../common/encryption.service";
import { PlanLimitsService } from "../stripe/plan-limits.service";
@Injectable() @Injectable()
export class PlaidService { export class PlaidService {
private readonly logger = new Logger(PlaidService.name);
private readonly client: PlaidApi; private readonly client: PlaidApi;
private readonly webhookKeys = new Map<string, JsonWebKey>();
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly encryption: EncryptionService, private readonly encryption: EncryptionService,
private readonly planLimits: PlanLimitsService,
) { ) {
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments; const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
const clientId = this.requireEnv("PLAID_CLIENT_ID"); const clientId = this.requireEnv("PLAID_CLIENT_ID");
@ -55,6 +59,7 @@ export class PlaidService {
country_codes: countryCodes, country_codes: countryCodes,
language: "en", language: "en",
redirect_uri: redirectUri || undefined, redirect_uri: redirectUri || undefined,
webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined,
}); });
return { return {
@ -86,6 +91,14 @@ export class PlaidService {
const institutionName = institutionId const institutionName = institutionId
? await this.getInstitutionName(institutionId) ? await this.getInstitutionName(institutionId)
: "Plaid institution"; : "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) { for (const account of accountsResponse.data.accounts) {
await this.prisma.account.upsert({ await this.prisma.account.upsert({
@ -162,7 +175,7 @@ export class PlaidService {
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, 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[]>(); const tokenMap = new Map<string, string[]>();
for (const account of accounts) { for (const account of accounts) {
if (!account.plaidAccessToken || !account.plaidAccountId) continue; if (!account.plaidAccessToken || !account.plaidAccountId) continue;
@ -172,8 +185,15 @@ export class PlaidService {
tokenMap.set(raw, list); tokenMap.set(raw, list);
} }
const now = new Date();
await this.prisma.account.updateMany({
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
data: { syncStatus: "syncing", lastSyncAttemptAt: now, lastSyncError: null },
});
let created = 0; let created = 0;
for (const [rawToken] of tokenMap) { for (const [rawToken, plaidAccountIds] of tokenMap) {
try {
const response = await this.client.transactionsGet({ const response = await this.client.transactionsGet({
access_token: rawToken, access_token: rawToken,
start_date: startDate, start_date: startDate,
@ -209,11 +229,326 @@ export class PlaidService {
}); });
created += 1; 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 },
},
});
}
} }
return { created }; 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) { private requireEnv(name: string) {
const value = process.env[name]; const value = process.env[name];
if (!value) { if (!value) {
@ -222,6 +557,13 @@ export class PlaidService {
return value; 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) { private async getInstitutionName(institutionId: string) {
try { try {
const response = await this.client.institutionsGetById({ 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;
};

View 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));
}
}

View 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;
}
}

View 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 };
}
}

View 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));
}
}

View 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 {}

View File

@ -63,21 +63,212 @@ export class RulesService {
private matchesRule( private matchesRule(
conditions: Record<string, unknown>, conditions: Record<string, unknown>,
tx: { description: string; amount: number | string }, tx: {
description: string;
amount: number | string;
date: Date;
source: string;
category?: string | null;
},
): boolean { ): boolean {
const textContains = typeof conditions.textContains === "string" ? conditions.textContains : ""; if (this.hasDslConditions(conditions)) {
const amountGt = typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null; return this.matchesDsl(conditions, tx);
const amountLt = typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null; }
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; 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); const amount = Number(tx.amount);
if (amountGt !== null && amount <= amountGt) return false; if (amountGt !== null && amount <= amountGt) return false;
if (amountLt !== null && amount >= amountLt) 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; 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) { async execute(userId: string, id: string) {
const rule = await this.prisma.rule.findFirst({ where: { id, userId } }); const rule = await this.prisma.rule.findFirst({ where: { id, userId } });
if (!rule || !rule.isActive) return { id, status: "skipped" }; if (!rule || !rule.isActive) return { id, status: "skipped" };
@ -92,22 +283,26 @@ export class RulesService {
let applied = 0; let applied = 0;
for (const tx of transactions) { 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; continue;
} }
await this.prisma.transactionDerived.upsert({ await this.prisma.transactionDerived.upsert({
where: { rawTransactionId: tx.id }, where: { rawTransactionId: tx.id },
update: { update: {
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : tx.derived?.userCategory ?? null, ...this.buildDerivedActions(actions, tx.derived),
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : tx.derived?.isHidden ?? false,
modifiedAt: new Date(), modifiedAt: new Date(),
modifiedBy: "rule", modifiedBy: "rule",
}, },
create: { create: {
rawTransactionId: tx.id, rawTransactionId: tx.id,
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : null, ...this.buildDerivedActions(actions, null),
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false,
modifiedAt: new Date(), modifiedAt: new Date(),
modifiedBy: "rule", modifiedBy: "rule",
}, },
@ -128,33 +323,141 @@ export class RulesService {
} }
async suggest(userId: string) { async suggest(userId: string) {
const derived = await this.prisma.transactionDerived.findMany({ const [derived, transactions] = await Promise.all([
this.prisma.transactionDerived.findMany({
where: { where: {
raw: { account: { userId } }, raw: { account: { userId } },
userCategory: { not: null }, userCategory: { not: null },
}, },
include: { raw: { select: { description: true } } }, include: { raw: { select: { description: true } } },
take: 200, 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) { for (const item of derived) {
const key = item.raw.description.toLowerCase(); const key = this.merchantKey(item.raw.description);
const category = item.userCategory ?? "Uncategorized"; const category = item.userCategory ?? "Uncategorized";
const entry = bucket.get(key) ?? { category, count: 0 }; const entry = categoryBucket.get(key) ?? { category, count: 0 };
entry.count += 1; 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) .filter(([, value]) => value.count >= 2)
.slice(0, 5) .slice(0, 5)) {
.map(([description, value], index) => ({ suggestions.push({
id: `suggestion_${index + 1}`, id: `category_${suggestions.length + 1}`,
name: `Auto: ${value.category}`, name: `Auto: ${value.category}`,
conditions: { textContains: description }, conditions: { textContains: description },
actions: { setCategory: value.category }, actions: { setCategory: value.category },
confidence: Math.min(0.95, 0.5 + value.count * 0.1), 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);
} }
} }

View 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.`,
);
}
}
}

View File

@ -28,19 +28,22 @@ export class StripeController {
} }
@Post("checkout") @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({ const user = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { email: true }, select: { email: true },
}); });
if (!user) return ok({ error: "User not found" }); 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); return ok(data);
} }
@Post("portal") @Post("portal")
async portal(@CurrentUser() userId: string) { async portal(@CurrentUser() userId: string, @Body("returnUrl") returnUrl?: string) {
const data = await this.stripeService.createPortalSession(userId); const data = await this.stripeService.createPortalSession(userId, returnUrl);
return ok(data); return ok(data);
} }

View File

@ -1,11 +1,12 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PlanLimitsService } from "./plan-limits.service";
import { StripeController } from "./stripe.controller"; import { StripeController } from "./stripe.controller";
import { StripeService } from "./stripe.service"; import { StripeService } from "./stripe.service";
import { SubscriptionGuard } from "./subscription.guard"; import { SubscriptionGuard } from "./subscription.guard";
@Module({ @Module({
controllers: [StripeController], controllers: [StripeController],
providers: [StripeService, SubscriptionGuard], providers: [StripeService, SubscriptionGuard, PlanLimitsService],
exports: [StripeService, SubscriptionGuard], exports: [StripeService, SubscriptionGuard, PlanLimitsService],
}) })
export class StripeModule {} export class StripeModule {}

View File

@ -3,8 +3,8 @@ import Stripe from "stripe";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
export const PLAN_LIMITS: Record<string, { accounts: number; exports: number }> = { export const PLAN_LIMITS: Record<string, { accounts: number; exports: number }> = {
free: { accounts: 2, exports: 5 }, free: { accounts: 2, exports: 0 },
pro: { accounts: 10, exports: 100 }, pro: { accounts: 10, exports: -1 },
elite: { accounts: -1, exports: -1 }, // -1 = unlimited elite: { accounts: -1, exports: -1 }, // -1 = unlimited
}; };
@ -32,28 +32,33 @@ export class StripeService {
return customer.id; 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 customerId = await this.getOrCreateCustomer(userId, email);
const session = await this.stripe.checkout.sessions.create({ const session = await this.stripe.checkout.sessions.create({
customer: customerId, customer: customerId,
payment_method_types: ["card"], payment_method_types: ["card"],
mode: "subscription", mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }], line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/settings/billing?success=true`, success_url: payload.successUrl ?? `${process.env.APP_URL}/settings/subscription?success=true`,
cancel_url: `${process.env.APP_URL}/settings/billing?cancelled=true`, cancel_url: payload.cancelUrl ?? `${process.env.APP_URL}/settings/subscription?cancelled=true`,
metadata: { userId }, metadata: { userId },
}); });
return { url: session.url }; return { url: session.url };
} }
async createPortalSession(userId: string) { async createPortalSession(userId: string, returnUrl?: string) {
const sub = await this.prisma.subscription.findUnique({ where: { userId } }); const sub = await this.prisma.subscription.findUnique({ where: { userId } });
if (!sub?.stripeCustomerId) { if (!sub?.stripeCustomerId) {
throw new BadRequestException("No Stripe customer found. Please upgrade first."); throw new BadRequestException("No Stripe customer found. Please upgrade first.");
} }
const session = await this.stripe.billingPortal.sessions.create({ const session = await this.stripe.billingPortal.sessions.create({
customer: sub.stripeCustomerId, customer: sub.stripeCustomerId,
return_url: `${process.env.APP_URL}/settings/billing`, return_url: returnUrl ?? `${process.env.APP_URL}/settings/subscription`,
}); });
return { url: session.url }; 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.");
}
} }

View File

@ -8,6 +8,7 @@ import {
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
import { Request } from "express"; import { Request } from "express";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { IS_PUBLIC_KEY } from "../common/guards/jwt-auth.guard";
export const REQUIRED_PLAN_KEY = "requiredPlan"; export const REQUIRED_PLAN_KEY = "requiredPlan";
export const RequiredPlan = (plan: "pro" | "elite") => export const RequiredPlan = (plan: "pro" | "elite") =>
@ -23,6 +24,12 @@ export class SubscriptionGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { 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, [ const required = this.reflector.getAllAndOverride<string>(REQUIRED_PLAN_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),

View File

@ -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 { ok } from "../common/response";
import { CreateTaxReturnDto } from "./dto/create-return.dto"; import { CreateTaxReturnDto } from "./dto/create-return.dto";
import { UpdateTaxReturnDto } from "./dto/update-return.dto"; import { UpdateTaxReturnDto } from "./dto/update-return.dto";
@ -41,9 +41,41 @@ export class TaxController {
return ok(data); 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") @Post("returns/:id/export")
async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) { async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) {
const data = await this.taxService.exportReturn(userId, id); const data = await this.taxService.exportReturn(userId, id);
return ok(data); 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);
}
} }

View File

@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import * as crypto from "crypto";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { CreateTaxReturnDto } from "./dto/create-return.dto"; import { CreateTaxReturnDto } from "./dto/create-return.dto";
import { UpdateTaxReturnDto } from "./dto/update-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) { async exportReturn(userId: string, id: string) {
const taxReturn = await this.prisma.taxReturn.findFirst({ const taxReturn = await this.prisma.taxReturn.findFirst({
where: { id, userId }, where: { id, userId },
@ -70,6 +123,263 @@ export class TaxService {
data: { status: "exported" }, 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)}`,
}),
};
} }
} }

View 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 };
};
};

View 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 {}

View 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;
};

View File

@ -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 { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service"; import { PlaidService } from "../plaid/plaid.service";
@Injectable() @Injectable()
export class AutoSyncService implements OnModuleInit, OnModuleDestroy { export class AutoSyncService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(AutoSyncService.name);
private intervalId: NodeJS.Timeout | null = null; private intervalId: NodeJS.Timeout | null = null;
private running = false;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@ -14,17 +16,19 @@ export class AutoSyncService implements OnModuleInit, OnModuleDestroy {
onModuleInit() { onModuleInit() {
const enabled = (process.env.AUTO_SYNC_ENABLED ?? "true").toLowerCase() !== "false"; const enabled = (process.env.AUTO_SYNC_ENABLED ?? "true").toLowerCase() !== "false";
if (!enabled) { if (!enabled) {
this.logger.log("Auto-sync disabled.");
return; return;
} }
if (!process.env.PLAID_CLIENT_ID || !process.env.PLAID_SECRET) { if (!process.env.PLAID_CLIENT_ID || !process.env.PLAID_SECRET) {
this.logger.warn("Auto-sync disabled because Plaid credentials are missing.");
return; return;
} }
const minutes = Number(process.env.AUTO_SYNC_INTERVAL_MINUTES ?? "15"); 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.intervalId = setInterval(() => {
this.run().catch(() => undefined); this.runOnce().catch((error) => this.logger.error(error));
}, interval * 60 * 1000); }, interval * 60 * 1000);
this.run().catch(() => undefined); this.runOnce().catch((error) => this.logger.error(error));
} }
onModuleDestroy() { onModuleDestroy() {
@ -34,21 +38,61 @@ export class AutoSyncService implements OnModuleInit, OnModuleDestroy {
} }
} }
async runOnce() {
if (this.running) {
this.logger.debug("Auto-sync skipped because a previous run is still active.");
return { skipped: true, reason: "already_running" };
}
this.running = true;
try {
return await this.run();
} finally {
this.running = false;
}
}
private async run() { 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({ const accounts = await this.prisma.account.findMany({
where: { plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, 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 } select: { userId: true }
}); });
const userIds = Array.from(new Set(accounts.map((acct) => acct.userId))); const userIds = accounts.map((acct) => acct.userId);
if (!userIds.length) { if (!userIds.length) {
return; return { usersScanned: 0, usersSynced: 0, created: 0, failed: 0 };
} }
const endDate = new Date().toISOString().slice(0, 10); const endDate = new Date().toISOString().slice(0, 10);
const startDate = new Date(new Date().setDate(new Date().getDate() - 7)) 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() .toISOString()
.slice(0, 10); .slice(0, 10);
let created = 0;
let failed = 0;
for (const userId of userIds) { for (const userId of userIds) {
await this.plaidService.syncTransactionsForUser(userId, startDate, endDate); 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 };
}
} }

View File

@ -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 { export class CreateManualTransactionDto {
@IsOptional() @IsOptional()
@ -22,6 +22,26 @@ export class CreateManualTransactionDto {
@IsString() @IsString()
note?: string; 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() @IsOptional()
@IsBoolean() @IsBoolean()
hidden?: boolean; hidden?: boolean;

View File

@ -1,5 +1,9 @@
export type UpdateDerivedDto = { export type UpdateDerivedDto = {
userCategory?: string; userCategory?: string;
userNotes?: string; userNotes?: string;
attribution?: "mine" | "yours" | "ours";
splitMode?: "none" | "equal" | "custom";
splitMinePercent?: number;
splitYoursPercent?: number;
isHidden?: boolean; isHidden?: boolean;
}; };

View File

@ -7,9 +7,10 @@ import {
Post, Post,
Query, Query,
UploadedFile, UploadedFile,
UploadedFiles,
UseInterceptors, UseInterceptors,
} from "@nestjs/common"; } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express"; import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express";
import { ok } from "../common/response"; import { ok } from "../common/response";
import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { UpdateDerivedDto } from "./dto/update-derived.dto";
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
@ -33,7 +34,7 @@ export class TransactionsController {
@Query("search") search?: string, @Query("search") search?: string,
@Query("include_hidden") includeHidden?: string, @Query("include_hidden") includeHidden?: string,
@Query("page") page = 1, @Query("page") page = 1,
@Query("limit") limit = 50, @Query("limit") limit = 25,
) { ) {
const data = await this.transactionsService.list(userId, { const data = await this.transactionsService.list(userId, {
startDate, startDate,
@ -56,8 +57,31 @@ export class TransactionsController {
async importCsv( async importCsv(
@CurrentUser() userId: string, @CurrentUser() userId: string,
@UploadedFile() file: Express.Multer.File, @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); return ok(data);
} }

View File

@ -1,12 +1,14 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PlaidModule } from "../plaid/plaid.module"; import { PlaidModule } from "../plaid/plaid.module";
import { ExportsModule } from "../exports/exports.module";
import { TransactionsController } from "./transactions.controller"; import { TransactionsController } from "./transactions.controller";
import { TransactionsService } from "./transactions.service"; import { TransactionsService } from "./transactions.service";
import { AutoSyncService } from "./auto-sync.service"; import { AutoSyncService } from "./auto-sync.service";
@Module({ @Module({
imports: [PlaidModule], imports: [PlaidModule, ExportsModule],
controllers: [TransactionsController], controllers: [TransactionsController],
providers: [TransactionsService, AutoSyncService] providers: [TransactionsService, AutoSyncService],
exports: [TransactionsService],
}) })
export class TransactionsModule {} export class TransactionsModule {}

View File

@ -4,15 +4,29 @@ import { parse } from "csv-parse/sync";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.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 { UpdateDerivedDto } from "./dto/update-derived.dto";
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.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 ────────────────────────────────────────── // ─── Bank CSV format auto-detection ──────────────────────────────────────────
type ParsedRow = { date: string; description: string; amount: number }; 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 text = buffer.toString("utf8").trim();
const rows: Record<string, string>[] = parse(text, { const rows: Record<string, string>[] = parse(text, {
columns: true, columns: true,
@ -20,6 +34,65 @@ function detectAndParse(buffer: Buffer): ParsedRow[] {
trim: true, trim: true,
bom: 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 []; if (!rows.length) return [];
const headers = Object.keys(rows[0]).map((h) => h.toLowerCase()); 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)."); 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() @Injectable()
export class TransactionsService { export class TransactionsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly plaidService: PlaidService, 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( async list(
userId: string, userId: string,
filters: { filters: {
@ -120,20 +209,29 @@ export class TransactionsService {
} }
if (filters.accountId) { if (filters.accountId) {
where.accountId = filters.accountId; where.accountId = this.opaqueIds.decode("account", userId, filters.accountId);
} }
if (filters.includeHidden !== "true") { if (filters.includeHidden !== "true") {
where.OR = [{ derived: null }, { derived: { isHidden: false } }]; 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 skip = ((filters.page ?? 1) - 1) * take;
const [rows, total] = await Promise.all([ const [rows, total] = await Promise.all([
this.prisma.transactionRaw.findMany({ this.prisma.transactionRaw.findMany({
where, where,
include: { derived: true }, include: {
derived: true,
account: {
select: {
ownershipType: true,
ownerUserId: true,
},
},
},
orderBy: { date: "desc" }, orderBy: { date: "desc" },
take, take,
skip, skip,
@ -142,22 +240,24 @@ export class TransactionsService {
]); ]);
const transactions = rows.map((row) => ({ const transactions = rows.map((row) => ({
id: row.id, id: this.opaqueIds.encode("transaction", userId, row.id),
name: row.description, name: row.description,
amount: Number(row.amount).toFixed(2), amount: Number(row.amount).toFixed(2),
category: row.derived?.userCategory ?? "Uncategorized", category: row.derived?.userCategory ?? "Uncategorized",
note: row.derived?.userNotes ?? "", 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", status: row.derived?.modifiedBy ?? "raw",
hidden: row.derived?.isHidden ?? false, hidden: row.derived?.isHidden ?? false,
date: row.date.toISOString().slice(0, 10), date: row.date.toISOString().slice(0, 10),
source: row.source, source: row.source,
accountId: row.accountId, accountId: this.opaqueIds.encode("account", userId, row.accountId),
})); }));
return { transactions, total, page: filters.page ?? 1, limit: take }; 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) { if (!file?.buffer) {
throw new BadRequestException("No file uploaded."); throw new BadRequestException("No file uploaded.");
} }
@ -165,11 +265,51 @@ export class TransactionsService {
throw new BadRequestException("File must be a CSV."); 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) { if (!rows.length) {
throw new BadRequestException("CSV file is empty or could not be parsed."); 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 // Find or create a manual import account for this user
let account = await this.prisma.account.findFirst({ let account = await this.prisma.account.findFirst({
where: { userId, institutionName: "CSV Import", plaidAccessToken: null }, where: { userId, institutionName: "CSV Import", plaidAccessToken: null },
@ -223,9 +363,83 @@ export class TransactionsService {
return { imported, skipped, total: rows.length }; 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) { async createManualTransaction(userId: string, payload: CreateManualTransactionDto) {
const account = payload.accountId const accountId = payload.accountId
? await this.prisma.account.findFirst({ where: { id: payload.accountId, userId } }) ? 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 } }); : await this.prisma.account.findFirst({ where: { userId } });
if (!account) { 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({ await this.prisma.transactionDerived.create({
data: { data: {
rawTransactionId: raw.id, rawTransactionId: raw.id,
userCategory: payload.category ?? null, userCategory: payload.category ?? null,
userNotes: payload.note ?? null, userNotes: payload.note ?? null,
attribution: attribution ?? this.defaultAttributionForAccount(account),
splitMode: split.mode,
splitMinePercent: split.minePercent,
splitYoursPercent: split.yoursPercent,
isHidden: payload.hidden ?? false, isHidden: payload.hidden ?? false,
modifiedAt: new Date(), modifiedAt: new Date(),
modifiedBy: "user", 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) { 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 // Ensure the transaction belongs to the user
const tx = await this.prisma.transactionRaw.findFirst({ 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."); if (!tx) throw new BadRequestException("Transaction not found.");
return this.prisma.transactionDerived.upsert({ const derived = await this.prisma.transactionDerived.upsert({
where: { rawTransactionId: id }, where: { rawTransactionId: transactionId },
update: { update: {
userCategory: payload.userCategory, userCategory: payload.userCategory,
userNotes: payload.userNotes, userNotes: payload.userNotes,
attribution: attribution ?? this.defaultAttributionForAccount(tx.account),
splitMode: split.mode,
splitMinePercent: split.minePercent,
splitYoursPercent: split.yoursPercent,
isHidden: payload.isHidden ?? false, isHidden: payload.isHidden ?? false,
modifiedAt: new Date(), modifiedAt: new Date(),
modifiedBy: "user", modifiedBy: "user",
}, },
create: { create: {
rawTransactionId: id, rawTransactionId: transactionId,
userCategory: payload.userCategory, userCategory: payload.userCategory,
userNotes: payload.userNotes, userNotes: payload.userNotes,
attribution: attribution ?? this.defaultAttributionForAccount(tx.account),
splitMode: split.mode,
splitMinePercent: split.minePercent,
splitYoursPercent: split.yoursPercent,
isHidden: payload.isHidden ?? false, isHidden: payload.isHidden ?? false,
modifiedAt: new Date(), modifiedAt: new Date(),
modifiedBy: "user", modifiedBy: "user",
}, },
}); });
await this.syncGoogleSheetsBestEffort(userId, "transaction_derived_update");
return derived;
} }
async sync(userId: string, startDate: string, endDate: string) { 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) { async summary(userId: string, startDate: string, endDate: string) {
@ -351,7 +596,7 @@ export class TransactionsService {
} }
async merchantInsights(userId: string, limit = 6) { 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({ const rows = await this.prisma.transactionRaw.findMany({
where: { account: { userId } }, where: { account: { userId } },
select: { description: true, amount: true }, select: { description: true, amount: true },
@ -375,4 +620,82 @@ export class TransactionsService {
count: value.count, 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;
}
} }

View 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" }),
}));
});
});

View 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" },
}));
});
});

View 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
View 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" }),
});
});
});

View 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);
});
});

View File

@ -1,14 +1,39 @@
import { ExportsService } from "../src/exports/exports.service"; import { ExportsService } from "../src/exports/exports.service";
import { createPrismaMock } from "./utils/mock-prisma"; import { createPrismaMock } from "./utils/mock-prisma";
import * as XLSX from "xlsx";
describe("ExportsService", () => { describe("ExportsService", () => {
it("returns missing_user when user id is absent", async () => { const abuse = { recordExportActivity: jest.fn() };
const prisma = createPrismaMock(); const createObjectStorage = () => ({
const service = new ExportsService(prisma as any); 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); beforeEach(() => {
expect(result.status).toBe("missing_user"); abuse.recordExportActivity.mockReset();
expect(prisma.exportLog.create).not.toHaveBeenCalled(); });
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 () => { it("exports csv with headers and rows", async () => {
@ -24,16 +49,326 @@ describe("ExportsService", () => {
} }
]); ]);
prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); 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.status).toBe("ready");
expect(result.rowCount).toBe(1); 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(prisma.exportLog.create).toHaveBeenCalledWith(
expect.objectContaining({ 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();
}); });
}); });

View 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",
},
});
});
});

View 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
View 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",
}),
});
});
});

View 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
View 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
View 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
View 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);
});
});

View 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
View 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
View 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" }),
}));
});
});

View File

@ -8,6 +8,8 @@ describe("TransactionsController", () => {
summary: jest.fn().mockResolvedValue({ total: "0.00", count: 0 }), summary: jest.fn().mockResolvedValue({ total: "0.00", count: 0 }),
updateDerived: jest.fn().mockResolvedValue({}), updateDerived: jest.fn().mockResolvedValue({}),
createManualTransaction: jest.fn().mockResolvedValue({ id: "tx_1" }), 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([]), cashflow: jest.fn().mockResolvedValue([]),
merchantInsights: jest.fn().mockResolvedValue([]) merchantInsights: jest.fn().mockResolvedValue([])
}; };
@ -16,49 +18,69 @@ describe("TransactionsController", () => {
it("lists transactions", async () => { it("lists transactions", async () => {
const { controller, service } = createController(); 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(service.list).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user_1" }) "user_1",
expect.objectContaining({ page: 1, limit: 25 })
); );
expect(result.data).toEqual([]); expect(result.data).toEqual([]);
}); });
it("syncs transactions", async () => { it("syncs transactions", async () => {
const { controller, service } = createController(); const { controller, service } = createController();
const result = await controller.sync({ userId: "user_1" }); const result = await controller.sync("user_1", "2025-01-01", "2025-01-31");
expect(service.sync).toHaveBeenCalled(); expect(service.sync).toHaveBeenCalledWith("user_1", "2025-01-01", "2025-01-31");
expect(result.data).toEqual({ created: 0 }); expect(result.data).toEqual({ created: 0 });
}); });
it("returns summary", async () => { it("returns summary", async () => {
const { controller, service } = createController(); const { controller, service } = createController();
const result = await controller.summary({ user_id: "user_1" }); const result = await controller.summary("user_1", "2025-01-01", "2025-01-31");
expect(service.summary).toHaveBeenCalled(); expect(service.summary).toHaveBeenCalledWith("user_1", "2025-01-01", "2025-01-31");
expect(result.data.total).toBe("0.00"); expect(result.data.total).toBe("0.00");
}); });
it("creates manual transaction", async () => { it("creates manual transaction", async () => {
const { controller, service } = createController(); const { controller, service } = createController();
const result = await controller.manual({ const result = await controller.manual("user_1", {
userId: "user_1", date: "2025-01-01",
description: "Manual",
amount: 10
});
expect(service.createManualTransaction).toHaveBeenCalledWith("user_1", {
date: "2025-01-01", date: "2025-01-01",
description: "Manual", description: "Manual",
amount: 10 amount: 10
}); });
expect(service.createManualTransaction).toHaveBeenCalled();
expect(result.data.id).toBe("tx_1"); 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 () => { it("returns cashflow", async () => {
const { controller, service } = createController(); 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(service.cashflow).toHaveBeenCalledWith("user_1", 3);
expect(result.data).toEqual([]); expect(result.data).toEqual([]);
}); });
it("returns merchant insights", async () => { it("returns merchant insights", async () => {
const { controller, service } = createController(); 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(service.merchantInsights).toHaveBeenCalledWith("user_1", 5);
expect(result.data).toEqual([]); expect(result.data).toEqual([]);
}); });

View File

@ -1,11 +1,17 @@
import { TransactionsService } from "../src/transactions/transactions.service"; import { TransactionsService } from "../src/transactions/transactions.service";
import { createPrismaMock } from "./utils/mock-prisma"; import { createPrismaMock } from "./utils/mock-prisma";
import { BadRequestException } from "@nestjs/common";
const createService = () => { const createService = () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const plaid = { syncTransactionsForUser: jest.fn() }; const plaid = { syncTransactionsForUser: jest.fn() };
const service = new TransactionsService(prisma as any, plaid as any); const opaqueIds = {
return { service, prisma, plaid }; 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", () => { describe("TransactionsService", () => {
@ -34,7 +40,7 @@ describe("TransactionsService", () => {
const result = await service.cashflow("user_1", 3); const result = await service.cashflow("user_1", 3);
expect(result).toHaveLength(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 () => { it("returns merchant insights sorted by spend", async () => {
@ -59,34 +65,221 @@ describe("TransactionsService", () => {
prisma.transactionRaw.create.mockResolvedValue({ id: "tx_1" }); prisma.transactionRaw.create.mockResolvedValue({ id: "tx_1" });
prisma.transactionDerived.create.mockResolvedValue({ id: "derived_1" }); prisma.transactionDerived.create.mockResolvedValue({ id: "derived_1" });
const result = await service.createManualTransaction({ const result = await service.createManualTransaction("user_1", {
userId: "user_1",
accountId: "acct_1", accountId: "acct_1",
date: "2025-01-15", date: "2025-01-15",
description: "Manual payment", description: "Manual payment",
amount: 123.45, amount: 123.45,
category: "Operations", category: "Operations",
note: "Test note", note: "Test note",
attribution: "ours",
splitMode: "equal",
hidden: false hidden: false
}); });
expect(result).toEqual({ id: "tx_1" }); expect(result).toEqual({ id: "opaque_transaction_tx_1" });
expect(prisma.transactionRaw.create).toHaveBeenCalled(); 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(); const { service, prisma } = createService();
prisma.account.findFirst.mockResolvedValue(null); prisma.account.findFirst.mockResolvedValue(null);
const result = await service.createManualTransaction({ await expect(service.createManualTransaction("user_1", {
userId: "user_1",
date: "2025-01-15", date: "2025-01-15",
description: "Manual payment", description: "Manual payment",
amount: 10 amount: 10
}); })).rejects.toBeInstanceOf(BadRequestException);
expect(result).toBeNull();
expect(prisma.transactionRaw.create).not.toHaveBeenCalled(); 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.");
});
}); });

View File

@ -1,20 +1,103 @@
/// <reference types="jest" /> /// <reference types="jest" />
export const createPrismaMock = () => ({ export const createPrismaMock = () => ({
account: { user: {
findMany: jest.fn(),
findUnique: jest.fn()
},
household: {
create: jest.fn(),
findFirst: jest.fn(), findFirst: jest.fn(),
findMany: jest.fn() findMany: jest.fn()
}, },
transactionRaw: { householdMember: {
count: jest.fn(),
findFirst: jest.fn(),
findMany: 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(), create: jest.fn(),
upsert: jest.fn() upsert: jest.fn()
}, },
transactionDerived: { transactionDerived: {
create: jest.fn(), create: jest.fn(),
findMany: jest.fn(),
upsert: jest.fn()
},
csvImportMapping: {
findUnique: jest.fn(),
upsert: jest.fn() upsert: jest.fn()
}, },
exportLog: { 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() create: jest.fn()
} }
}); });