From ade95e6fdfa2f3c9c4189823ce940df9ba952c2e Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 15:07:01 +0530 Subject: [PATCH] feat: implement LedgerOne backend backlog features --- .env.example | 67 +- Dockerfile | 2 +- package-lock.json | 257 ++++++- package.json | 4 +- .../migration.sql | 12 + .../migration.sql | 20 + .../migration.sql | 9 + .../migration.sql | 5 + .../migration.sql | 22 + .../20260716000600_user_roles/migration.sql | 1 + .../20260716000700_households/migration.sql | 32 + .../migration.sql | 25 + .../migration.sql | 10 + .../migration.sql | 1 + .../migration.sql | 3 + prisma/schema.prisma | 243 ++++++- src/abuse/abuse.controller.ts | 14 + src/abuse/abuse.module.ts | 11 + src/abuse/abuse.service.ts | 125 ++++ src/abuse/abuse.types.ts | 13 + src/accounts/accounts.controller.ts | 13 +- src/accounts/accounts.module.ts | 4 +- src/accounts/accounts.service.ts | 148 +++- .../dto/update-account-ownership.dto.ts | 17 + src/admin/admin.controller.ts | 17 + src/admin/admin.module.ts | 9 + src/admin/admin.service.ts | 25 + src/app.module.ts | 10 + src/auth/auth.controller.ts | 45 +- src/auth/auth.module.ts | 13 +- src/auth/auth.service.ts | 141 +++- src/auth/social-auth.service.ts | 296 ++++++++ src/common/common.module.ts | 5 +- src/common/decorators/roles.decorator.ts | 4 + src/common/guards/jwt-auth.guard.ts | 36 +- src/common/guards/roles.guard.ts | 46 ++ src/common/opaque-id.service.ts | 31 + src/common/sentry.filter.ts | 37 +- src/config/env.validation.ts | 36 +- src/email/email.service.ts | 24 + src/exports/export-object-storage.service.ts | 101 +++ src/exports/exports.controller.ts | 44 +- src/exports/exports.module.ts | 6 +- src/exports/exports.service.ts | 638 ++++++++++++++++-- src/google/google.service.ts | 20 +- .../dto/accept-household-invite.dto.ts | 7 + .../dto/create-household-invite.dto.ts | 11 + src/households/dto/create-household.dto.ts | 12 + .../dto/update-household-member.dto.ts | 17 + src/households/households.controller.ts | 67 ++ src/households/households.module.ts | 11 + src/households/households.service.ts | 535 +++++++++++++++ src/main.ts | 12 +- src/plaid/plaid.controller.ts | 41 +- src/plaid/plaid.module.ts | 2 + src/plaid/plaid.service.ts | 424 +++++++++++- src/public-api/api-key.controller.ts | 27 + src/public-api/api-key.guard.ts | 23 + src/public-api/api-key.service.ts | 97 +++ src/public-api/public-api.controller.ts | 58 ++ src/public-api/public-api.module.ts | 15 + src/rules/rules.service.ts | 357 +++++++++- src/stripe/plan-limits.service.ts | 31 + src/stripe/stripe.controller.ts | 11 +- src/stripe/stripe.module.ts | 5 +- src/stripe/stripe.service.ts | 25 +- src/stripe/subscription.guard.ts | 7 + src/tax/tax.controller.ts | 34 +- src/tax/tax.service.ts | 312 ++++++++- src/teller/teller.controller.ts | 38 ++ src/teller/teller.module.ts | 12 + src/teller/teller.service.ts | 304 +++++++++ src/transactions/auto-sync.service.ts | 80 ++- .../dto/create-manual-transaction.dto.ts | 22 +- src/transactions/dto/update-derived.dto.ts | 4 + src/transactions/transactions.controller.ts | 30 +- src/transactions/transactions.module.ts | 6 +- src/transactions/transactions.service.ts | 361 +++++++++- test/abuse.service.spec.ts | 49 ++ test/accounts.service.spec.ts | 115 ++++ test/api-key.service.spec.ts | 76 +++ test/auth.service.spec.ts | 143 ++++ test/auto-sync.service.spec.ts | 70 ++ test/exports.service.spec.ts | 355 +++++++++- test/google.service.spec.ts | 35 + test/households.service.spec.ts | 333 +++++++++ test/plaid.webhook.spec.ts | 169 +++++ test/plan-limits.service.spec.ts | 23 + test/plan-limits.spec.ts | 9 + test/roles.guard.spec.ts | 50 ++ test/rules.service.spec.ts | 213 ++++++ test/subscription.guard.spec.ts | 49 ++ test/tax.service.spec.ts | 182 +++++ test/teller.service.spec.ts | 108 +++ test/transactions.controller.spec.ts | 44 +- test/transactions.service.spec.ts | 219 +++++- test/utils/mock-prisma.ts | 87 ++- 97 files changed, 7687 insertions(+), 282 deletions(-) create mode 100644 prisma/migrations/20260716000100_export_log_audit_fields/migration.sql create mode 100644 prisma/migrations/20260716000200_export_download_tokens/migration.sql create mode 100644 prisma/migrations/20260716000300_export_download_object_storage/migration.sql create mode 100644 prisma/migrations/20260716000400_google_drive_mirror_status/migration.sql create mode 100644 prisma/migrations/20260716000500_public_api_keys/migration.sql create mode 100644 prisma/migrations/20260716000600_user_roles/migration.sql create mode 100644 prisma/migrations/20260716000700_households/migration.sql create mode 100644 prisma/migrations/20260716000800_household_invites/migration.sql create mode 100644 prisma/migrations/20260716000900_account_ownership/migration.sql create mode 100644 prisma/migrations/20260716001000_transaction_attribution/migration.sql create mode 100644 prisma/migrations/20260716001100_transaction_splits/migration.sql create mode 100644 src/abuse/abuse.controller.ts create mode 100644 src/abuse/abuse.module.ts create mode 100644 src/abuse/abuse.service.ts create mode 100644 src/abuse/abuse.types.ts create mode 100644 src/accounts/dto/update-account-ownership.dto.ts create mode 100644 src/admin/admin.controller.ts create mode 100644 src/admin/admin.module.ts create mode 100644 src/admin/admin.service.ts create mode 100644 src/auth/social-auth.service.ts create mode 100644 src/common/decorators/roles.decorator.ts create mode 100644 src/common/guards/roles.guard.ts create mode 100644 src/common/opaque-id.service.ts create mode 100644 src/exports/export-object-storage.service.ts create mode 100644 src/households/dto/accept-household-invite.dto.ts create mode 100644 src/households/dto/create-household-invite.dto.ts create mode 100644 src/households/dto/create-household.dto.ts create mode 100644 src/households/dto/update-household-member.dto.ts create mode 100644 src/households/households.controller.ts create mode 100644 src/households/households.module.ts create mode 100644 src/households/households.service.ts create mode 100644 src/public-api/api-key.controller.ts create mode 100644 src/public-api/api-key.guard.ts create mode 100644 src/public-api/api-key.service.ts create mode 100644 src/public-api/public-api.controller.ts create mode 100644 src/public-api/public-api.module.ts create mode 100644 src/stripe/plan-limits.service.ts create mode 100644 src/teller/teller.controller.ts create mode 100644 src/teller/teller.module.ts create mode 100644 src/teller/teller.service.ts create mode 100644 test/abuse.service.spec.ts create mode 100644 test/accounts.service.spec.ts create mode 100644 test/api-key.service.spec.ts create mode 100644 test/auth.service.spec.ts create mode 100644 test/auto-sync.service.spec.ts create mode 100644 test/google.service.spec.ts create mode 100644 test/households.service.spec.ts create mode 100644 test/plaid.webhook.spec.ts create mode 100644 test/plan-limits.service.spec.ts create mode 100644 test/plan-limits.spec.ts create mode 100644 test/roles.guard.spec.ts create mode 100644 test/rules.service.spec.ts create mode 100644 test/subscription.guard.spec.ts create mode 100644 test/tax.service.spec.ts create mode 100644 test/teller.service.spec.ts diff --git a/.env.example b/.env.example index 7bac32d..4f6908a 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,68 @@ DATABASE_URL=postgresql://user:password@localhost:5432/ledgerone -JWT_SECRET=change_me -SUPABASE_URL=http://127.0.0.1:54321 -SUPABASE_SERVICE_KEY=your_service_role_key -SUPABASE_ANON_KEY=your_anon_key +JWT_SECRET=replace_with_32_plus_char_secret +JWT_REFRESH_SECRET=replace_with_32_plus_char_refresh_secret +JWT_ACCESS_TTL_SECONDS=60 +ENCRYPTION_KEY=replace_with_64_hex_chars_for_aes_256_gcm + PLAID_CLIENT_ID=your_client_id PLAID_SECRET=your_sandbox_secret PLAID_ENV=sandbox PLAID_PRODUCTS=transactions PLAID_COUNTRY_CODES=US -PLAID_REDIRECT_URI=http://localhost:3001/app/connect +PLAID_REDIRECT_URI=http://localhost:3052/app/connect +PLAID_WEBHOOK_URL=http://localhost:3051/api/plaid/webhook +PLAID_VERIFY_WEBHOOKS=true + +TELLER_APPLICATION_ID= +TELLER_ENV=sandbox +TELLER_PRODUCTS=transactions,balance +TELLER_API_BASE_URL=https://api.teller.io +TELLER_CERT_PATH= +TELLER_KEY_PATH= +TELLER_CERT_PEM= +TELLER_KEY_PEM= + +SUPABASE_URL=http://127.0.0.1:54321 +SUPABASE_SERVICE_KEY=your_service_role_key +SUPABASE_ANON_KEY=your_anon_key + +EXPORT_OBJECT_STORAGE_DRIVER=local +EXPORT_OBJECT_STORAGE_BUCKET=ledgerone-exports +EXPORT_OBJECT_STORAGE_DIR=data/export-objects + +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_PRO= +STRIPE_PRICE_ELITE= + +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@ledgerone.app + +APP_URL=http://localhost:3052 +PORT=3051 +NODE_ENV=development +CORS_ORIGIN=http://localhost:3052 + +AUTO_SYNC_ENABLED=true +AUTO_SYNC_INTERVAL_MINUTES=15 +AUTO_SYNC_STALE_MINUTES=15 +AUTO_SYNC_LOOKBACK_DAYS=7 +AUTO_SYNC_MAX_USERS_PER_RUN=25 + +SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE=0 + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= + +SOCIAL_AUTH_REDIRECT_URI=http://localhost:3052/auth/social/callback +GOOGLE_AUTH_CLIENT_ID= +GOOGLE_AUTH_CLIENT_SECRET= +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= diff --git a/Dockerfile b/Dockerfile index aa8a617..7dfb602 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ -RUN npm ci --omit=dev --legacy-peer-deps +RUN npm ci --legacy-peer-deps COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma diff --git a/package-lock.json b/package-lock.json index 1537e12..b77a0f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,8 @@ "rxjs": "^7.8.1", "speakeasy": "^2.0.0", "stripe": "^20.4.0", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@nestjs/cli": "^10.3.2", @@ -56,6 +57,7 @@ "prisma": "^5.18.0", "supertest": "^7.0.0", "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", "typescript": "^5.3.3" } }, @@ -816,6 +818,30 @@ "node": ">=0.1.90" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@fastify/otel": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz", @@ -2918,6 +2944,34 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3551,6 +3605,28 @@ "acorn": "^8" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3691,6 +3767,13 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4201,6 +4284,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4415,6 +4511,15 @@ "node": ">= 0.12.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -4620,6 +4725,18 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4642,6 +4759,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4803,6 +4927,16 @@ "wrappy": "1" } }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5506,6 +5640,15 @@ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", "license": "MIT" }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -9303,6 +9446,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9922,6 +10077,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -10140,6 +10339,13 @@ "node": ">= 0.4.0" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -10320,6 +10526,24 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -10407,6 +10631,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -10462,6 +10707,16 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 59536b3..53e4485 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "rxjs": "^7.8.1", "speakeasy": "^2.0.0", "stripe": "^20.4.0", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@nestjs/cli": "^10.3.2", @@ -60,6 +61,7 @@ "prisma": "^5.18.0", "supertest": "^7.0.0", "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", "typescript": "^5.3.3" } } diff --git a/prisma/migrations/20260716000100_export_log_audit_fields/migration.sql b/prisma/migrations/20260716000100_export_log_audit_fields/migration.sql new file mode 100644 index 0000000..84007fc --- /dev/null +++ b/prisma/migrations/20260716000100_export_log_audit_fields/migration.sql @@ -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"); diff --git a/prisma/migrations/20260716000200_export_download_tokens/migration.sql b/prisma/migrations/20260716000200_export_download_tokens/migration.sql new file mode 100644 index 0000000..f07ee91 --- /dev/null +++ b/prisma/migrations/20260716000200_export_download_tokens/migration.sql @@ -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; diff --git a/prisma/migrations/20260716000300_export_download_object_storage/migration.sql b/prisma/migrations/20260716000300_export_download_object_storage/migration.sql new file mode 100644 index 0000000..ab385c8 --- /dev/null +++ b/prisma/migrations/20260716000300_export_download_object_storage/migration.sql @@ -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"); diff --git a/prisma/migrations/20260716000400_google_drive_mirror_status/migration.sql b/prisma/migrations/20260716000400_google_drive_mirror_status/migration.sql new file mode 100644 index 0000000..a8b2bb2 --- /dev/null +++ b/prisma/migrations/20260716000400_google_drive_mirror_status/migration.sql @@ -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); diff --git a/prisma/migrations/20260716000500_public_api_keys/migration.sql b/prisma/migrations/20260716000500_public_api_keys/migration.sql new file mode 100644 index 0000000..6371763 --- /dev/null +++ b/prisma/migrations/20260716000500_public_api_keys/migration.sql @@ -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; diff --git a/prisma/migrations/20260716000600_user_roles/migration.sql b/prisma/migrations/20260716000600_user_roles/migration.sql new file mode 100644 index 0000000..3ba9bf3 --- /dev/null +++ b/prisma/migrations/20260716000600_user_roles/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "User" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user'; diff --git a/prisma/migrations/20260716000700_households/migration.sql b/prisma/migrations/20260716000700_households/migration.sql new file mode 100644 index 0000000..6bd8042 --- /dev/null +++ b/prisma/migrations/20260716000700_households/migration.sql @@ -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; diff --git a/prisma/migrations/20260716000800_household_invites/migration.sql b/prisma/migrations/20260716000800_household_invites/migration.sql new file mode 100644 index 0000000..041e386 --- /dev/null +++ b/prisma/migrations/20260716000800_household_invites/migration.sql @@ -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; diff --git a/prisma/migrations/20260716000900_account_ownership/migration.sql b/prisma/migrations/20260716000900_account_ownership/migration.sql new file mode 100644 index 0000000..08428cb --- /dev/null +++ b/prisma/migrations/20260716000900_account_ownership/migration.sql @@ -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; diff --git a/prisma/migrations/20260716001000_transaction_attribution/migration.sql b/prisma/migrations/20260716001000_transaction_attribution/migration.sql new file mode 100644 index 0000000..45e9b0d --- /dev/null +++ b/prisma/migrations/20260716001000_transaction_attribution/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "TransactionDerived" ADD COLUMN "attribution" TEXT NOT NULL DEFAULT 'mine'; diff --git a/prisma/migrations/20260716001100_transaction_splits/migration.sql b/prisma/migrations/20260716001100_transaction_splits/migration.sql new file mode 100644 index 0000000..6244c05 --- /dev/null +++ b/prisma/migrations/20260716001100_transaction_splits/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc0f22b..cb851d8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,7 @@ model User { state String? postalCode String? country String? + role String @default("user") emailVerified Boolean @default(false) twoFactorEnabled Boolean @default(false) twoFactorSecret String? @@ -29,34 +30,152 @@ model User { accounts Account[] rules Rule[] exports ExportLog[] + exportDownloadTokens ExportDownloadToken[] auditLogs AuditLog[] + abuseEvents AbuseEvent[] googleConnection GoogleConnection? + apiKeys ApiKey[] emailVerificationToken EmailVerificationToken? passwordResetTokens PasswordResetToken[] refreshTokens RefreshToken[] + sessions Session[] + socialAccounts SocialAccount[] subscription Subscription? taxReturns TaxReturn[] + csvImportMappings CsvImportMapping[] + createdHouseholds Household[] @relation("HouseholdCreator") + householdMemberships HouseholdMember[] + sentHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteInviter") + acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter") + ownedAccounts Account[] @relation("AccountOwnerUser") +} + +model Household { + id String @id @default(uuid()) + name String + createdByUserId String + metadata Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + createdBy User @relation("HouseholdCreator", fields: [createdByUserId], references: [id], onDelete: Cascade) + members HouseholdMember[] + invites HouseholdInvite[] + accounts Account[] + + @@index([createdByUserId, createdAt]) +} + +model HouseholdMember { + id String @id @default(uuid()) + householdId String + userId String + role String @default("member") + status String @default("active") + joinedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + household Household @relation(fields: [householdId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([householdId, userId]) + @@index([userId, status]) + @@index([householdId, role]) +} + +model HouseholdInvite { + id String @id @default(uuid()) + householdId String + invitedById String + email String + role String @default("member") + tokenHash String @unique + status String @default("pending") + expiresAt DateTime + acceptedAt DateTime? + acceptedById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + household Household @relation(fields: [householdId], references: [id], onDelete: Cascade) + invitedBy User @relation("HouseholdInviteInviter", fields: [invitedById], references: [id], onDelete: Cascade) + acceptedBy User? @relation("HouseholdInviteAccepter", fields: [acceptedById], references: [id], onDelete: SetNull) + + @@index([householdId, status]) + @@index([email, status]) + @@index([expiresAt]) +} + +model SocialAccount { + id String @id @default(uuid()) + userId String + provider String + providerAccountId String + email String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@index([userId]) } model Account { id String @id @default(uuid()) userId String + householdId String? + ownerUserId String? + ownershipType String @default("mine") institutionName String accountType String mask String? plaidAccessToken String? plaidItemId String? plaidAccountId String? @unique + tellerAccessToken String? + tellerEnrollmentId String? + tellerAccountId String? @unique currentBalance Decimal? availableBalance Decimal? isoCurrencyCode String? lastBalanceSync DateTime? + lastTransactionSync DateTime? + lastSyncAttemptAt DateTime? + syncStatus String @default("idle") + lastSyncError String? + syncConsecutiveFailures Int @default(0) + plaidWebhookCode String? + plaidWebhookAt DateTime? isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) + household Household? @relation(fields: [householdId], references: [id], onDelete: SetNull) + ownerUser User? @relation("AccountOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull) transactionsRaw TransactionRaw[] + + @@index([householdId, ownershipType]) + @@index([ownerUserId]) + @@index([plaidItemId]) + @@index([tellerEnrollmentId]) +} + +model PlaidWebhookEvent { + id String @id @default(uuid()) + itemId String? + webhookType String + webhookCode String + payload Json + status String @default("received") + error String? + receivedAt DateTime @default(now()) + processedAt DateTime? + + @@index([itemId, receivedAt]) + @@index([webhookType, webhookCode]) } model TransactionRaw { @@ -80,6 +199,10 @@ model TransactionDerived { rawTransactionId String @unique userCategory String? userNotes String? + attribution String @default("mine") + splitMode String @default("none") + splitMinePercent Decimal? + splitYoursPercent Decimal? isHidden Boolean @default(false) modifiedAt DateTime @default(now()) modifiedBy String @@ -87,6 +210,22 @@ model TransactionDerived { raw TransactionRaw @relation(fields: [rawTransactionId], references: [id]) } +model CsvImportMapping { + id String @id @default(uuid()) + userId String + headerSignature String + name String? + mapping Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastUsedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, headerSignature]) + @@index([userId, lastUsedAt]) +} + model Rule { id String @id @default(uuid()) userId String @@ -113,13 +252,46 @@ model RuleExecution { } model ExportLog { - id String @id @default(uuid()) - userId String - filters Json - rowCount Int - createdAt DateTime @default(now()) + id String @id @default(uuid()) + userId String + format String @default("csv") + destination String @default("download") + filters Json + rowCount Int + fileName String? + mimeType String? + fileHash String? + ipAddress String? + userAgent String? + metadata Json @default("{}") + createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([userId, createdAt]) + @@index([format, createdAt]) +} + +model ExportDownloadToken { + id String @id @default(uuid()) + userId String + tokenHash String @unique + format String + filters Json + storageProvider String? + storageKey String? + fileName String? + mimeType String? + rowCount Int? + fileHash String? + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([expiresAt]) } model AuditLog { @@ -132,6 +304,23 @@ model AuditLog { user User @relation(fields: [userId], references: [id]) } +model AbuseEvent { + id String @id @default(uuid()) + userId String? + eventType String + riskPoints Int + severity String + ipAddress String? + userAgent String? + metadata Json @default("{}") + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([userId, createdAt]) + @@index([eventType, createdAt]) +} + model GoogleConnection { id String @id @default(uuid()) userId String @unique @@ -139,6 +328,10 @@ model GoogleConnection { refreshToken String accessToken String? spreadsheetId String? + driveMirrorEnabled Boolean @default(false) + driveMirrorStatus String @default("not_started") + driveMirrorSpreadsheetUrl String? + driveMirrorLastSyncedAt DateTime? isConnected Boolean @default(true) connectedAt DateTime @default(now()) lastSyncedAt DateTime? @@ -148,6 +341,24 @@ model GoogleConnection { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model ApiKey { + id String @id @default(uuid()) + userId String + name String + prefix String + keyHash String @unique + scopes String[] @default(["transactions:read"]) + lastUsedAt DateTime? + revokedAt DateTime? + expiresAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([prefix]) +} + model EmailVerificationToken { id String @id @default(uuid()) userId String @unique @@ -169,15 +380,35 @@ model PasswordResetToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model Session { + id String @id @default(uuid()) + userId String + ipHash String + userAgentHash String + createdAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + expiresAt DateTime + revokedAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + refreshTokens RefreshToken[] + + @@index([userId, revokedAt]) +} + model RefreshToken { id String @id @default(uuid()) userId String + sessionId String? tokenHash String @unique expiresAt DateTime revokedAt DateTime? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + session Session? @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@index([sessionId]) } model Subscription { diff --git a/src/abuse/abuse.controller.ts b/src/abuse/abuse.controller.ts new file mode 100644 index 0000000..6d4d046 --- /dev/null +++ b/src/abuse/abuse.controller.ts @@ -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)); + } +} diff --git a/src/abuse/abuse.module.ts b/src/abuse/abuse.module.ts new file mode 100644 index 0000000..ab4de14 --- /dev/null +++ b/src/abuse/abuse.module.ts @@ -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 {} diff --git a/src/abuse/abuse.service.ts b/src/abuse/abuse.service.ts new file mode 100644 index 0000000..9ca9f2b --- /dev/null +++ b/src/abuse/abuse.service.ts @@ -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; +}; + +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, 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"); + } +} diff --git a/src/abuse/abuse.types.ts b/src/abuse/abuse.types.ts new file mode 100644 index 0000000..ec01aff --- /dev/null +++ b/src/abuse/abuse.types.ts @@ -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, + }; +} diff --git a/src/accounts/accounts.controller.ts b/src/accounts/accounts.controller.ts index 02692a3..2601800 100644 --- a/src/accounts/accounts.controller.ts +++ b/src/accounts/accounts.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Get, Post, Query } from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; import { ok } from "../common/response"; import { AccountsService } from "./accounts.service"; import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto"; @Controller("accounts") export class AccountsController { @@ -37,4 +38,14 @@ export class AccountsController { const data = await this.accountsService.refreshBalances(userId); return ok(data); } + + @Patch(":id/ownership") + async updateOwnership( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: UpdateAccountOwnershipDto, + ) { + const data = await this.accountsService.updateOwnership(userId, id, payload); + return ok(data); + } } diff --git a/src/accounts/accounts.module.ts b/src/accounts/accounts.module.ts index ff2e84c..1eddfd4 100644 --- a/src/accounts/accounts.module.ts +++ b/src/accounts/accounts.module.ts @@ -1,10 +1,12 @@ import { Module } from "@nestjs/common"; import { PlaidModule } from "../plaid/plaid.module"; +import { StripeModule } from "../stripe/stripe.module"; +import { TellerModule } from "../teller/teller.module"; import { AccountsController } from "./accounts.controller"; import { AccountsService } from "./accounts.service"; @Module({ - imports: [PlaidModule], + imports: [PlaidModule, TellerModule, StripeModule], controllers: [AccountsController], providers: [AccountsService] }) diff --git a/src/accounts/accounts.service.ts b/src/accounts/accounts.service.ts index 031fe65..5ae3494 100644 --- a/src/accounts/accounts.service.ts +++ b/src/accounts/accounts.service.ts @@ -1,18 +1,26 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { PlaidService } from "../plaid/plaid.service"; +import { TellerService } from "../teller/teller.service"; +import { OpaqueIdService } from "../common/opaque-id.service"; +import { PlanLimitsService } from "../stripe/plan-limits.service"; +import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto"; -const MAX_PAGE_SIZE = 100; +const UI_PAGE_SIZE_LIMIT = 25; @Injectable() export class AccountsService { constructor( private readonly prisma: PrismaService, private readonly plaidService: PlaidService, + private readonly tellerService: TellerService, + private readonly opaqueIds: OpaqueIdService, + private readonly planLimits: PlanLimitsService, ) {} async list(userId: string, page = 1, limit = 20) { - const take = Math.min(limit, MAX_PAGE_SIZE); + const requestedLimit = Number.isFinite(limit) && limit ? limit : 20; + const take = Math.min(Math.max(requestedLimit, 1), UI_PAGE_SIZE_LIMIT); const skip = (page - 1) * take; const [accounts, total] = await Promise.all([ this.prisma.account.findMany({ @@ -29,6 +37,17 @@ export class AccountsService { availableBalance: true, isoCurrencyCode: true, lastBalanceSync: true, + lastTransactionSync: true, + lastSyncAttemptAt: true, + syncStatus: true, + lastSyncError: true, + syncConsecutiveFailures: true, + plaidWebhookCode: true, + plaidWebhookAt: true, + tellerAccountId: true, + householdId: true, + ownerUserId: true, + ownershipType: true, isActive: true, createdAt: true, // Intentionally omit plaidAccessToken — never expose the encrypted token @@ -36,27 +55,62 @@ export class AccountsService { }), this.prisma.account.count({ where: { userId, isActive: true } }), ]); - return { accounts, total, page, limit: take }; + return { + accounts: accounts.map((account) => ({ + id: this.opaqueIds.encode("account", userId, account.id), + institutionName: account.institutionName, + accountType: account.accountType, + mask: account.mask, + currentBalance: account.currentBalance, + availableBalance: account.availableBalance, + isoCurrencyCode: account.isoCurrencyCode, + lastBalanceSync: account.lastBalanceSync, + lastTransactionSync: account.lastTransactionSync, + lastSyncAttemptAt: account.lastSyncAttemptAt, + syncStatus: account.syncStatus, + lastSyncError: account.lastSyncError, + syncConsecutiveFailures: account.syncConsecutiveFailures, + plaidWebhookCode: account.plaidWebhookCode, + plaidWebhookAt: account.plaidWebhookAt, + tellerConnected: Boolean(account.tellerAccountId), + householdId: account.householdId, + ownerUserId: account.ownerUserId, + ownershipType: account.ownershipType, + isActive: account.isActive, + createdAt: account.createdAt, + })), + total, + page, + limit: take, + }; } async createLinkToken(userId: string) { + await this.planLimits.assertCanAddAccounts(userId, 1); return this.plaidService.createLinkToken(userId); } async refreshBalances(userId: string) { - return this.plaidService.syncBalancesForUser(userId); + const [plaid, teller] = await Promise.all([ + this.plaidService.syncBalancesForUser(userId), + this.tellerService.syncBalancesForUser(userId), + ]); + return { updated: plaid.updated + teller.updated, plaid, teller }; } async createManualAccount( userId: string, payload: { institutionName: string; accountType: string; mask?: string }, ) { - return this.prisma.account.create({ + await this.planLimits.assertCanAddAccounts(userId, 1); + const account = await this.prisma.account.create({ data: { userId, institutionName: payload.institutionName, accountType: payload.accountType, mask: payload.mask ?? null, + ownerUserId: userId, + ownershipType: "mine", isActive: true, }, select: { @@ -64,9 +118,91 @@ export class AccountsService { institutionName: true, accountType: true, mask: true, + ownerUserId: true, + ownershipType: true, isActive: true, createdAt: true, }, }); + return { + ...account, + id: this.opaqueIds.encode("account", userId, account.id), + }; + } + + async updateOwnership(userId: string, accountHandle: string, payload: UpdateAccountOwnershipDto) { + const accountId = this.opaqueIds.decode("account", userId, accountHandle); + const account = await this.prisma.account.findFirst({ + where: { id: accountId, userId, isActive: true }, + }); + if (!account) throw new BadRequestException("Account not found."); + + let householdId: string | null = null; + let ownerUserId: string | null = userId; + + if (payload.ownershipType === "mine") { + householdId = null; + ownerUserId = userId; + } else { + if (!payload.householdId) { + throw new BadRequestException("Household is required for shared account ownership."); + } + const requesterMembership = await this.prisma.householdMember.findFirst({ + where: { householdId: payload.householdId, userId, status: "active" }, + }); + if (!requesterMembership) throw new BadRequestException("Household not found."); + householdId = payload.householdId; + + if (payload.ownershipType === "joint") { + ownerUserId = null; + } else { + if (!payload.ownerUserId || payload.ownerUserId === userId) { + throw new BadRequestException("Owner user must be another active household member for 'theirs' accounts."); + } + const ownerMembership = await this.prisma.householdMember.findFirst({ + where: { householdId, userId: payload.ownerUserId, status: "active" }, + }); + if (!ownerMembership) throw new BadRequestException("Owner must be an active household member."); + ownerUserId = payload.ownerUserId; + } + } + + const updated = await this.prisma.account.update({ + where: { id: accountId }, + data: { + ownershipType: payload.ownershipType, + householdId, + ownerUserId, + }, + select: { + id: true, + institutionName: true, + accountType: true, + mask: true, + householdId: true, + ownerUserId: true, + ownershipType: true, + isActive: true, + createdAt: true, + }, + }); + + await this.prisma.auditLog.create({ + data: { + userId, + action: "account.ownership.update", + metadata: { + accountId, + householdId, + ownerUserId, + ownershipType: payload.ownershipType, + }, + }, + }); + + return { + ...updated, + id: this.opaqueIds.encode("account", userId, updated.id), + }; } } diff --git a/src/accounts/dto/update-account-ownership.dto.ts b/src/accounts/dto/update-account-ownership.dto.ts new file mode 100644 index 0000000..5edd121 --- /dev/null +++ b/src/accounts/dto/update-account-ownership.dto.ts @@ -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; +} diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts new file mode 100644 index 0000000..ebc5014 --- /dev/null +++ b/src/admin/admin.controller.ts @@ -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()); + } +} diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts new file mode 100644 index 0000000..470a892 --- /dev/null +++ b/src/admin/admin.module.ts @@ -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 {} diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts new file mode 100644 index 0000000..e2d9866 --- /dev/null +++ b/src/admin/admin.service.ts @@ -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 } }, + }, + }); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 838967e..6f29b05 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { SupabaseModule } from "./supabase/supabase.module"; import { EmailModule } from "./email/email.module"; import { AuthModule } from "./auth/auth.module"; import { PlaidModule } from "./plaid/plaid.module"; +import { TellerModule } from "./teller/teller.module"; import { TaxModule } from "./tax/tax.module"; import { TransactionsModule } from "./transactions/transactions.module"; import { AccountsModule } from "./accounts/accounts.module"; @@ -19,6 +20,10 @@ import { ExportsModule } from "./exports/exports.module"; import { StripeModule } from "./stripe/stripe.module"; import { TwoFactorModule } from "./auth/twofa/two-factor.module"; import { GoogleModule } from "./google/google.module"; +import { AbuseModule } from "./abuse/abuse.module"; +import { PublicApiModule } from "./public-api/public-api.module"; +import { AdminModule } from "./admin/admin.module"; +import { HouseholdsModule } from "./households/households.module"; import { LoggerModule } from "nestjs-pino"; import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; @@ -57,10 +62,12 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; StorageModule, SupabaseModule, EmailModule, + AbuseModule, // ─── Feature modules ───────────────────────────────────────────────────── AuthModule, PlaidModule, + TellerModule, TaxModule, TransactionsModule, AccountsModule, @@ -69,6 +76,9 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; StripeModule, TwoFactorModule, GoogleModule, + PublicApiModule, + AdminModule, + HouseholdsModule, ], providers: [ // Apply rate limiting globally diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 39517b7..bcc28db 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,6 +1,9 @@ -import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req, UseGuards } from "@nestjs/common"; +import { Request } from "express"; import { ok } from "../common/response"; +import { requestContextFrom } from "../abuse/abuse.types"; import { AuthService } from "./auth.service"; +import { SocialAuthService } from "./social-auth.service"; import { LoginDto } from "./dto/login.dto"; import { RegisterDto } from "./dto/register.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; @@ -13,18 +16,21 @@ import { Public } from "../common/decorators/public.decorator"; @Controller("auth") @UseGuards(JwtAuthGuard) export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly socialAuthService: SocialAuthService, + ) {} @Public() @Post("register") - async register(@Body() payload: RegisterDto) { - return ok(await this.authService.register(payload)); + async register(@Body() payload: RegisterDto, @Req() req: Request) { + return ok(await this.authService.register(payload, requestContextFrom(req))); } @Public() @Post("login") - async login(@Body() payload: LoginDto) { - return ok(await this.authService.login(payload)); + async login(@Body() payload: LoginDto, @Req() req: Request) { + return ok(await this.authService.login(payload, requestContextFrom(req))); } @Public() @@ -35,8 +41,8 @@ export class AuthController { @Public() @Post("refresh") - async refresh(@Body("refreshToken") refreshToken: string) { - return ok(await this.authService.refreshAccessToken(refreshToken)); + async refresh(@Body("refreshToken") refreshToken: string, @Req() req: Request) { + return ok(await this.authService.refreshAccessToken(refreshToken, requestContextFrom(req))); } @Post("logout") @@ -56,6 +62,24 @@ export class AuthController { return ok(await this.authService.resetPassword(payload)); } + @Public() + @Get("social/:provider/url") + async socialAuthUrl(@Param("provider") provider: string, @Query("next") next: string) { + return ok(this.socialAuthService.getAuthorizationUrl(provider, next)); + } + + @Public() + @Post("social/:provider/callback") + async socialCallback( + @Param("provider") provider: string, + @Body("provider") bodyProvider: string | undefined, + @Body("code") code: string, + @Body("state") state: string, + @Req() req: Request, + ) { + return ok(await this.socialAuthService.completeLogin(bodyProvider ?? provider, code, state, requestContextFrom(req))); + } + @Get("me") async me(@CurrentUser() userId: string) { return ok(await this.authService.getProfile(userId)); @@ -65,4 +89,9 @@ export class AuthController { async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) { return ok(await this.authService.updateProfile(userId, payload)); } + + @Delete("me") + async deleteMe(@CurrentUser() userId: string) { + return ok(await this.authService.deleteAccount(userId)); + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index e126ee5..a4f3161 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,18 +1,23 @@ import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { JwtModule } from "@nestjs/jwt"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; +import { SocialAuthService } from "./social-auth.service"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; @Module({ imports: [ - JwtModule.register({ - secret: process.env.JWT_SECRET, - signOptions: { expiresIn: "15m" }, + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.getOrThrow("JWT_SECRET"), + signOptions: { expiresIn: `${config.get("JWT_ACCESS_TTL_SECONDS", 60)}s` }, + }), }), ], controllers: [AuthController], - providers: [AuthService, JwtAuthGuard], + providers: [AuthService, SocialAuthService, JwtAuthGuard], exports: [AuthService, JwtModule, JwtAuthGuard], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5808e76..0b1880e 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException, + Optional, UnauthorizedException, } from "@nestjs/common"; import * as crypto from "crypto"; @@ -15,10 +16,13 @@ import { RegisterDto } from "./dto/register.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ForgotPasswordDto } from "./dto/forgot-password.dto"; import { ResetPasswordDto } from "./dto/reset-password.dto"; +import { AbuseService } from "../abuse/abuse.service"; +import { RequestContext } from "../abuse/abuse.types"; const VERIFY_TOKEN_TTL_HOURS = 24; const RESET_TOKEN_TTL_HOURS = 1; const REFRESH_TOKEN_TTL_DAYS = 30; +const SESSION_TTL_DAYS = 30; @Injectable() export class AuthService { @@ -27,9 +31,10 @@ export class AuthService { private readonly jwtService: JwtService, private readonly emailService: EmailService, private readonly encryption: EncryptionService, + @Optional() private readonly abuseService?: AbuseService, ) {} - async register(payload: RegisterDto) { + async register(payload: RegisterDto, context?: RequestContext) { const email = payload.email.toLowerCase().trim(); const existing = await this.prisma.user.findUnique({ where: { email } }); if (existing) throw new BadRequestException("Email already registered."); @@ -47,8 +52,7 @@ export class AuthService { }); await this.emailService.sendVerificationEmail(email, verifyToken); - const accessToken = this.signAccessToken(user.id); - const refreshToken = await this.createRefreshToken(user.id); + const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context); return { user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, accessToken, @@ -57,10 +61,11 @@ export class AuthService { }; } - async login(payload: LoginDto) { + async login(payload: LoginDto, context?: RequestContext) { const email = payload.email.toLowerCase().trim(); const user = await this.prisma.user.findUnique({ where: { email } }); if (!user || !this.verifyPassword(payload.password, user.passwordHash)) { + await this.abuseService?.recordLoginFailure(email, user?.id, context); throw new UnauthorizedException("Invalid credentials."); } @@ -77,8 +82,7 @@ export class AuthService { } await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } }); - const accessToken = this.signAccessToken(user.id); - const refreshToken = await this.createRefreshToken(user.id); + const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context); return { user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, accessToken, @@ -96,24 +100,46 @@ export class AuthService { return { message: "Email verified successfully." }; } - async refreshAccessToken(rawRefreshToken: string) { + async refreshAccessToken(rawRefreshToken: string, context?: RequestContext) { const tokenHash = this.hashToken(rawRefreshToken); - const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } }); - if (!record || record.revokedAt || record.expiresAt < new Date()) { + const record = await this.prisma.refreshToken.findUnique({ + where: { tokenHash }, + include: { session: true }, + }); + if (!record || record.revokedAt || record.expiresAt < new Date() || !record.session) { throw new UnauthorizedException("Invalid or expired refresh token."); } + this.assertSessionMatches(record.session, context); await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } }); - const accessToken = this.signAccessToken(record.userId); - const refreshToken = await this.createRefreshToken(record.userId); + await this.prisma.session.update({ + where: { id: record.sessionId! }, + data: { lastSeenAt: new Date() }, + }); + const accessToken = this.signAccessToken(record.userId, record.sessionId!); + const refreshToken = await this.createRefreshToken(record.userId, record.sessionId!); + return { accessToken, refreshToken }; + } + + async issueTokensForUser(userId: string, context?: RequestContext) { + const session = await this.createSession(userId, context); + const accessToken = this.signAccessToken(userId, session.id); + const refreshToken = await this.createRefreshToken(userId, session.id); return { accessToken, refreshToken }; } async logout(rawRefreshToken: string) { const tokenHash = this.hashToken(rawRefreshToken); + const token = await this.prisma.refreshToken.findUnique({ where: { tokenHash }, select: { sessionId: true } }); await this.prisma.refreshToken.updateMany({ where: { tokenHash, revokedAt: null }, data: { revokedAt: new Date() }, }); + if (token?.sessionId) { + await this.prisma.session.updateMany({ + where: { id: token.sessionId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } return { message: "Logged out." }; } @@ -140,6 +166,10 @@ export class AuthService { where: { userId: record.userId, revokedAt: null }, data: { revokedAt: new Date() }, }); + await this.prisma.session.updateMany({ + where: { userId: record.userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); return { message: "Password reset successfully. Please log in." }; } @@ -180,19 +210,92 @@ export class AuthService { }; } - verifyToken(token: string): { sub: string } { - return this.jwtService.verify<{ sub: string }>(token); + async deleteAccount(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } }); + if (!user) throw new NotFoundException("User not found."); + + await this.prisma.$transaction(async (tx) => { + const [accounts, rawTransactions, rules, taxReturns] = await Promise.all([ + tx.account.findMany({ where: { userId }, select: { id: true } }), + tx.transactionRaw.findMany({ where: { account: { userId } }, select: { id: true } }), + tx.rule.findMany({ where: { userId }, select: { id: true } }), + tx.taxReturn.findMany({ where: { userId }, select: { id: true } }), + ]); + + const accountIds = accounts.map((account) => account.id); + const transactionIds = rawTransactions.map((transaction) => transaction.id); + const ruleIds = rules.map((rule) => rule.id); + const taxReturnIds = taxReturns.map((taxReturn) => taxReturn.id); + + await tx.taxDocument.deleteMany({ where: { taxReturnId: { in: taxReturnIds } } }); + await tx.ruleExecution.deleteMany({ + where: { + OR: [ + { ruleId: { in: ruleIds } }, + { transactionId: { in: transactionIds } }, + ], + }, + }); + await tx.transactionDerived.deleteMany({ where: { rawTransactionId: { in: transactionIds } } }); + await tx.transactionRaw.deleteMany({ where: { id: { in: transactionIds } } }); + await tx.account.deleteMany({ where: { id: { in: accountIds } } }); + await tx.rule.deleteMany({ where: { userId } }); + await tx.exportLog.deleteMany({ where: { userId } }); + await tx.auditLog.deleteMany({ where: { userId } }); + await tx.abuseEvent.deleteMany({ where: { userId } }); + await tx.googleConnection.deleteMany({ where: { userId } }); + await tx.emailVerificationToken.deleteMany({ where: { userId } }); + await tx.passwordResetToken.deleteMany({ where: { userId } }); + await tx.refreshToken.deleteMany({ where: { userId } }); + await tx.session.deleteMany({ where: { userId } }); + await tx.socialAccount.deleteMany({ where: { userId } }); + await tx.subscription.deleteMany({ where: { userId } }); + await tx.taxReturn.deleteMany({ where: { userId } }); + await tx.user.delete({ where: { id: userId } }); + }); + + return { message: "Account and associated personal data deleted." }; } - private signAccessToken(userId: string): string { - return this.jwtService.sign({ sub: userId }); + verifyToken(token: string): { sub: string; sid: string } { + return this.jwtService.verify<{ sub: string; sid: string }>(token); } - private async createRefreshToken(userId: string): Promise { + 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 { const raw = crypto.randomBytes(40).toString("hex"); const tokenHash = this.hashToken(raw); const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000); - await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } }); + await this.prisma.refreshToken.create({ data: { userId, sessionId, tokenHash, expiresAt } }); return raw; } @@ -200,6 +303,10 @@ export class AuthService { return crypto.createHash("sha256").update(token).digest("hex"); } + private hashBindingValue(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); + } + private hashPassword(password: string): string { const salt = crypto.randomBytes(16).toString("hex"); const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex"); diff --git a/src/auth/social-auth.service.ts b/src/auth/social-auth.service.ts new file mode 100644 index 0000000..dff8778 --- /dev/null +++ b/src/auth/social-auth.service.ts @@ -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 { + 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 { + 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"); + } +} diff --git a/src/common/common.module.ts b/src/common/common.module.ts index 7a4bcbd..40d2c45 100644 --- a/src/common/common.module.ts +++ b/src/common/common.module.ts @@ -1,9 +1,10 @@ import { Global, Module } from "@nestjs/common"; import { EncryptionService } from "./encryption.service"; +import { OpaqueIdService } from "./opaque-id.service"; @Global() @Module({ - providers: [EncryptionService], - exports: [EncryptionService], + providers: [EncryptionService, OpaqueIdService], + exports: [EncryptionService, OpaqueIdService], }) export class CommonModule {} diff --git a/src/common/decorators/roles.decorator.ts b/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..3d51da8 --- /dev/null +++ b/src/common/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from "@nestjs/common"; + +export const ROLES_KEY = "roles"; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index 3058184..efae9bf 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -7,6 +7,10 @@ import { import { Reflector } from "@nestjs/core"; import { JwtService } from "@nestjs/jwt"; import { Request } from "express"; +import * as crypto from "crypto"; +import { AbuseService } from "../../abuse/abuse.service"; +import { requestContextFrom } from "../../abuse/abuse.types"; +import { PrismaService } from "../../prisma/prisma.service"; export const IS_PUBLIC_KEY = "isPublic"; @@ -15,9 +19,11 @@ export class JwtAuthGuard implements CanActivate { constructor( private readonly jwtService: JwtService, private readonly reflector: Reflector, + private readonly abuseService: AbuseService, + private readonly prisma: PrismaService, ) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), @@ -33,12 +39,32 @@ export class JwtAuthGuard implements CanActivate { } try { - const payload = this.jwtService.verify<{ sub: string }>(token, { + const payload = this.jwtService.verify<{ sub: string; sid?: string }>(token, { secret: process.env.JWT_SECRET, }); - (request as Request & { user: { sub: string } }).user = payload; + if (!payload.sid) { + throw new UnauthorizedException("Session-bound token required."); + } + const session = await this.prisma.session.findUnique({ where: { id: payload.sid } }); + const context = requestContextFrom(request); + if ( + !session || + session.userId !== payload.sub || + session.revokedAt || + session.expiresAt < new Date() || + session.ipHash !== this.hashBindingValue(context.ipAddress ?? "unknown-ip") || + session.userAgentHash !== this.hashBindingValue(context.userAgent ?? "unknown-user-agent") + ) { + throw new UnauthorizedException("Invalid session binding."); + } + await this.prisma.session.update({ + where: { id: session.id }, + data: { lastSeenAt: new Date() }, + }); + (request as Request & { user: { sub: string; sid: string } }).user = { sub: payload.sub, sid: payload.sid }; return true; } catch { + await this.abuseService.recordInvalidToken(requestContextFrom(request)); throw new UnauthorizedException("Invalid or expired token."); } } @@ -50,4 +76,8 @@ export class JwtAuthGuard implements CanActivate { } return null; } + + private hashBindingValue(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); + } } diff --git a/src/common/guards/roles.guard.ts b/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..77ecbc2 --- /dev/null +++ b/src/common/guards/roles.guard.ts @@ -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 { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRoles?.length) return true; + + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/src/common/opaque-id.service.ts b/src/common/opaque-id.service.ts new file mode 100644 index 0000000..2292ed5 --- /dev/null +++ b/src/common/opaque-id.service.ts @@ -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."); + } + } +} diff --git a/src/common/sentry.filter.ts b/src/common/sentry.filter.ts index 71fff69..6ba2971 100644 --- a/src/common/sentry.filter.ts +++ b/src/common/sentry.filter.ts @@ -12,6 +12,7 @@ import { Request, Response } from "express"; @Catch() export class SentryExceptionFilter implements ExceptionFilter { private readonly logger = new Logger("ExceptionFilter"); + private readonly sentryEnabled = Boolean(process.env.SENTRY_DSN); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); @@ -23,17 +24,35 @@ export class SentryExceptionFilter implements ExceptionFilter { ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; - // Only report 5xx errors to Sentry - if (status >= 500 && process.env.SENTRY_DSN) { - Sentry.captureException(exception, { - extra: { - url: request.url, + const userId = (request as Request & { user?: { sub: string } }).user?.sub; + + // Only report server-side failures to Sentry. Client errors remain local + // responses and do not create noisy operational alerts. + if (status >= 500 && this.sentryEnabled) { + const eventId = Sentry.withScope((scope) => { + if (userId) { + scope.setUser({ id: userId }); + } + scope.setTag("http.status_code", String(status)); + scope.setContext("request", { method: request.method, - userId: (request as Request & { user?: { sub: string } }).user?.sub, - }, + path: request.path, + query: request.query, + ip: request.ip, + userAgent: request.headers["user-agent"], + }); + return Sentry.captureException(exception); }); + + this.logger.error( + `Captured server exception in Sentry: ${eventId}`, + exception instanceof Error ? exception.stack : undefined, + ); } else if (status >= 500) { - this.logger.error(exception); + this.logger.error( + "Unhandled server exception", + exception instanceof Error ? exception.stack : String(exception), + ); } const message = @@ -50,7 +69,7 @@ export class SentryExceptionFilter implements ExceptionFilter { ? message : (message as { message?: string }).message ?? "Internal server error", }, - meta: { timestamp: new Date().toISOString(), version: "1.0" }, + meta: { timestamp: new Date().toISOString(), version: "v1" }, }); } } diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index f9e274f..a60a46b 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -4,6 +4,7 @@ export const envValidationSchema = Joi.object({ DATABASE_URL: Joi.string().required(), JWT_SECRET: Joi.string().min(32).required(), JWT_REFRESH_SECRET: Joi.string().min(32).required(), + JWT_ACCESS_TTL_SECONDS: Joi.number().integer().min(30).max(60).default(60), ENCRYPTION_KEY: Joi.string().length(64).required(), PLAID_CLIENT_ID: Joi.string().required(), @@ -12,11 +13,26 @@ export const envValidationSchema = Joi.object({ PLAID_PRODUCTS: Joi.string().default("transactions"), PLAID_COUNTRY_CODES: Joi.string().default("US"), PLAID_REDIRECT_URI: Joi.string().uri().optional().allow(""), + PLAID_WEBHOOK_URL: Joi.string().uri().optional().allow(""), + PLAID_VERIFY_WEBHOOKS: Joi.boolean().default(true), + + TELLER_APPLICATION_ID: Joi.string().optional().allow(""), + TELLER_ENV: Joi.string().valid("sandbox", "development", "production").default("sandbox"), + TELLER_PRODUCTS: Joi.string().default("transactions,balance"), + TELLER_API_BASE_URL: Joi.string().uri().default("https://api.teller.io"), + TELLER_CERT_PATH: Joi.string().optional().allow(""), + TELLER_KEY_PATH: Joi.string().optional().allow(""), + TELLER_CERT_PEM: Joi.string().optional().allow(""), + TELLER_KEY_PEM: Joi.string().optional().allow(""), SUPABASE_URL: Joi.string().optional().allow(""), SUPABASE_SERVICE_KEY: Joi.string().optional().allow(""), SUPABASE_ANON_KEY: Joi.string().optional().allow(""), + EXPORT_OBJECT_STORAGE_DRIVER: Joi.string().valid("local", "supabase").default("local"), + EXPORT_OBJECT_STORAGE_BUCKET: Joi.string().default("ledgerone-exports"), + EXPORT_OBJECT_STORAGE_DIR: Joi.string().default("data/export-objects"), + STRIPE_SECRET_KEY: Joi.string().optional().allow(""), STRIPE_WEBHOOK_SECRET: Joi.string().optional().allow(""), STRIPE_PRICE_PRO: Joi.string().optional().allow(""), @@ -35,11 +51,27 @@ export const envValidationSchema = Joi.object({ CORS_ORIGIN: Joi.string().default("http://localhost:3052"), AUTO_SYNC_ENABLED: Joi.boolean().default(true), - AUTO_SYNC_INTERVAL_MINUTES: Joi.number().default(15), + AUTO_SYNC_INTERVAL_MINUTES: Joi.number().integer().min(1).default(15), + AUTO_SYNC_STALE_MINUTES: Joi.number().integer().min(1).optional(), + AUTO_SYNC_LOOKBACK_DAYS: Joi.number().integer().min(1).max(365).default(7), + AUTO_SYNC_MAX_USERS_PER_RUN: Joi.number().integer().min(1).max(500).default(25), - SENTRY_DSN: Joi.string().optional().allow(""), + SENTRY_DSN: Joi.when("NODE_ENV", { + is: "production", + then: Joi.string().uri().required(), + otherwise: Joi.string().uri().optional().allow(""), + }), + SENTRY_TRACES_SAMPLE_RATE: Joi.number().min(0).max(1).default(0), GOOGLE_CLIENT_ID: Joi.string().optional().allow(""), GOOGLE_CLIENT_SECRET: Joi.string().optional().allow(""), GOOGLE_REDIRECT_URI: Joi.string().uri().optional().allow(""), + + SOCIAL_AUTH_REDIRECT_URI: Joi.string().uri().optional().allow(""), + GOOGLE_AUTH_CLIENT_ID: Joi.string().optional().allow(""), + GOOGLE_AUTH_CLIENT_SECRET: Joi.string().optional().allow(""), + APPLE_CLIENT_ID: Joi.string().optional().allow(""), + APPLE_TEAM_ID: Joi.string().optional().allow(""), + APPLE_KEY_ID: Joi.string().optional().allow(""), + APPLE_PRIVATE_KEY: Joi.string().optional().allow(""), }); diff --git a/src/email/email.service.ts b/src/email/email.service.ts index fe471fb..6518e65 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -77,4 +77,28 @@ export class EmailService { this.logger.error(`Failed to send password reset email to ${email}`, err); } } + + async sendHouseholdInviteEmail(email: string, householdName: string, inviterName: string, token: string): Promise { + 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: ` +

Join ${householdName}

+

${inviterName} invited you to collaborate in a LedgerOne household.

+

Accept Invite

+

Or copy this link: ${url}

+

This invitation expires in 7 days.

+ `, + }); + 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); + } + } } diff --git a/src/exports/export-object-storage.service.ts b/src/exports/export-object-storage.service.ts new file mode 100644 index 0000000..306bf79 --- /dev/null +++ b/src/exports/export-object-storage.service.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/exports/exports.controller.ts b/src/exports/exports.controller.ts index 621f5e9..df071ea 100644 --- a/src/exports/exports.controller.ts +++ b/src/exports/exports.controller.ts @@ -1,21 +1,55 @@ -import { Controller, Get, Post, Query } from "@nestjs/common"; +import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common"; +import { Request, Response } from "express"; import { ok } from "../common/response"; import { ExportsService } from "./exports.service"; import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { requestContextFrom } from "../abuse/abuse.types"; +import { RequiredPlan, SubscriptionGuard } from "../stripe/subscription.guard"; +import { Public } from "../common/decorators/public.decorator"; @Controller("exports") +@RequiredPlan("pro") +@UseGuards(SubscriptionGuard) export class ExportsController { constructor(private readonly exportsService: ExportsService) {} @Get("csv") - async exportCsv(@CurrentUser() userId: string, @Query() query: Record) { - const data = await this.exportsService.exportCsv(userId, query); + async exportCsv(@CurrentUser() userId: string, @Query() query: Record, @Req() req: Request) { + const data = await this.exportsService.createSignedDownloadUrl(userId, "csv", query, requestContextFrom(req)); return ok(data); } + @Get("json") + async exportJson(@CurrentUser() userId: string, @Query() query: Record, @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, @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, @Req() req: Request) { + const data = await this.exportsService.createSignedDownloadUrl(userId, "pdf", query, requestContextFrom(req)); + return ok(data); + } + + @Public() + @Get("download/:token") + async downloadSignedExport(@Param("token") token: string, @Req() req: Request, @Res() res: Response) { + const file = await this.exportsService.consumeSignedDownloadUrl(token, requestContextFrom(req)); + res.setHeader("Content-Type", file.mimeType); + res.setHeader("Content-Disposition", `attachment; filename="${file.fileName.replace(/"/g, "")}"`); + res.setHeader("Cache-Control", "no-store"); + res.send(file.content); + } + @Post("sheets") - async exportSheets(@CurrentUser() userId: string, @Query() query: Record) { - const data = await this.exportsService.exportSheets(userId, query); + async exportSheets(@CurrentUser() userId: string, @Query() query: Record, @Req() req: Request) { + const data = await this.exportsService.exportSheets(userId, query, requestContextFrom(req)); return ok(data); } } diff --git a/src/exports/exports.module.ts b/src/exports/exports.module.ts index 243e514..8fa4519 100644 --- a/src/exports/exports.module.ts +++ b/src/exports/exports.module.ts @@ -1,9 +1,13 @@ import { Module } from "@nestjs/common"; import { ExportsController } from "./exports.controller"; import { ExportsService } from "./exports.service"; +import { ExportObjectStorageService } from "./export-object-storage.service"; +import { StripeModule } from "../stripe/stripe.module"; @Module({ + imports: [StripeModule], controllers: [ExportsController], - providers: [ExportsService], + providers: [ExportsService, ExportObjectStorageService], + exports: [ExportsService], }) export class ExportsModule {} diff --git a/src/exports/exports.service.ts b/src/exports/exports.service.ts index 4b1a72c..48b0cf4 100644 --- a/src/exports/exports.service.ts +++ b/src/exports/exports.service.ts @@ -1,12 +1,32 @@ -import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, GoneException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import * as crypto from "crypto"; import { google } from "googleapis"; +import { Prisma } from "@prisma/client"; +import * as XLSX from "xlsx"; import { PrismaService } from "../prisma/prisma.service"; +import { AbuseService } from "../abuse/abuse.service"; +import { RequestContext } from "../abuse/abuse.types"; +import { ExportObjectStorageService } from "./export-object-storage.service"; + +type ExportWatermark = { + label: string; + userId: string; + generatedAt: string; + traceId: string; + text: string; +}; @Injectable() export class ExportsService { private readonly logger = new Logger(ExportsService.name); + private readonly syncSheetTitle = "LedgerOne Sync"; + private readonly downloadTokenTtlMs = 2 * 60 * 1000; - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly abuseService: AbuseService, + private readonly exportObjectStorage: ExportObjectStorageService, + ) {} private toCsv(rows: Array>) { if (!rows.length) return ""; @@ -19,6 +39,102 @@ export class ExportsService { return lines.join("\n"); } + private createWatermark(userId: string): ExportWatermark { + const generatedAt = new Date().toISOString(); + const traceId = crypto.randomBytes(12).toString("hex"); + const label = "LedgerOne Export Watermark"; + return { + label, + userId, + generatedAt, + traceId, + text: `${label} | user=${userId} | generated=${generatedAt} | trace=${traceId}`, + }; + } + + private applyWatermarkToRows(rows: ReturnType, 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>, watermark: ExportWatermark) { + const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"]; + const lines = [ + "LedgerOne Export", + `Generated ${new Date().toISOString()}`, + `Rows ${rows.length}`, + watermark.text, + "", + headers.join(" | "), + ...rows.map((row) => headers.map((header) => row[header] ?? "").join(" | ")), + ].map((line) => line.length > 118 ? `${line.slice(0, 115)}...` : line); + + const pageHeight = 792; + const margin = 36; + const lineHeight = 11; + const fontSize = 8; + const linesPerPage = Math.floor((pageHeight - margin * 2) / lineHeight); + const pages: string[] = []; + for (let index = 0; index < lines.length; index += linesPerPage) { + const pageLines = lines.slice(index, index + linesPerPage); + const commands = ["BT", `/F1 ${fontSize} Tf`]; + pageLines.forEach((line, lineIndex) => { + const y = pageHeight - margin - lineIndex * lineHeight; + commands.push(`1 0 0 1 ${margin} ${y} Tm (${this.escapePdfText(line)}) Tj`); + }); + commands.push(`1 0 0 1 ${margin} ${margin - 14} Tm (${this.escapePdfText(watermark.text)}) Tj`); + commands.push("ET"); + pages.push(commands.join("\n")); + } + + const objects: string[] = []; + const pageObjectIds = pages.map((_, index) => 4 + index * 2); + objects[0] = "<< /Type /Catalog /Pages 2 0 R >>"; + objects[1] = `<< /Type /Pages /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pages.length} >>`; + objects[2] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; + pages.forEach((content, index) => { + const pageObjectId = 4 + index * 2; + const contentObjectId = pageObjectId + 1; + objects[pageObjectId - 1] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentObjectId} 0 R >>`; + objects[contentObjectId - 1] = `<< /Length ${Buffer.byteLength(content, "binary")} >>\nstream\n${content}\nendstream`; + }); + + let pdf = "%PDF-1.4\n"; + const offsets = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "binary")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + const xrefOffset = Buffer.byteLength(pdf, "binary"); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (let index = 1; index < offsets.length; index += 1) { + pdf += `${String(offsets[index]).padStart(10, "0")} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + return Buffer.from(pdf, "binary"); + } + private async getTransactions( userId: string, filters: Record, @@ -50,7 +166,14 @@ export class ExportsService { } return this.prisma.transactionRaw.findMany({ where, - include: { derived: true }, + include: { + derived: true, + account: { + select: { + ownershipType: true, + }, + }, + }, orderBy: { date: "desc" }, take: limit, }); @@ -64,30 +187,356 @@ export class ExportsService { amount: Number(tx.amount).toFixed(2), category: tx.derived?.userCategory ?? "", notes: tx.derived?.userNotes ?? "", + attribution: tx.derived?.attribution ?? this.defaultAttributionForAccount(tx.account?.ownershipType), + splitMode: tx.derived?.splitMode ?? "none", + splitMinePercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"), + splitYoursPercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"), + splitMineAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"), + splitYoursAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"), hidden: tx.derived?.isHidden ? "true" : "false", source: tx.source, })); } - async exportCsv(userId: string, filters: Record = {}) { - const transactions = await this.getTransactions(userId, filters); - const rows = this.toRows(transactions); - const csv = this.toCsv(rows); - - await this.prisma.exportLog.create({ - data: { userId, filters, rowCount: rows.length }, - }); - - return { status: "ready", csv, rowCount: rows.length }; + private defaultAttributionForAccount(ownershipType?: string | null) { + if (ownershipType === "joint") return "ours"; + if (ownershipType === "theirs") return "yours"; + return "mine"; } - async exportSheets(userId: string, filters: Record = {}) { - // Get the user's Google connection + private splitPercent(mode: string | null | undefined, value: unknown, side: "mine" | "yours") { + if (!mode || mode === "none") return side === "mine" ? "100.00" : "0.00"; + if (mode === "equal") return "50.00"; + return Number(value ?? 0).toFixed(2); + } + + private splitAmount(amount: number, mode: string | null | undefined, value: unknown, side: "mine" | "yours") { + const percent = Number(this.splitPercent(mode, value, side)); + return (amount * (percent / 100)).toFixed(2); + } + + private hashContent(content: string | Buffer) { + return crypto.createHash("sha256").update(content).digest("hex"); + } + + private hashToken(token: string) { + return crypto.createHash("sha256").update(token).digest("hex"); + } + + private async createExportLog( + userId: string, + filters: Record, + rowCount: number, + audit: { + format: string; + destination?: string; + fileName?: string; + mimeType?: string; + fileContent?: string | Buffer; + metadata?: Record; + }, + 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 = {}) { + 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 = {}, 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 = {}) { + 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 = {}, 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 = {}) { + 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 = {}, 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 = {}) { + 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 = {}, 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 = {}, + 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; + 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) { + 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) { + 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) { + const headers = this.getHeaders(rows); + return [ + headers, + ...rows.map((row) => headers.map((header) => row[header as keyof typeof row] ?? "")), + ]; + } + + private async createGoogleSheetsClient(userId: string, failWhenDisconnected: boolean) { const gc = await this.prisma.googleConnection.findUnique({ where: { userId } }); if (!gc || !gc.isConnected) { - throw new BadRequestException( - "Google account not connected. Please connect via /api/google/connect.", - ); + if (failWhenDisconnected) { + throw new BadRequestException( + "Google account not connected. Please connect via /api/google/connect.", + ); + } + return null; } const oauth2Client = new google.auth.OAuth2( @@ -99,7 +548,6 @@ export class ExportsService { refresh_token: gc.refreshToken, }); - // Refresh the access token if needed const { credentials } = await oauth2Client.refreshAccessToken(); await this.prisma.googleConnection.update({ where: { userId }, @@ -110,39 +558,76 @@ export class ExportsService { }); oauth2Client.setCredentials(credentials); - const sheets = google.sheets({ version: "v4", auth: oauth2Client }); - const transactions = await this.getTransactions(userId, filters); - const rows = this.toRows(transactions); + return { + gc, + sheets: google.sheets({ version: "v4", auth: oauth2Client }), + }; + } - const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`; - - let spreadsheetId = gc.spreadsheetId; - if (!spreadsheetId) { - // Create a new spreadsheet - const spreadsheet = await sheets.spreadsheets.create({ - requestBody: { properties: { title: "LedgerOne" } }, - }); - spreadsheetId = spreadsheet.data.spreadsheetId!; - await this.prisma.googleConnection.update({ - where: { userId }, - data: { spreadsheetId }, - }); + private async ensureSpreadsheet( + userId: string, + sheets: ReturnType, + spreadsheetId?: string | null, + ) { + if (spreadsheetId) { + await this.markDriveMirror(userId, spreadsheetId, "ready"); + return spreadsheetId; } - // Add a new sheet tab - await sheets.spreadsheets.batchUpdate({ - spreadsheetId, - requestBody: { - requests: [{ addSheet: { properties: { title: sheetTitle } } }], + const spreadsheet = await sheets.spreadsheets.create({ + requestBody: { properties: { title: "LedgerOne User-Owned Ledger" } }, + }); + const createdSpreadsheetId = spreadsheet.data.spreadsheetId!; + await this.markDriveMirror(userId, createdSpreadsheetId, "ready"); + return createdSpreadsheetId; + } + + private async markDriveMirror(userId: string, spreadsheetId: string, status: string, syncedAt?: Date) { + await this.prisma.googleConnection.update({ + where: { userId }, + data: { + spreadsheetId, + driveMirrorEnabled: true, + driveMirrorStatus: status, + driveMirrorSpreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`, + ...(syncedAt ? { driveMirrorLastSyncedAt: syncedAt, lastSyncedAt: syncedAt } : {}), }, }); + } - // Build values: header + data rows - const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"]; - const values = [ - headers, - ...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")), - ]; + private async addSheetIfMissing( + sheets: ReturnType, + spreadsheetId: string, + sheetTitle: string, + ) { + try { + await sheets.spreadsheets.batchUpdate({ + spreadsheetId, + requestBody: { + requests: [{ addSheet: { properties: { title: sheetTitle } } }], + }, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : ""; + if (!/already exists|duplicate/i.test(message)) { + throw error; + } + } + } + + private async writeSheetValues( + sheets: ReturnType, + spreadsheetId: string, + sheetTitle: string, + values: string[][], + clearFirst = false, + ) { + if (clearFirst) { + await sheets.spreadsheets.values.clear({ + spreadsheetId, + range: `'${sheetTitle}'!A:Z`, + }); + } await sheets.spreadsheets.values.update({ spreadsheetId, @@ -150,11 +635,34 @@ export class ExportsService { valueInputOption: "RAW", requestBody: { values }, }); + } - await this.prisma.exportLog.create({ - data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length }, + async exportSheets(userId: string, filters: Record = {}, context?: RequestContext) { + const client = await this.createGoogleSheetsClient(userId, true); + const { gc, sheets } = client!; + const transactions = await this.getTransactions(userId, filters); + const rows = this.toRows(transactions); + + const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`; + const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId); + + await sheets.spreadsheets.batchUpdate({ + spreadsheetId, + requestBody: { + requests: [{ addSheet: { properties: { title: sheetTitle } } }], + }, }); + await this.writeSheetValues(sheets, spreadsheetId, sheetTitle, this.toSheetValues(rows)); + await this.markDriveMirror(userId, spreadsheetId, "synced", new Date()); + + await this.createExportLog(userId, filters, rows.length, { + format: "google_sheets", + destination: "google_sheets", + metadata: { spreadsheetId, sheetTitle, url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}` }, + }, context); + await this.abuseService.recordExportActivity(userId, rows.length, { ...filters, destination: "google_sheets" }, context); + this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`); return { @@ -165,4 +673,38 @@ export class ExportsService { url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`, }; } + + async syncGoogleSheets(userId: string, reason = "transaction_change") { + const client = await this.createGoogleSheetsClient(userId, false); + if (!client) { + return { status: "skipped", reason: "not_connected" }; + } + + const { gc, sheets } = client; + const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId); + const transactions = await this.getTransactions(userId, {}, 5000); + const rows = this.toRows(transactions); + + await this.addSheetIfMissing(sheets, spreadsheetId, this.syncSheetTitle); + await this.writeSheetValues( + sheets, + spreadsheetId, + this.syncSheetTitle, + this.toSheetValues(rows), + true, + ); + + await this.markDriveMirror(userId, spreadsheetId, "synced", new Date()); + + this.logger.log(`Synced ${rows.length} rows to Google Sheets for user ${userId}`); + + return { + status: "synced", + reason, + rowCount: rows.length, + spreadsheetId, + sheetTitle: this.syncSheetTitle, + url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`, + }; + } } diff --git a/src/google/google.service.ts b/src/google/google.service.ts index 15817b8..c44ad27 100644 --- a/src/google/google.service.ts +++ b/src/google/google.service.ts @@ -67,6 +67,10 @@ export class GoogleService { isConnected: true, connectedAt: new Date(), spreadsheetId: null, // reset so a new spreadsheet is created on next export + driveMirrorEnabled: false, + driveMirrorStatus: "pending_first_sync", + driveMirrorSpreadsheetUrl: null, + driveMirrorLastSyncedAt: null, }, create: { userId, @@ -74,6 +78,8 @@ export class GoogleService { refreshToken: tokens.refresh_token, accessToken: tokens.access_token ?? null, isConnected: true, + driveMirrorEnabled: false, + driveMirrorStatus: "pending_first_sync", }, }); @@ -89,6 +95,18 @@ export class GoogleService { async getStatus(userId: string) { const gc = await this.prisma.googleConnection.findUnique({ where: { userId } }); if (!gc || !gc.isConnected) return { connected: false }; - return { connected: true, googleEmail: gc.googleEmail, connectedAt: gc.connectedAt }; + return { + connected: true, + googleEmail: gc.googleEmail, + connectedAt: gc.connectedAt, + driveMirror: { + enabled: gc.driveMirrorEnabled, + status: gc.driveMirrorStatus, + spreadsheetId: gc.spreadsheetId, + url: gc.driveMirrorSpreadsheetUrl, + lastSyncedAt: gc.driveMirrorLastSyncedAt ?? gc.lastSyncedAt, + ownership: "user_google_drive", + }, + }; } } diff --git a/src/households/dto/accept-household-invite.dto.ts b/src/households/dto/accept-household-invite.dto.ts new file mode 100644 index 0000000..0fdb1ea --- /dev/null +++ b/src/households/dto/accept-household-invite.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from "class-validator"; + +export class AcceptHouseholdInviteDto { + @IsString() + @MinLength(32) + token!: string; +} diff --git a/src/households/dto/create-household-invite.dto.ts b/src/households/dto/create-household-invite.dto.ts new file mode 100644 index 0000000..a1f30d4 --- /dev/null +++ b/src/households/dto/create-household-invite.dto.ts @@ -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; +} diff --git a/src/households/dto/create-household.dto.ts b/src/households/dto/create-household.dto.ts new file mode 100644 index 0000000..f893ade --- /dev/null +++ b/src/households/dto/create-household.dto.ts @@ -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; +} diff --git a/src/households/dto/update-household-member.dto.ts b/src/households/dto/update-household-member.dto.ts new file mode 100644 index 0000000..dd7ff64 --- /dev/null +++ b/src/households/dto/update-household-member.dto.ts @@ -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; +} diff --git a/src/households/households.controller.ts b/src/households/households.controller.ts new file mode 100644 index 0000000..0c504ca --- /dev/null +++ b/src/households/households.controller.ts @@ -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)); + } +} diff --git a/src/households/households.module.ts b/src/households/households.module.ts new file mode 100644 index 0000000..2f883e8 --- /dev/null +++ b/src/households/households.module.ts @@ -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 {} diff --git a/src/households/households.service.ts b/src/households/households.service.ts new file mode 100644 index 0000000..355a811 --- /dev/null +++ b/src/households/households.service.ts @@ -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(); + 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)), + }; + } +} diff --git a/src/main.ts b/src/main.ts index f0e582f..7b0b373 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,11 +9,14 @@ import { AppModule } from "./app.module"; import { SentryExceptionFilter } from "./common/sentry.filter"; async function bootstrap() { + const isProduction = process.env.NODE_ENV === "production"; + // ─── Sentry initialization (before app creation) ────────────────────────── if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV ?? "development", + tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0), }); } @@ -23,15 +26,20 @@ async function bootstrap() { }); // ─── Security headers ───────────────────────────────────────────────────── + const scriptSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"]; + const styleSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"]; app.use( helmet({ crossOriginEmbedderPolicy: false, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'"], // Swagger UI needs inline scripts - styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc, + styleSrc, imgSrc: ["'self'", "data:", "https:"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + frameAncestors: ["'none'"], }, }, }), diff --git a/src/plaid/plaid.controller.ts b/src/plaid/plaid.controller.ts index dc1d02e..75cd0f1 100644 --- a/src/plaid/plaid.controller.ts +++ b/src/plaid/plaid.controller.ts @@ -1,11 +1,17 @@ -import { Body, Controller, Post } from "@nestjs/common"; +import { Body, Controller, Headers, Post, Req } from "@nestjs/common"; +import { Request } from "express"; import { ok } from "../common/response"; import { PlaidService } from "./plaid.service"; import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { Public } from "../common/decorators/public.decorator"; +import { OpaqueIdService } from "../common/opaque-id.service"; @Controller("plaid") export class PlaidController { - constructor(private readonly plaidService: PlaidService) {} + constructor( + private readonly plaidService: PlaidService, + private readonly opaqueIds: OpaqueIdService, + ) {} @Post("link-token") async createLinkToken(@CurrentUser() userId: string) { @@ -21,4 +27,35 @@ export class PlaidController { const data = await this.plaidService.exchangePublicTokenForUser(userId, payload.publicToken); return ok(data); } + + @Post("update-link-token") + async createUpdateLinkToken( + @CurrentUser() userId: string, + @Body() payload: { accountId: string }, + ) { + const accountId = this.opaqueIds.decode("account", userId, payload.accountId); + const data = await this.plaidService.createUpdateModeLinkToken(userId, accountId); + return ok(data); + } + + @Post("repair-complete") + async repairComplete( + @CurrentUser() userId: string, + @Body() payload: { accountId: string }, + ) { + const accountId = this.opaqueIds.decode("account", userId, payload.accountId); + const data = await this.plaidService.markItemRepairComplete(userId, accountId); + return ok(data); + } + + @Public() + @Post("webhook") + async webhook( + @Body() payload: Record, + @Headers("plaid-verification") verification: string | undefined, + @Req() request: Request & { rawBody?: Buffer }, + ) { + const data = await this.plaidService.handleWebhook(payload, verification, request.rawBody); + return ok(data); + } } diff --git a/src/plaid/plaid.module.ts b/src/plaid/plaid.module.ts index 829fcb9..39bf73d 100644 --- a/src/plaid/plaid.module.ts +++ b/src/plaid/plaid.module.ts @@ -1,8 +1,10 @@ import { Module } from "@nestjs/common"; +import { StripeModule } from "../stripe/stripe.module"; import { PlaidController } from "./plaid.controller"; import { PlaidService } from "./plaid.service"; @Module({ + imports: [StripeModule], controllers: [PlaidController], providers: [PlaidService], exports: [PlaidService] diff --git a/src/plaid/plaid.service.ts b/src/plaid/plaid.service.ts index 6f0a3a5..0fac87c 100644 --- a/src/plaid/plaid.service.ts +++ b/src/plaid/plaid.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { Configuration, CountryCode, @@ -10,14 +10,18 @@ import * as crypto from "crypto"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { EncryptionService } from "../common/encryption.service"; +import { PlanLimitsService } from "../stripe/plan-limits.service"; @Injectable() export class PlaidService { + private readonly logger = new Logger(PlaidService.name); private readonly client: PlaidApi; + private readonly webhookKeys = new Map(); constructor( private readonly prisma: PrismaService, private readonly encryption: EncryptionService, + private readonly planLimits: PlanLimitsService, ) { const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments; const clientId = this.requireEnv("PLAID_CLIENT_ID"); @@ -55,6 +59,7 @@ export class PlaidService { country_codes: countryCodes, language: "en", redirect_uri: redirectUri || undefined, + webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined, }); return { @@ -86,6 +91,14 @@ export class PlaidService { const institutionName = institutionId ? await this.getInstitutionName(institutionId) : "Plaid institution"; + const incomingAccountIds = accountsResponse.data.accounts.map((account) => account.account_id); + const existingAccounts = await this.prisma.account.findMany({ + where: { userId, plaidAccountId: { in: incomingAccountIds } }, + select: { plaidAccountId: true }, + }); + const existingIds = new Set(existingAccounts.map((account) => account.plaidAccountId)); + const newAccountCount = incomingAccountIds.filter((id) => !existingIds.has(id)).length; + await this.planLimits.assertCanAddAccounts(userId, newAccountCount); for (const account of accountsResponse.data.accounts) { await this.prisma.account.upsert({ @@ -162,7 +175,7 @@ export class PlaidService { where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, }); - // Build map: raw decrypted token → plaidAccountIds + // Build map: raw decrypted token → Plaid account ids for one item. const tokenMap = new Map(); for (const account of accounts) { if (!account.plaidAccessToken || !account.plaidAccountId) continue; @@ -172,48 +185,370 @@ export class PlaidService { tokenMap.set(raw, list); } - let created = 0; - for (const [rawToken] of tokenMap) { - const response = await this.client.transactionsGet({ - access_token: rawToken, - start_date: startDate, - end_date: endDate, - options: { count: 500, offset: 0 }, - }); + const now = new Date(); + await this.prisma.account.updateMany({ + where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, + data: { syncStatus: "syncing", lastSyncAttemptAt: now, lastSyncError: null }, + }); - for (const tx of response.data.transactions) { - const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id); - if (!account) continue; - const rawPayload = tx as unknown as Prisma.InputJsonValue; - await this.prisma.transactionRaw.upsert({ - where: { bankTransactionId: tx.transaction_id }, - update: { - accountId: account.id, - date: new Date(tx.date), - amount: tx.amount, - description: tx.name ?? "Plaid transaction", - rawPayload, - source: "plaid", - ingestedAt: new Date(), - }, - create: { - accountId: account.id, - bankTransactionId: tx.transaction_id, - date: new Date(tx.date), - amount: tx.amount, - description: tx.name ?? "Plaid transaction", - rawPayload, - ingestedAt: new Date(), - source: "plaid", + let created = 0; + for (const [rawToken, plaidAccountIds] of tokenMap) { + try { + const response = await this.client.transactionsGet({ + access_token: rawToken, + start_date: startDate, + end_date: endDate, + options: { count: 500, offset: 0 }, + }); + + for (const tx of response.data.transactions) { + const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id); + if (!account) continue; + const rawPayload = tx as unknown as Prisma.InputJsonValue; + await this.prisma.transactionRaw.upsert({ + where: { bankTransactionId: tx.transaction_id }, + update: { + accountId: account.id, + date: new Date(tx.date), + amount: tx.amount, + description: tx.name ?? "Plaid transaction", + rawPayload, + source: "plaid", + ingestedAt: new Date(), + }, + create: { + accountId: account.id, + bankTransactionId: tx.transaction_id, + date: new Date(tx.date), + amount: tx.amount, + description: tx.name ?? "Plaid transaction", + rawPayload, + ingestedAt: new Date(), + source: "plaid", + }, + }); + created += 1; + } + + await this.prisma.account.updateMany({ + where: { userId, plaidAccountId: { in: plaidAccountIds } }, + data: { + syncStatus: "idle", + lastTransactionSync: new Date(), + lastSyncError: null, + syncConsecutiveFailures: 0, + }, + }); + } catch (error: unknown) { + const err = error as { response?: { data?: { error_message?: string; error_code?: string } }; message?: string }; + const message = err.response?.data?.error_message ?? err.message ?? "Plaid transaction sync failed."; + await this.prisma.account.updateMany({ + where: { userId, plaidAccountId: { in: plaidAccountIds } }, + data: { + syncStatus: "error", + lastSyncError: message.slice(0, 500), + syncConsecutiveFailures: { increment: 1 }, }, }); - created += 1; } } return { created }; } + async createUpdateModeLinkToken(userId: string, accountId: string) { + const account = await this.prisma.account.findFirst({ + where: { + id: accountId, + userId, + plaidAccessToken: { not: null }, + plaidItemId: { not: null }, + isActive: true, + }, + select: { + plaidAccessToken: true, + }, + }); + + if (!account?.plaidAccessToken) { + throw new BadRequestException("Plaid account not found for update mode."); + } + + const rawAccessToken = this.encryption.decrypt(account.plaidAccessToken); + try { + const response = await this.client.linkTokenCreate({ + user: { client_user_id: userId }, + client_name: "LedgerOne", + country_codes: this.getCountryCodes(), + language: "en", + access_token: rawAccessToken, + redirect_uri: process.env.PLAID_REDIRECT_URI?.trim() || undefined, + webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined, + }); + + return { + linkToken: response.data.link_token, + expiration: response.data.expiration, + }; + } catch (error: unknown) { + const err = error as { response?: { data?: { error_message?: string } } }; + const message = + err.response?.data?.error_message ?? "Plaid update-mode link token request failed."; + throw new BadRequestException(message); + } + } + + async markItemRepairComplete(userId: string, accountId: string) { + const account = await this.prisma.account.findFirst({ + where: { + id: accountId, + userId, + plaidItemId: { not: null }, + isActive: true, + }, + select: { + plaidItemId: true, + }, + }); + + if (!account?.plaidItemId) { + throw new BadRequestException("Plaid account not found for repair completion."); + } + + const updated = await this.prisma.account.updateMany({ + where: { + userId, + plaidItemId: account.plaidItemId, + }, + data: { + syncStatus: "idle", + lastSyncError: null, + syncConsecutiveFailures: 0, + plaidWebhookCode: "UPDATE_MODE_COMPLETED", + plaidWebhookAt: new Date(), + }, + }); + + return { updated: updated.count }; + } + + async handleWebhook(payload: PlaidWebhookPayload, verificationHeader?: string, rawBody?: Buffer) { + if (this.shouldVerifyWebhooks()) { + const valid = await this.verifyWebhook(verificationHeader, rawBody); + if (!valid) { + throw new BadRequestException("Invalid Plaid webhook signature."); + } + } + + const webhookType = payload.webhook_type ?? "UNKNOWN"; + const webhookCode = payload.webhook_code ?? "UNKNOWN"; + const itemId = payload.item_id ?? null; + const event = await this.prisma.plaidWebhookEvent.create({ + data: { + itemId, + webhookType, + webhookCode, + payload: payload as Prisma.InputJsonValue, + }, + }); + + try { + const result = await this.processWebhook(payload); + await this.prisma.plaidWebhookEvent.update({ + where: { id: event.id }, + data: { + status: "processed", + processedAt: new Date(), + }, + }); + return { received: true, processed: true, webhookType, webhookCode, ...result }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Plaid webhook processing failed."; + this.logger.error(`Plaid webhook ${webhookType}:${webhookCode} failed: ${message}`); + await this.prisma.plaidWebhookEvent.update({ + where: { id: event.id }, + data: { + status: "error", + error: message.slice(0, 500), + processedAt: new Date(), + }, + }); + throw error; + } + } + + private async processWebhook(payload: PlaidWebhookPayload) { + if (!payload.item_id) { + return { skipped: true, reason: "missing_item_id" }; + } + + const accounts = await this.prisma.account.findMany({ + where: { plaidItemId: payload.item_id }, + select: { userId: true }, + distinct: ["userId"], + }); + const userIds = accounts.map((account) => account.userId); + if (userIds.length === 0) { + return { skipped: true, reason: "item_not_found" }; + } + + const webhookType = payload.webhook_type ?? "UNKNOWN"; + const webhookCode = payload.webhook_code ?? "UNKNOWN"; + await this.prisma.account.updateMany({ + where: { plaidItemId: payload.item_id }, + data: { + plaidWebhookCode: webhookCode, + plaidWebhookAt: new Date(), + }, + }); + + if ( + webhookType === "TRANSACTIONS" && + ["SYNC_UPDATES_AVAILABLE", "INITIAL_UPDATE", "HISTORICAL_UPDATE", "DEFAULT_UPDATE"].includes(webhookCode) + ) { + const days = Number(process.env.PLAID_WEBHOOK_LOOKBACK_DAYS ?? process.env.AUTO_SYNC_LOOKBACK_DAYS ?? 30); + const endDate = new Date().toISOString().slice(0, 10); + const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + let created = 0; + for (const userId of userIds) { + const result = await this.syncTransactionsForUser(userId, startDate, endDate); + created += result.created; + } + return { usersSynced: userIds.length, created }; + } + + if (webhookType === "ITEM") { + return this.handleItemWebhook(payload, userIds); + } + + return { skipped: true, reason: "unsupported_webhook" }; + } + + private async handleItemWebhook(payload: PlaidWebhookPayload, userIds: string[]) { + if (!payload.item_id) return { skipped: true, reason: "missing_item_id" }; + const webhookCode = payload.webhook_code ?? "UNKNOWN"; + const errorCode = payload.error?.error_code ?? webhookCode; + const errorMessage = payload.error?.error_message ?? webhookCode; + + if (webhookCode === "ERROR" || errorCode === "ITEM_LOGIN_REQUIRED") { + await this.prisma.account.updateMany({ + where: { plaidItemId: payload.item_id }, + data: { + syncStatus: "needs_reauth", + lastSyncError: errorMessage.slice(0, 500), + syncConsecutiveFailures: { increment: 1 }, + }, + }); + return { usersMarked: userIds.length, status: "needs_reauth" }; + } + + if (["PENDING_EXPIRATION", "PENDING_DISCONNECT", "NEW_ACCOUNTS_AVAILABLE"].includes(webhookCode)) { + await this.prisma.account.updateMany({ + where: { plaidItemId: payload.item_id }, + data: { + syncStatus: "attention_required", + lastSyncError: webhookCode, + }, + }); + return { usersMarked: userIds.length, status: "attention_required" }; + } + + if (["USER_PERMISSION_REVOKED", "USER_ACCOUNT_REVOKED"].includes(webhookCode)) { + await this.prisma.account.updateMany({ + where: { plaidItemId: payload.item_id }, + data: { + isActive: false, + syncStatus: "revoked", + lastSyncError: webhookCode, + }, + }); + return { usersMarked: userIds.length, status: "revoked" }; + } + + if (webhookCode === "LOGIN_REPAIRED") { + await this.prisma.account.updateMany({ + where: { plaidItemId: payload.item_id }, + data: { + syncStatus: "idle", + lastSyncError: null, + syncConsecutiveFailures: 0, + }, + }); + return { usersMarked: userIds.length, status: "repaired" }; + } + + return { skipped: true, reason: "unsupported_item_webhook" }; + } + + private shouldVerifyWebhooks() { + if (process.env.NODE_ENV === "test") return false; + return process.env.PLAID_VERIFY_WEBHOOKS !== "false"; + } + + private async verifyWebhook(verificationHeader?: string, rawBody?: Buffer) { + if (!verificationHeader || !rawBody) return false; + const parts = verificationHeader.split("."); + if (parts.length !== 3) return false; + + const header = this.decodeJwtPart<{ alg?: string; kid?: string }>(parts[0]); + if (header.alg !== "ES256" || !header.kid) return false; + + const key = await this.getWebhookKey(header.kid); + const publicKey = crypto.createPublicKey({ key, format: "jwk" } as crypto.JsonWebKeyInput); + const verifier = crypto.createVerify("SHA256"); + verifier.update(`${parts[0]}.${parts[1]}`); + verifier.end(); + + const signature = this.ecJoseSignatureToDer(Buffer.from(parts[2], "base64url")); + if (!verifier.verify(publicKey, signature)) return false; + + const claims = this.decodeJwtPart<{ iat?: number; request_body_sha256?: string }>(parts[1]); + if (!claims.iat || Math.abs(Date.now() / 1000 - claims.iat) > 300) return false; + if (!claims.request_body_sha256) return false; + + const actualHash = crypto.createHash("sha256").update(rawBody).digest("hex"); + return this.timingSafeEqual(actualHash, claims.request_body_sha256); + } + + private async getWebhookKey(keyId: string) { + const cached = this.webhookKeys.get(keyId); + if (cached) return cached; + const response = await (this.client as unknown as { + webhookVerificationKeyGet(request: { key_id: string }): Promise<{ data: { key: JsonWebKey } }>; + }).webhookVerificationKeyGet({ key_id: keyId }); + this.webhookKeys.set(keyId, response.data.key); + return response.data.key; + } + + private decodeJwtPart(part: string): T { + return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T; + } + + private timingSafeEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); + } + + private ecJoseSignatureToDer(signature: Buffer) { + if (signature.length !== 64) return signature; + const r = this.derInteger(signature.subarray(0, 32)); + const s = this.derInteger(signature.subarray(32)); + const length = r.length + s.length; + return Buffer.concat([Buffer.from([0x30, length]), r, s]); + } + + private derInteger(bytes: Buffer) { + let value = bytes; + while (value.length > 1 && value[0] === 0) { + value = value.subarray(1); + } + if (value[0] & 0x80) { + value = Buffer.concat([Buffer.from([0]), value]); + } + return Buffer.concat([Buffer.from([0x02, value.length]), value]); + } + private requireEnv(name: string) { const value = process.env[name]; if (!value) { @@ -222,6 +557,13 @@ export class PlaidService { return value; } + private getCountryCodes() { + return (process.env.PLAID_COUNTRY_CODES ?? "US") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) as CountryCode[]; + } + private async getInstitutionName(institutionId: string) { try { const response = await this.client.institutionsGetById({ @@ -234,3 +576,15 @@ export class PlaidService { } } } + +type PlaidWebhookPayload = { + webhook_type?: string; + webhook_code?: string; + item_id?: string; + environment?: string; + error?: { + error_code?: string; + error_message?: string; + }; + [key: string]: unknown; +}; diff --git a/src/public-api/api-key.controller.ts b/src/public-api/api-key.controller.ts new file mode 100644 index 0000000..6a80f01 --- /dev/null +++ b/src/public-api/api-key.controller.ts @@ -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)); + } +} diff --git a/src/public-api/api-key.guard.ts b/src/public-api/api-key.guard.ts new file mode 100644 index 0000000..28bef62 --- /dev/null +++ b/src/public-api/api-key.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/src/public-api/api-key.service.ts b/src/public-api/api-key.service.ts new file mode 100644 index 0000000..ded4f38 --- /dev/null +++ b/src/public-api/api-key.service.ts @@ -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 }; + } +} diff --git a/src/public-api/public-api.controller.ts b/src/public-api/public-api.controller.ts new file mode 100644 index 0000000..138f289 --- /dev/null +++ b/src/public-api/public-api.controller.ts @@ -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)); + } +} diff --git a/src/public-api/public-api.module.ts b/src/public-api/public-api.module.ts new file mode 100644 index 0000000..dbd5c19 --- /dev/null +++ b/src/public-api/public-api.module.ts @@ -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 {} diff --git a/src/rules/rules.service.ts b/src/rules/rules.service.ts index b7f2d7c..4124b74 100644 --- a/src/rules/rules.service.ts +++ b/src/rules/rules.service.ts @@ -63,21 +63,212 @@ export class RulesService { private matchesRule( conditions: Record, - tx: { description: string; amount: number | string }, + tx: { + description: string; + amount: number | string; + date: Date; + source: string; + category?: string | null; + }, ): boolean { - const textContains = typeof conditions.textContains === "string" ? conditions.textContains : ""; - const amountGt = typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null; - const amountLt = typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null; + if (this.hasDslConditions(conditions)) { + return this.matchesDsl(conditions, tx); + } - if (textContains && !tx.description.toLowerCase().includes(textContains.toLowerCase())) { + const textContains = this.asString(conditions.textContains); + const textNotContains = this.asString(conditions.textNotContains); + const textEquals = this.asString(conditions.textEquals); + const textRegex = this.asString(conditions.textRegex); + const sourceEquals = this.asString(conditions.sourceEquals); + const categoryEquals = this.asString(conditions.categoryEquals); + const amountGt = this.asNumber(conditions.amountGreaterThan); + const amountLt = this.asNumber(conditions.amountLessThan); + const amountGte = this.asNumber(conditions.amountGreaterThanOrEqual); + const amountLte = this.asNumber(conditions.amountLessThanOrEqual); + const amountEquals = this.asNumber(conditions.amountEquals); + const dateAfter = this.asDate(conditions.dateAfter); + const dateBefore = this.asDate(conditions.dateBefore); + + const description = tx.description.toLowerCase(); + if (textContains && !description.includes(textContains.toLowerCase())) { return false; } + if (textNotContains && description.includes(textNotContains.toLowerCase())) return false; + if (textEquals && description !== textEquals.toLowerCase()) return false; + if (textRegex) { + try { + if (!new RegExp(textRegex, "i").test(tx.description)) return false; + } catch { + return false; + } + } const amount = Number(tx.amount); if (amountGt !== null && amount <= amountGt) return false; if (amountLt !== null && amount >= amountLt) return false; + if (amountGte !== null && amount < amountGte) return false; + if (amountLte !== null && amount > amountLte) return false; + if (amountEquals !== null && amount !== amountEquals) return false; + if (sourceEquals && tx.source.toLowerCase() !== sourceEquals.toLowerCase()) return false; + if (categoryEquals && (tx.category ?? "").toLowerCase() !== categoryEquals.toLowerCase()) return false; + if (dateAfter && tx.date < dateAfter) return false; + if (dateBefore && tx.date > dateBefore) return false; return true; } + private hasDslConditions(conditions: Record) { + 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; + + 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, + current?: { userCategory?: string | null; userNotes?: string | null; isHidden?: boolean | null } | null, + ) { + const clearCategory = actions.clearCategory === true; + const setCategory = this.asString(actions.setCategory); + const clearNote = actions.clearNote === true; + const setNote = this.asString(actions.setNote); + const appendNote = this.asString(actions.appendNote); + + let userCategory = current?.userCategory ?? null; + if (clearCategory) userCategory = null; + if (setCategory) userCategory = setCategory; + + let userNotes = current?.userNotes ?? null; + if (clearNote) userNotes = null; + if (setNote) userNotes = setNote; + if (appendNote) { + userNotes = userNotes ? `${userNotes}\n${appendNote}` : appendNote; + } + + const isHidden = typeof actions.setHidden === "boolean" + ? actions.setHidden + : current?.isHidden ?? false; + + return { userCategory, userNotes, isHidden }; + } + async execute(userId: string, id: string) { const rule = await this.prisma.rule.findFirst({ where: { id, userId } }); if (!rule || !rule.isActive) return { id, status: "skipped" }; @@ -92,22 +283,26 @@ export class RulesService { let applied = 0; for (const tx of transactions) { - if (!this.matchesRule(conditions, { description: tx.description, amount: Number(tx.amount) })) { + if (!this.matchesRule(conditions, { + description: tx.description, + amount: Number(tx.amount), + date: tx.date, + source: tx.source, + category: tx.derived?.userCategory, + })) { continue; } await this.prisma.transactionDerived.upsert({ where: { rawTransactionId: tx.id }, update: { - userCategory: typeof actions.setCategory === "string" ? actions.setCategory : tx.derived?.userCategory ?? null, - isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : tx.derived?.isHidden ?? false, + ...this.buildDerivedActions(actions, tx.derived), modifiedAt: new Date(), modifiedBy: "rule", }, create: { rawTransactionId: tx.id, - userCategory: typeof actions.setCategory === "string" ? actions.setCategory : null, - isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false, + ...this.buildDerivedActions(actions, null), modifiedAt: new Date(), modifiedBy: "rule", }, @@ -128,33 +323,141 @@ export class RulesService { } async suggest(userId: string) { - const derived = await this.prisma.transactionDerived.findMany({ - where: { - raw: { account: { userId } }, - userCategory: { not: null }, - }, - include: { raw: { select: { description: true } } }, - take: 200, - }); + const [derived, transactions] = await Promise.all([ + this.prisma.transactionDerived.findMany({ + where: { + raw: { account: { userId } }, + userCategory: { not: null }, + }, + include: { raw: { select: { description: true } } }, + take: 300, + }), + this.prisma.transactionRaw.findMany({ + where: { account: { userId } }, + include: { derived: true }, + orderBy: { date: "desc" }, + take: 500, + }), + ]); - const bucket = new Map(); + const suggestions: Array<{ + id: string; + name: string; + conditions: Record; + actions: Record; + confidence: number; + reason: string; + matchCount: number; + type: string; + }> = []; + + const categoryBucket = new Map(); for (const item of derived) { - const key = item.raw.description.toLowerCase(); + const key = this.merchantKey(item.raw.description); const category = item.userCategory ?? "Uncategorized"; - const entry = bucket.get(key) ?? { category, count: 0 }; + const entry = categoryBucket.get(key) ?? { category, count: 0 }; entry.count += 1; - bucket.set(key, entry); + categoryBucket.set(key, entry); } - return Array.from(bucket.entries()) + for (const [description, value] of Array.from(categoryBucket.entries()) .filter(([, value]) => value.count >= 2) - .slice(0, 5) - .map(([description, value], index) => ({ - id: `suggestion_${index + 1}`, + .slice(0, 5)) { + suggestions.push({ + id: `category_${suggestions.length + 1}`, name: `Auto: ${value.category}`, conditions: { textContains: description }, actions: { setCategory: value.category }, confidence: Math.min(0.95, 0.5 + value.count * 0.1), - })); + reason: `${value.count} categorized transactions used the same merchant text.`, + matchCount: value.count, + type: "category", + }); + } + + const merchantBucket = new Map(); + 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); } } diff --git a/src/stripe/plan-limits.service.ts b/src/stripe/plan-limits.service.ts new file mode 100644 index 0000000..2c7cc42 --- /dev/null +++ b/src/stripe/plan-limits.service.ts @@ -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.`, + ); + } + } +} diff --git a/src/stripe/stripe.controller.ts b/src/stripe/stripe.controller.ts index e1d66a6..99d6c19 100644 --- a/src/stripe/stripe.controller.ts +++ b/src/stripe/stripe.controller.ts @@ -28,19 +28,22 @@ export class StripeController { } @Post("checkout") - async checkout(@CurrentUser() userId: string, @Body("priceId") priceId: string) { + async checkout( + @CurrentUser() userId: string, + @Body() payload: { priceId?: string; plan?: "pro" | "elite"; successUrl?: string; cancelUrl?: string }, + ) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true }, }); if (!user) return ok({ error: "User not found" }); - const data = await this.stripeService.createCheckoutSession(userId, user.email, priceId); + const data = await this.stripeService.createCheckoutSession(userId, user.email, payload); return ok(data); } @Post("portal") - async portal(@CurrentUser() userId: string) { - const data = await this.stripeService.createPortalSession(userId); + async portal(@CurrentUser() userId: string, @Body("returnUrl") returnUrl?: string) { + const data = await this.stripeService.createPortalSession(userId, returnUrl); return ok(data); } diff --git a/src/stripe/stripe.module.ts b/src/stripe/stripe.module.ts index 504ddb8..b7cc059 100644 --- a/src/stripe/stripe.module.ts +++ b/src/stripe/stripe.module.ts @@ -1,11 +1,12 @@ import { Module } from "@nestjs/common"; +import { PlanLimitsService } from "./plan-limits.service"; import { StripeController } from "./stripe.controller"; import { StripeService } from "./stripe.service"; import { SubscriptionGuard } from "./subscription.guard"; @Module({ controllers: [StripeController], - providers: [StripeService, SubscriptionGuard], - exports: [StripeService, SubscriptionGuard], + providers: [StripeService, SubscriptionGuard, PlanLimitsService], + exports: [StripeService, SubscriptionGuard, PlanLimitsService], }) export class StripeModule {} diff --git a/src/stripe/stripe.service.ts b/src/stripe/stripe.service.ts index ba248e0..34ad233 100644 --- a/src/stripe/stripe.service.ts +++ b/src/stripe/stripe.service.ts @@ -3,8 +3,8 @@ import Stripe from "stripe"; import { PrismaService } from "../prisma/prisma.service"; export const PLAN_LIMITS: Record = { - free: { accounts: 2, exports: 5 }, - pro: { accounts: 10, exports: 100 }, + free: { accounts: 2, exports: 0 }, + pro: { accounts: 10, exports: -1 }, elite: { accounts: -1, exports: -1 }, // -1 = unlimited }; @@ -32,28 +32,33 @@ export class StripeService { return customer.id; } - async createCheckoutSession(userId: string, email: string, priceId: string) { + async createCheckoutSession( + userId: string, + email: string, + payload: { priceId?: string; plan?: "pro" | "elite"; successUrl?: string; cancelUrl?: string }, + ) { + const priceId = payload.priceId ?? this.priceIdForPlan(payload.plan); const customerId = await this.getOrCreateCustomer(userId, email); const session = await this.stripe.checkout.sessions.create({ customer: customerId, payment_method_types: ["card"], mode: "subscription", line_items: [{ price: priceId, quantity: 1 }], - success_url: `${process.env.APP_URL}/settings/billing?success=true`, - cancel_url: `${process.env.APP_URL}/settings/billing?cancelled=true`, + success_url: payload.successUrl ?? `${process.env.APP_URL}/settings/subscription?success=true`, + cancel_url: payload.cancelUrl ?? `${process.env.APP_URL}/settings/subscription?cancelled=true`, metadata: { userId }, }); return { url: session.url }; } - async createPortalSession(userId: string) { + async createPortalSession(userId: string, returnUrl?: string) { const sub = await this.prisma.subscription.findUnique({ where: { userId } }); if (!sub?.stripeCustomerId) { throw new BadRequestException("No Stripe customer found. Please upgrade first."); } const session = await this.stripe.billingPortal.sessions.create({ customer: sub.stripeCustomerId, - return_url: `${process.env.APP_URL}/settings/billing`, + return_url: returnUrl ?? `${process.env.APP_URL}/settings/subscription`, }); return { url: session.url }; } @@ -125,4 +130,10 @@ export class StripeService { }, }); } + + private priceIdForPlan(plan?: "pro" | "elite") { + if (plan === "pro" && process.env.STRIPE_PRICE_PRO) return process.env.STRIPE_PRICE_PRO; + if (plan === "elite" && process.env.STRIPE_PRICE_ELITE) return process.env.STRIPE_PRICE_ELITE; + throw new BadRequestException("Missing Stripe price for selected plan."); + } } diff --git a/src/stripe/subscription.guard.ts b/src/stripe/subscription.guard.ts index f98ec42..8e6ff60 100644 --- a/src/stripe/subscription.guard.ts +++ b/src/stripe/subscription.guard.ts @@ -8,6 +8,7 @@ import { import { Reflector } from "@nestjs/core"; import { Request } from "express"; import { PrismaService } from "../prisma/prisma.service"; +import { IS_PUBLIC_KEY } from "../common/guards/jwt-auth.guard"; export const REQUIRED_PLAN_KEY = "requiredPlan"; export const RequiredPlan = (plan: "pro" | "elite") => @@ -23,6 +24,12 @@ export class SubscriptionGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + const required = this.reflector.getAllAndOverride(REQUIRED_PLAN_KEY, [ context.getHandler(), context.getClass(), diff --git a/src/tax/tax.controller.ts b/src/tax/tax.controller.ts index 0ec0437..64f4e9d 100644 --- a/src/tax/tax.controller.ts +++ b/src/tax/tax.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, Post, Put } from "@nestjs/common"; import { ok } from "../common/response"; import { CreateTaxReturnDto } from "./dto/create-return.dto"; import { UpdateTaxReturnDto } from "./dto/update-return.dto"; @@ -41,9 +41,41 @@ export class TaxController { return ok(data); } + @Get("returns/:id/intake") + async getIntake(@CurrentUser() userId: string, @Param("id") id: string) { + const data = await this.taxService.getIntake(userId, id); + return ok(data); + } + + @Put("returns/:id/intake") + async saveIntake( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: { intake: Record; submit?: boolean }, + ) { + const data = await this.taxService.saveIntake(userId, id, payload.intake, Boolean(payload.submit)); + return ok(data); + } + @Post("returns/:id/export") async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) { const data = await this.taxService.exportReturn(userId, id); return ok(data); } + + @Post("returns/:id/efile") + async submitEFile( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: { consentAccepted?: boolean }, + ) { + const data = await this.taxService.submitEFile(userId, id, Boolean(payload.consentAccepted)); + return ok(data); + } + + @Get("returns/:id/efile") + async getEFileStatus(@CurrentUser() userId: string, @Param("id") id: string) { + const data = await this.taxService.getEFileStatus(userId, id); + return ok(data); + } } diff --git a/src/tax/tax.service.ts b/src/tax/tax.service.ts index 33089eb..c24ff62 100644 --- a/src/tax/tax.service.ts +++ b/src/tax/tax.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; +import * as crypto from "crypto"; import { PrismaService } from "../prisma/prisma.service"; import { CreateTaxReturnDto } from "./dto/create-return.dto"; import { UpdateTaxReturnDto } from "./dto/update-return.dto"; @@ -58,6 +59,58 @@ export class TaxService { }); } + async getIntake(userId: string, id: string) { + const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id, userId } }); + if (!taxReturn) throw new BadRequestException("Tax return not found."); + const summary = this.asRecord(taxReturn.summary); + return { + taxReturnId: taxReturn.id, + status: taxReturn.status, + intake: this.asRecord(summary.intake), + readiness: this.intakeReadiness(this.asRecord(summary.intake)), + }; + } + + async saveIntake(userId: string, id: string, intake: Record, submit = false) { + const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id, userId } }); + if (!taxReturn) throw new BadRequestException("Tax return not found."); + const existingSummary = this.asRecord(taxReturn.summary); + const normalizedIntake = this.normalizeIntake(intake); + const readiness = this.intakeReadiness(normalizedIntake); + const nextStatus = submit && readiness.complete ? "ready" : "draft"; + + const updated = await this.prisma.taxReturn.update({ + where: { id }, + data: { + status: nextStatus, + summary: { + ...existingSummary, + intake: normalizedIntake, + intakeReadiness: readiness, + intakeSubmittedAt: submit ? new Date().toISOString() : existingSummary.intakeSubmittedAt ?? null, + } as Prisma.InputJsonValue, + }, + }); + + await this.prisma.auditLog.create({ + data: { + userId, + action: submit ? "tax.intake_submit" : "tax.intake_save", + metadata: { + taxReturnId: id, + complete: readiness.complete, + missingFields: readiness.missingFields, + }, + }, + }); + + return { + return: updated, + intake: normalizedIntake, + readiness, + }; + } + async exportReturn(userId: string, id: string) { const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id, userId }, @@ -70,6 +123,263 @@ export class TaxService { data: { status: "exported" }, }); - return { return: { ...taxReturn, status: "exported" }, documents: taxReturn.documents }; + const exportedReturn = { ...taxReturn, status: "exported" }; + const generatedAt = new Date().toISOString(); + const packageId = `taxpkg_${crypto.randomUUID()}`; + const requiredDocuments = this.requiredDocumentsFor(exportedReturn.filingType); + const providedDocumentTypes = new Set(taxReturn.documents.map((document) => document.docType)); + const missingDocuments = requiredDocuments.filter((docType) => !providedDocumentTypes.has(docType)); + + const summary = this.asRecord(exportedReturn.summary); + const packageBody = { + packageId, + packageVersion: "2026.1", + generatedAt, + manifest: { + userId, + taxReturnId: taxReturn.id, + taxYear: taxReturn.taxYear, + filingType: taxReturn.filingType, + jurisdictions: taxReturn.jurisdictions, + status: "exported", + documentCount: taxReturn.documents.length, + requiredDocuments, + missingDocuments, + intakeReadiness: this.intakeReadiness(this.asRecord(summary.intake)), + readiness: missingDocuments.length === 0 ? "complete" : "needs_documents", + }, + return: exportedReturn, + documents: taxReturn.documents.map((document) => ({ + id: document.id, + docType: document.docType, + metadata: document.metadata, + createdAt: document.createdAt, + })), + summary, + intake: summary.intake ?? {}, + }; + const packageHash = crypto + .createHash("sha256") + .update(JSON.stringify(packageBody)) + .digest("hex"); + + await this.prisma.auditLog.create({ + data: { + userId, + action: "tax.export_package", + metadata: { + taxReturnId: taxReturn.id, + packageId, + packageHash, + documentCount: taxReturn.documents.length, + missingDocuments, + }, + }, + }); + + return { + ...packageBody, + packageHash, + }; + } + + async submitEFile(userId: string, id: string, consentAccepted: boolean) { + if (!consentAccepted) { + throw new BadRequestException("E-file consent must be accepted before submission."); + } + + const taxReturn = await this.prisma.taxReturn.findFirst({ + where: { id, userId }, + include: { documents: true }, + }); + if (!taxReturn) throw new BadRequestException("Tax return not found."); + + const summary = this.asRecord(taxReturn.summary); + const intake = this.asRecord(summary.intake); + const intakeReadiness = this.intakeReadiness(intake); + const requiredDocuments = this.requiredDocumentsFor(taxReturn.filingType); + const providedDocumentTypes = new Set(taxReturn.documents.map((document) => document.docType)); + const missingDocuments = requiredDocuments.filter((docType) => !providedDocumentTypes.has(docType)); + if (!intakeReadiness.complete || missingDocuments.length > 0) { + throw new BadRequestException({ + message: "Tax return is not ready for e-file submission.", + missingFields: intakeReadiness.missingFields, + missingDocuments, + }); + } + + const provider = this.resolveEFileProvider(); + const submittedAt = new Date().toISOString(); + const submissionPackage = { + provider: provider.name, + taxReturnId: taxReturn.id, + userId, + taxYear: taxReturn.taxYear, + filingType: taxReturn.filingType, + jurisdictions: taxReturn.jurisdictions, + documentIds: taxReturn.documents.map((document) => document.id), + intake, + submittedAt, + }; + const packageHash = crypto.createHash("sha256").update(JSON.stringify(submissionPackage)).digest("hex"); + const providerResponse = await provider.submit({ ...submissionPackage, packageHash }); + const eFile = { + provider: provider.name, + providerMode: provider.mode, + submissionId: providerResponse.submissionId, + status: providerResponse.status, + acknowledgementId: providerResponse.acknowledgementId, + submittedAt, + lastCheckedAt: submittedAt, + packageHash, + consentAcceptedAt: submittedAt, + }; + + const updated = await this.prisma.taxReturn.update({ + where: { id }, + data: { + status: "efile_submitted", + summary: { + ...summary, + eFile, + } as Prisma.InputJsonValue, + }, + }); + + await this.prisma.auditLog.create({ + data: { + userId, + action: "tax.efile_submit", + metadata: { + taxReturnId: id, + provider: provider.name, + submissionId: eFile.submissionId, + status: eFile.status, + packageHash, + }, + }, + }); + + return { + return: updated, + eFile, + }; + } + + async getEFileStatus(userId: string, id: string) { + const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id, userId } }); + if (!taxReturn) throw new BadRequestException("Tax return not found."); + const summary = this.asRecord(taxReturn.summary); + const eFile = this.asRecord(summary.eFile); + if (!eFile.submissionId) { + return { + taxReturnId: id, + status: "not_submitted", + }; + } + + const provider = this.resolveEFileProvider(String(eFile.provider ?? "")); + const checkedAt = new Date().toISOString(); + const providerStatus = await provider.status(String(eFile.submissionId)); + const nextEFile = { + ...eFile, + submissionId: String(eFile.submissionId), + status: providerStatus.status, + acknowledgementId: providerStatus.acknowledgementId ?? eFile.acknowledgementId, + lastCheckedAt: checkedAt, + }; + + await this.prisma.taxReturn.update({ + where: { id }, + data: { + status: providerStatus.status === "accepted" ? "efile_accepted" : taxReturn.status, + summary: { + ...summary, + eFile: nextEFile, + } as Prisma.InputJsonValue, + }, + }); + + await this.prisma.auditLog.create({ + data: { + userId, + action: "tax.efile_status", + metadata: { + taxReturnId: id, + provider: provider.name, + submissionId: nextEFile.submissionId, + status: nextEFile.status, + }, + }, + }); + + return { + taxReturnId: id, + eFile: nextEFile, + }; + } + + private requiredDocumentsFor(filingType: string) { + if (filingType === "business") { + return ["income_statement", "balance_sheet", "bank_statements", "entity_information"]; + } + return ["w2_or_1099", "interest_and_dividend_forms", "deduction_support", "identity_information"]; + } + + private normalizeIntake(intake: Record) { + 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) { + 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 { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; + } + + 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)}`, + }), + }; } } diff --git a/src/teller/teller.controller.ts b/src/teller/teller.controller.ts new file mode 100644 index 0000000..25680a2 --- /dev/null +++ b/src/teller/teller.controller.ts @@ -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 }; + }; +}; diff --git a/src/teller/teller.module.ts b/src/teller/teller.module.ts new file mode 100644 index 0000000..918662a --- /dev/null +++ b/src/teller/teller.module.ts @@ -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 {} diff --git a/src/teller/teller.service.ts b/src/teller/teller.service.ts new file mode 100644 index 0000000..da88191 --- /dev/null +++ b/src/teller/teller.service.ts @@ -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("/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( + `/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(`/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(path: string, accessToken: string): Promise { + 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((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; +}; diff --git a/src/transactions/auto-sync.service.ts b/src/transactions/auto-sync.service.ts index 9cda098..9b502f3 100644 --- a/src/transactions/auto-sync.service.ts +++ b/src/transactions/auto-sync.service.ts @@ -1,10 +1,12 @@ -import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { PlaidService } from "../plaid/plaid.service"; @Injectable() export class AutoSyncService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(AutoSyncService.name); private intervalId: NodeJS.Timeout | null = null; + private running = false; constructor( private readonly prisma: PrismaService, @@ -14,17 +16,19 @@ export class AutoSyncService implements OnModuleInit, OnModuleDestroy { onModuleInit() { const enabled = (process.env.AUTO_SYNC_ENABLED ?? "true").toLowerCase() !== "false"; if (!enabled) { + this.logger.log("Auto-sync disabled."); return; } if (!process.env.PLAID_CLIENT_ID || !process.env.PLAID_SECRET) { + this.logger.warn("Auto-sync disabled because Plaid credentials are missing."); return; } const minutes = Number(process.env.AUTO_SYNC_INTERVAL_MINUTES ?? "15"); - const interval = Number.isNaN(minutes) ? 15 : minutes; + const interval = Number.isNaN(minutes) || minutes < 1 ? 15 : minutes; this.intervalId = setInterval(() => { - this.run().catch(() => undefined); + this.runOnce().catch((error) => this.logger.error(error)); }, interval * 60 * 1000); - this.run().catch(() => undefined); + this.runOnce().catch((error) => this.logger.error(error)); } onModuleDestroy() { @@ -34,21 +38,61 @@ export class AutoSyncService implements OnModuleInit, OnModuleDestroy { } } - private async run() { - const accounts = await this.prisma.account.findMany({ - where: { plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, - select: { userId: true } - }); - const userIds = Array.from(new Set(accounts.map((acct) => acct.userId))); - if (!userIds.length) { - return; + async runOnce() { + if (this.running) { + this.logger.debug("Auto-sync skipped because a previous run is still active."); + return { skipped: true, reason: "already_running" }; } - const endDate = new Date().toISOString().slice(0, 10); - const startDate = new Date(new Date().setDate(new Date().getDate() - 7)) - .toISOString() - .slice(0, 10); - for (const userId of userIds) { - await this.plaidService.syncTransactionsForUser(userId, startDate, endDate); + this.running = true; + try { + return await this.run(); + } finally { + this.running = false; } } + + private async run() { + const intervalMinutes = Number(process.env.AUTO_SYNC_INTERVAL_MINUTES ?? "15"); + const staleMinutes = Number(process.env.AUTO_SYNC_STALE_MINUTES ?? intervalMinutes); + const maxUsers = Number(process.env.AUTO_SYNC_MAX_USERS_PER_RUN ?? "25"); + const cutoff = new Date(Date.now() - (Number.isNaN(staleMinutes) ? 15 : staleMinutes) * 60 * 1000); + const accounts = await this.prisma.account.findMany({ + where: { + isActive: true, + plaidAccessToken: { not: null }, + plaidAccountId: { not: null }, + syncStatus: { not: "syncing" }, + OR: [ + { lastTransactionSync: null }, + { lastTransactionSync: { lte: cutoff } }, + { syncStatus: "error", lastSyncAttemptAt: { lte: cutoff } }, + ], + }, + distinct: ["userId"], + orderBy: { lastTransactionSync: "asc" }, + take: Number.isNaN(maxUsers) ? 25 : Math.max(1, maxUsers), + select: { userId: true } + }); + const userIds = accounts.map((acct) => acct.userId); + if (!userIds.length) { + return { usersScanned: 0, usersSynced: 0, created: 0, failed: 0 }; + } + const endDate = new Date().toISOString().slice(0, 10); + const lookbackDays = Number(process.env.AUTO_SYNC_LOOKBACK_DAYS ?? "7"); + const startDate = new Date(new Date().setDate(new Date().getDate() - (Number.isNaN(lookbackDays) ? 7 : lookbackDays))) + .toISOString() + .slice(0, 10); + let created = 0; + let failed = 0; + for (const userId of userIds) { + try { + const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate); + created += result.created; + } catch (error) { + failed += 1; + this.logger.warn(`Auto-sync failed for user ${userId}: ${(error as Error).message}`); + } + } + return { usersScanned: userIds.length, usersSynced: userIds.length - failed, created, failed }; + } } diff --git a/src/transactions/dto/create-manual-transaction.dto.ts b/src/transactions/dto/create-manual-transaction.dto.ts index ace5f19..4d64b26 100644 --- a/src/transactions/dto/create-manual-transaction.dto.ts +++ b/src/transactions/dto/create-manual-transaction.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsNumber, IsOptional, IsString, IsDateString } from "class-validator"; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsDateString, Max, Min } from "class-validator"; export class CreateManualTransactionDto { @IsOptional() @@ -22,6 +22,26 @@ export class CreateManualTransactionDto { @IsString() note?: string; + @IsOptional() + @IsIn(["mine", "yours", "ours"]) + attribution?: "mine" | "yours" | "ours"; + + @IsOptional() + @IsIn(["none", "equal", "custom"]) + splitMode?: "none" | "equal" | "custom"; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + splitMinePercent?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + splitYoursPercent?: number; + @IsOptional() @IsBoolean() hidden?: boolean; diff --git a/src/transactions/dto/update-derived.dto.ts b/src/transactions/dto/update-derived.dto.ts index 0ed50dc..957dfd5 100644 --- a/src/transactions/dto/update-derived.dto.ts +++ b/src/transactions/dto/update-derived.dto.ts @@ -1,5 +1,9 @@ export type UpdateDerivedDto = { userCategory?: string; userNotes?: string; + attribution?: "mine" | "yours" | "ours"; + splitMode?: "none" | "equal" | "custom"; + splitMinePercent?: number; + splitYoursPercent?: number; isHidden?: boolean; }; diff --git a/src/transactions/transactions.controller.ts b/src/transactions/transactions.controller.ts index 9037d8d..7480060 100644 --- a/src/transactions/transactions.controller.ts +++ b/src/transactions/transactions.controller.ts @@ -7,9 +7,10 @@ import { Post, Query, UploadedFile, + UploadedFiles, UseInterceptors, } from "@nestjs/common"; -import { FileInterceptor } from "@nestjs/platform-express"; +import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express"; import { ok } from "../common/response"; import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; @@ -33,7 +34,7 @@ export class TransactionsController { @Query("search") search?: string, @Query("include_hidden") includeHidden?: string, @Query("page") page = 1, - @Query("limit") limit = 50, + @Query("limit") limit = 25, ) { const data = await this.transactionsService.list(userId, { startDate, @@ -56,8 +57,31 @@ export class TransactionsController { async importCsv( @CurrentUser() userId: string, @UploadedFile() file: Express.Multer.File, + @Body("mapping") mapping?: string, ) { - const data = await this.transactionsService.importCsv(userId, file); + const data = await this.transactionsService.importCsv(userId, file, mapping); + return ok(data); + } + + @Post("import/preview") + @UseInterceptors(FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } })) + async previewCsv( + @CurrentUser() userId: string, + @UploadedFile() file: Express.Multer.File, + ) { + const data = await this.transactionsService.previewCsv(userId, file); + return ok(data); + } + + @Post("import/batch") + @UseInterceptors(FilesInterceptor("files", 20, { limits: { fileSize: 5 * 1024 * 1024 } })) + async importCsvBatch( + @CurrentUser() userId: string, + @UploadedFiles() files: Express.Multer.File[], + @Body("mapping") mapping?: string, + @Body("mappings") mappings?: string, + ) { + const data = await this.transactionsService.importCsvBatch(userId, files, { mapping, mappings }); return ok(data); } diff --git a/src/transactions/transactions.module.ts b/src/transactions/transactions.module.ts index 037b594..9ea67e8 100644 --- a/src/transactions/transactions.module.ts +++ b/src/transactions/transactions.module.ts @@ -1,12 +1,14 @@ import { Module } from "@nestjs/common"; import { PlaidModule } from "../plaid/plaid.module"; +import { ExportsModule } from "../exports/exports.module"; import { TransactionsController } from "./transactions.controller"; import { TransactionsService } from "./transactions.service"; import { AutoSyncService } from "./auto-sync.service"; @Module({ - imports: [PlaidModule], + imports: [PlaidModule, ExportsModule], controllers: [TransactionsController], - providers: [TransactionsService, AutoSyncService] + providers: [TransactionsService, AutoSyncService], + exports: [TransactionsService], }) export class TransactionsModule {} diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index 86ba147..b477a08 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -4,15 +4,29 @@ import { parse } from "csv-parse/sync"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { PlaidService } from "../plaid/plaid.service"; +import { OpaqueIdService } from "../common/opaque-id.service"; +import { ExportsService } from "../exports/exports.service"; import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; -const MAX_PAGE_SIZE = 100; +const UI_PAGE_SIZE_LIMIT = 25; +const TRANSACTION_ATTRIBUTIONS = ["mine", "yours", "ours"] as const; +type TransactionAttribution = typeof TRANSACTION_ATTRIBUTIONS[number]; +const TRANSACTION_SPLIT_MODES = ["none", "equal", "custom"] as const; +type TransactionSplitMode = typeof TRANSACTION_SPLIT_MODES[number]; // ─── Bank CSV format auto-detection ────────────────────────────────────────── type ParsedRow = { date: string; description: string; amount: number }; +type CsvMapping = { + date: string; + description: string; + amount: string; + category?: string; + notes?: string; + amountMultiplier?: number; +}; -function detectAndParse(buffer: Buffer): ParsedRow[] { +function parseCsvRecords(buffer: Buffer) { const text = buffer.toString("utf8").trim(); const rows: Record[] = parse(text, { columns: true, @@ -20,6 +34,65 @@ function detectAndParse(buffer: Buffer): ParsedRow[] { trim: true, bom: true, }); + const headers = rows.length ? Object.keys(rows[0]) : []; + return { rows, headers }; +} + +function headerSignature(headers: string[]) { + return crypto + .createHash("sha256") + .update(headers.map((header) => header.trim().toLowerCase()).join("|")) + .digest("hex"); +} + +function findHeader(headers: string[], pattern: RegExp) { + return headers.find((header) => pattern.test(header.trim().toLowerCase())); +} + +function inferMapping(headers: string[]): Partial { + return { + date: findHeader(headers, /^(transaction\s*)?date$|posted|posting/) ?? "", + description: findHeader(headers, /description|desc|memo|narr|payee|merchant/) ?? "", + amount: findHeader(headers, /amount|debit|credit|withdrawal|deposit/) ?? "", + category: findHeader(headers, /category/) ?? undefined, + notes: findHeader(headers, /note|notes/) ?? undefined, + amountMultiplier: 1, + }; +} + +function normalizeMapping(input: unknown, headers: string[]): CsvMapping { + const mapping = typeof input === "string" ? JSON.parse(input) as CsvMapping : input as CsvMapping; + const required = ["date", "description", "amount"] as const; + for (const key of required) { + if (!mapping?.[key] || !headers.includes(mapping[key])) { + throw new BadRequestException(`CSV mapping requires a valid ${key} column.`); + } + } + return { + date: mapping.date, + description: mapping.description, + amount: mapping.amount, + category: mapping.category && headers.includes(mapping.category) ? mapping.category : undefined, + notes: mapping.notes && headers.includes(mapping.notes) ? mapping.notes : undefined, + amountMultiplier: mapping.amountMultiplier === -1 ? -1 : 1, + }; +} + +function parseWithMapping(buffer: Buffer, mapping: CsvMapping): ParsedRow[] { + const { rows, headers } = parseCsvRecords(buffer); + const normalized = normalizeMapping(mapping, headers); + return rows.map((row) => { + const amountRaw = row[normalized.amount] ?? "0"; + return { + date: row[normalized.date], + description: row[normalized.description], + amount: parseFloat(amountRaw.replace(/[^0-9.-]/g, "")) * (normalized.amountMultiplier ?? 1), + }; + }).filter((row) => row.date && row.description && !Number.isNaN(row.amount)); +} + +function detectAndParse(buffer: Buffer): ParsedRow[] { + const { rows } = parseCsvRecords(buffer); if (!rows.length) return []; const headers = Object.keys(rows[0]).map((h) => h.toLowerCase()); @@ -68,13 +141,29 @@ function detectAndParse(buffer: Buffer): ParsedRow[] { throw new BadRequestException("Unrecognized CSV format. Supported: Chase, Bank of America, Wells Fargo, or generic (date/amount/description columns)."); } +function parseOptionalJson(value: unknown): T | undefined { + if (!value) return undefined; + if (typeof value !== "string") return value as T; + return JSON.parse(value) as T; +} + @Injectable() export class TransactionsService { constructor( private readonly prisma: PrismaService, private readonly plaidService: PlaidService, + private readonly opaqueIds: OpaqueIdService, + private readonly exportsService?: ExportsService, ) {} + private async syncGoogleSheetsBestEffort(userId: string, reason: string) { + try { + await this.exportsService?.syncGoogleSheets(userId, reason); + } catch { + // Google Sheets live sync is best-effort and must not block ledger writes. + } + } + async list( userId: string, filters: { @@ -120,20 +209,29 @@ export class TransactionsService { } if (filters.accountId) { - where.accountId = filters.accountId; + where.accountId = this.opaqueIds.decode("account", userId, filters.accountId); } if (filters.includeHidden !== "true") { where.OR = [{ derived: null }, { derived: { isHidden: false } }]; } - const take = Math.min(filters.limit ?? 50, MAX_PAGE_SIZE); + const requestedLimit = Number.isFinite(filters.limit) && filters.limit ? filters.limit : UI_PAGE_SIZE_LIMIT; + const take = Math.min(Math.max(requestedLimit, 1), UI_PAGE_SIZE_LIMIT); const skip = ((filters.page ?? 1) - 1) * take; const [rows, total] = await Promise.all([ this.prisma.transactionRaw.findMany({ where, - include: { derived: true }, + include: { + derived: true, + account: { + select: { + ownershipType: true, + ownerUserId: true, + }, + }, + }, orderBy: { date: "desc" }, take, skip, @@ -142,22 +240,24 @@ export class TransactionsService { ]); const transactions = rows.map((row) => ({ - id: row.id, + id: this.opaqueIds.encode("transaction", userId, row.id), name: row.description, amount: Number(row.amount).toFixed(2), category: row.derived?.userCategory ?? "Uncategorized", note: row.derived?.userNotes ?? "", + attribution: this.resolveAttribution(row.derived?.attribution, row.account), + split: this.resolveSplit(row.derived, Number(row.amount)), status: row.derived?.modifiedBy ?? "raw", hidden: row.derived?.isHidden ?? false, date: row.date.toISOString().slice(0, 10), source: row.source, - accountId: row.accountId, + accountId: this.opaqueIds.encode("account", userId, row.accountId), })); return { transactions, total, page: filters.page ?? 1, limit: take }; } - async importCsv(userId: string, file: Express.Multer.File) { + async previewCsv(userId: string, file: Express.Multer.File) { if (!file?.buffer) { throw new BadRequestException("No file uploaded."); } @@ -165,11 +265,51 @@ export class TransactionsService { throw new BadRequestException("File must be a CSV."); } - const rows = detectAndParse(file.buffer); + const { rows, headers } = parseCsvRecords(file.buffer); + if (!rows.length) { + throw new BadRequestException("CSV file is empty or could not be parsed."); + } + const signature = headerSignature(headers); + const remembered = await this.prisma.csvImportMapping.findUnique({ + where: { userId_headerSignature: { userId, headerSignature: signature } }, + }); + + return { + fileName: file.originalname, + headerSignature: signature, + headers, + sampleRows: rows.slice(0, 5), + mapping: remembered?.mapping ?? inferMapping(headers), + remembered: Boolean(remembered), + }; + } + + async importCsv(userId: string, file: Express.Multer.File, mapping?: unknown, syncAfter = true) { + if (!file?.buffer) { + throw new BadRequestException("No file uploaded."); + } + if (!file.originalname.toLowerCase().endsWith(".csv")) { + throw new BadRequestException("File must be a CSV."); + } + + const rows = mapping ? parseWithMapping(file.buffer, parseOptionalJson(mapping) as CsvMapping) : detectAndParse(file.buffer); if (!rows.length) { throw new BadRequestException("CSV file is empty or could not be parsed."); } + if (mapping) { + const { headers } = parseCsvRecords(file.buffer); + await this.rememberCsvMapping(userId, headers, parseOptionalJson(mapping), file.originalname); + } + + const result = await this.importParsedRows(userId, rows); + if (syncAfter && result.imported > 0) { + await this.syncGoogleSheetsBestEffort(userId, "csv_import"); + } + return result; + } + + private async importParsedRows(userId: string, rows: ParsedRow[]) { // Find or create a manual import account for this user let account = await this.prisma.account.findFirst({ where: { userId, institutionName: "CSV Import", plaidAccessToken: null }, @@ -223,9 +363,83 @@ export class TransactionsService { return { imported, skipped, total: rows.length }; } + private async rememberCsvMapping(userId: string, headers: string[], mapping: unknown, name?: string) { + const normalized = normalizeMapping(mapping, headers); + const signature = headerSignature(headers); + await this.prisma.csvImportMapping.upsert({ + where: { userId_headerSignature: { userId, headerSignature: signature } }, + update: { + name, + mapping: normalized as unknown as Prisma.InputJsonValue, + lastUsedAt: new Date(), + }, + create: { + userId, + headerSignature: signature, + name, + mapping: normalized as unknown as Prisma.InputJsonValue, + }, + }); + } + + async importCsvBatch(userId: string, files: Express.Multer.File[] = [], options?: { mapping?: unknown; mappings?: unknown }) { + if (!files.length) { + throw new BadRequestException("No CSV files uploaded."); + } + const sharedMapping = parseOptionalJson(options?.mapping); + const mappingsByFile = parseOptionalJson>(options?.mappings); + + const results: Array<{ + fileName: string; + imported: number; + skipped: number; + total: number; + error?: string; + }> = []; + + for (const file of files) { + try { + const mapping = mappingsByFile?.[file.originalname] ?? sharedMapping; + const result = await this.importCsv(userId, file, mapping, false); + results.push({ + fileName: file.originalname, + imported: result.imported, + skipped: result.skipped, + total: result.total, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "CSV import failed."; + results.push({ + fileName: file?.originalname ?? "unknown.csv", + imported: 0, + skipped: 0, + total: 0, + error: message, + }); + } + } + + const summary = { + totalFiles: files.length, + processedFiles: results.filter((result) => !result.error).length, + failedFiles: results.filter((result) => result.error).length, + imported: results.reduce((sum, result) => sum + result.imported, 0), + skipped: results.reduce((sum, result) => sum + result.skipped, 0), + total: results.reduce((sum, result) => sum + result.total, 0), + results, + }; + if (summary.imported > 0) { + await this.syncGoogleSheetsBestEffort(userId, "csv_batch_import"); + } + return summary; + } + async createManualTransaction(userId: string, payload: CreateManualTransactionDto) { - const account = payload.accountId - ? await this.prisma.account.findFirst({ where: { id: payload.accountId, userId } }) + const accountId = payload.accountId + ? this.opaqueIds.decode("account", userId, payload.accountId) + : undefined; + const account = accountId + ? await this.prisma.account.findFirst({ where: { id: accountId, userId } }) : await this.prisma.account.findFirst({ where: { userId } }); if (!account) { @@ -246,12 +460,18 @@ export class TransactionsService { }, }); - if (payload.category || payload.note || payload.hidden) { + const attribution = this.normalizeAttribution(payload.attribution); + const split = this.normalizeSplit(payload); + if (payload.category || payload.note || payload.hidden || attribution || split.mode !== "none") { await this.prisma.transactionDerived.create({ data: { rawTransactionId: raw.id, userCategory: payload.category ?? null, userNotes: payload.note ?? null, + attribution: attribution ?? this.defaultAttributionForAccount(account), + splitMode: split.mode, + splitMinePercent: split.minePercent, + splitYoursPercent: split.yoursPercent, isHidden: payload.hidden ?? false, modifiedAt: new Date(), modifiedBy: "user", @@ -259,38 +479,63 @@ export class TransactionsService { }); } - return { id: raw.id }; + await this.syncGoogleSheetsBestEffort(userId, "manual_transaction"); + + return { id: this.opaqueIds.encode("transaction", userId, raw.id) }; } async updateDerived(userId: string, id: string, payload: UpdateDerivedDto) { + const transactionId = this.opaqueIds.decode("transaction", userId, id); + const attribution = this.normalizeAttribution(payload.attribution); + const split = this.normalizeSplit(payload); // Ensure the transaction belongs to the user const tx = await this.prisma.transactionRaw.findFirst({ - where: { id, account: { userId } }, + where: { id: transactionId, account: { userId } }, + include: { + account: { + select: { + ownershipType: true, + ownerUserId: true, + }, + }, + }, }); if (!tx) throw new BadRequestException("Transaction not found."); - return this.prisma.transactionDerived.upsert({ - where: { rawTransactionId: id }, + const derived = await this.prisma.transactionDerived.upsert({ + where: { rawTransactionId: transactionId }, update: { userCategory: payload.userCategory, userNotes: payload.userNotes, + attribution: attribution ?? this.defaultAttributionForAccount(tx.account), + splitMode: split.mode, + splitMinePercent: split.minePercent, + splitYoursPercent: split.yoursPercent, isHidden: payload.isHidden ?? false, modifiedAt: new Date(), modifiedBy: "user", }, create: { - rawTransactionId: id, + rawTransactionId: transactionId, userCategory: payload.userCategory, userNotes: payload.userNotes, + attribution: attribution ?? this.defaultAttributionForAccount(tx.account), + splitMode: split.mode, + splitMinePercent: split.minePercent, + splitYoursPercent: split.yoursPercent, isHidden: payload.isHidden ?? false, modifiedAt: new Date(), modifiedBy: "user", }, }); + await this.syncGoogleSheetsBestEffort(userId, "transaction_derived_update"); + return derived; } async sync(userId: string, startDate: string, endDate: string) { - return this.plaidService.syncTransactionsForUser(userId, startDate, endDate); + const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate); + await this.syncGoogleSheetsBestEffort(userId, "plaid_sync"); + return result; } async summary(userId: string, startDate: string, endDate: string) { @@ -351,7 +596,7 @@ export class TransactionsService { } async merchantInsights(userId: string, limit = 6) { - const capped = Math.min(limit, MAX_PAGE_SIZE); + const capped = Math.min(Math.max(limit, 1), UI_PAGE_SIZE_LIMIT); const rows = await this.prisma.transactionRaw.findMany({ where: { account: { userId } }, select: { description: true, amount: true }, @@ -375,4 +620,82 @@ export class TransactionsService { count: value.count, })); } + + private normalizeAttribution(value?: string | null): TransactionAttribution | undefined { + if (value === undefined || value === null || value === "") return undefined; + if ((TRANSACTION_ATTRIBUTIONS as readonly string[]).includes(value)) { + return value as TransactionAttribution; + } + throw new BadRequestException("Transaction attribution must be mine, yours, or ours."); + } + + private resolveAttribution(value: string | null | undefined, account?: { ownershipType?: string | null; ownerUserId?: string | null }) { + return this.normalizeAttribution(value) ?? this.defaultAttributionForAccount(account); + } + + private defaultAttributionForAccount(account?: { ownershipType?: string | null; ownerUserId?: string | null }): TransactionAttribution { + if (account?.ownershipType === "joint") return "ours"; + if (account?.ownershipType === "theirs") return "yours"; + return "mine"; + } + + private normalizeSplit(payload: { splitMode?: string; splitMinePercent?: number; splitYoursPercent?: number }) { + const mode = this.normalizeSplitMode(payload.splitMode); + if (mode === "none") { + return { mode, minePercent: null, yoursPercent: null }; + } + if (mode === "equal") { + return { mode, minePercent: 50, yoursPercent: 50 }; + } + + const minePercent = Number(payload.splitMinePercent); + const yoursPercent = Number(payload.splitYoursPercent); + if (!Number.isFinite(minePercent) || !Number.isFinite(yoursPercent)) { + throw new BadRequestException("Custom split requires mine and yours percentages."); + } + if (minePercent < 0 || minePercent > 100 || yoursPercent < 0 || yoursPercent > 100) { + throw new BadRequestException("Split percentages must be between 0 and 100."); + } + if (Math.round((minePercent + yoursPercent) * 100) / 100 !== 100) { + throw new BadRequestException("Split percentages must total 100."); + } + return { + mode, + minePercent: this.roundCurrency(minePercent), + yoursPercent: this.roundCurrency(yoursPercent), + }; + } + + private normalizeSplitMode(value?: string | null): TransactionSplitMode { + if (value === undefined || value === null || value === "") return "none"; + if ((TRANSACTION_SPLIT_MODES as readonly string[]).includes(value)) { + return value as TransactionSplitMode; + } + throw new BadRequestException("Transaction split mode must be none, equal, or custom."); + } + + private resolveSplit( + derived: { splitMode?: string | null; splitMinePercent?: unknown; splitYoursPercent?: unknown } | null | undefined, + amount: number, + ) { + const mode = this.normalizeSplitMode(derived?.splitMode); + const minePercent = mode === "none" ? 100 : this.toNumber(derived?.splitMinePercent ?? (mode === "equal" ? 50 : 0)); + const yoursPercent = mode === "none" ? 0 : this.toNumber(derived?.splitYoursPercent ?? (mode === "equal" ? 50 : 0)); + return { + mode, + minePercent: this.roundCurrency(minePercent), + yoursPercent: this.roundCurrency(yoursPercent), + mineAmount: this.roundCurrency(amount * (minePercent / 100)), + yoursAmount: this.roundCurrency(amount * (yoursPercent / 100)), + }; + } + + private toNumber(value: unknown) { + if (value === null || value === undefined) return 0; + return Number(value); + } + + private roundCurrency(value: number) { + return Math.round((value + Number.EPSILON) * 100) / 100; + } } diff --git a/test/abuse.service.spec.ts b/test/abuse.service.spec.ts new file mode 100644 index 0000000..eb69e96 --- /dev/null +++ b/test/abuse.service.spec.ts @@ -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" }), + })); + }); +}); diff --git a/test/accounts.service.spec.ts b/test/accounts.service.spec.ts new file mode 100644 index 0000000..e90be95 --- /dev/null +++ b/test/accounts.service.spec.ts @@ -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" }, + })); + }); +}); diff --git a/test/api-key.service.spec.ts b/test/api-key.service.spec.ts new file mode 100644 index 0000000..fc740d3 --- /dev/null +++ b/test/api-key.service.spec.ts @@ -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); + }); +}); diff --git a/test/auth.service.spec.ts b/test/auth.service.spec.ts new file mode 100644 index 0000000..36bc94e --- /dev/null +++ b/test/auth.service.spec.ts @@ -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) => 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" }), + }); + }); +}); diff --git a/test/auto-sync.service.spec.ts b/test/auto-sync.service.spec.ts new file mode 100644 index 0000000..0b4237d --- /dev/null +++ b/test/auto-sync.service.spec.ts @@ -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); + }); +}); diff --git a/test/exports.service.spec.ts b/test/exports.service.spec.ts index 5f984a9..a67b483 100644 --- a/test/exports.service.spec.ts +++ b/test/exports.service.spec.ts @@ -1,14 +1,39 @@ import { ExportsService } from "../src/exports/exports.service"; import { createPrismaMock } from "./utils/mock-prisma"; +import * as XLSX from "xlsx"; describe("ExportsService", () => { - it("returns missing_user when user id is absent", async () => { - const prisma = createPrismaMock(); - const service = new ExportsService(prisma as any); + const abuse = { recordExportActivity: jest.fn() }; + const createObjectStorage = () => ({ + store: jest.fn(async (input: { content: string | Buffer }) => ({ + provider: "local", + key: "exports/user_1/test.csv", + sizeBytes: Buffer.isBuffer(input.content) ? input.content.byteLength : Buffer.byteLength(input.content), + })), + load: jest.fn(async () => ({ content: Buffer.from("id,date,description,amount,category,notes,hidden,source\ntx_1,2025-01-10,Lunch,20.00,Meals,Team lunch,false,manual") })), + }); - const result = await service.exportCsv(undefined); - expect(result.status).toBe("missing_user"); - expect(prisma.exportLog.create).not.toHaveBeenCalled(); + beforeEach(() => { + abuse.recordExportActivity.mockReset(); + }); + + it("exports an empty csv when the user has no matching rows", async () => { + const prisma = createPrismaMock(); + prisma.transactionRaw.findMany.mockResolvedValue([]); + prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); + const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); + + const result = await service.exportCsv("user_1"); + expect(result.status).toBe("ready"); + expect(result.rowCount).toBe(0); + expect(result.csv).toContain("id,date,description,amount,category,notes,attribution,splitMode,splitMinePercent,splitYoursPercent,splitMineAmount,splitYoursAmount,hidden,source,watermark"); + expect(result.csv).toContain("LedgerOne Export Watermark"); + expect(prisma.exportLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: "user_1", rowCount: 0 }) + }) + ); + expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 0, {}, undefined); }); it("exports csv with headers and rows", async () => { @@ -24,16 +49,326 @@ describe("ExportsService", () => { } ]); prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); - const service = new ExportsService(prisma as any); + const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); - const result = await service.exportCsv("user_1", { category: "Meals" }); + const result = await service.exportCsv("user_1", { category: "Meals" }, { + ipAddress: "203.0.113.10", + userAgent: "jest-agent", + }); expect(result.status).toBe("ready"); expect(result.rowCount).toBe(1); - expect(result.csv).toContain("id,date,description,amount,category,notes,hidden,source"); + expect(result.csv).toContain("id,date,description,amount,category,notes,attribution,splitMode,splitMinePercent,splitYoursPercent,splitMineAmount,splitYoursAmount,hidden,source,watermark"); + expect(result.csv).toContain("LedgerOne Export Watermark"); expect(prisma.exportLog.create).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ userId: "user_1", rowCount: 1 }) + data: expect.objectContaining({ + userId: "user_1", + format: "csv", + destination: "download", + filters: { category: "Meals" }, + rowCount: 1, + fileName: expect.stringMatching(/ledgerone-export-\d{4}-\d{2}-\d{2}\.csv/), + mimeType: "text/csv", + fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), + ipAddress: "203.0.113.10", + userAgent: "jest-agent", + metadata: { watermark: expect.objectContaining({ label: "LedgerOne Export Watermark", userId: "user_1" }) }, + }) }) ); + expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals" }, { + ipAddress: "203.0.113.10", + userAgent: "jest-agent", + }); + }); + + it("exports json as a downloadable base64 transaction file", async () => { + const prisma = createPrismaMock(); + prisma.transactionRaw.findMany.mockResolvedValue([ + { + id: "tx_1", + date: new Date("2025-01-10"), + description: "Lunch", + amount: 20, + source: "manual", + derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } + } + ]); + prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); + const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); + + const result = await service.exportJson("user_1", { category: "Meals" }); + const payload = JSON.parse(Buffer.from(result.base64, "base64").toString("utf8")); + + expect(result.status).toBe("ready"); + expect(result.rowCount).toBe(1); + expect(result.fileName).toMatch(/ledgerone-export-\d{4}-\d{2}-\d{2}\.json/); + expect(result.mimeType).toBe("application/json"); + expect(payload.rowCount).toBe(1); + expect(payload.filters).toEqual({ category: "Meals" }); + expect(payload.watermark).toEqual(expect.objectContaining({ + label: "LedgerOne Export Watermark", + userId: "user_1", + traceId: expect.any(String), + })); + expect(payload.transactions[0]).toEqual(expect.objectContaining({ + id: "tx_1", + description: "Lunch", + category: "Meals", + })); + expect(prisma.exportLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: "user_1", + format: "json", + destination: "download", + rowCount: 1, + filters: { category: "Meals" }, + fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), + }) + }) + ); + expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "json" }, undefined); + }); + + it("exports xlsx as a downloadable base64 workbook", async () => { + const prisma = createPrismaMock(); + prisma.transactionRaw.findMany.mockResolvedValue([ + { + id: "tx_1", + date: new Date("2025-01-10"), + description: "Lunch", + amount: 20, + source: "manual", + derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } + } + ]); + prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); + const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); + + const result = await service.exportXlsx("user_1", { category: "Meals" }); + const fileBuffer = Buffer.from(result.base64, "base64"); + + expect(result.status).toBe("ready"); + expect(result.rowCount).toBe(1); + expect(result.fileName).toMatch(/ledgerone-export-\d{4}-\d{2}-\d{2}\.xlsx/); + expect(result.mimeType).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + expect(fileBuffer.subarray(0, 2).toString()).toBe("PK"); + const workbook = XLSX.read(fileBuffer, { type: "buffer" }); + expect(workbook.SheetNames).toContain("Watermark"); + const watermarkRows = XLSX.utils.sheet_to_json(workbook.Sheets.Watermark); + expect(watermarkRows[0]).toEqual(expect.objectContaining({ + label: "LedgerOne Export Watermark", + userId: "user_1", + })); + const exportRows = XLSX.utils.sheet_to_json(workbook.Sheets["LedgerOne Export"]); + expect(exportRows[0]).toEqual(expect.objectContaining({ + watermark: expect.stringContaining("LedgerOne Export Watermark"), + })); + expect(prisma.exportLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: "user_1", + format: "xlsx", + destination: "download", + rowCount: 1, + filters: { category: "Meals" }, + fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), + }) + }) + ); + expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "xlsx" }, undefined); + }); + + it("exports pdf as a downloadable base64 document", async () => { + const prisma = createPrismaMock(); + prisma.transactionRaw.findMany.mockResolvedValue([ + { + id: "tx_1", + date: new Date("2025-01-10"), + description: "Lunch", + amount: 20, + source: "manual", + derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } + } + ]); + prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); + const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); + + const result = await service.exportPdf("user_1", { category: "Meals" }); + const fileBuffer = Buffer.from(result.base64, "base64"); + + expect(result.status).toBe("ready"); + expect(result.rowCount).toBe(1); + expect(result.fileName).toMatch(/ledgerone-export-\d{4}-\d{2}-\d{2}\.pdf/); + expect(result.mimeType).toBe("application/pdf"); + expect(fileBuffer.subarray(0, 4).toString()).toBe("%PDF"); + expect(fileBuffer.toString("binary")).toContain("LedgerOne Export Watermark"); + expect(prisma.exportLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: "user_1", + format: "pdf", + destination: "download", + rowCount: 1, + filters: { category: "Meals" }, + fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), + }) + }) + ); + expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { category: "Meals", format: "pdf" }, undefined); + }); + + it("skips real-time Google Sheets sync when Google is not connected", async () => { + const prisma = createPrismaMock(); + prisma.googleConnection.findUnique.mockResolvedValue(null); + const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); + + const result = await service.syncGoogleSheets("user_1"); + + expect(result).toEqual({ status: "skipped", reason: "not_connected" }); + expect(prisma.transactionRaw.findMany).not.toHaveBeenCalled(); + }); + + it("creates a short-lived single-use signed download URL", async () => { + const prisma = createPrismaMock(); + prisma.exportDownloadToken.create.mockResolvedValue({ id: "token_1" }); + prisma.transactionRaw.findMany.mockResolvedValue([ + { + id: "tx_1", + date: new Date("2025-01-10"), + description: "Lunch", + amount: 20, + source: "manual", + derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } + } + ]); + const objectStorage = createObjectStorage(); + const service = new ExportsService(prisma as any, abuse as any, objectStorage as any); + + const result = await service.createSignedDownloadUrl("user_1", "csv", { category: "Meals" }); + + expect(result.status).toBe("signed"); + expect(result.singleUse).toBe(true); + expect(result.expiresInSeconds).toBe(120); + expect(result.downloadUrl).toMatch(/^\/api\/exports\/download\/[A-Za-z0-9_-]+$/); + expect(prisma.exportDownloadToken.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: "user_1", + tokenHash: expect.stringMatching(/^[a-f0-9]{64}$/), + format: "csv", + filters: { category: "Meals" }, + storageProvider: "local", + storageKey: "exports/user_1/test.csv", + fileName: expect.stringMatching(/ledgerone-export-\d{4}-\d{2}-\d{2}\.csv/), + mimeType: "text/csv", + rowCount: 1, + fileHash: expect.stringMatching(/^[a-f0-9]{64}$/), + expiresAt: expect.any(Date), + }), + }) + ); + expect(objectStorage.store).toHaveBeenCalledWith(expect.objectContaining({ + userId: "user_1", + format: "csv", + mimeType: "text/csv", + content: expect.stringContaining("Lunch"), + })); + expect(objectStorage.store).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining("LedgerOne Export Watermark"), + })); + }); + + it("consumes a signed download URL once and records export audit data", async () => { + const prisma = createPrismaMock(); + prisma.exportDownloadToken.findUnique.mockResolvedValue({ + id: "token_1", + userId: "user_1", + format: "csv", + filters: { category: "Meals" }, + storageProvider: "local", + storageKey: "exports/user_1/test.csv", + fileName: "ledgerone-export-2025-01-10.csv", + mimeType: "text/csv", + rowCount: 1, + fileHash: "hash_1", + expiresAt: new Date(Date.now() + 60_000), + usedAt: null, + }); + prisma.exportDownloadToken.updateMany.mockResolvedValue({ count: 1 }); + prisma.transactionRaw.findMany.mockResolvedValue([ + { + id: "tx_1", + date: new Date("2025-01-10"), + description: "Lunch", + amount: 20, + source: "manual", + derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false } + } + ]); + prisma.exportLog.create.mockResolvedValue({ id: "log_1" }); + const objectStorage = createObjectStorage(); + const service = new ExportsService(prisma as any, abuse as any, objectStorage as any); + + const file = await service.consumeSignedDownloadUrl("raw_token", { + ipAddress: "203.0.113.10", + userAgent: "jest-agent", + }); + + expect(file.fileName).toBe("ledgerone-export-2025-01-10.csv"); + expect(file.mimeType).toBe("text/csv"); + expect(file.rowCount).toBe(1); + expect(String(file.content)).toContain("Lunch"); + expect(objectStorage.load).toHaveBeenCalledWith("local", "exports/user_1/test.csv"); + expect(prisma.exportDownloadToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: "token_1", usedAt: null }), + data: { usedAt: expect.any(Date) }, + }) + ); + expect(prisma.exportLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: "user_1", + format: "csv", + destination: "download", + rowCount: 1, + ipAddress: "203.0.113.10", + userAgent: "jest-agent", + metadata: expect.objectContaining({ + signedUrl: true, + tokenId: "token_1", + storageProvider: "local", + storageKey: "exports/user_1/test.csv", + precomputedFileHash: "hash_1", + }), + }), + }) + ); + expect(abuse.recordExportActivity).toHaveBeenCalledWith("user_1", 1, { + category: "Meals", + format: "csv", + signedUrl: "true", + }, { + ipAddress: "203.0.113.10", + userAgent: "jest-agent", + }); + }); + + it("rejects a signed download URL that was already used", async () => { + const prisma = createPrismaMock(); + prisma.exportDownloadToken.findUnique.mockResolvedValue({ + id: "token_1", + userId: "user_1", + format: "csv", + filters: {}, + expiresAt: new Date(Date.now() + 60_000), + usedAt: new Date(), + }); + const service = new ExportsService(prisma as any, abuse as any, createObjectStorage() as any); + + await expect(service.consumeSignedDownloadUrl("raw_token")).rejects.toThrow("already been used"); + expect(prisma.transactionRaw.findMany).not.toHaveBeenCalled(); }); }); diff --git a/test/google.service.spec.ts b/test/google.service.spec.ts new file mode 100644 index 0000000..e5c6203 --- /dev/null +++ b/test/google.service.spec.ts @@ -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", + }, + }); + }); +}); diff --git a/test/households.service.spec.ts b/test/households.service.spec.ts new file mode 100644 index 0000000..1d07e3d --- /dev/null +++ b/test/households.service.spec.ts @@ -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(); + }); +}); diff --git a/test/plaid.webhook.spec.ts b/test/plaid.webhook.spec.ts new file mode 100644 index 0000000..3210040 --- /dev/null +++ b/test/plaid.webhook.spec.ts @@ -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", + }), + }); + }); +}); diff --git a/test/plan-limits.service.spec.ts b/test/plan-limits.service.spec.ts new file mode 100644 index 0000000..b97add0 --- /dev/null +++ b/test/plan-limits.service.spec.ts @@ -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(); + }); +}); diff --git a/test/plan-limits.spec.ts b/test/plan-limits.spec.ts new file mode 100644 index 0000000..242d12a --- /dev/null +++ b/test/plan-limits.spec.ts @@ -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); + }); +}); diff --git a/test/roles.guard.spec.ts b/test/roles.guard.spec.ts new file mode 100644 index 0000000..b93907e --- /dev/null +++ b/test/roles.guard.spec.ts @@ -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(); + }); +}); diff --git a/test/rules.service.spec.ts b/test/rules.service.spec.ts new file mode 100644 index 0000000..91b4e61 --- /dev/null +++ b/test/rules.service.spec.ts @@ -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); + }); +}); diff --git a/test/subscription.guard.spec.ts b/test/subscription.guard.spec.ts new file mode 100644 index 0000000..0c78b83 --- /dev/null +++ b/test/subscription.guard.spec.ts @@ -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(); + }); +}); diff --git a/test/tax.service.spec.ts b/test/tax.service.spec.ts new file mode 100644 index 0000000..fdfa8bb --- /dev/null +++ b/test/tax.service.spec.ts @@ -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(); + }); +}); diff --git a/test/teller.service.spec.ts b/test/teller.service.spec.ts new file mode 100644 index 0000000..3f03fae --- /dev/null +++ b/test/teller.service.spec.ts @@ -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" }), + })); + }); +}); diff --git a/test/transactions.controller.spec.ts b/test/transactions.controller.spec.ts index 522a209..121f9c7 100644 --- a/test/transactions.controller.spec.ts +++ b/test/transactions.controller.spec.ts @@ -8,6 +8,8 @@ describe("TransactionsController", () => { summary: jest.fn().mockResolvedValue({ total: "0.00", count: 0 }), updateDerived: jest.fn().mockResolvedValue({}), createManualTransaction: jest.fn().mockResolvedValue({ id: "tx_1" }), + previewCsv: jest.fn().mockResolvedValue({ headers: ["Date", "Description", "Amount"] }), + importCsvBatch: jest.fn().mockResolvedValue({ totalFiles: 2, imported: 3 }), cashflow: jest.fn().mockResolvedValue([]), merchantInsights: jest.fn().mockResolvedValue([]) }; @@ -16,49 +18,69 @@ describe("TransactionsController", () => { it("lists transactions", async () => { const { controller, service } = createController(); - const result = await controller.list({ user_id: "user_1" }); + const result = await controller.list("user_1"); expect(service.list).toHaveBeenCalledWith( - expect.objectContaining({ userId: "user_1" }) + "user_1", + expect.objectContaining({ page: 1, limit: 25 }) ); expect(result.data).toEqual([]); }); it("syncs transactions", async () => { const { controller, service } = createController(); - const result = await controller.sync({ userId: "user_1" }); - expect(service.sync).toHaveBeenCalled(); + const result = await controller.sync("user_1", "2025-01-01", "2025-01-31"); + expect(service.sync).toHaveBeenCalledWith("user_1", "2025-01-01", "2025-01-31"); expect(result.data).toEqual({ created: 0 }); }); it("returns summary", async () => { const { controller, service } = createController(); - const result = await controller.summary({ user_id: "user_1" }); - expect(service.summary).toHaveBeenCalled(); + const result = await controller.summary("user_1", "2025-01-01", "2025-01-31"); + expect(service.summary).toHaveBeenCalledWith("user_1", "2025-01-01", "2025-01-31"); expect(result.data.total).toBe("0.00"); }); it("creates manual transaction", async () => { const { controller, service } = createController(); - const result = await controller.manual({ - userId: "user_1", + const result = await controller.manual("user_1", { + date: "2025-01-01", + description: "Manual", + amount: 10 + }); + expect(service.createManualTransaction).toHaveBeenCalledWith("user_1", { date: "2025-01-01", description: "Manual", amount: 10 }); - expect(service.createManualTransaction).toHaveBeenCalled(); expect(result.data.id).toBe("tx_1"); }); + it("imports a batch of CSV files", async () => { + const { controller, service } = createController(); + const files = [{ originalname: "one.csv" }, { originalname: "two.csv" }] as Express.Multer.File[]; + const result = await controller.importCsvBatch("user_1", files, "{\"date\":\"Date\"}"); + expect(service.importCsvBatch).toHaveBeenCalledWith("user_1", files, { mapping: "{\"date\":\"Date\"}", mappings: undefined }); + expect(result.data).toEqual({ totalFiles: 2, imported: 3 }); + }); + + it("previews a CSV file", async () => { + const { controller, service } = createController(); + const file = { originalname: "preview.csv" } as Express.Multer.File; + const result = await controller.previewCsv("user_1", file); + expect(service.previewCsv).toHaveBeenCalledWith("user_1", file); + expect(result.data.headers).toEqual(["Date", "Description", "Amount"]); + }); + it("returns cashflow", async () => { const { controller, service } = createController(); - const result = await controller.cashflow({ user_id: "user_1", months: "3" }); + const result = await controller.cashflow("user_1", 3); expect(service.cashflow).toHaveBeenCalledWith("user_1", 3); expect(result.data).toEqual([]); }); it("returns merchant insights", async () => { const { controller, service } = createController(); - const result = await controller.merchants({ user_id: "user_1", limit: "5" }); + const result = await controller.merchants("user_1", 5); expect(service.merchantInsights).toHaveBeenCalledWith("user_1", 5); expect(result.data).toEqual([]); }); diff --git a/test/transactions.service.spec.ts b/test/transactions.service.spec.ts index bf70c67..bb51474 100644 --- a/test/transactions.service.spec.ts +++ b/test/transactions.service.spec.ts @@ -1,11 +1,17 @@ import { TransactionsService } from "../src/transactions/transactions.service"; import { createPrismaMock } from "./utils/mock-prisma"; +import { BadRequestException } from "@nestjs/common"; const createService = () => { const prisma = createPrismaMock(); const plaid = { syncTransactionsForUser: jest.fn() }; - const service = new TransactionsService(prisma as any, plaid as any); - return { service, prisma, plaid }; + const opaqueIds = { + encode: jest.fn((kind: string, _userId: string, id: string) => `opaque_${kind}_${id}`), + decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")), + }; + const exportsService = { syncGoogleSheets: jest.fn().mockResolvedValue({ status: "synced" }) }; + const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, exportsService as any); + return { service, prisma, plaid, opaqueIds, exportsService }; }; describe("TransactionsService", () => { @@ -34,7 +40,7 @@ describe("TransactionsService", () => { const result = await service.cashflow("user_1", 3); expect(result).toHaveLength(3); - expect(result.some((row) => row.month.endsWith("-01"))).toBe(true); + expect(result.every((row) => /^\d{4}-\d{2}$/.test(row.month))).toBe(true); }); it("returns merchant insights sorted by spend", async () => { @@ -59,34 +65,221 @@ describe("TransactionsService", () => { prisma.transactionRaw.create.mockResolvedValue({ id: "tx_1" }); prisma.transactionDerived.create.mockResolvedValue({ id: "derived_1" }); - const result = await service.createManualTransaction({ - userId: "user_1", + const result = await service.createManualTransaction("user_1", { accountId: "acct_1", date: "2025-01-15", description: "Manual payment", amount: 123.45, category: "Operations", note: "Test note", + attribution: "ours", + splitMode: "equal", hidden: false }); - expect(result).toEqual({ id: "tx_1" }); + expect(result).toEqual({ id: "opaque_transaction_tx_1" }); expect(prisma.transactionRaw.create).toHaveBeenCalled(); - expect(prisma.transactionDerived.create).toHaveBeenCalled(); + expect(prisma.transactionDerived.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + attribution: "ours", + splitMode: "equal", + splitMinePercent: 50, + splitYoursPercent: 50, + }), + })); }); - it("returns null when no account is available for manual transaction", async () => { + it("returns opaque transaction and account IDs in list responses", async () => { + const { service, prisma } = createService(); + prisma.transactionRaw.findMany.mockResolvedValue([ + { + id: "tx_raw_1", + accountId: "acct_raw_1", + description: "Coffee", + amount: 4.25, + derived: null, + account: { ownershipType: "joint", ownerUserId: null }, + date: new Date("2026-07-01"), + source: "csv", + }, + ]); + prisma.transactionRaw.count.mockResolvedValue(1); + + const result = await service.list("user_1", {}); + + expect(result.transactions[0].id).toBe("opaque_transaction_tx_raw_1"); + expect(result.transactions[0].accountId).toBe("opaque_account_acct_raw_1"); + expect(result.transactions[0].attribution).toBe("ours"); + expect(result.transactions[0].split).toEqual({ + mode: "none", + minePercent: 100, + yoursPercent: 0, + mineAmount: 4.25, + yoursAmount: 0, + }); + expect(result.transactions[0].id).not.toBe("tx_raw_1"); + expect(result.transactions[0].accountId).not.toBe("acct_raw_1"); + }); + + it("updates derived transaction attribution", async () => { + const { service, prisma } = createService(); + prisma.transactionRaw.findFirst.mockResolvedValue({ + id: "tx_1", + account: { ownershipType: "mine", ownerUserId: "user_1" }, + }); + prisma.transactionDerived.upsert.mockResolvedValue({ + rawTransactionId: "tx_1", + attribution: "yours", + }); + + const result = await service.updateDerived("user_1", "opaque_transaction_tx_1", { + userCategory: "Dining", + userNotes: "Partner paid", + attribution: "yours", + splitMode: "custom", + splitMinePercent: 40, + splitYoursPercent: 60, + isHidden: false, + }); + + expect(result.attribution).toBe("yours"); + expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({ + update: expect.objectContaining({ attribution: "yours", splitMode: "custom", splitMinePercent: 40, splitYoursPercent: 60 }), + create: expect.objectContaining({ attribution: "yours", splitMode: "custom", splitMinePercent: 40, splitYoursPercent: 60 }), + })); + }); + + it("rejects custom splits that do not total 100", async () => { + const { service, prisma } = createService(); + prisma.transactionRaw.findFirst.mockResolvedValue({ + id: "tx_1", + account: { ownershipType: "joint", ownerUserId: null }, + }); + + await expect(service.updateDerived("user_1", "opaque_transaction_tx_1", { + splitMode: "custom", + splitMinePercent: 70, + splitYoursPercent: 20, + })).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.transactionDerived.upsert).not.toHaveBeenCalled(); + }); + + it("caps UI transaction list responses at 25 rows", async () => { + const { service, prisma } = createService(); + prisma.transactionRaw.findMany.mockResolvedValue([]); + prisma.transactionRaw.count.mockResolvedValue(0); + + const result = await service.list("user_1", { limit: 100 }); + + expect(result.limit).toBe(25); + expect(prisma.transactionRaw.findMany).toHaveBeenCalledWith(expect.objectContaining({ + take: 25, + })); + }); + + it("throws when no account is available for manual transaction", async () => { const { service, prisma } = createService(); prisma.account.findFirst.mockResolvedValue(null); - const result = await service.createManualTransaction({ - userId: "user_1", + await expect(service.createManualTransaction("user_1", { date: "2025-01-15", description: "Manual payment", amount: 10 - }); - - expect(result).toBeNull(); + })).rejects.toBeInstanceOf(BadRequestException); expect(prisma.transactionRaw.create).not.toHaveBeenCalled(); }); + + it("imports multiple CSV files with per-file results", async () => { + const { service, prisma, exportsService } = createService(); + prisma.account.findFirst.mockResolvedValue({ id: "acct_csv", userId: "user_1" }); + prisma.transactionRaw.upsert.mockResolvedValue({}); + + const makeFile = (name: string, body: string) => ({ + originalname: name, + buffer: Buffer.from(body), + }) as Express.Multer.File; + + const result = await service.importCsvBatch("user_1", [ + makeFile("first.csv", "Date,Description,Amount\n2026-07-01,Coffee,4.25\n"), + makeFile("second.csv", "Date,Description,Amount\n2026-07-02,Lunch,12.50\n"), + ]); + + expect(result.totalFiles).toBe(2); + expect(result.processedFiles).toBe(2); + expect(result.failedFiles).toBe(0); + expect(result.imported).toBe(2); + expect(result.results).toHaveLength(2); + expect(prisma.transactionRaw.upsert).toHaveBeenCalledTimes(2); + expect(exportsService.syncGoogleSheets).toHaveBeenCalledTimes(1); + expect(exportsService.syncGoogleSheets).toHaveBeenCalledWith("user_1", "csv_batch_import"); + }); + + it("previews CSV columns with inferred mapping", async () => { + const { service, prisma } = createService(); + prisma.csvImportMapping.findUnique.mockResolvedValue(null); + const file = { + originalname: "preview.csv", + buffer: Buffer.from("Posted Date,Merchant,Amount\n2026-07-01,Coffee,4.25\n"), + } as Express.Multer.File; + + const result = await service.previewCsv("user_1", file); + + expect(result.headers).toEqual(["Posted Date", "Merchant", "Amount"]); + expect(result.mapping).toEqual(expect.objectContaining({ + date: "Posted Date", + description: "Merchant", + amount: "Amount", + })); + expect(result.remembered).toBe(false); + expect(result.sampleRows).toHaveLength(1); + }); + + it("uses mapped CSV import and remembers the mapping", async () => { + const { service, prisma } = createService(); + prisma.account.findFirst.mockResolvedValue({ id: "acct_csv", userId: "user_1" }); + prisma.transactionRaw.upsert.mockResolvedValue({}); + prisma.csvImportMapping.upsert.mockResolvedValue({}); + const file = { + originalname: "mapped.csv", + buffer: Buffer.from("When,Who,Cost\n2026-07-01,Coffee,4.25\n"), + } as Express.Multer.File; + + const result = await service.importCsv("user_1", file, { + date: "When", + description: "Who", + amount: "Cost", + amountMultiplier: 1, + }); + + expect(result.imported).toBe(1); + expect(prisma.csvImportMapping.upsert).toHaveBeenCalled(); + expect(prisma.transactionRaw.upsert).toHaveBeenCalledWith(expect.objectContaining({ + create: expect.objectContaining({ + description: "Coffee", + amount: 4.25, + }), + })); + }); + + it("keeps batch import going when one CSV file fails", async () => { + const { service, prisma } = createService(); + prisma.account.findFirst.mockResolvedValue({ id: "acct_csv", userId: "user_1" }); + prisma.transactionRaw.upsert.mockResolvedValue({}); + + const makeFile = (name: string, body: string) => ({ + originalname: name, + buffer: Buffer.from(body), + }) as Express.Multer.File; + + const result = await service.importCsvBatch("user_1", [ + makeFile("good.csv", "Date,Description,Amount\n2026-07-01,Coffee,4.25\n"), + makeFile("bad.txt", "not,csv\n1,2\n"), + ]); + + expect(result.totalFiles).toBe(2); + expect(result.processedFiles).toBe(1); + expect(result.failedFiles).toBe(1); + expect(result.imported).toBe(1); + expect(result.results[1].error).toBe("File must be a CSV."); + }); }); diff --git a/test/utils/mock-prisma.ts b/test/utils/mock-prisma.ts index 977e705..fab244d 100644 --- a/test/utils/mock-prisma.ts +++ b/test/utils/mock-prisma.ts @@ -1,20 +1,103 @@ /// export const createPrismaMock = () => ({ - account: { + user: { + findMany: jest.fn(), + findUnique: jest.fn() + }, + household: { + create: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() }, - transactionRaw: { + householdMember: { + count: jest.fn(), + findFirst: jest.fn(), findMany: jest.fn(), + update: jest.fn(), + upsert: jest.fn() + }, + householdInvite: { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn() + }, + account: { + count: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn() + }, + transactionRaw: { + findFirst: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), create: jest.fn(), upsert: jest.fn() }, transactionDerived: { create: jest.fn(), + findMany: jest.fn(), + upsert: jest.fn() + }, + csvImportMapping: { + findUnique: jest.fn(), upsert: jest.fn() }, exportLog: { + create: jest.fn(), + count: jest.fn() + }, + exportDownloadToken: { + create: jest.fn(), + findUnique: jest.fn(), + updateMany: jest.fn() + }, + googleConnection: { + findUnique: jest.fn(), + update: jest.fn(), + upsert: jest.fn(), + deleteMany: jest.fn() + }, + apiKey: { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn() + }, + subscription: { + findUnique: jest.fn(), + findFirst: jest.fn(), + upsert: jest.fn(), + update: jest.fn(), + deleteMany: jest.fn() + }, + abuseEvent: { + create: jest.fn(), + findMany: jest.fn() + }, + rule: { + count: jest.fn(), + create: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn() + }, + ruleExecution: { + create: jest.fn() + }, + taxReturn: { + create: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn() + }, + taxDocument: { + create: jest.fn() + }, + auditLog: { create: jest.fn() } });