Compare commits
No commits in common. "2d829ee9692df294135df4bf629d0ae7dfc52827" and "4a37d5befbe00d06a3897bc9ebd7e5b28a04fb5a" have entirely different histories.
2d829ee969
...
4a37d5befb
70
.env.example
70
.env.example
@ -1,71 +1,11 @@
|
|||||||
DATABASE_URL=postgresql://user:password@localhost:5432/ledgerone
|
DATABASE_URL=postgresql://user:password@localhost:5432/ledgerone
|
||||||
JWT_SECRET=replace_with_32_plus_char_secret
|
JWT_SECRET=change_me
|
||||||
JWT_REFRESH_SECRET=replace_with_32_plus_char_refresh_secret
|
SUPABASE_URL=http://127.0.0.1:54321
|
||||||
JWT_ACCESS_TTL_SECONDS=60
|
SUPABASE_SERVICE_KEY=your_service_role_key
|
||||||
ENCRYPTION_KEY=replace_with_64_hex_chars_for_aes_256_gcm
|
SUPABASE_ANON_KEY=your_anon_key
|
||||||
|
|
||||||
PLAID_CLIENT_ID=your_client_id
|
PLAID_CLIENT_ID=your_client_id
|
||||||
PLAID_SECRET=your_sandbox_secret
|
PLAID_SECRET=your_sandbox_secret
|
||||||
PLAID_ENV=sandbox
|
PLAID_ENV=sandbox
|
||||||
PLAID_PRODUCTS=transactions
|
PLAID_PRODUCTS=transactions
|
||||||
PLAID_COUNTRY_CODES=US
|
PLAID_COUNTRY_CODES=US
|
||||||
PLAID_REDIRECT_URI=http://localhost:3052/app/connect
|
PLAID_REDIRECT_URI=http://localhost:3001/app/connect
|
||||||
PLAID_WEBHOOK_URL=http://localhost:3051/api/plaid/webhook
|
|
||||||
PLAID_VERIFY_WEBHOOKS=true
|
|
||||||
|
|
||||||
TELLER_APPLICATION_ID=
|
|
||||||
TELLER_ENV=sandbox
|
|
||||||
TELLER_PRODUCTS=transactions,balance
|
|
||||||
TELLER_API_BASE_URL=https://api.teller.io
|
|
||||||
TELLER_CERT_PATH=
|
|
||||||
TELLER_KEY_PATH=
|
|
||||||
TELLER_CERT_PEM=
|
|
||||||
TELLER_KEY_PEM=
|
|
||||||
|
|
||||||
SUPABASE_URL=http://127.0.0.1:54321
|
|
||||||
SUPABASE_SERVICE_KEY=your_service_role_key
|
|
||||||
SUPABASE_ANON_KEY=your_anon_key
|
|
||||||
|
|
||||||
EXPORT_OBJECT_STORAGE_DRIVER=local
|
|
||||||
EXPORT_OBJECT_STORAGE_BUCKET=ledgerone-exports
|
|
||||||
EXPORT_OBJECT_STORAGE_DIR=data/export-objects
|
|
||||||
|
|
||||||
STRIPE_SECRET_KEY=
|
|
||||||
STRIPE_WEBHOOK_SECRET=
|
|
||||||
STRIPE_PRICE_PRO=
|
|
||||||
STRIPE_PRICE_ELITE=
|
|
||||||
|
|
||||||
SMTP_HOST=
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
SMTP_FROM=noreply@ledgerone.app
|
|
||||||
VAPID_PUBLIC_KEY=
|
|
||||||
VAPID_PRIVATE_KEY=
|
|
||||||
VAPID_SUBJECT=mailto:support@ledgerone.app
|
|
||||||
|
|
||||||
APP_URL=http://localhost:3052
|
|
||||||
PORT=3051
|
|
||||||
NODE_ENV=development
|
|
||||||
CORS_ORIGIN=http://localhost:3052
|
|
||||||
|
|
||||||
AUTO_SYNC_ENABLED=true
|
|
||||||
AUTO_SYNC_INTERVAL_MINUTES=15
|
|
||||||
AUTO_SYNC_STALE_MINUTES=15
|
|
||||||
AUTO_SYNC_LOOKBACK_DAYS=7
|
|
||||||
AUTO_SYNC_MAX_USERS_PER_RUN=25
|
|
||||||
|
|
||||||
SENTRY_DSN=
|
|
||||||
SENTRY_TRACES_SAMPLE_RATE=0
|
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
GOOGLE_REDIRECT_URI=
|
|
||||||
|
|
||||||
SOCIAL_AUTH_REDIRECT_URI=http://localhost:3052/auth/social/callback
|
|
||||||
GOOGLE_AUTH_CLIENT_ID=
|
|
||||||
GOOGLE_AUTH_CLIENT_SECRET=
|
|
||||||
APPLE_CLIENT_ID=
|
|
||||||
APPLE_TEAM_ID=
|
|
||||||
APPLE_KEY_ID=
|
|
||||||
APPLE_PRIVATE_KEY=
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,3 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
data/export-objects/
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --legacy-peer-deps
|
RUN npm ci --omit=dev --legacy-peer-deps
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
|||||||
341
package-lock.json
generated
341
package-lock.json
generated
@ -21,7 +21,6 @@
|
|||||||
"@supabase/supabase-js": "^2.49.1",
|
"@supabase/supabase-js": "^2.49.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/speakeasy": "^2.0.10",
|
"@types/speakeasy": "^2.0.10",
|
||||||
"@types/web-push": "^3.6.4",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.4",
|
"class-validator": "^0.14.4",
|
||||||
@ -41,9 +40,7 @@
|
|||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"stripe": "^20.4.0",
|
"stripe": "^20.4.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1"
|
||||||
"web-push": "^3.6.7",
|
|
||||||
"xlsx": "^0.18.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.2",
|
"@nestjs/cli": "^10.3.2",
|
||||||
@ -59,7 +56,6 @@
|
|||||||
"prisma": "^5.18.0",
|
"prisma": "^5.18.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -820,30 +816,6 @@
|
|||||||
"node": ">=0.1.90"
|
"node": ">=0.1.90"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cspotcode/source-map-support": {
|
|
||||||
"version": "0.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
|
||||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/trace-mapping": "0.3.9"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
|
|
||||||
"version": "0.3.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
|
||||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/resolve-uri": "^3.0.3",
|
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fastify/otel": {
|
"node_modules/@fastify/otel": {
|
||||||
"version": "0.16.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz",
|
||||||
@ -2946,34 +2918,6 @@
|
|||||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tsconfig/node10": {
|
|
||||||
"version": "1.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
|
||||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tsconfig/node12": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tsconfig/node14": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tsconfig/node16": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@ -3372,15 +3316,6 @@
|
|||||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/web-push": {
|
|
||||||
"version": "3.6.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
|
||||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@ -3616,28 +3551,6 @@
|
|||||||
"acorn": "^8"
|
"acorn": "^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn-walk": {
|
|
||||||
"version": "8.3.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
|
||||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"acorn": "^8.11.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/adler-32": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@ -3778,13 +3691,6 @@
|
|||||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/arg": {
|
|
||||||
"version": "4.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
|
||||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
@ -3811,18 +3717,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/asn1.js": {
|
|
||||||
"version": "5.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
|
||||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bn.js": "^4.0.0",
|
|
||||||
"inherits": "^2.0.1",
|
|
||||||
"minimalistic-assert": "^1.0.0",
|
|
||||||
"safer-buffer": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@ -4057,12 +3951,6 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bn.js": {
|
|
||||||
"version": "4.12.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
|
||||||
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||||
@ -4313,19 +4201,6 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/cfb": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"adler-32": "~1.3.0",
|
|
||||||
"crc-32": "~1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@ -4540,15 +4415,6 @@
|
|||||||
"node": ">= 0.12.0"
|
"node": ">= 0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/codepage": {
|
|
||||||
"version": "1.15.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
|
||||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/collect-v8-coverage": {
|
"node_modules/collect-v8-coverage": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
||||||
@ -4754,18 +4620,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/crc-32": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"crc32": "bin/crc32.njs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/create-jest": {
|
"node_modules/create-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||||
@ -4788,13 +4642,6 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/create-require": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -4956,16 +4803,6 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/diff": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/diff-sequences": {
|
"node_modules/diff-sequences": {
|
||||||
"version": "29.6.3",
|
"version": "29.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||||
@ -5669,15 +5506,6 @@
|
|||||||
"integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==",
|
"integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/frac": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
@ -6149,15 +5977,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/http_ece": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@ -7852,12 +7671,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimalistic-assert": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@ -9490,18 +9303,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/ssf": {
|
|
||||||
"version": "0.11.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
|
||||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"frac": "~1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stack-utils": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
@ -10121,50 +9922,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-node": {
|
|
||||||
"version": "10.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
|
||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
|
||||||
"@tsconfig/node10": "^1.0.7",
|
|
||||||
"@tsconfig/node12": "^1.0.7",
|
|
||||||
"@tsconfig/node14": "^1.0.0",
|
|
||||||
"@tsconfig/node16": "^1.0.2",
|
|
||||||
"acorn": "^8.4.1",
|
|
||||||
"acorn-walk": "^8.1.1",
|
|
||||||
"arg": "^4.1.0",
|
|
||||||
"create-require": "^1.1.0",
|
|
||||||
"diff": "^4.0.1",
|
|
||||||
"make-error": "^1.1.1",
|
|
||||||
"v8-compile-cache-lib": "^3.0.1",
|
|
||||||
"yn": "3.1.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"ts-node": "dist/bin.js",
|
|
||||||
"ts-node-cwd": "dist/bin-cwd.js",
|
|
||||||
"ts-node-esm": "dist/bin-esm.js",
|
|
||||||
"ts-node-script": "dist/bin-script.js",
|
|
||||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
|
||||||
"ts-script": "dist/bin-script-deprecated.js"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@swc/core": ">=1.2.50",
|
|
||||||
"@swc/wasm": ">=1.2.50",
|
|
||||||
"@types/node": "*",
|
|
||||||
"typescript": ">=2.7"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@swc/core": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@swc/wasm": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tsconfig-paths": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
||||||
@ -10383,13 +10140,6 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/v8-compile-cache-lib": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/v8-to-istanbul": {
|
"node_modules/v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||||
@ -10457,46 +10207,6 @@
|
|||||||
"defaults": "^1.0.3"
|
"defaults": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/web-push": {
|
|
||||||
"version": "3.6.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
|
||||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"asn1.js": "^5.3.0",
|
|
||||||
"http_ece": "1.2.0",
|
|
||||||
"https-proxy-agent": "^7.0.0",
|
|
||||||
"jws": "^4.0.0",
|
|
||||||
"minimist": "^1.2.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"web-push": "src/cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/web-push/node_modules/jwa": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer-equal-constant-time": "^1.0.1",
|
|
||||||
"ecdsa-sig-formatter": "1.0.11",
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/web-push/node_modules/jws": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"jwa": "^2.0.1",
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/web-streams-polyfill": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
@ -10610,24 +10320,6 @@
|
|||||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/wmf": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/word": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wordwrap": {
|
"node_modules/wordwrap": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||||
@ -10715,27 +10407,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xlsx": {
|
|
||||||
"version": "0.18.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
|
||||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"adler-32": "~1.3.0",
|
|
||||||
"cfb": "~1.2.1",
|
|
||||||
"codepage": "~1.15.0",
|
|
||||||
"crc-32": "~1.2.1",
|
|
||||||
"ssf": "~0.11.2",
|
|
||||||
"wmf": "~1.0.1",
|
|
||||||
"word": "~0.3.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"xlsx": "bin/xlsx.njs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
@ -10791,16 +10462,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yn": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@ -25,7 +25,6 @@
|
|||||||
"@supabase/supabase-js": "^2.49.1",
|
"@supabase/supabase-js": "^2.49.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/speakeasy": "^2.0.10",
|
"@types/speakeasy": "^2.0.10",
|
||||||
"@types/web-push": "^3.6.4",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.4",
|
"class-validator": "^0.14.4",
|
||||||
@ -45,9 +44,7 @@
|
|||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"stripe": "^20.4.0",
|
"stripe": "^20.4.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1"
|
||||||
"web-push": "^3.6.7",
|
|
||||||
"xlsx": "^0.18.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.2",
|
"@nestjs/cli": "^10.3.2",
|
||||||
@ -63,7 +60,6 @@
|
|||||||
"prisma": "^5.18.0",
|
"prisma": "^5.18.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,299 +0,0 @@
|
|||||||
-- Baseline schema required before the incremental LedgerOne migrations.
|
|
||||||
CREATE TABLE "User" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"passwordHash" TEXT NOT NULL,
|
|
||||||
"fullName" TEXT,
|
|
||||||
"phone" TEXT,
|
|
||||||
"companyName" TEXT,
|
|
||||||
"addressLine1" TEXT,
|
|
||||||
"addressLine2" TEXT,
|
|
||||||
"city" TEXT,
|
|
||||||
"state" TEXT,
|
|
||||||
"postalCode" TEXT,
|
|
||||||
"country" TEXT,
|
|
||||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"twoFactorSecret" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "SocialAccount" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"provider" TEXT NOT NULL,
|
|
||||||
"providerAccountId" TEXT NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "SocialAccount_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "Account" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"institutionName" TEXT NOT NULL,
|
|
||||||
"accountType" TEXT NOT NULL,
|
|
||||||
"mask" TEXT,
|
|
||||||
"plaidAccessToken" TEXT,
|
|
||||||
"plaidItemId" TEXT,
|
|
||||||
"plaidAccountId" TEXT,
|
|
||||||
"tellerAccessToken" TEXT,
|
|
||||||
"tellerEnrollmentId" TEXT,
|
|
||||||
"tellerAccountId" TEXT,
|
|
||||||
"currentBalance" DECIMAL(65,30),
|
|
||||||
"availableBalance" DECIMAL(65,30),
|
|
||||||
"isoCurrencyCode" TEXT,
|
|
||||||
"lastBalanceSync" TIMESTAMP(3),
|
|
||||||
"lastTransactionSync" TIMESTAMP(3),
|
|
||||||
"lastSyncAttemptAt" TIMESTAMP(3),
|
|
||||||
"syncStatus" TEXT NOT NULL DEFAULT 'idle',
|
|
||||||
"lastSyncError" TEXT,
|
|
||||||
"syncConsecutiveFailures" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"plaidWebhookCode" TEXT,
|
|
||||||
"plaidWebhookAt" TIMESTAMP(3),
|
|
||||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "PlaidWebhookEvent" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"itemId" TEXT,
|
|
||||||
"webhookType" TEXT NOT NULL,
|
|
||||||
"webhookCode" TEXT NOT NULL,
|
|
||||||
"payload" JSONB NOT NULL,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'received',
|
|
||||||
"error" TEXT,
|
|
||||||
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"processedAt" TIMESTAMP(3),
|
|
||||||
CONSTRAINT "PlaidWebhookEvent_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "TransactionRaw" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"accountId" TEXT NOT NULL,
|
|
||||||
"bankTransactionId" TEXT NOT NULL,
|
|
||||||
"date" TIMESTAMP(3) NOT NULL,
|
|
||||||
"amount" DECIMAL(65,30) NOT NULL,
|
|
||||||
"description" TEXT NOT NULL,
|
|
||||||
"rawPayload" JSONB NOT NULL,
|
|
||||||
"ingestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"source" TEXT NOT NULL,
|
|
||||||
CONSTRAINT "TransactionRaw_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "TransactionDerived" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"rawTransactionId" TEXT NOT NULL,
|
|
||||||
"userCategory" TEXT,
|
|
||||||
"userNotes" TEXT,
|
|
||||||
"isHidden" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"modifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"modifiedBy" TEXT NOT NULL,
|
|
||||||
CONSTRAINT "TransactionDerived_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "CsvImportMapping" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"headerSignature" TEXT NOT NULL,
|
|
||||||
"name" TEXT,
|
|
||||||
"mapping" JSONB NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "CsvImportMapping_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "Rule" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"priority" INTEGER NOT NULL,
|
|
||||||
"conditions" JSONB NOT NULL,
|
|
||||||
"actions" JSONB NOT NULL,
|
|
||||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "Rule_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "RuleExecution" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"ruleId" TEXT NOT NULL,
|
|
||||||
"transactionId" TEXT NOT NULL,
|
|
||||||
"executedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"result" JSONB NOT NULL,
|
|
||||||
CONSTRAINT "RuleExecution_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "ExportLog" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"filters" JSONB NOT NULL,
|
|
||||||
"rowCount" INTEGER NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "ExportLog_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "AuditLog" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"action" TEXT NOT NULL,
|
|
||||||
"metadata" JSONB NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "AbuseEvent" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT,
|
|
||||||
"eventType" TEXT NOT NULL,
|
|
||||||
"riskPoints" INTEGER NOT NULL,
|
|
||||||
"severity" TEXT NOT NULL,
|
|
||||||
"ipAddress" TEXT,
|
|
||||||
"userAgent" TEXT,
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "AbuseEvent_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "GoogleConnection" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"googleEmail" TEXT NOT NULL,
|
|
||||||
"refreshToken" TEXT NOT NULL,
|
|
||||||
"accessToken" TEXT,
|
|
||||||
"spreadsheetId" TEXT,
|
|
||||||
"isConnected" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"connectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"lastSyncedAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "GoogleConnection_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "EmailVerificationToken" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"token" TEXT NOT NULL,
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "EmailVerificationToken_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "PasswordResetToken" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"token" TEXT NOT NULL,
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"usedAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "Session" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"ipHash" TEXT NOT NULL,
|
|
||||||
"userAgentHash" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"revokedAt" TIMESTAMP(3),
|
|
||||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "RefreshToken" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"sessionId" TEXT,
|
|
||||||
"tokenHash" TEXT NOT NULL,
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"revokedAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "Subscription" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"plan" TEXT NOT NULL DEFAULT 'free',
|
|
||||||
"stripeCustomerId" TEXT,
|
|
||||||
"stripeSubId" TEXT,
|
|
||||||
"currentPeriodEnd" TIMESTAMP(3),
|
|
||||||
"cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "TaxReturn" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"taxYear" INTEGER NOT NULL,
|
|
||||||
"filingType" TEXT NOT NULL,
|
|
||||||
"jurisdictions" JSONB NOT NULL,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'draft',
|
|
||||||
"summary" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "TaxReturn_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "TaxDocument" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"taxReturnId" TEXT NOT NULL,
|
|
||||||
"docType" TEXT NOT NULL,
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "TaxDocument_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
CREATE INDEX "SocialAccount_userId_idx" ON "SocialAccount"("userId");
|
|
||||||
CREATE UNIQUE INDEX "SocialAccount_provider_providerAccountId_key" ON "SocialAccount"("provider", "providerAccountId");
|
|
||||||
CREATE UNIQUE INDEX "Account_plaidAccountId_key" ON "Account"("plaidAccountId");
|
|
||||||
CREATE UNIQUE INDEX "Account_tellerAccountId_key" ON "Account"("tellerAccountId");
|
|
||||||
CREATE INDEX "Account_plaidItemId_idx" ON "Account"("plaidItemId");
|
|
||||||
CREATE INDEX "Account_tellerEnrollmentId_idx" ON "Account"("tellerEnrollmentId");
|
|
||||||
CREATE INDEX "PlaidWebhookEvent_itemId_receivedAt_idx" ON "PlaidWebhookEvent"("itemId", "receivedAt");
|
|
||||||
CREATE INDEX "PlaidWebhookEvent_webhookType_webhookCode_idx" ON "PlaidWebhookEvent"("webhookType", "webhookCode");
|
|
||||||
CREATE UNIQUE INDEX "TransactionRaw_bankTransactionId_key" ON "TransactionRaw"("bankTransactionId");
|
|
||||||
CREATE UNIQUE INDEX "TransactionDerived_rawTransactionId_key" ON "TransactionDerived"("rawTransactionId");
|
|
||||||
CREATE INDEX "CsvImportMapping_userId_lastUsedAt_idx" ON "CsvImportMapping"("userId", "lastUsedAt");
|
|
||||||
CREATE UNIQUE INDEX "CsvImportMapping_userId_headerSignature_key" ON "CsvImportMapping"("userId", "headerSignature");
|
|
||||||
CREATE INDEX "AbuseEvent_userId_createdAt_idx" ON "AbuseEvent"("userId", "createdAt");
|
|
||||||
CREATE INDEX "AbuseEvent_eventType_createdAt_idx" ON "AbuseEvent"("eventType", "createdAt");
|
|
||||||
CREATE UNIQUE INDEX "GoogleConnection_userId_key" ON "GoogleConnection"("userId");
|
|
||||||
CREATE UNIQUE INDEX "EmailVerificationToken_userId_key" ON "EmailVerificationToken"("userId");
|
|
||||||
CREATE UNIQUE INDEX "EmailVerificationToken_token_key" ON "EmailVerificationToken"("token");
|
|
||||||
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
|
|
||||||
CREATE INDEX "Session_userId_revokedAt_idx" ON "Session"("userId", "revokedAt");
|
|
||||||
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
|
|
||||||
CREATE INDEX "RefreshToken_sessionId_idx" ON "RefreshToken"("sessionId");
|
|
||||||
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");
|
|
||||||
|
|
||||||
ALTER TABLE "SocialAccount" ADD CONSTRAINT "SocialAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "TransactionRaw" ADD CONSTRAINT "TransactionRaw_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "TransactionDerived" ADD CONSTRAINT "TransactionDerived_rawTransactionId_fkey" FOREIGN KEY ("rawTransactionId") REFERENCES "TransactionRaw"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "CsvImportMapping" ADD CONSTRAINT "CsvImportMapping_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "Rule" ADD CONSTRAINT "Rule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "RuleExecution" ADD CONSTRAINT "RuleExecution_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "Rule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "RuleExecution" ADD CONSTRAINT "RuleExecution_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "TransactionRaw"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "ExportLog" ADD CONSTRAINT "ExportLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "AbuseEvent" ADD CONSTRAINT "AbuseEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "GoogleConnection" ADD CONSTRAINT "GoogleConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "EmailVerificationToken" ADD CONSTRAINT "EmailVerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "TaxReturn" ADD CONSTRAINT "TaxReturn_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "TaxDocument" ADD CONSTRAINT "TaxDocument_taxReturnId_fkey" FOREIGN KEY ("taxReturnId") REFERENCES "TaxReturn"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
ALTER TABLE "ExportLog"
|
|
||||||
ADD COLUMN "format" TEXT NOT NULL DEFAULT 'csv',
|
|
||||||
ADD COLUMN "destination" TEXT NOT NULL DEFAULT 'download',
|
|
||||||
ADD COLUMN "fileName" TEXT,
|
|
||||||
ADD COLUMN "mimeType" TEXT,
|
|
||||||
ADD COLUMN "fileHash" TEXT,
|
|
||||||
ADD COLUMN "ipAddress" TEXT,
|
|
||||||
ADD COLUMN "userAgent" TEXT,
|
|
||||||
ADD COLUMN "metadata" JSONB NOT NULL DEFAULT '{}';
|
|
||||||
|
|
||||||
CREATE INDEX "ExportLog_userId_createdAt_idx" ON "ExportLog"("userId", "createdAt");
|
|
||||||
CREATE INDEX "ExportLog_format_createdAt_idx" ON "ExportLog"("format", "createdAt");
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
CREATE TABLE "ExportDownloadToken" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"tokenHash" TEXT NOT NULL,
|
|
||||||
"format" TEXT NOT NULL,
|
|
||||||
"filters" JSONB NOT NULL,
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"usedAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "ExportDownloadToken_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "ExportDownloadToken_tokenHash_key" ON "ExportDownloadToken"("tokenHash");
|
|
||||||
CREATE INDEX "ExportDownloadToken_userId_createdAt_idx" ON "ExportDownloadToken"("userId", "createdAt");
|
|
||||||
CREATE INDEX "ExportDownloadToken_expiresAt_idx" ON "ExportDownloadToken"("expiresAt");
|
|
||||||
|
|
||||||
ALTER TABLE "ExportDownloadToken"
|
|
||||||
ADD CONSTRAINT "ExportDownloadToken_userId_fkey"
|
|
||||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
ALTER TABLE "ExportDownloadToken"
|
|
||||||
ADD COLUMN "storageProvider" TEXT,
|
|
||||||
ADD COLUMN "storageKey" TEXT,
|
|
||||||
ADD COLUMN "fileName" TEXT,
|
|
||||||
ADD COLUMN "mimeType" TEXT,
|
|
||||||
ADD COLUMN "rowCount" INTEGER,
|
|
||||||
ADD COLUMN "fileHash" TEXT;
|
|
||||||
|
|
||||||
CREATE INDEX "ExportDownloadToken_storageKey_idx" ON "ExportDownloadToken"("storageKey");
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
ALTER TABLE "GoogleConnection"
|
|
||||||
ADD COLUMN "driveMirrorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
ADD COLUMN "driveMirrorStatus" TEXT NOT NULL DEFAULT 'not_started',
|
|
||||||
ADD COLUMN "driveMirrorSpreadsheetUrl" TEXT,
|
|
||||||
ADD COLUMN "driveMirrorLastSyncedAt" TIMESTAMP(3);
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
CREATE TABLE "ApiKey" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"prefix" TEXT NOT NULL,
|
|
||||||
"keyHash" TEXT NOT NULL,
|
|
||||||
"scopes" TEXT[] NOT NULL DEFAULT ARRAY['transactions:read']::TEXT[],
|
|
||||||
"lastUsedAt" TIMESTAMP(3),
|
|
||||||
"revokedAt" TIMESTAMP(3),
|
|
||||||
"expiresAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
|
|
||||||
CREATE INDEX "ApiKey_userId_createdAt_idx" ON "ApiKey"("userId", "createdAt");
|
|
||||||
CREATE INDEX "ApiKey_prefix_idx" ON "ApiKey"("prefix");
|
|
||||||
|
|
||||||
ALTER TABLE "ApiKey"
|
|
||||||
ADD CONSTRAINT "ApiKey_userId_fkey"
|
|
||||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1 +0,0 @@
|
|||||||
ALTER TABLE "User" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user';
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
CREATE TABLE "Household" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"createdByUserId" TEXT NOT NULL,
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Household_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "HouseholdMember" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"householdId" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"role" TEXT NOT NULL DEFAULT 'member',
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'active',
|
|
||||||
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "HouseholdMember_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "Household_createdByUserId_createdAt_idx" ON "Household"("createdByUserId", "createdAt");
|
|
||||||
CREATE UNIQUE INDEX "HouseholdMember_householdId_userId_key" ON "HouseholdMember"("householdId", "userId");
|
|
||||||
CREATE INDEX "HouseholdMember_userId_status_idx" ON "HouseholdMember"("userId", "status");
|
|
||||||
CREATE INDEX "HouseholdMember_householdId_role_idx" ON "HouseholdMember"("householdId", "role");
|
|
||||||
|
|
||||||
ALTER TABLE "Household" ADD CONSTRAINT "Household_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "HouseholdMember" ADD CONSTRAINT "HouseholdMember_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "HouseholdMember" ADD CONSTRAINT "HouseholdMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
CREATE TABLE "HouseholdInvite" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"householdId" TEXT NOT NULL,
|
|
||||||
"invitedById" TEXT NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"role" TEXT NOT NULL DEFAULT 'member',
|
|
||||||
"tokenHash" TEXT NOT NULL,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"acceptedAt" TIMESTAMP(3),
|
|
||||||
"acceptedById" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "HouseholdInvite_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "HouseholdInvite_tokenHash_key" ON "HouseholdInvite"("tokenHash");
|
|
||||||
CREATE INDEX "HouseholdInvite_householdId_status_idx" ON "HouseholdInvite"("householdId", "status");
|
|
||||||
CREATE INDEX "HouseholdInvite_email_status_idx" ON "HouseholdInvite"("email", "status");
|
|
||||||
CREATE INDEX "HouseholdInvite_expiresAt_idx" ON "HouseholdInvite"("expiresAt");
|
|
||||||
|
|
||||||
ALTER TABLE "HouseholdInvite" ADD CONSTRAINT "HouseholdInvite_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "HouseholdInvite" ADD CONSTRAINT "HouseholdInvite_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "HouseholdInvite" ADD CONSTRAINT "HouseholdInvite_acceptedById_fkey" FOREIGN KEY ("acceptedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
ALTER TABLE "Account"
|
|
||||||
ADD COLUMN "householdId" TEXT,
|
|
||||||
ADD COLUMN "ownerUserId" TEXT,
|
|
||||||
ADD COLUMN "ownershipType" TEXT NOT NULL DEFAULT 'mine';
|
|
||||||
|
|
||||||
CREATE INDEX "Account_householdId_ownershipType_idx" ON "Account"("householdId", "ownershipType");
|
|
||||||
CREATE INDEX "Account_ownerUserId_idx" ON "Account"("ownerUserId");
|
|
||||||
|
|
||||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@ -1 +0,0 @@
|
|||||||
ALTER TABLE "TransactionDerived" ADD COLUMN "attribution" TEXT NOT NULL DEFAULT 'mine';
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE "TransactionDerived" ADD COLUMN "splitMode" TEXT NOT NULL DEFAULT 'none';
|
|
||||||
ALTER TABLE "TransactionDerived" ADD COLUMN "splitMinePercent" DECIMAL(5, 2);
|
|
||||||
ALTER TABLE "TransactionDerived" ADD COLUMN "splitYoursPercent" DECIMAL(5, 2);
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
CREATE TABLE "HouseholdGoal" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"householdId" TEXT NOT NULL,
|
|
||||||
"createdByUserId" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"description" TEXT,
|
|
||||||
"targetAmount" DECIMAL(18, 2) NOT NULL,
|
|
||||||
"currentAmount" DECIMAL(18, 2) NOT NULL DEFAULT 0,
|
|
||||||
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
|
|
||||||
"targetDate" TIMESTAMP(3),
|
|
||||||
"priority" TEXT NOT NULL DEFAULT 'medium',
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'active',
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "HouseholdGoal_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "HouseholdGoal_householdId_status_idx" ON "HouseholdGoal"("householdId", "status");
|
|
||||||
CREATE INDEX "HouseholdGoal_createdByUserId_createdAt_idx" ON "HouseholdGoal"("createdByUserId", "createdAt");
|
|
||||||
CREATE INDEX "HouseholdGoal_targetDate_idx" ON "HouseholdGoal"("targetDate");
|
|
||||||
|
|
||||||
ALTER TABLE "HouseholdGoal" ADD CONSTRAINT "HouseholdGoal_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "HouseholdGoal" ADD CONSTRAINT "HouseholdGoal_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
CREATE TABLE "NotificationPreference" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"pushEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"minSeverity" TEXT NOT NULL DEFAULT 'info',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "Notification" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"severity" TEXT NOT NULL DEFAULT 'info',
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"body" TEXT NOT NULL,
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"channels" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
|
||||||
"readAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "PushSubscription" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"endpoint" TEXT NOT NULL,
|
|
||||||
"p256dh" TEXT NOT NULL,
|
|
||||||
"auth" TEXT NOT NULL,
|
|
||||||
"userAgent" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"lastUsedAt" TIMESTAMP(3),
|
|
||||||
"revokedAt" TIMESTAMP(3),
|
|
||||||
|
|
||||||
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId");
|
|
||||||
CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt");
|
|
||||||
CREATE INDEX "Notification_userId_readAt_idx" ON "Notification"("userId", "readAt");
|
|
||||||
CREATE INDEX "Notification_type_createdAt_idx" ON "Notification"("type", "createdAt");
|
|
||||||
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
|
|
||||||
CREATE INDEX "PushSubscription_userId_revokedAt_idx" ON "PushSubscription"("userId", "revokedAt");
|
|
||||||
|
|
||||||
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
CREATE TABLE "BillPayee" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"nickname" TEXT,
|
|
||||||
"category" TEXT,
|
|
||||||
"website" TEXT,
|
|
||||||
"accountNumberLast4" TEXT,
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "BillPayee_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "Bill" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"payeeId" TEXT,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"amount" DECIMAL(65,30) NOT NULL,
|
|
||||||
"currency" TEXT NOT NULL DEFAULT 'USD',
|
|
||||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
"recurrence" TEXT NOT NULL DEFAULT 'none',
|
|
||||||
"autopay" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"reminderDays" INTEGER NOT NULL DEFAULT 3,
|
|
||||||
"notes" TEXT,
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"paidAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Bill_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "BillPayment" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"billId" TEXT NOT NULL,
|
|
||||||
"amount" DECIMAL(65,30) NOT NULL,
|
|
||||||
"paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"method" TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
"confirmationNumber" TEXT,
|
|
||||||
"notes" TEXT,
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "BillPayment_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "BillPayee_userId_name_idx" ON "BillPayee"("userId", "name");
|
|
||||||
CREATE INDEX "Bill_userId_dueDate_idx" ON "Bill"("userId", "dueDate");
|
|
||||||
CREATE INDEX "Bill_userId_status_idx" ON "Bill"("userId", "status");
|
|
||||||
CREATE INDEX "Bill_payeeId_idx" ON "Bill"("payeeId");
|
|
||||||
CREATE INDEX "BillPayment_userId_paidAt_idx" ON "BillPayment"("userId", "paidAt");
|
|
||||||
CREATE INDEX "BillPayment_billId_idx" ON "BillPayment"("billId");
|
|
||||||
|
|
||||||
ALTER TABLE "BillPayee" ADD CONSTRAINT "BillPayee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "Bill" ADD CONSTRAINT "Bill_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "Bill" ADD CONSTRAINT "Bill_payeeId_fkey" FOREIGN KEY ("payeeId") REFERENCES "BillPayee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "BillPayment" ADD CONSTRAINT "BillPayment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "BillPayment" ADD CONSTRAINT "BillPayment_billId_fkey" FOREIGN KEY ("billId") REFERENCES "Bill"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
CREATE TABLE "CreditScoreEntry" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"score" INTEGER NOT NULL,
|
|
||||||
"bureau" TEXT NOT NULL DEFAULT 'unknown',
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
"model" TEXT NOT NULL DEFAULT 'vantage_score_3',
|
|
||||||
"scoreDate" TIMESTAMP(3) NOT NULL,
|
|
||||||
"factors" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "CreditScoreEntry_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "CreditScoreEntry_userId_scoreDate_idx" ON "CreditScoreEntry"("userId", "scoreDate");
|
|
||||||
CREATE INDEX "CreditScoreEntry_userId_bureau_idx" ON "CreditScoreEntry"("userId", "bureau");
|
|
||||||
|
|
||||||
ALTER TABLE "CreditScoreEntry" ADD CONSTRAINT "CreditScoreEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1 +0,0 @@
|
|||||||
ALTER TABLE "Session" ADD COLUMN "nonceHash" TEXT NOT NULL DEFAULT '';
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
CREATE TABLE "TransactionComment" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"rawTransactionId" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"body" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "TransactionComment_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "TransactionComment_rawTransactionId_createdAt_idx" ON "TransactionComment"("rawTransactionId", "createdAt");
|
|
||||||
CREATE INDEX "TransactionComment_userId_createdAt_idx" ON "TransactionComment"("userId", "createdAt");
|
|
||||||
|
|
||||||
ALTER TABLE "TransactionComment" ADD CONSTRAINT "TransactionComment_rawTransactionId_fkey" FOREIGN KEY ("rawTransactionId") REFERENCES "TransactionRaw"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "TransactionComment" ADD CONSTRAINT "TransactionComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
CREATE TABLE "HouseholdBudget" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"householdId" TEXT NOT NULL,
|
|
||||||
"createdByUserId" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"category" TEXT,
|
|
||||||
"period" TEXT NOT NULL DEFAULT 'monthly',
|
|
||||||
"limitAmount" DECIMAL(65,30) NOT NULL,
|
|
||||||
"spentAmount" DECIMAL(65,30) NOT NULL DEFAULT 0,
|
|
||||||
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
|
|
||||||
"startDate" TIMESTAMP(3),
|
|
||||||
"endDate" TIMESTAMP(3),
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'active',
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "HouseholdBudget_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "PersonalGoal" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"description" TEXT,
|
|
||||||
"targetAmount" DECIMAL(65,30) NOT NULL,
|
|
||||||
"currentAmount" DECIMAL(65,30) NOT NULL DEFAULT 0,
|
|
||||||
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
|
|
||||||
"targetDate" TIMESTAMP(3),
|
|
||||||
"priority" TEXT NOT NULL DEFAULT 'medium',
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'active',
|
|
||||||
"automation" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "PersonalGoal_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "InvestmentHolding" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"accountId" TEXT,
|
|
||||||
"symbol" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"assetClass" TEXT NOT NULL DEFAULT 'stock',
|
|
||||||
"quantity" DECIMAL(65,30) NOT NULL,
|
|
||||||
"price" DECIMAL(65,30) NOT NULL,
|
|
||||||
"marketValue" DECIMAL(65,30) NOT NULL,
|
|
||||||
"costBasis" DECIMAL(65,30),
|
|
||||||
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
|
|
||||||
"asOfDate" TIMESTAMP(3) NOT NULL,
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "InvestmentHolding_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "NetWorthSnapshot" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"snapshotDate" TIMESTAMP(3) NOT NULL,
|
|
||||||
"assets" DECIMAL(65,30) NOT NULL,
|
|
||||||
"liabilities" DECIMAL(65,30) NOT NULL,
|
|
||||||
"netWorth" DECIMAL(65,30) NOT NULL,
|
|
||||||
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'computed',
|
|
||||||
"breakdown" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "NetWorthSnapshot_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "RecurringTransaction" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"merchant" TEXT NOT NULL,
|
|
||||||
"cadence" TEXT NOT NULL,
|
|
||||||
"averageAmount" DECIMAL(65,30) NOT NULL,
|
|
||||||
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
|
|
||||||
"nextExpectedDate" TIMESTAMP(3),
|
|
||||||
"lastSeenDate" TIMESTAMP(3),
|
|
||||||
"occurrenceCount" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"confidence" DECIMAL(65,30) NOT NULL DEFAULT 0,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'active',
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'detected',
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
CONSTRAINT "RecurringTransaction_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "HouseholdBudget_householdId_status_idx" ON "HouseholdBudget"("householdId", "status");
|
|
||||||
CREATE INDEX "HouseholdBudget_createdByUserId_createdAt_idx" ON "HouseholdBudget"("createdByUserId", "createdAt");
|
|
||||||
CREATE INDEX "PersonalGoal_userId_status_idx" ON "PersonalGoal"("userId", "status");
|
|
||||||
CREATE INDEX "PersonalGoal_targetDate_idx" ON "PersonalGoal"("targetDate");
|
|
||||||
CREATE INDEX "InvestmentHolding_userId_asOfDate_idx" ON "InvestmentHolding"("userId", "asOfDate");
|
|
||||||
CREATE INDEX "InvestmentHolding_userId_symbol_idx" ON "InvestmentHolding"("userId", "symbol");
|
|
||||||
CREATE INDEX "NetWorthSnapshot_userId_snapshotDate_idx" ON "NetWorthSnapshot"("userId", "snapshotDate");
|
|
||||||
CREATE INDEX "RecurringTransaction_userId_status_idx" ON "RecurringTransaction"("userId", "status");
|
|
||||||
CREATE INDEX "RecurringTransaction_userId_merchant_idx" ON "RecurringTransaction"("userId", "merchant");
|
|
||||||
|
|
||||||
ALTER TABLE "HouseholdBudget" ADD CONSTRAINT "HouseholdBudget_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "HouseholdBudget" ADD CONSTRAINT "HouseholdBudget_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "PersonalGoal" ADD CONSTRAINT "PersonalGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "InvestmentHolding" ADD CONSTRAINT "InvestmentHolding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "InvestmentHolding" ADD CONSTRAINT "InvestmentHolding_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "NetWorthSnapshot" ADD CONSTRAINT "NetWorthSnapshot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "RecurringTransaction" ADD CONSTRAINT "RecurringTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
-- Add household-scoped accountant/advisor workflow tasks.
|
|
||||||
CREATE TABLE "AccountantTask" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"householdId" TEXT NOT NULL,
|
|
||||||
"createdByUserId" TEXT NOT NULL,
|
|
||||||
"assignedToUserId" TEXT,
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"description" TEXT,
|
|
||||||
"taskType" TEXT NOT NULL DEFAULT 'review',
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'open',
|
|
||||||
"priority" TEXT NOT NULL DEFAULT 'medium',
|
|
||||||
"dueDate" TIMESTAMP(3),
|
|
||||||
"completedAt" TIMESTAMP(3),
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "AccountantTask_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "AccountantTask_householdId_status_idx" ON "AccountantTask"("householdId", "status");
|
|
||||||
CREATE INDEX "AccountantTask_assignedToUserId_status_idx" ON "AccountantTask"("assignedToUserId", "status");
|
|
||||||
CREATE INDEX "AccountantTask_dueDate_idx" ON "AccountantTask"("dueDate");
|
|
||||||
|
|
||||||
ALTER TABLE "AccountantTask" ADD CONSTRAINT "AccountantTask_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "AccountantTask" ADD CONSTRAINT "AccountantTask_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "AccountantTask" ADD CONSTRAINT "AccountantTask_assignedToUserId_fkey" FOREIGN KEY ("assignedToUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
-- Track provider-backed bill payment initiation state.
|
|
||||||
ALTER TABLE "BillPayment" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'completed';
|
|
||||||
ALTER TABLE "BillPayment" ADD COLUMN "provider" TEXT;
|
|
||||||
ALTER TABLE "BillPayment" ADD COLUMN "providerPaymentId" TEXT;
|
|
||||||
|
|
||||||
CREATE INDEX "BillPayment_userId_status_idx" ON "BillPayment"("userId", "status");
|
|
||||||
CREATE INDEX "BillPayment_provider_providerPaymentId_idx" ON "BillPayment"("provider", "providerPaymentId");
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
-- Add Stripe webhook idempotency ledger.
|
|
||||||
CREATE TABLE "StripeWebhookEvent" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'received',
|
|
||||||
"processedAt" TIMESTAMP(3),
|
|
||||||
"error" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "StripeWebhookEvent_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "StripeWebhookEvent_type_status_idx" ON "StripeWebhookEvent"("type", "status");
|
|
||||||
CREATE INDEX "StripeWebhookEvent_createdAt_idx" ON "StripeWebhookEvent"("createdAt");
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
-- Track user-selected data ownership/system-of-record posture.
|
|
||||||
ALTER TABLE "GoogleConnection" ADD COLUMN "dataSystemMode" TEXT NOT NULL DEFAULT 'backend_db';
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
-- Align historical incremental migrations with the current Prisma schema.
|
|
||||||
DROP INDEX IF EXISTS "ExportDownloadToken_storageKey_idx";
|
|
||||||
|
|
||||||
ALTER TABLE "HouseholdGoal"
|
|
||||||
ALTER COLUMN "targetAmount" TYPE DECIMAL(65,30),
|
|
||||||
ALTER COLUMN "currentAmount" TYPE DECIMAL(65,30);
|
|
||||||
|
|
||||||
ALTER TABLE "TransactionDerived"
|
|
||||||
ALTER COLUMN "splitMinePercent" TYPE DECIMAL(65,30),
|
|
||||||
ALTER COLUMN "splitYoursPercent" TYPE DECIMAL(65,30);
|
|
||||||
@ -20,7 +20,6 @@ model User {
|
|||||||
state String?
|
state String?
|
||||||
postalCode String?
|
postalCode String?
|
||||||
country String?
|
country String?
|
||||||
role String @default("user")
|
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
twoFactorSecret String?
|
twoFactorSecret String?
|
||||||
@ -30,468 +29,34 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
rules Rule[]
|
rules Rule[]
|
||||||
exports ExportLog[]
|
exports ExportLog[]
|
||||||
exportDownloadTokens ExportDownloadToken[]
|
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
abuseEvents AbuseEvent[]
|
|
||||||
googleConnection GoogleConnection?
|
googleConnection GoogleConnection?
|
||||||
apiKeys ApiKey[]
|
|
||||||
emailVerificationToken EmailVerificationToken?
|
emailVerificationToken EmailVerificationToken?
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
sessions Session[]
|
|
||||||
socialAccounts SocialAccount[]
|
|
||||||
subscription Subscription?
|
subscription Subscription?
|
||||||
taxReturns TaxReturn[]
|
taxReturns TaxReturn[]
|
||||||
csvImportMappings CsvImportMapping[]
|
|
||||||
createdHouseholds Household[] @relation("HouseholdCreator")
|
|
||||||
householdMemberships HouseholdMember[]
|
|
||||||
sentHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteInviter")
|
|
||||||
acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter")
|
|
||||||
createdAccountantTasks AccountantTask[] @relation("AccountantTaskCreator")
|
|
||||||
assignedAccountantTasks AccountantTask[] @relation("AccountantTaskAssignee")
|
|
||||||
ownedAccounts Account[] @relation("AccountOwnerUser")
|
|
||||||
createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator")
|
|
||||||
personalGoals PersonalGoal[]
|
|
||||||
investmentHoldings InvestmentHolding[]
|
|
||||||
netWorthSnapshots NetWorthSnapshot[]
|
|
||||||
recurringTransactions RecurringTransaction[]
|
|
||||||
createdHouseholdBudgets HouseholdBudget[] @relation("HouseholdBudgetCreator")
|
|
||||||
notificationPreferences NotificationPreference?
|
|
||||||
notifications Notification[]
|
|
||||||
pushSubscriptions PushSubscription[]
|
|
||||||
billPayees BillPayee[]
|
|
||||||
bills Bill[]
|
|
||||||
billPayments BillPayment[]
|
|
||||||
creditScoreEntries CreditScoreEntry[]
|
|
||||||
transactionComments TransactionComment[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model NotificationPreference {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String @unique
|
|
||||||
emailEnabled Boolean @default(true)
|
|
||||||
pushEnabled Boolean @default(false)
|
|
||||||
minSeverity String @default("info")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model Notification {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
type String
|
|
||||||
severity String @default("info")
|
|
||||||
title String
|
|
||||||
body String
|
|
||||||
metadata Json @default("{}")
|
|
||||||
channels String[] @default([])
|
|
||||||
readAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
@@index([userId, readAt])
|
|
||||||
@@index([type, createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model PushSubscription {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
endpoint String @unique
|
|
||||||
p256dh String
|
|
||||||
auth String
|
|
||||||
userAgent String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
lastUsedAt DateTime?
|
|
||||||
revokedAt DateTime?
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId, revokedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model BillPayee {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
name String
|
|
||||||
nickname String?
|
|
||||||
category String?
|
|
||||||
website String?
|
|
||||||
accountNumberLast4 String?
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
bills Bill[]
|
|
||||||
|
|
||||||
@@index([userId, name])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Bill {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
payeeId String?
|
|
||||||
name String
|
|
||||||
amount Decimal
|
|
||||||
currency String @default("USD")
|
|
||||||
dueDate DateTime
|
|
||||||
status String @default("pending")
|
|
||||||
recurrence String @default("none")
|
|
||||||
autopay Boolean @default(false)
|
|
||||||
reminderDays Int @default(3)
|
|
||||||
notes String?
|
|
||||||
metadata Json @default("{}")
|
|
||||||
paidAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
payee BillPayee? @relation(fields: [payeeId], references: [id], onDelete: SetNull)
|
|
||||||
payments BillPayment[]
|
|
||||||
|
|
||||||
@@index([userId, dueDate])
|
|
||||||
@@index([userId, status])
|
|
||||||
@@index([payeeId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model BillPayment {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
billId String
|
|
||||||
amount Decimal
|
|
||||||
paidAt DateTime @default(now())
|
|
||||||
method String @default("manual")
|
|
||||||
status String @default("completed")
|
|
||||||
provider String?
|
|
||||||
providerPaymentId String?
|
|
||||||
confirmationNumber String?
|
|
||||||
notes String?
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
bill Bill @relation(fields: [billId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId, paidAt])
|
|
||||||
@@index([userId, status])
|
|
||||||
@@index([provider, providerPaymentId])
|
|
||||||
@@index([billId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model CreditScoreEntry {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
score Int
|
|
||||||
bureau String @default("unknown")
|
|
||||||
source String @default("manual")
|
|
||||||
model String @default("vantage_score_3")
|
|
||||||
scoreDate DateTime
|
|
||||||
factors Json @default("{}")
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId, scoreDate])
|
|
||||||
@@index([userId, bureau])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Household {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String
|
|
||||||
createdByUserId String
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
createdBy User @relation("HouseholdCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
|
||||||
members HouseholdMember[]
|
|
||||||
invites HouseholdInvite[]
|
|
||||||
accounts Account[]
|
|
||||||
goals HouseholdGoal[]
|
|
||||||
budgets HouseholdBudget[]
|
|
||||||
accountantTasks AccountantTask[]
|
|
||||||
|
|
||||||
@@index([createdByUserId, createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model AccountantTask {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
householdId String
|
|
||||||
createdByUserId String
|
|
||||||
assignedToUserId String?
|
|
||||||
title String
|
|
||||||
description String?
|
|
||||||
taskType String @default("review")
|
|
||||||
status String @default("open")
|
|
||||||
priority String @default("medium")
|
|
||||||
dueDate DateTime?
|
|
||||||
completedAt DateTime?
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
|
||||||
createdBy User @relation("AccountantTaskCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
|
||||||
assignedTo User? @relation("AccountantTaskAssignee", fields: [assignedToUserId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([householdId, status])
|
|
||||||
@@index([assignedToUserId, status])
|
|
||||||
@@index([dueDate])
|
|
||||||
}
|
|
||||||
|
|
||||||
model HouseholdBudget {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
householdId String
|
|
||||||
createdByUserId String
|
|
||||||
name String
|
|
||||||
category String?
|
|
||||||
period String @default("monthly")
|
|
||||||
limitAmount Decimal
|
|
||||||
spentAmount Decimal @default(0)
|
|
||||||
isoCurrencyCode String @default("USD")
|
|
||||||
startDate DateTime?
|
|
||||||
endDate DateTime?
|
|
||||||
status String @default("active")
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
|
||||||
createdBy User @relation("HouseholdBudgetCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([householdId, status])
|
|
||||||
@@index([createdByUserId, createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model PersonalGoal {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
name String
|
|
||||||
description String?
|
|
||||||
targetAmount Decimal
|
|
||||||
currentAmount Decimal @default(0)
|
|
||||||
isoCurrencyCode String @default("USD")
|
|
||||||
targetDate DateTime?
|
|
||||||
priority String @default("medium")
|
|
||||||
status String @default("active")
|
|
||||||
automation Json @default("{}")
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId, status])
|
|
||||||
@@index([targetDate])
|
|
||||||
}
|
|
||||||
|
|
||||||
model InvestmentHolding {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
accountId String?
|
|
||||||
symbol String
|
|
||||||
name String
|
|
||||||
assetClass String @default("stock")
|
|
||||||
quantity Decimal
|
|
||||||
price Decimal
|
|
||||||
marketValue Decimal
|
|
||||||
costBasis Decimal?
|
|
||||||
isoCurrencyCode String @default("USD")
|
|
||||||
asOfDate DateTime
|
|
||||||
source String @default("manual")
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([userId, asOfDate])
|
|
||||||
@@index([userId, symbol])
|
|
||||||
}
|
|
||||||
|
|
||||||
model NetWorthSnapshot {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
snapshotDate DateTime
|
|
||||||
assets Decimal
|
|
||||||
liabilities Decimal
|
|
||||||
netWorth Decimal
|
|
||||||
isoCurrencyCode String @default("USD")
|
|
||||||
source String @default("computed")
|
|
||||||
breakdown Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId, snapshotDate])
|
|
||||||
}
|
|
||||||
|
|
||||||
model RecurringTransaction {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
merchant String
|
|
||||||
cadence String
|
|
||||||
averageAmount Decimal
|
|
||||||
isoCurrencyCode String @default("USD")
|
|
||||||
nextExpectedDate DateTime?
|
|
||||||
lastSeenDate DateTime?
|
|
||||||
occurrenceCount Int @default(0)
|
|
||||||
confidence Decimal @default(0)
|
|
||||||
status String @default("active")
|
|
||||||
source String @default("detected")
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId, status])
|
|
||||||
@@index([userId, merchant])
|
|
||||||
}
|
|
||||||
|
|
||||||
model HouseholdGoal {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
householdId String
|
|
||||||
createdByUserId String
|
|
||||||
name String
|
|
||||||
description String?
|
|
||||||
targetAmount Decimal
|
|
||||||
currentAmount Decimal @default(0)
|
|
||||||
isoCurrencyCode String @default("USD")
|
|
||||||
targetDate DateTime?
|
|
||||||
priority String @default("medium")
|
|
||||||
status String @default("active")
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
|
||||||
createdBy User @relation("HouseholdGoalCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([householdId, status])
|
|
||||||
@@index([createdByUserId, createdAt])
|
|
||||||
@@index([targetDate])
|
|
||||||
}
|
|
||||||
|
|
||||||
model HouseholdMember {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
householdId String
|
|
||||||
userId String
|
|
||||||
role String @default("member")
|
|
||||||
status String @default("active")
|
|
||||||
joinedAt DateTime @default(now())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([householdId, userId])
|
|
||||||
@@index([userId, status])
|
|
||||||
@@index([householdId, role])
|
|
||||||
}
|
|
||||||
|
|
||||||
model HouseholdInvite {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
householdId String
|
|
||||||
invitedById String
|
|
||||||
email String
|
|
||||||
role String @default("member")
|
|
||||||
tokenHash String @unique
|
|
||||||
status String @default("pending")
|
|
||||||
expiresAt DateTime
|
|
||||||
acceptedAt DateTime?
|
|
||||||
acceptedById String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
|
||||||
invitedBy User @relation("HouseholdInviteInviter", fields: [invitedById], references: [id], onDelete: Cascade)
|
|
||||||
acceptedBy User? @relation("HouseholdInviteAccepter", fields: [acceptedById], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([householdId, status])
|
|
||||||
@@index([email, status])
|
|
||||||
@@index([expiresAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model SocialAccount {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
provider String
|
|
||||||
providerAccountId String
|
|
||||||
email String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
householdId String?
|
|
||||||
ownerUserId String?
|
|
||||||
ownershipType String @default("mine")
|
|
||||||
institutionName String
|
institutionName String
|
||||||
accountType String
|
accountType String
|
||||||
mask String?
|
mask String?
|
||||||
plaidAccessToken String?
|
plaidAccessToken String?
|
||||||
plaidItemId String?
|
plaidItemId String?
|
||||||
plaidAccountId String? @unique
|
plaidAccountId String? @unique
|
||||||
tellerAccessToken String?
|
|
||||||
tellerEnrollmentId String?
|
|
||||||
tellerAccountId String? @unique
|
|
||||||
currentBalance Decimal?
|
currentBalance Decimal?
|
||||||
availableBalance Decimal?
|
availableBalance Decimal?
|
||||||
isoCurrencyCode String?
|
isoCurrencyCode String?
|
||||||
lastBalanceSync DateTime?
|
lastBalanceSync DateTime?
|
||||||
lastTransactionSync DateTime?
|
|
||||||
lastSyncAttemptAt DateTime?
|
|
||||||
syncStatus String @default("idle")
|
|
||||||
lastSyncError String?
|
|
||||||
syncConsecutiveFailures Int @default(0)
|
|
||||||
plaidWebhookCode String?
|
|
||||||
plaidWebhookAt DateTime?
|
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
household Household? @relation(fields: [householdId], references: [id], onDelete: SetNull)
|
|
||||||
ownerUser User? @relation("AccountOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
|
|
||||||
transactionsRaw TransactionRaw[]
|
transactionsRaw TransactionRaw[]
|
||||||
investmentHoldings InvestmentHolding[]
|
|
||||||
|
|
||||||
@@index([householdId, ownershipType])
|
|
||||||
@@index([ownerUserId])
|
|
||||||
@@index([plaidItemId])
|
|
||||||
@@index([tellerEnrollmentId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model PlaidWebhookEvent {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
itemId String?
|
|
||||||
webhookType String
|
|
||||||
webhookCode String
|
|
||||||
payload Json
|
|
||||||
status String @default("received")
|
|
||||||
error String?
|
|
||||||
receivedAt DateTime @default(now())
|
|
||||||
processedAt DateTime?
|
|
||||||
|
|
||||||
@@index([itemId, receivedAt])
|
|
||||||
@@index([webhookType, webhookCode])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model TransactionRaw {
|
model TransactionRaw {
|
||||||
@ -508,7 +73,6 @@ model TransactionRaw {
|
|||||||
account Account @relation(fields: [accountId], references: [id])
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
derived TransactionDerived?
|
derived TransactionDerived?
|
||||||
ruleExecutions RuleExecution[]
|
ruleExecutions RuleExecution[]
|
||||||
comments TransactionComment[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model TransactionDerived {
|
model TransactionDerived {
|
||||||
@ -516,10 +80,6 @@ model TransactionDerived {
|
|||||||
rawTransactionId String @unique
|
rawTransactionId String @unique
|
||||||
userCategory String?
|
userCategory String?
|
||||||
userNotes String?
|
userNotes String?
|
||||||
attribution String @default("mine")
|
|
||||||
splitMode String @default("none")
|
|
||||||
splitMinePercent Decimal?
|
|
||||||
splitYoursPercent Decimal?
|
|
||||||
isHidden Boolean @default(false)
|
isHidden Boolean @default(false)
|
||||||
modifiedAt DateTime @default(now())
|
modifiedAt DateTime @default(now())
|
||||||
modifiedBy String
|
modifiedBy String
|
||||||
@ -527,37 +87,6 @@ model TransactionDerived {
|
|||||||
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id])
|
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model TransactionComment {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
rawTransactionId String
|
|
||||||
userId String
|
|
||||||
body String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([rawTransactionId, createdAt])
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model CsvImportMapping {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
headerSignature String
|
|
||||||
name String?
|
|
||||||
mapping Json
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
lastUsedAt DateTime @default(now())
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, headerSignature])
|
|
||||||
@@index([userId, lastUsedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Rule {
|
model Rule {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
@ -584,46 +113,13 @@ model RuleExecution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ExportLog {
|
model ExportLog {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
|
||||||
format String @default("csv")
|
|
||||||
destination String @default("download")
|
|
||||||
filters Json
|
|
||||||
rowCount Int
|
|
||||||
fileName String?
|
|
||||||
mimeType String?
|
|
||||||
fileHash String?
|
|
||||||
ipAddress String?
|
|
||||||
userAgent String?
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
@@index([format, createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ExportDownloadToken {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
userId String
|
||||||
tokenHash String @unique
|
|
||||||
format String
|
|
||||||
filters Json
|
filters Json
|
||||||
storageProvider String?
|
rowCount Int
|
||||||
storageKey String?
|
createdAt DateTime @default(now())
|
||||||
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)
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
@@index([expiresAt])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
@ -636,23 +132,6 @@ model AuditLog {
|
|||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AbuseEvent {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String?
|
|
||||||
eventType String
|
|
||||||
riskPoints Int
|
|
||||||
severity String
|
|
||||||
ipAddress String?
|
|
||||||
userAgent String?
|
|
||||||
metadata Json @default("{}")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
@@index([eventType, createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model GoogleConnection {
|
model GoogleConnection {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
@ -660,11 +139,6 @@ model GoogleConnection {
|
|||||||
refreshToken String
|
refreshToken String
|
||||||
accessToken String?
|
accessToken String?
|
||||||
spreadsheetId String?
|
spreadsheetId String?
|
||||||
driveMirrorEnabled Boolean @default(false)
|
|
||||||
driveMirrorStatus String @default("not_started")
|
|
||||||
driveMirrorSpreadsheetUrl String?
|
|
||||||
driveMirrorLastSyncedAt DateTime?
|
|
||||||
dataSystemMode String @default("backend_db")
|
|
||||||
isConnected Boolean @default(true)
|
isConnected Boolean @default(true)
|
||||||
connectedAt DateTime @default(now())
|
connectedAt DateTime @default(now())
|
||||||
lastSyncedAt DateTime?
|
lastSyncedAt DateTime?
|
||||||
@ -674,24 +148,6 @@ model GoogleConnection {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model ApiKey {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
name String
|
|
||||||
prefix String
|
|
||||||
keyHash String @unique
|
|
||||||
scopes String[] @default(["transactions:read"])
|
|
||||||
lastUsedAt DateTime?
|
|
||||||
revokedAt DateTime?
|
|
||||||
expiresAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
@@index([prefix])
|
|
||||||
}
|
|
||||||
|
|
||||||
model EmailVerificationToken {
|
model EmailVerificationToken {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
@ -713,36 +169,15 @@ model PasswordResetToken {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
ipHash String
|
|
||||||
userAgentHash String
|
|
||||||
nonceHash String @default("")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
lastSeenAt DateTime @default(now())
|
|
||||||
expiresAt DateTime
|
|
||||||
revokedAt DateTime?
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
refreshTokens RefreshToken[]
|
|
||||||
|
|
||||||
@@index([userId, revokedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
sessionId String?
|
|
||||||
tokenHash String @unique
|
tokenHash String @unique
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
session Session? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([sessionId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Subscription {
|
model Subscription {
|
||||||
@ -759,19 +194,6 @@ model Subscription {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model StripeWebhookEvent {
|
|
||||||
id String @id
|
|
||||||
type String
|
|
||||||
status String @default("received")
|
|
||||||
processedAt DateTime?
|
|
||||||
error String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([type, status])
|
|
||||||
@@index([createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model TaxReturn {
|
model TaxReturn {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
import { Controller, Get } from "@nestjs/common";
|
|
||||||
import { ok } from "../common/response";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
||||||
import { AbuseService } from "./abuse.service";
|
|
||||||
|
|
||||||
@Controller("security")
|
|
||||||
export class AbuseController {
|
|
||||||
constructor(private readonly abuseService: AbuseService) {}
|
|
||||||
|
|
||||||
@Get("risk")
|
|
||||||
async risk(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.abuseService.getRiskProfile(userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { Global, Module } from "@nestjs/common";
|
|
||||||
import { AbuseController } from "./abuse.controller";
|
|
||||||
import { AbuseService } from "./abuse.service";
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
controllers: [AbuseController],
|
|
||||||
providers: [AbuseService],
|
|
||||||
exports: [AbuseService],
|
|
||||||
})
|
|
||||||
export class AbuseModule {}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { RequestContext } from "./abuse.types";
|
|
||||||
|
|
||||||
type AbuseSeverity = "low" | "medium" | "high";
|
|
||||||
|
|
||||||
type RecordAbuseEventInput = RequestContext & {
|
|
||||||
userId?: string;
|
|
||||||
eventType: string;
|
|
||||||
riskPoints: number;
|
|
||||||
severity: AbuseSeverity;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RISK_WINDOW_HOURS = 24;
|
|
||||||
const PAGINATION_EVENT_TYPES = ["PAGINATION_LIMIT_CLAMPED", "PAGINATION_DEEP_SCAN"] as const;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AbuseService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async recordEvent(input: RecordAbuseEventInput) {
|
|
||||||
return this.prisma.abuseEvent.create({
|
|
||||||
data: {
|
|
||||||
userId: input.userId,
|
|
||||||
eventType: input.eventType,
|
|
||||||
riskPoints: input.riskPoints,
|
|
||||||
severity: input.severity,
|
|
||||||
ipAddress: input.ipAddress,
|
|
||||||
userAgent: input.userAgent,
|
|
||||||
metadata: (input.metadata ?? {}) as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordLoginFailure(email: string, userId: string | undefined, context?: RequestContext) {
|
|
||||||
await this.recordEvent({
|
|
||||||
userId,
|
|
||||||
eventType: "AUTH_LOGIN_FAILURE",
|
|
||||||
riskPoints: userId ? 8 : 4,
|
|
||||||
severity: userId ? "medium" : "low",
|
|
||||||
ipAddress: context?.ipAddress,
|
|
||||||
userAgent: context?.userAgent,
|
|
||||||
metadata: {
|
|
||||||
emailHash: this.hashValue(email.toLowerCase().trim()),
|
|
||||||
knownUser: Boolean(userId),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordInvalidToken(context?: RequestContext) {
|
|
||||||
await this.recordEvent({
|
|
||||||
eventType: "AUTH_INVALID_TOKEN",
|
|
||||||
riskPoints: 5,
|
|
||||||
severity: "low",
|
|
||||||
ipAddress: context?.ipAddress,
|
|
||||||
userAgent: context?.userAgent,
|
|
||||||
metadata: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordExportActivity(userId: string, rowCount: number, filters: Record<string, string>, context?: RequestContext) {
|
|
||||||
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
||||||
const recentExports = await this.prisma.exportLog.count({
|
|
||||||
where: { userId, createdAt: { gte: since } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rowCount >= 500) {
|
|
||||||
await this.recordEvent({
|
|
||||||
userId,
|
|
||||||
eventType: "EXPORT_LARGE",
|
|
||||||
riskPoints: rowCount >= 1000 ? 25 : 15,
|
|
||||||
severity: rowCount >= 1000 ? "high" : "medium",
|
|
||||||
ipAddress: context?.ipAddress,
|
|
||||||
userAgent: context?.userAgent,
|
|
||||||
metadata: { rowCount, filters },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recentExports >= 5) {
|
|
||||||
await this.recordEvent({
|
|
||||||
userId,
|
|
||||||
eventType: "EXPORT_REPEATED",
|
|
||||||
riskPoints: 20,
|
|
||||||
severity: "high",
|
|
||||||
ipAddress: context?.ipAddress,
|
|
||||||
userAgent: context?.userAgent,
|
|
||||||
metadata: { exportsInLastHour: recentExports, rowCount },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordPaginationActivity(
|
|
||||||
userId: string,
|
|
||||||
resource: string,
|
|
||||||
page: number,
|
|
||||||
requestedLimit: number | undefined,
|
|
||||||
appliedLimit: number,
|
|
||||||
context?: RequestContext,
|
|
||||||
) {
|
|
||||||
const normalizedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
|
||||||
const normalizedRequestedLimit = Number.isFinite(requestedLimit) && requestedLimit ? Math.floor(requestedLimit) : appliedLimit;
|
|
||||||
const isLimitClamped = normalizedRequestedLimit > appliedLimit;
|
|
||||||
const isDeepScan = normalizedPage >= 20;
|
|
||||||
if (!isLimitClamped && !isDeepScan) return;
|
|
||||||
|
|
||||||
const since = new Date(Date.now() - 60 * 60 * 1000);
|
|
||||||
const recentSignals = await this.prisma.abuseEvent.count({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
eventType: { in: [...PAGINATION_EVENT_TYPES] },
|
|
||||||
createdAt: { gte: since },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const repeated = recentSignals >= 10;
|
|
||||||
|
|
||||||
if (isLimitClamped) {
|
|
||||||
await this.recordEvent({
|
|
||||||
userId,
|
|
||||||
eventType: "PAGINATION_LIMIT_CLAMPED",
|
|
||||||
riskPoints: repeated ? 10 : 3,
|
|
||||||
severity: repeated ? "medium" : "low",
|
|
||||||
ipAddress: context?.ipAddress,
|
|
||||||
userAgent: context?.userAgent,
|
|
||||||
metadata: {
|
|
||||||
resource,
|
|
||||||
page: normalizedPage,
|
|
||||||
requestedLimit: normalizedRequestedLimit,
|
|
||||||
appliedLimit,
|
|
||||||
recentSignals,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDeepScan) {
|
|
||||||
await this.recordEvent({
|
|
||||||
userId,
|
|
||||||
eventType: "PAGINATION_DEEP_SCAN",
|
|
||||||
riskPoints: repeated || normalizedPage >= 100 ? 20 : 8,
|
|
||||||
severity: repeated || normalizedPage >= 100 ? "high" : "medium",
|
|
||||||
ipAddress: context?.ipAddress,
|
|
||||||
userAgent: context?.userAgent,
|
|
||||||
metadata: {
|
|
||||||
resource,
|
|
||||||
page: normalizedPage,
|
|
||||||
requestedLimit: normalizedRequestedLimit,
|
|
||||||
appliedLimit,
|
|
||||||
recentSignals,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRiskProfile(userId: string) {
|
|
||||||
const since = new Date(Date.now() - RISK_WINDOW_HOURS * 60 * 60 * 1000);
|
|
||||||
const events = await this.prisma.abuseEvent.findMany({
|
|
||||||
where: { userId, createdAt: { gte: since } },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 25,
|
|
||||||
});
|
|
||||||
const score = Math.min(100, events.reduce((sum, event) => sum + event.riskPoints, 0));
|
|
||||||
return {
|
|
||||||
score,
|
|
||||||
level: this.levelForScore(score),
|
|
||||||
windowHours: RISK_WINDOW_HOURS,
|
|
||||||
recentEvents: events.map((event) => ({
|
|
||||||
id: event.id,
|
|
||||||
eventType: event.eventType,
|
|
||||||
severity: event.severity,
|
|
||||||
riskPoints: event.riskPoints,
|
|
||||||
createdAt: event.createdAt,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private levelForScore(score: number) {
|
|
||||||
if (score >= 70) return "high";
|
|
||||||
if (score >= 30) return "medium";
|
|
||||||
return "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
private hashValue(value: string) {
|
|
||||||
return crypto.createHash("sha256").update(value).digest("hex");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
export type RequestContext = {
|
|
||||||
ipAddress?: string;
|
|
||||||
userAgent?: string;
|
|
||||||
sessionNonce?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function requestContextFrom(req: { ip?: string; headers?: { [key: string]: unknown } }): RequestContext {
|
|
||||||
const forwardedFor = req.headers?.["x-forwarded-for"];
|
|
||||||
const userAgent = req.headers?.["user-agent"];
|
|
||||||
return {
|
|
||||||
ipAddress: typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : req.ip,
|
|
||||||
userAgent: typeof userAgent === "string" ? userAgent : undefined,
|
|
||||||
sessionNonce: typeof req.headers?.["x-ledgerone-session-nonce"] === "string"
|
|
||||||
? req.headers["x-ledgerone-session-nonce"]
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,9 +1,7 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||||
import { ok } from "../common/response";
|
import { ok } from "../common/response";
|
||||||
import { AccountsService } from "./accounts.service";
|
import { AccountsService } from "./accounts.service";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { CreateManualAccountDto } from "./dto/create-manual-account.dto";
|
|
||||||
import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto";
|
|
||||||
|
|
||||||
@Controller("accounts")
|
@Controller("accounts")
|
||||||
export class AccountsController {
|
export class AccountsController {
|
||||||
@ -28,7 +26,7 @@ export class AccountsController {
|
|||||||
@Post("manual")
|
@Post("manual")
|
||||||
async manual(
|
async manual(
|
||||||
@CurrentUser() userId: string,
|
@CurrentUser() userId: string,
|
||||||
@Body() payload: CreateManualAccountDto,
|
@Body() payload: { institutionName: string; accountType: string; mask?: string },
|
||||||
) {
|
) {
|
||||||
const data = await this.accountsService.createManualAccount(userId, payload);
|
const data = await this.accountsService.createManualAccount(userId, payload);
|
||||||
return ok(data);
|
return ok(data);
|
||||||
@ -39,14 +37,4 @@ export class AccountsController {
|
|||||||
const data = await this.accountsService.refreshBalances(userId);
|
const data = await this.accountsService.refreshBalances(userId);
|
||||||
return ok(data);
|
return ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id/ownership")
|
|
||||||
async updateOwnership(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() payload: UpdateAccountOwnershipDto,
|
|
||||||
) {
|
|
||||||
const data = await this.accountsService.updateOwnership(userId, id, payload);
|
|
||||||
return ok(data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PlaidModule } from "../plaid/plaid.module";
|
import { PlaidModule } from "../plaid/plaid.module";
|
||||||
import { StripeModule } from "../stripe/stripe.module";
|
|
||||||
import { TellerModule } from "../teller/teller.module";
|
|
||||||
import { AccountsController } from "./accounts.controller";
|
import { AccountsController } from "./accounts.controller";
|
||||||
import { AccountsService } from "./accounts.service";
|
import { AccountsService } from "./accounts.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlaidModule, TellerModule, StripeModule],
|
imports: [PlaidModule],
|
||||||
controllers: [AccountsController],
|
controllers: [AccountsController],
|
||||||
providers: [AccountsService]
|
providers: [AccountsService]
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,26 +1,18 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { PlaidService } from "../plaid/plaid.service";
|
import { PlaidService } from "../plaid/plaid.service";
|
||||||
import { TellerService } from "../teller/teller.service";
|
|
||||||
import { OpaqueIdService } from "../common/opaque-id.service";
|
|
||||||
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
|
||||||
import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto";
|
|
||||||
|
|
||||||
const UI_PAGE_SIZE_LIMIT = 25;
|
const MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountsService {
|
export class AccountsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly plaidService: PlaidService,
|
private readonly plaidService: PlaidService,
|
||||||
private readonly tellerService: TellerService,
|
|
||||||
private readonly opaqueIds: OpaqueIdService,
|
|
||||||
private readonly planLimits: PlanLimitsService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(userId: string, page = 1, limit = 20) {
|
async list(userId: string, page = 1, limit = 20) {
|
||||||
const requestedLimit = Number.isFinite(limit) && limit ? limit : 20;
|
const take = Math.min(limit, MAX_PAGE_SIZE);
|
||||||
const take = Math.min(Math.max(requestedLimit, 1), UI_PAGE_SIZE_LIMIT);
|
|
||||||
const skip = (page - 1) * take;
|
const skip = (page - 1) * take;
|
||||||
const [accounts, total] = await Promise.all([
|
const [accounts, total] = await Promise.all([
|
||||||
this.prisma.account.findMany({
|
this.prisma.account.findMany({
|
||||||
@ -37,17 +29,6 @@ export class AccountsService {
|
|||||||
availableBalance: true,
|
availableBalance: true,
|
||||||
isoCurrencyCode: true,
|
isoCurrencyCode: true,
|
||||||
lastBalanceSync: true,
|
lastBalanceSync: true,
|
||||||
lastTransactionSync: true,
|
|
||||||
lastSyncAttemptAt: true,
|
|
||||||
syncStatus: true,
|
|
||||||
lastSyncError: true,
|
|
||||||
syncConsecutiveFailures: true,
|
|
||||||
plaidWebhookCode: true,
|
|
||||||
plaidWebhookAt: true,
|
|
||||||
tellerAccountId: true,
|
|
||||||
householdId: true,
|
|
||||||
ownerUserId: true,
|
|
||||||
ownershipType: true,
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
// Intentionally omit plaidAccessToken — never expose the encrypted token
|
// Intentionally omit plaidAccessToken — never expose the encrypted token
|
||||||
@ -55,62 +36,27 @@ export class AccountsService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.account.count({ where: { userId, isActive: true } }),
|
this.prisma.account.count({ where: { userId, isActive: true } }),
|
||||||
]);
|
]);
|
||||||
return {
|
return { accounts, total, page, limit: take };
|
||||||
accounts: accounts.map((account) => ({
|
|
||||||
id: this.opaqueIds.encode("account", userId, account.id),
|
|
||||||
institutionName: account.institutionName,
|
|
||||||
accountType: account.accountType,
|
|
||||||
mask: account.mask,
|
|
||||||
currentBalance: account.currentBalance,
|
|
||||||
availableBalance: account.availableBalance,
|
|
||||||
isoCurrencyCode: account.isoCurrencyCode,
|
|
||||||
lastBalanceSync: account.lastBalanceSync,
|
|
||||||
lastTransactionSync: account.lastTransactionSync,
|
|
||||||
lastSyncAttemptAt: account.lastSyncAttemptAt,
|
|
||||||
syncStatus: account.syncStatus,
|
|
||||||
lastSyncError: account.lastSyncError,
|
|
||||||
syncConsecutiveFailures: account.syncConsecutiveFailures,
|
|
||||||
plaidWebhookCode: account.plaidWebhookCode,
|
|
||||||
plaidWebhookAt: account.plaidWebhookAt,
|
|
||||||
tellerConnected: Boolean(account.tellerAccountId),
|
|
||||||
householdId: account.householdId,
|
|
||||||
ownerUserId: account.ownerUserId,
|
|
||||||
ownershipType: account.ownershipType,
|
|
||||||
isActive: account.isActive,
|
|
||||||
createdAt: account.createdAt,
|
|
||||||
})),
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit: take,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createLinkToken(userId: string) {
|
async createLinkToken(userId: string) {
|
||||||
await this.planLimits.assertCanAddAccounts(userId, 1);
|
|
||||||
return this.plaidService.createLinkToken(userId);
|
return this.plaidService.createLinkToken(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshBalances(userId: string) {
|
async refreshBalances(userId: string) {
|
||||||
const [plaid, teller] = await Promise.all([
|
return this.plaidService.syncBalancesForUser(userId);
|
||||||
this.plaidService.syncBalancesForUser(userId),
|
|
||||||
this.tellerService.syncBalancesForUser(userId),
|
|
||||||
]);
|
|
||||||
return { updated: plaid.updated + teller.updated, plaid, teller };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createManualAccount(
|
async createManualAccount(
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: { institutionName: string; accountType: string; mask?: string },
|
payload: { institutionName: string; accountType: string; mask?: string },
|
||||||
) {
|
) {
|
||||||
await this.planLimits.assertCanAddAccounts(userId, 1);
|
return this.prisma.account.create({
|
||||||
const account = await this.prisma.account.create({
|
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
institutionName: payload.institutionName,
|
institutionName: payload.institutionName,
|
||||||
accountType: payload.accountType,
|
accountType: payload.accountType,
|
||||||
mask: payload.mask ?? null,
|
mask: payload.mask ?? null,
|
||||||
ownerUserId: userId,
|
|
||||||
ownershipType: "mine",
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@ -118,91 +64,9 @@ export class AccountsService {
|
|||||||
institutionName: true,
|
institutionName: true,
|
||||||
accountType: true,
|
accountType: true,
|
||||||
mask: true,
|
mask: true,
|
||||||
ownerUserId: true,
|
|
||||||
ownershipType: true,
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
|
||||||
...account,
|
|
||||||
id: this.opaqueIds.encode("account", userId, account.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateOwnership(userId: string, accountHandle: string, payload: UpdateAccountOwnershipDto) {
|
|
||||||
const accountId = this.opaqueIds.decode("account", userId, accountHandle);
|
|
||||||
const account = await this.prisma.account.findFirst({
|
|
||||||
where: { id: accountId, userId, isActive: true },
|
|
||||||
});
|
|
||||||
if (!account) throw new BadRequestException("Account not found.");
|
|
||||||
|
|
||||||
let householdId: string | null = null;
|
|
||||||
let ownerUserId: string | null = userId;
|
|
||||||
|
|
||||||
if (payload.ownershipType === "mine") {
|
|
||||||
householdId = null;
|
|
||||||
ownerUserId = userId;
|
|
||||||
} else {
|
|
||||||
if (!payload.householdId) {
|
|
||||||
throw new BadRequestException("Household is required for shared account ownership.");
|
|
||||||
}
|
|
||||||
const requesterMembership = await this.prisma.householdMember.findFirst({
|
|
||||||
where: { householdId: payload.householdId, userId, status: "active" },
|
|
||||||
});
|
|
||||||
if (!requesterMembership) throw new BadRequestException("Household not found.");
|
|
||||||
householdId = payload.householdId;
|
|
||||||
|
|
||||||
if (payload.ownershipType === "joint") {
|
|
||||||
ownerUserId = null;
|
|
||||||
} else {
|
|
||||||
if (!payload.ownerUserId || payload.ownerUserId === userId) {
|
|
||||||
throw new BadRequestException("Owner user must be another active household member for 'theirs' accounts.");
|
|
||||||
}
|
|
||||||
const ownerMembership = await this.prisma.householdMember.findFirst({
|
|
||||||
where: { householdId, userId: payload.ownerUserId, status: "active" },
|
|
||||||
});
|
|
||||||
if (!ownerMembership) throw new BadRequestException("Owner must be an active household member.");
|
|
||||||
ownerUserId = payload.ownerUserId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await this.prisma.account.update({
|
|
||||||
where: { id: accountId },
|
|
||||||
data: {
|
|
||||||
ownershipType: payload.ownershipType,
|
|
||||||
householdId,
|
|
||||||
ownerUserId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
institutionName: true,
|
|
||||||
accountType: true,
|
|
||||||
mask: true,
|
|
||||||
householdId: true,
|
|
||||||
ownerUserId: true,
|
|
||||||
ownershipType: true,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
action: "account.ownership.update",
|
|
||||||
metadata: {
|
|
||||||
accountId,
|
|
||||||
householdId,
|
|
||||||
ownerUserId,
|
|
||||||
ownershipType: payload.ownershipType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...updated,
|
|
||||||
id: this.opaqueIds.encode("account", userId, updated.id),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateManualAccountDto {
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
institutionName!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsIn(["checking", "savings", "credit", "investment", "loan", "cash", "other"])
|
|
||||||
accountType!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(12)
|
|
||||||
mask?: string;
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
|
||||||
|
|
||||||
export const ACCOUNT_OWNERSHIP_TYPES = ["mine", "theirs", "joint"] as const;
|
|
||||||
export type AccountOwnershipType = typeof ACCOUNT_OWNERSHIP_TYPES[number];
|
|
||||||
|
|
||||||
export class UpdateAccountOwnershipDto {
|
|
||||||
@IsIn(ACCOUNT_OWNERSHIP_TYPES)
|
|
||||||
ownershipType!: AccountOwnershipType;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
householdId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
ownerUserId?: string;
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
|
||||||
import { ok } from "../common/response";
|
|
||||||
import { Roles } from "../common/decorators/roles.decorator";
|
|
||||||
import { RolesGuard } from "../common/guards/roles.guard";
|
|
||||||
import { AdminService } from "./admin.service";
|
|
||||||
|
|
||||||
@Roles("admin")
|
|
||||||
@UseGuards(RolesGuard)
|
|
||||||
@Controller("admin")
|
|
||||||
export class AdminController {
|
|
||||||
constructor(private readonly adminService: AdminService) {}
|
|
||||||
|
|
||||||
@Get("users")
|
|
||||||
async users() {
|
|
||||||
return ok(await this.adminService.listUsers());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { AdminController } from "./admin.controller";
|
|
||||||
import { AdminService } from "./admin.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [AdminController],
|
|
||||||
providers: [AdminService],
|
|
||||||
})
|
|
||||||
export class AdminModule {}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AdminService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async listUsers() {
|
|
||||||
return this.prisma.user.findMany({
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 100,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
fullName: true,
|
|
||||||
role: true,
|
|
||||||
emailVerified: true,
|
|
||||||
twoFactorEnabled: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
subscription: { select: { plan: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
|
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
|
||||||
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
|
|
||||||
import { envValidationSchema } from "./config/env.validation";
|
import { envValidationSchema } from "./config/env.validation";
|
||||||
import { CommonModule } from "./common/common.module";
|
import { CommonModule } from "./common/common.module";
|
||||||
@ -11,7 +11,6 @@ import { SupabaseModule } from "./supabase/supabase.module";
|
|||||||
import { EmailModule } from "./email/email.module";
|
import { EmailModule } from "./email/email.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { PlaidModule } from "./plaid/plaid.module";
|
import { PlaidModule } from "./plaid/plaid.module";
|
||||||
import { TellerModule } from "./teller/teller.module";
|
|
||||||
import { TaxModule } from "./tax/tax.module";
|
import { TaxModule } from "./tax/tax.module";
|
||||||
import { TransactionsModule } from "./transactions/transactions.module";
|
import { TransactionsModule } from "./transactions/transactions.module";
|
||||||
import { AccountsModule } from "./accounts/accounts.module";
|
import { AccountsModule } from "./accounts/accounts.module";
|
||||||
@ -20,19 +19,8 @@ import { ExportsModule } from "./exports/exports.module";
|
|||||||
import { StripeModule } from "./stripe/stripe.module";
|
import { StripeModule } from "./stripe/stripe.module";
|
||||||
import { TwoFactorModule } from "./auth/twofa/two-factor.module";
|
import { TwoFactorModule } from "./auth/twofa/two-factor.module";
|
||||||
import { GoogleModule } from "./google/google.module";
|
import { GoogleModule } from "./google/google.module";
|
||||||
import { AbuseModule } from "./abuse/abuse.module";
|
|
||||||
import { PublicApiModule } from "./public-api/public-api.module";
|
|
||||||
import { AdminModule } from "./admin/admin.module";
|
|
||||||
import { HouseholdsModule } from "./households/households.module";
|
|
||||||
import { NotificationsModule } from "./notifications/notifications.module";
|
|
||||||
import { BillPayModule } from "./bill-pay/bill-pay.module";
|
|
||||||
import { CreditScoreModule } from "./credit-score/credit-score.module";
|
|
||||||
import { ViewModule } from "./view/view.module";
|
|
||||||
import { ComplianceModule } from "./compliance/compliance.module";
|
|
||||||
import { PlanningModule } from "./planning/planning.module";
|
|
||||||
import { LoggerModule } from "nestjs-pino";
|
import { LoggerModule } from "nestjs-pino";
|
||||||
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
|
||||||
import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.interceptor";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -69,12 +57,10 @@ import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.intercep
|
|||||||
StorageModule,
|
StorageModule,
|
||||||
SupabaseModule,
|
SupabaseModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
AbuseModule,
|
|
||||||
|
|
||||||
// ─── Feature modules ─────────────────────────────────────────────────────
|
// ─── Feature modules ─────────────────────────────────────────────────────
|
||||||
AuthModule,
|
AuthModule,
|
||||||
PlaidModule,
|
PlaidModule,
|
||||||
TellerModule,
|
|
||||||
TaxModule,
|
TaxModule,
|
||||||
TransactionsModule,
|
TransactionsModule,
|
||||||
AccountsModule,
|
AccountsModule,
|
||||||
@ -83,23 +69,12 @@ import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.intercep
|
|||||||
StripeModule,
|
StripeModule,
|
||||||
TwoFactorModule,
|
TwoFactorModule,
|
||||||
GoogleModule,
|
GoogleModule,
|
||||||
PublicApiModule,
|
|
||||||
AdminModule,
|
|
||||||
HouseholdsModule,
|
|
||||||
NotificationsModule,
|
|
||||||
BillPayModule,
|
|
||||||
CreditScoreModule,
|
|
||||||
ViewModule,
|
|
||||||
ComplianceModule,
|
|
||||||
PlanningModule,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Apply rate limiting globally
|
// Apply rate limiting globally
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
// Apply JWT auth globally (routes decorated with @Public() are exempt)
|
// Apply JWT auth globally (routes decorated with @Public() are exempt)
|
||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
// Treat browser/API clients as untrusted presentation clients by default.
|
|
||||||
{ provide: APP_INTERCEPTOR, useClass: BrowserUntrustedInterceptor },
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -1,34 +1,30 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req } from "@nestjs/common";
|
import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
|
||||||
import { ok } from "../common/response";
|
import { ok } from "../common/response";
|
||||||
import { requestContextFrom } from "../abuse/abuse.types";
|
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { SocialAuthService } from "./social-auth.service";
|
|
||||||
import { LoginDto } from "./dto/login.dto";
|
import { LoginDto } from "./dto/login.dto";
|
||||||
import { RegisterDto } from "./dto/register.dto";
|
import { RegisterDto } from "./dto/register.dto";
|
||||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
||||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { Public } from "../common/decorators/public.decorator";
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(private readonly authService: AuthService) {}
|
||||||
private readonly authService: AuthService,
|
|
||||||
private readonly socialAuthService: SocialAuthService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post("register")
|
@Post("register")
|
||||||
async register(@Body() payload: RegisterDto, @Req() req: Request) {
|
async register(@Body() payload: RegisterDto) {
|
||||||
return ok(await this.authService.register(payload, requestContextFrom(req)));
|
return ok(await this.authService.register(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post("login")
|
@Post("login")
|
||||||
async login(@Body() payload: LoginDto, @Req() req: Request) {
|
async login(@Body() payload: LoginDto) {
|
||||||
return ok(await this.authService.login(payload, requestContextFrom(req)));
|
return ok(await this.authService.login(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -39,8 +35,8 @@ export class AuthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post("refresh")
|
@Post("refresh")
|
||||||
async refresh(@Body("refreshToken") refreshToken: string, @Req() req: Request) {
|
async refresh(@Body("refreshToken") refreshToken: string) {
|
||||||
return ok(await this.authService.refreshAccessToken(refreshToken, requestContextFrom(req)));
|
return ok(await this.authService.refreshAccessToken(refreshToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("logout")
|
@Post("logout")
|
||||||
@ -60,46 +56,13 @@ export class AuthController {
|
|||||||
return ok(await this.authService.resetPassword(payload));
|
return ok(await this.authService.resetPassword(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Get("social/:provider/url")
|
|
||||||
async socialAuthUrl(@Param("provider") provider: string, @Query("next") next: string) {
|
|
||||||
return ok(this.socialAuthService.getAuthorizationUrl(provider, next));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Post("social/:provider/callback")
|
|
||||||
async socialCallback(
|
|
||||||
@Param("provider") provider: string,
|
|
||||||
@Body("provider") bodyProvider: string | undefined,
|
|
||||||
@Body("code") code: string,
|
|
||||||
@Body("state") state: string,
|
|
||||||
@Req() req: Request,
|
|
||||||
) {
|
|
||||||
return ok(await this.socialAuthService.completeLogin(bodyProvider ?? provider, code, state, requestContextFrom(req)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("me")
|
@Get("me")
|
||||||
async me(@CurrentUser() userId: string) {
|
async me(@CurrentUser() userId: string) {
|
||||||
return ok(await this.authService.getProfile(userId));
|
return ok(await this.authService.getProfile(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("me/privacy-summary")
|
|
||||||
async privacySummary(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.authService.getPrivacySummary(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("me/data-export")
|
|
||||||
async dataExport(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.authService.exportPersonalData(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch("profile")
|
@Patch("profile")
|
||||||
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
|
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
|
||||||
return ok(await this.authService.updateProfile(userId, payload));
|
return ok(await this.authService.updateProfile(userId, payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete("me")
|
|
||||||
async deleteMe(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.authService.deleteAccount(userId));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,18 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { SocialAuthService } from "./social-auth.service";
|
|
||||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
JwtModule.registerAsync({
|
JwtModule.register({
|
||||||
inject: [ConfigService],
|
secret: process.env.JWT_SECRET,
|
||||||
useFactory: (config: ConfigService) => ({
|
signOptions: { expiresIn: "15m" },
|
||||||
secret: config.getOrThrow<string>("JWT_SECRET"),
|
|
||||||
signOptions: { expiresIn: `${config.get<number>("JWT_ACCESS_TTL_SECONDS", 60)}s` },
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, SocialAuthService, JwtAuthGuard],
|
providers: [AuthService, JwtAuthGuard],
|
||||||
exports: [AuthService, JwtModule, JwtAuthGuard],
|
exports: [AuthService, JwtModule, JwtAuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Optional,
|
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
@ -16,13 +15,10 @@ import { RegisterDto } from "./dto/register.dto";
|
|||||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
||||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||||
import { AbuseService } from "../abuse/abuse.service";
|
|
||||||
import { RequestContext } from "../abuse/abuse.types";
|
|
||||||
|
|
||||||
const VERIFY_TOKEN_TTL_HOURS = 24;
|
const VERIFY_TOKEN_TTL_HOURS = 24;
|
||||||
const RESET_TOKEN_TTL_HOURS = 1;
|
const RESET_TOKEN_TTL_HOURS = 1;
|
||||||
const REFRESH_TOKEN_TTL_DAYS = 30;
|
const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||||
const SESSION_TTL_DAYS = 30;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -31,10 +27,9 @@ export class AuthService {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly emailService: EmailService,
|
private readonly emailService: EmailService,
|
||||||
private readonly encryption: EncryptionService,
|
private readonly encryption: EncryptionService,
|
||||||
@Optional() private readonly abuseService?: AbuseService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(payload: RegisterDto, context?: RequestContext) {
|
async register(payload: RegisterDto) {
|
||||||
const email = payload.email.toLowerCase().trim();
|
const email = payload.email.toLowerCase().trim();
|
||||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||||
if (existing) throw new BadRequestException("Email already registered.");
|
if (existing) throw new BadRequestException("Email already registered.");
|
||||||
@ -52,21 +47,20 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
await this.emailService.sendVerificationEmail(email, verifyToken);
|
await this.emailService.sendVerificationEmail(email, verifyToken);
|
||||||
|
|
||||||
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
|
const accessToken = this.signAccessToken(user.id);
|
||||||
|
const refreshToken = await this.createRefreshToken(user.id);
|
||||||
return {
|
return {
|
||||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
sessionNonce,
|
|
||||||
message: "Registration successful. Please verify your email.",
|
message: "Registration successful. Please verify your email.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(payload: LoginDto, context?: RequestContext) {
|
async login(payload: LoginDto) {
|
||||||
const email = payload.email.toLowerCase().trim();
|
const email = payload.email.toLowerCase().trim();
|
||||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||||
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
|
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
|
||||||
await this.abuseService?.recordLoginFailure(email, user?.id, context);
|
|
||||||
throw new UnauthorizedException("Invalid credentials.");
|
throw new UnauthorizedException("Invalid credentials.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,12 +77,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
|
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
|
||||||
const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context);
|
const accessToken = this.signAccessToken(user.id);
|
||||||
|
const refreshToken = await this.createRefreshToken(user.id);
|
||||||
return {
|
return {
|
||||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
sessionNonce,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,47 +96,24 @@ export class AuthService {
|
|||||||
return { message: "Email verified successfully." };
|
return { message: "Email verified successfully." };
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAccessToken(rawRefreshToken: string, context?: RequestContext) {
|
async refreshAccessToken(rawRefreshToken: string) {
|
||||||
const tokenHash = this.hashToken(rawRefreshToken);
|
const tokenHash = this.hashToken(rawRefreshToken);
|
||||||
const record = await this.prisma.refreshToken.findUnique({
|
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
|
||||||
where: { tokenHash },
|
if (!record || record.revokedAt || record.expiresAt < new Date()) {
|
||||||
include: { session: true },
|
|
||||||
});
|
|
||||||
if (!record || record.revokedAt || record.expiresAt < new Date() || !record.session) {
|
|
||||||
throw new UnauthorizedException("Invalid or expired refresh token.");
|
throw new UnauthorizedException("Invalid or expired refresh token.");
|
||||||
}
|
}
|
||||||
this.assertSessionMatches(record.session, context);
|
|
||||||
const sessionNonce = this.createSessionNonce();
|
|
||||||
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
|
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
|
||||||
await this.prisma.session.update({
|
const accessToken = this.signAccessToken(record.userId);
|
||||||
where: { id: record.sessionId! },
|
const refreshToken = await this.createRefreshToken(record.userId);
|
||||||
data: { lastSeenAt: new Date(), nonceHash: this.hashToken(sessionNonce) },
|
return { accessToken, refreshToken };
|
||||||
});
|
|
||||||
const accessToken = this.signAccessToken(record.userId, record.sessionId!);
|
|
||||||
const refreshToken = await this.createRefreshToken(record.userId, record.sessionId!);
|
|
||||||
return { accessToken, refreshToken, sessionNonce };
|
|
||||||
}
|
|
||||||
|
|
||||||
async issueTokensForUser(userId: string, context?: RequestContext) {
|
|
||||||
const { session, sessionNonce } = await this.createSession(userId, context);
|
|
||||||
const accessToken = this.signAccessToken(userId, session.id);
|
|
||||||
const refreshToken = await this.createRefreshToken(userId, session.id);
|
|
||||||
return { accessToken, refreshToken, sessionNonce };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(rawRefreshToken: string) {
|
async logout(rawRefreshToken: string) {
|
||||||
const tokenHash = this.hashToken(rawRefreshToken);
|
const tokenHash = this.hashToken(rawRefreshToken);
|
||||||
const token = await this.prisma.refreshToken.findUnique({ where: { tokenHash }, select: { sessionId: true } });
|
|
||||||
await this.prisma.refreshToken.updateMany({
|
await this.prisma.refreshToken.updateMany({
|
||||||
where: { tokenHash, revokedAt: null },
|
where: { tokenHash, revokedAt: null },
|
||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
if (token?.sessionId) {
|
|
||||||
await this.prisma.session.updateMany({
|
|
||||||
where: { id: token.sessionId, revokedAt: null },
|
|
||||||
data: { revokedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { message: "Logged out." };
|
return { message: "Logged out." };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,10 +140,6 @@ export class AuthService {
|
|||||||
where: { userId: record.userId, revokedAt: null },
|
where: { userId: record.userId, revokedAt: null },
|
||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
await this.prisma.session.updateMany({
|
|
||||||
where: { userId: record.userId, revokedAt: null },
|
|
||||||
data: { revokedAt: new Date() },
|
|
||||||
});
|
|
||||||
return { message: "Password reset successfully. Please log in." };
|
return { message: "Password reset successfully. Please log in." };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,329 +180,19 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrivacySummary(userId: string) {
|
verifyToken(token: string): { sub: string } {
|
||||||
const user = await this.prisma.user.findUnique({
|
return this.jwtService.verify<{ sub: string }>(token);
|
||||||
where: { id: userId },
|
|
||||||
select: { id: true, email: true, emailVerified: true, twoFactorEnabled: true, createdAt: true },
|
|
||||||
});
|
|
||||||
if (!user) throw new NotFoundException("User not found.");
|
|
||||||
|
|
||||||
const [
|
|
||||||
accounts,
|
|
||||||
transactions,
|
|
||||||
transactionComments,
|
|
||||||
rules,
|
|
||||||
taxReturns,
|
|
||||||
exports,
|
|
||||||
exportDownloadTokens,
|
|
||||||
apiKeys,
|
|
||||||
csvImportMappings,
|
|
||||||
notifications,
|
|
||||||
pushSubscriptions,
|
|
||||||
bills,
|
|
||||||
billPayees,
|
|
||||||
billPayments,
|
|
||||||
creditScores,
|
|
||||||
householdMemberships,
|
|
||||||
householdInvites,
|
|
||||||
householdGoals,
|
|
||||||
] = await Promise.all([
|
|
||||||
this.prisma.account.count({ where: { userId } }),
|
|
||||||
this.prisma.transactionRaw.count({ where: { account: { userId } } }),
|
|
||||||
this.prisma.transactionComment.count({ where: { userId } }),
|
|
||||||
this.prisma.rule.count({ where: { userId } }),
|
|
||||||
this.prisma.taxReturn.count({ where: { userId } }),
|
|
||||||
this.prisma.exportLog.count({ where: { userId } }),
|
|
||||||
this.prisma.exportDownloadToken.count({ where: { userId } }),
|
|
||||||
this.prisma.apiKey.count({ where: { userId } }),
|
|
||||||
this.prisma.csvImportMapping.count({ where: { userId } }),
|
|
||||||
this.prisma.notification.count({ where: { userId } }),
|
|
||||||
this.prisma.pushSubscription.count({ where: { userId } }),
|
|
||||||
this.prisma.bill.count({ where: { userId } }),
|
|
||||||
this.prisma.billPayee.count({ where: { userId } }),
|
|
||||||
this.prisma.billPayment.count({ where: { userId } }),
|
|
||||||
this.prisma.creditScoreEntry.count({ where: { userId } }),
|
|
||||||
this.prisma.householdMember.count({ where: { userId } }),
|
|
||||||
this.prisma.householdInvite.count({ where: { OR: [{ invitedById: userId }, { acceptedById: userId }] } }),
|
|
||||||
this.prisma.householdGoal.count({ where: { createdByUserId: userId } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
subject: user,
|
|
||||||
dataCategories: {
|
|
||||||
accounts,
|
|
||||||
transactions,
|
|
||||||
transactionComments,
|
|
||||||
rules,
|
|
||||||
taxReturns,
|
|
||||||
exports,
|
|
||||||
exportDownloadTokens,
|
|
||||||
apiKeys,
|
|
||||||
csvImportMappings,
|
|
||||||
notifications,
|
|
||||||
pushSubscriptions,
|
|
||||||
bills,
|
|
||||||
billPayees,
|
|
||||||
billPayments,
|
|
||||||
creditScores,
|
|
||||||
householdMemberships,
|
|
||||||
householdInvites,
|
|
||||||
householdGoals,
|
|
||||||
},
|
|
||||||
controls: {
|
|
||||||
exportEndpoint: "/api/auth/me/data-export",
|
|
||||||
deletionEndpoint: "/api/auth/me",
|
|
||||||
excludedFromExport: [
|
|
||||||
"passwordHash",
|
|
||||||
"twoFactorSecret",
|
|
||||||
"session and refresh tokens",
|
|
||||||
"OAuth tokens",
|
|
||||||
"API key hashes",
|
|
||||||
"bank access tokens",
|
|
||||||
"raw bank payloads",
|
|
||||||
"stable internal account and transaction IDs",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportPersonalData(userId: string) {
|
private signAccessToken(userId: string): string {
|
||||||
const user = await this.prisma.user.findUnique({
|
return this.jwtService.sign({ sub: userId });
|
||||||
where: { id: userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
fullName: true,
|
|
||||||
phone: true,
|
|
||||||
companyName: true,
|
|
||||||
addressLine1: true,
|
|
||||||
addressLine2: true,
|
|
||||||
city: true,
|
|
||||||
state: true,
|
|
||||||
postalCode: true,
|
|
||||||
country: true,
|
|
||||||
role: true,
|
|
||||||
emailVerified: true,
|
|
||||||
twoFactorEnabled: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!user) throw new NotFoundException("User not found.");
|
|
||||||
|
|
||||||
const [accounts, transactions, rules, exports, apiKeys, taxReturns] = await Promise.all([
|
|
||||||
this.prisma.account.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: {
|
|
||||||
institutionName: true,
|
|
||||||
accountType: true,
|
|
||||||
mask: true,
|
|
||||||
ownershipType: true,
|
|
||||||
currentBalance: true,
|
|
||||||
availableBalance: true,
|
|
||||||
isoCurrencyCode: true,
|
|
||||||
syncStatus: true,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.transactionRaw.findMany({
|
|
||||||
where: { account: { userId } },
|
|
||||||
select: {
|
|
||||||
date: true,
|
|
||||||
amount: true,
|
|
||||||
description: true,
|
|
||||||
source: true,
|
|
||||||
ingestedAt: true,
|
|
||||||
derived: {
|
|
||||||
select: {
|
|
||||||
userCategory: true,
|
|
||||||
userNotes: true,
|
|
||||||
attribution: true,
|
|
||||||
splitMode: true,
|
|
||||||
splitMinePercent: true,
|
|
||||||
splitYoursPercent: true,
|
|
||||||
isHidden: true,
|
|
||||||
modifiedAt: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { date: "desc" },
|
|
||||||
}),
|
|
||||||
this.prisma.rule.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { name: true, priority: true, conditions: true, actions: true, isActive: true, createdAt: true },
|
|
||||||
orderBy: { priority: "asc" },
|
|
||||||
}),
|
|
||||||
this.prisma.exportLog.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { format: true, destination: true, filters: true, rowCount: true, fileName: true, mimeType: true, fileHash: true, metadata: true, createdAt: true },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
}),
|
|
||||||
this.prisma.apiKey.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { name: true, prefix: true, scopes: true, lastUsedAt: true, revokedAt: true, expiresAt: true, createdAt: true },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
}),
|
|
||||||
this.prisma.taxReturn.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { taxYear: true, filingType: true, jurisdictions: true, status: true, summary: true, createdAt: true, updatedAt: true },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
subject: user,
|
|
||||||
minimizationNotes: [
|
|
||||||
"Secrets, raw bank payloads, access tokens, token hashes, and stable internal account/transaction IDs are excluded.",
|
|
||||||
],
|
|
||||||
data: { accounts, transactions, rules, exports, apiKeys, taxReturns },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAccount(userId: string) {
|
private async createRefreshToken(userId: string): Promise<string> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
|
|
||||||
if (!user) throw new NotFoundException("User not found.");
|
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
|
||||||
const [accounts, rawTransactions, rules, taxReturns, bills, createdHouseholds] = await Promise.all([
|
|
||||||
tx.account.findMany({ where: { userId }, select: { id: true } }),
|
|
||||||
tx.transactionRaw.findMany({ where: { account: { userId } }, select: { id: true } }),
|
|
||||||
tx.rule.findMany({ where: { userId }, select: { id: true } }),
|
|
||||||
tx.taxReturn.findMany({ where: { userId }, select: { id: true } }),
|
|
||||||
tx.bill.findMany({ where: { userId }, select: { id: true } }),
|
|
||||||
tx.household.findMany({ where: { createdByUserId: userId }, select: { id: true } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const accountIds = accounts.map((account) => account.id);
|
|
||||||
const transactionIds = rawTransactions.map((transaction) => transaction.id);
|
|
||||||
const ruleIds = rules.map((rule) => rule.id);
|
|
||||||
const taxReturnIds = taxReturns.map((taxReturn) => taxReturn.id);
|
|
||||||
const billIds = bills.map((bill) => bill.id);
|
|
||||||
const createdHouseholdIds = createdHouseholds.map((household) => household.id);
|
|
||||||
|
|
||||||
await tx.taxDocument.deleteMany({ where: { taxReturnId: { in: taxReturnIds } } });
|
|
||||||
await tx.ruleExecution.deleteMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ ruleId: { in: ruleIds } },
|
|
||||||
{ transactionId: { in: transactionIds } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.transactionComment.deleteMany({
|
|
||||||
where: { OR: [{ userId }, { rawTransactionId: { in: transactionIds } }] },
|
|
||||||
});
|
|
||||||
await tx.transactionDerived.deleteMany({ where: { rawTransactionId: { in: transactionIds } } });
|
|
||||||
await tx.transactionRaw.deleteMany({ where: { id: { in: transactionIds } } });
|
|
||||||
await tx.account.deleteMany({ where: { id: { in: accountIds } } });
|
|
||||||
await tx.rule.deleteMany({ where: { userId } });
|
|
||||||
await tx.exportDownloadToken.deleteMany({ where: { userId } });
|
|
||||||
await tx.exportLog.deleteMany({ where: { userId } });
|
|
||||||
await tx.auditLog.deleteMany({ where: { userId } });
|
|
||||||
await tx.abuseEvent.deleteMany({ where: { userId } });
|
|
||||||
await tx.apiKey.deleteMany({ where: { userId } });
|
|
||||||
await tx.csvImportMapping.deleteMany({ where: { userId } });
|
|
||||||
await tx.googleConnection.deleteMany({ where: { userId } });
|
|
||||||
await tx.notificationPreference.deleteMany({ where: { userId } });
|
|
||||||
await tx.notification.deleteMany({ where: { userId } });
|
|
||||||
await tx.pushSubscription.deleteMany({ where: { userId } });
|
|
||||||
await tx.billPayment.deleteMany({
|
|
||||||
where: { OR: [{ userId }, { billId: { in: billIds } }] },
|
|
||||||
});
|
|
||||||
await tx.bill.deleteMany({ where: { userId } });
|
|
||||||
await tx.billPayee.deleteMany({ where: { userId } });
|
|
||||||
await tx.creditScoreEntry.deleteMany({ where: { userId } });
|
|
||||||
await tx.householdInvite.deleteMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ invitedById: userId },
|
|
||||||
{ acceptedById: userId },
|
|
||||||
{ householdId: { in: createdHouseholdIds } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.householdGoal.deleteMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ createdByUserId: userId },
|
|
||||||
{ householdId: { in: createdHouseholdIds } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.householdMember.deleteMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ userId },
|
|
||||||
{ householdId: { in: createdHouseholdIds } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.household.deleteMany({ where: { id: { in: createdHouseholdIds } } });
|
|
||||||
await tx.emailVerificationToken.deleteMany({ where: { userId } });
|
|
||||||
await tx.passwordResetToken.deleteMany({ where: { userId } });
|
|
||||||
await tx.refreshToken.deleteMany({ where: { userId } });
|
|
||||||
await tx.session.deleteMany({ where: { userId } });
|
|
||||||
await tx.socialAccount.deleteMany({ where: { userId } });
|
|
||||||
await tx.subscription.deleteMany({ where: { userId } });
|
|
||||||
await tx.taxReturn.deleteMany({ where: { userId } });
|
|
||||||
await tx.user.delete({ where: { id: userId } });
|
|
||||||
});
|
|
||||||
|
|
||||||
return { message: "Account and associated personal data deleted." };
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyToken(token: string): { sub: string; sid: string } {
|
|
||||||
return this.jwtService.verify<{ sub: string; sid: string }>(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private signAccessToken(userId: string, sessionId: string): string {
|
|
||||||
return this.jwtService.sign({ sub: userId, sid: sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createSession(userId: string, context?: RequestContext) {
|
|
||||||
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400 * 1000);
|
|
||||||
const sessionNonce = this.createSessionNonce();
|
|
||||||
const session = await this.prisma.session.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
ipHash: this.hashBindingValue(context?.ipAddress ?? "unknown-ip"),
|
|
||||||
userAgentHash: this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"),
|
|
||||||
nonceHash: this.hashToken(sessionNonce),
|
|
||||||
expiresAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { session, sessionNonce };
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertSessionMatches(
|
|
||||||
session: { revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string; nonceHash?: string | null },
|
|
||||||
context?: RequestContext,
|
|
||||||
) {
|
|
||||||
if (session.revokedAt || session.expiresAt < new Date()) {
|
|
||||||
throw new UnauthorizedException("Session expired.");
|
|
||||||
}
|
|
||||||
const ipHash = this.hashBindingValue(context?.ipAddress ?? "unknown-ip");
|
|
||||||
const userAgentHash = this.hashBindingValue(context?.userAgent ?? "unknown-user-agent");
|
|
||||||
if (session.ipHash !== ipHash || session.userAgentHash !== userAgentHash) {
|
|
||||||
throw new UnauthorizedException("Session binding mismatch.");
|
|
||||||
}
|
|
||||||
if (!session.nonceHash || !context?.sessionNonce || session.nonceHash !== this.hashToken(context.sessionNonce)) {
|
|
||||||
throw new UnauthorizedException("Session nonce mismatch.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createSessionNonce(): string {
|
|
||||||
return crypto.randomBytes(32).toString("base64url");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createRefreshToken(userId: string, sessionId: string): Promise<string> {
|
|
||||||
const raw = crypto.randomBytes(40).toString("hex");
|
const raw = crypto.randomBytes(40).toString("hex");
|
||||||
const tokenHash = this.hashToken(raw);
|
const tokenHash = this.hashToken(raw);
|
||||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000);
|
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000);
|
||||||
await this.prisma.refreshToken.create({ data: { userId, sessionId, tokenHash, expiresAt } });
|
await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -543,10 +200,6 @@ export class AuthService {
|
|||||||
return crypto.createHash("sha256").update(token).digest("hex");
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashBindingValue(value: string): string {
|
|
||||||
return crypto.createHash("sha256").update(value).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
private hashPassword(password: string): string {
|
private hashPassword(password: string): string {
|
||||||
const salt = crypto.randomBytes(16).toString("hex");
|
const salt = crypto.randomBytes(16).toString("hex");
|
||||||
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
||||||
|
|||||||
@ -1,296 +0,0 @@
|
|||||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
import { google } from "googleapis";
|
|
||||||
import { AuthService } from "./auth.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { RequestContext } from "../abuse/abuse.types";
|
|
||||||
|
|
||||||
type SocialProvider = "google" | "apple";
|
|
||||||
|
|
||||||
type SocialProfile = {
|
|
||||||
provider: SocialProvider;
|
|
||||||
providerAccountId: string;
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AppleJwk = {
|
|
||||||
kid: string;
|
|
||||||
alg: string;
|
|
||||||
kty: string;
|
|
||||||
use: string;
|
|
||||||
n: string;
|
|
||||||
e: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SocialAuthService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly authService: AuthService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
getAuthorizationUrl(provider: string, next = "/app") {
|
|
||||||
const normalized = this.normalizeProvider(provider);
|
|
||||||
const redirectUri = this.getRedirectUri();
|
|
||||||
const state = this.signState({ provider: normalized, next: this.safeNext(next), exp: Date.now() + 10 * 60 * 1000 });
|
|
||||||
|
|
||||||
if (normalized === "google") {
|
|
||||||
const clientId = process.env.GOOGLE_AUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID;
|
|
||||||
if (!clientId) throw new BadRequestException("Google social login is not configured.");
|
|
||||||
const client = new google.auth.OAuth2(clientId, process.env.GOOGLE_AUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET, redirectUri);
|
|
||||||
return {
|
|
||||||
authUrl: client.generateAuthUrl({
|
|
||||||
access_type: "online",
|
|
||||||
scope: ["openid", "email", "profile"],
|
|
||||||
prompt: "select_account",
|
|
||||||
state,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientId = process.env.APPLE_CLIENT_ID;
|
|
||||||
if (!clientId) throw new BadRequestException("Apple social login is not configured.");
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: clientId,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
response_type: "code",
|
|
||||||
response_mode: "query",
|
|
||||||
scope: "name email",
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
return { authUrl: `https://appleid.apple.com/auth/authorize?${params.toString()}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeLogin(provider: string, code: string, state: string, context?: RequestContext) {
|
|
||||||
const normalized = this.normalizeProvider(provider);
|
|
||||||
const parsedState = this.verifyState(state);
|
|
||||||
if (parsedState.provider !== normalized) {
|
|
||||||
throw new BadRequestException("OAuth state provider mismatch.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = normalized === "google"
|
|
||||||
? await this.exchangeGoogleCode(code)
|
|
||||||
: await this.exchangeAppleCode(code);
|
|
||||||
|
|
||||||
const user = await this.upsertSocialUser(profile);
|
|
||||||
const tokens = await this.authService.issueTokensForUser(user.id, context);
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
action: `auth.social.${normalized}.login`,
|
|
||||||
metadata: { provider: normalized, email: profile.email },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...tokens,
|
|
||||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
|
||||||
next: parsedState.next || "/app",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async exchangeGoogleCode(code: string): Promise<SocialProfile> {
|
|
||||||
const clientId = process.env.GOOGLE_AUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.GOOGLE_AUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET;
|
|
||||||
if (!clientId || !clientSecret) throw new BadRequestException("Google social login is not configured.");
|
|
||||||
|
|
||||||
const client = new google.auth.OAuth2(clientId, clientSecret, this.getRedirectUri());
|
|
||||||
const { tokens } = await client.getToken(code);
|
|
||||||
if (!tokens.id_token) throw new UnauthorizedException("Google did not return an identity token.");
|
|
||||||
|
|
||||||
const ticket = await client.verifyIdToken({ idToken: tokens.id_token, audience: clientId });
|
|
||||||
const payload = ticket.getPayload();
|
|
||||||
if (!payload?.sub || !payload.email) throw new UnauthorizedException("Google identity token is missing required claims.");
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: "google",
|
|
||||||
providerAccountId: payload.sub,
|
|
||||||
email: payload.email.toLowerCase(),
|
|
||||||
name: payload.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async exchangeAppleCode(code: string): Promise<SocialProfile> {
|
|
||||||
const clientId = process.env.APPLE_CLIENT_ID;
|
|
||||||
if (!clientId) throw new BadRequestException("Apple social login is not configured.");
|
|
||||||
|
|
||||||
const response = await fetch("https://appleid.apple.com/auth/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: this.createAppleClientSecret(),
|
|
||||||
code,
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
redirect_uri: this.getRedirectUri(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenData = await response.json() as { id_token?: string; error?: string };
|
|
||||||
if (!response.ok || !tokenData.id_token) {
|
|
||||||
throw new UnauthorizedException(tokenData.error || "Apple did not return an identity token.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await this.verifyAppleIdToken(tokenData.id_token);
|
|
||||||
if (!payload.sub || !payload.email) throw new UnauthorizedException("Apple identity token is missing required claims.");
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: "apple",
|
|
||||||
providerAccountId: payload.sub,
|
|
||||||
email: payload.email.toLowerCase(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async upsertSocialUser(profile: SocialProfile) {
|
|
||||||
const existingAccount = await this.prisma.socialAccount.findUnique({
|
|
||||||
where: { provider_providerAccountId: { provider: profile.provider, providerAccountId: profile.providerAccountId } },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
if (existingAccount) return existingAccount.user;
|
|
||||||
|
|
||||||
const user = await this.prisma.user.upsert({
|
|
||||||
where: { email: profile.email },
|
|
||||||
update: {
|
|
||||||
emailVerified: true,
|
|
||||||
...(profile.name ? { fullName: profile.name } : {}),
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
email: profile.email,
|
|
||||||
passwordHash: this.createUnusablePasswordHash(),
|
|
||||||
fullName: profile.name,
|
|
||||||
emailVerified: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.socialAccount.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
provider: profile.provider,
|
|
||||||
providerAccountId: profile.providerAccountId,
|
|
||||||
email: profile.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeProvider(provider: string): SocialProvider {
|
|
||||||
if (provider === "google" || provider === "apple") return provider;
|
|
||||||
throw new BadRequestException("Unsupported social login provider.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRedirectUri() {
|
|
||||||
return process.env.SOCIAL_AUTH_REDIRECT_URI || `${process.env.APP_URL || "http://localhost:3052"}/auth/social/callback`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private safeNext(next: string) {
|
|
||||||
return next.startsWith("/") && !next.startsWith("//") ? next : "/app";
|
|
||||||
}
|
|
||||||
|
|
||||||
private signState(payload: { provider: SocialProvider; next: string; exp: number }) {
|
|
||||||
const body = this.base64url(JSON.stringify(payload));
|
|
||||||
const signature = this.base64url(crypto.createHmac("sha256", this.getStateSecret()).update(body).digest());
|
|
||||||
return `${body}.${signature}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private verifyState(state: string): { provider: SocialProvider; next: string; exp: number } {
|
|
||||||
const [body, signature] = state.split(".");
|
|
||||||
if (!body || !signature) throw new BadRequestException("Invalid OAuth state.");
|
|
||||||
const expected = this.base64url(crypto.createHmac("sha256", this.getStateSecret()).update(body).digest());
|
|
||||||
const signatureBuffer = Buffer.from(signature);
|
|
||||||
const expectedBuffer = Buffer.from(expected);
|
|
||||||
if (signatureBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
|
|
||||||
throw new BadRequestException("Invalid OAuth state signature.");
|
|
||||||
}
|
|
||||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as { provider: SocialProvider; next: string; exp: number };
|
|
||||||
if (payload.exp < Date.now()) throw new BadRequestException("OAuth state expired.");
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getStateSecret() {
|
|
||||||
return process.env.JWT_SECRET || "social_auth_state_secret";
|
|
||||||
}
|
|
||||||
|
|
||||||
private createUnusablePasswordHash() {
|
|
||||||
const salt = crypto.randomBytes(16).toString("hex");
|
|
||||||
const hash = crypto.pbkdf2Sync(crypto.randomBytes(32).toString("hex"), salt, 100_000, 64, "sha512").toString("hex");
|
|
||||||
return `${salt}:${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createAppleClientSecret() {
|
|
||||||
const teamId = process.env.APPLE_TEAM_ID;
|
|
||||||
const keyId = process.env.APPLE_KEY_ID;
|
|
||||||
const clientId = process.env.APPLE_CLIENT_ID;
|
|
||||||
const rawPrivateKey = process.env.APPLE_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
|
||||||
if (!teamId || !keyId || !clientId || !rawPrivateKey) {
|
|
||||||
throw new BadRequestException("Apple social login is not configured.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const header = this.base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
|
|
||||||
const payload = this.base64url(JSON.stringify({
|
|
||||||
iss: teamId,
|
|
||||||
iat: now,
|
|
||||||
exp: now + 180 * 24 * 60 * 60,
|
|
||||||
aud: "https://appleid.apple.com",
|
|
||||||
sub: clientId,
|
|
||||||
}));
|
|
||||||
const data = `${header}.${payload}`;
|
|
||||||
const derSignature = crypto.sign("sha256", Buffer.from(data), rawPrivateKey);
|
|
||||||
return `${data}.${this.base64url(this.derToJose(derSignature, 64))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async verifyAppleIdToken(idToken: string): Promise<{ sub?: string; email?: string; aud?: string; iss?: string; exp?: number }> {
|
|
||||||
const [rawHeader, rawPayload, rawSignature] = idToken.split(".");
|
|
||||||
if (!rawHeader || !rawPayload || !rawSignature) throw new UnauthorizedException("Invalid Apple identity token.");
|
|
||||||
const header = JSON.parse(Buffer.from(rawHeader, "base64url").toString("utf8")) as { kid?: string; alg?: string };
|
|
||||||
const payload = JSON.parse(Buffer.from(rawPayload, "base64url").toString("utf8")) as { sub?: string; email?: string; aud?: string; iss?: string; exp?: number };
|
|
||||||
|
|
||||||
const response = await fetch("https://appleid.apple.com/auth/keys");
|
|
||||||
const data = await response.json() as { keys: AppleJwk[] };
|
|
||||||
const jwk = data.keys.find((key) => key.kid === header.kid && key.alg === "RS256");
|
|
||||||
if (!jwk) throw new UnauthorizedException("Apple signing key not found.");
|
|
||||||
|
|
||||||
const key = crypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
||||||
const isValid = crypto.verify(
|
|
||||||
"RSA-SHA256",
|
|
||||||
Buffer.from(`${rawHeader}.${rawPayload}`),
|
|
||||||
key,
|
|
||||||
Buffer.from(rawSignature, "base64url"),
|
|
||||||
);
|
|
||||||
if (!isValid) throw new UnauthorizedException("Invalid Apple identity token signature.");
|
|
||||||
if (payload.iss !== "https://appleid.apple.com" || payload.aud !== process.env.APPLE_CLIENT_ID || !payload.exp || payload.exp < Math.floor(Date.now() / 1000)) {
|
|
||||||
throw new UnauthorizedException("Invalid Apple identity token claims.");
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
private derToJose(signature: Buffer, size: number) {
|
|
||||||
let offset = 0;
|
|
||||||
if (signature[offset] !== 0x30) throw new BadRequestException("Invalid Apple signature.");
|
|
||||||
offset += 1;
|
|
||||||
|
|
||||||
const sequenceLength = signature[offset];
|
|
||||||
offset += 1;
|
|
||||||
if (sequenceLength + 2 !== signature.length) throw new BadRequestException("Invalid Apple signature.");
|
|
||||||
|
|
||||||
const readInteger = () => {
|
|
||||||
if (signature[offset] !== 0x02) throw new BadRequestException("Invalid Apple signature.");
|
|
||||||
offset += 1;
|
|
||||||
const length = signature[offset];
|
|
||||||
offset += 1;
|
|
||||||
let value = signature.subarray(offset, offset + length);
|
|
||||||
offset += length;
|
|
||||||
while (value.length > 0 && value[0] === 0) value = value.subarray(1);
|
|
||||||
if (value.length > size / 2) throw new BadRequestException("Invalid Apple signature.");
|
|
||||||
return Buffer.concat([Buffer.alloc(size / 2 - value.length), value]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return Buffer.concat([readInteger(), readInteger()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private base64url(value: string | Buffer) {
|
|
||||||
return Buffer.from(value).toString("base64url");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
||||||
import { ok } from "../common/response";
|
|
||||||
import { BillPayService } from "./bill-pay.service";
|
|
||||||
import { CreateBillDto, CreateBillPayeeDto, InitiateBillPaymentDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
|
|
||||||
|
|
||||||
@Controller("bill-pay")
|
|
||||||
export class BillPayController {
|
|
||||||
constructor(private readonly billPayService: BillPayService) {}
|
|
||||||
|
|
||||||
@Get("summary")
|
|
||||||
async summary(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.billPayService.summary(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("payees")
|
|
||||||
async payees(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.billPayService.listPayees(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("payees")
|
|
||||||
async createPayee(@CurrentUser() userId: string, @Body() body: CreateBillPayeeDto) {
|
|
||||||
return ok(await this.billPayService.createPayee(userId, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("bills")
|
|
||||||
async bills(@CurrentUser() userId: string, @Query("status") status?: string) {
|
|
||||||
return ok(await this.billPayService.listBills(userId, status));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("bills")
|
|
||||||
async createBill(@CurrentUser() userId: string, @Body() body: CreateBillDto) {
|
|
||||||
return ok(await this.billPayService.createBill(userId, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch("bills/:id")
|
|
||||||
async updateBill(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: UpdateBillDto) {
|
|
||||||
return ok(await this.billPayService.updateBill(userId, id, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("bills/:id/pay")
|
|
||||||
async markPaid(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: MarkBillPaidDto) {
|
|
||||||
return ok(await this.billPayService.markPaid(userId, id, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("bills/:id/initiate-payment")
|
|
||||||
async initiatePayment(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: InitiateBillPaymentDto) {
|
|
||||||
return ok(await this.billPayService.initiatePayment(userId, id, body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { NotificationsModule } from "../notifications/notifications.module";
|
|
||||||
import { BillPayController } from "./bill-pay.controller";
|
|
||||||
import { BillPayService } from "./bill-pay.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [NotificationsModule],
|
|
||||||
controllers: [BillPayController],
|
|
||||||
providers: [BillPayService],
|
|
||||||
})
|
|
||||||
export class BillPayModule {}
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { CreateBillDto, CreateBillPayeeDto, InitiateBillPaymentDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
|
|
||||||
|
|
||||||
const ACTIVE_STATUSES = ["pending", "scheduled"];
|
|
||||||
const SUPPORTED_PAYMENT_PROVIDERS = ["sandbox"];
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BillPayService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly notifications: NotificationsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listPayees(userId: string) {
|
|
||||||
return this.prisma.billPayee.findMany({
|
|
||||||
where: { userId },
|
|
||||||
include: { bills: { orderBy: { dueDate: "asc" }, take: 5 } },
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPayee(userId: string, dto: CreateBillPayeeDto) {
|
|
||||||
return this.prisma.billPayee.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
name: dto.name.trim(),
|
|
||||||
nickname: this.optionalString(dto.nickname),
|
|
||||||
category: this.optionalString(dto.category),
|
|
||||||
website: this.optionalString(dto.website),
|
|
||||||
accountNumberLast4: this.optionalString(dto.accountNumberLast4),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async listBills(userId: string, status?: string) {
|
|
||||||
const bills = await this.prisma.bill.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
...(status && status !== "all" ? { status } : {}),
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
payee: true,
|
|
||||||
payments: { orderBy: { paidAt: "desc" } },
|
|
||||||
},
|
|
||||||
orderBy: [{ dueDate: "asc" }, { createdAt: "desc" }],
|
|
||||||
take: 100,
|
|
||||||
});
|
|
||||||
return bills.map((bill) => this.withComputedStatus(bill));
|
|
||||||
}
|
|
||||||
|
|
||||||
async createBill(userId: string, dto: CreateBillDto) {
|
|
||||||
if (dto.payeeId) await this.assertPayee(userId, dto.payeeId);
|
|
||||||
const dueDate = this.parseDate(dto.dueDate, "due date");
|
|
||||||
const bill = await this.prisma.bill.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
payeeId: dto.payeeId,
|
|
||||||
name: dto.name.trim(),
|
|
||||||
amount: new Prisma.Decimal(dto.amount),
|
|
||||||
currency: (dto.currency ?? "USD").toUpperCase(),
|
|
||||||
dueDate,
|
|
||||||
status: dto.status ?? "pending",
|
|
||||||
recurrence: dto.recurrence ?? "none",
|
|
||||||
autopay: dto.autopay ?? false,
|
|
||||||
reminderDays: dto.reminderDays ?? 3,
|
|
||||||
notes: this.optionalString(dto.notes),
|
|
||||||
},
|
|
||||||
include: { payee: true, payments: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
action: "bill_pay.bill_create",
|
|
||||||
metadata: { billId: bill.id, dueDate: bill.dueDate, amount: bill.amount.toString() },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.notifications.notifyUser(userId, {
|
|
||||||
type: "bill.created",
|
|
||||||
severity: this.isDueSoon(bill.dueDate, bill.reminderDays) ? "warning" : "info",
|
|
||||||
title: `Bill added: ${bill.name}`,
|
|
||||||
body: `${this.formatMoney(bill.amount, bill.currency)} is due on ${bill.dueDate.toISOString().slice(0, 10)}.`,
|
|
||||||
metadata: { billId: bill.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.withComputedStatus(bill);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateBill(userId: string, billId: string, dto: UpdateBillDto) {
|
|
||||||
const existing = await this.assertBill(userId, billId);
|
|
||||||
if (dto.payeeId) await this.assertPayee(userId, dto.payeeId);
|
|
||||||
|
|
||||||
const bill = await this.prisma.bill.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: {
|
|
||||||
...(dto.payeeId !== undefined ? { payeeId: dto.payeeId } : {}),
|
|
||||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
|
||||||
...(dto.amount !== undefined ? { amount: new Prisma.Decimal(dto.amount) } : {}),
|
|
||||||
...(dto.currency !== undefined ? { currency: dto.currency.toUpperCase() } : {}),
|
|
||||||
...(dto.dueDate !== undefined ? { dueDate: this.parseDate(dto.dueDate, "due date") } : {}),
|
|
||||||
...(dto.status !== undefined ? { status: dto.status, paidAt: dto.status === "paid" ? existing.paidAt ?? new Date() : existing.paidAt } : {}),
|
|
||||||
...(dto.recurrence !== undefined ? { recurrence: dto.recurrence } : {}),
|
|
||||||
...(dto.autopay !== undefined ? { autopay: dto.autopay } : {}),
|
|
||||||
...(dto.reminderDays !== undefined ? { reminderDays: dto.reminderDays } : {}),
|
|
||||||
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
|
||||||
},
|
|
||||||
include: { payee: true, payments: { orderBy: { paidAt: "desc" } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
action: "bill_pay.bill_update",
|
|
||||||
metadata: { billId: bill.id, fields: Object.keys(dto) },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.withComputedStatus(bill);
|
|
||||||
}
|
|
||||||
|
|
||||||
async markPaid(userId: string, billId: string, dto: MarkBillPaidDto) {
|
|
||||||
const bill = await this.assertBill(userId, billId);
|
|
||||||
const paidAt = dto.paidAt ? this.parseDate(dto.paidAt, "paid date") : new Date();
|
|
||||||
const amount = new Prisma.Decimal(dto.amount ?? Number(bill.amount));
|
|
||||||
|
|
||||||
const payment = await (this.prisma as any).billPayment.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
billId: bill.id,
|
|
||||||
amount,
|
|
||||||
paidAt,
|
|
||||||
method: dto.method ?? "manual",
|
|
||||||
confirmationNumber: this.optionalString(dto.confirmationNumber),
|
|
||||||
notes: this.optionalString(dto.notes),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updated = await this.prisma.bill.update({
|
|
||||||
where: { id: bill.id },
|
|
||||||
data: { status: "paid", paidAt },
|
|
||||||
include: { payee: true, payments: { orderBy: { paidAt: "desc" } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
action: "bill_pay.bill_paid",
|
|
||||||
metadata: {
|
|
||||||
billId: bill.id,
|
|
||||||
paymentId: payment.id,
|
|
||||||
amount: amount.toString(),
|
|
||||||
method: payment.method,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.notifications.notifyUser(userId, {
|
|
||||||
type: "bill.paid",
|
|
||||||
severity: "info",
|
|
||||||
title: `Bill paid: ${bill.name}`,
|
|
||||||
body: `${this.formatMoney(amount, bill.currency)} was recorded as paid.`,
|
|
||||||
metadata: { billId: bill.id, paymentId: payment.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { bill: this.withComputedStatus(updated), payment };
|
|
||||||
}
|
|
||||||
|
|
||||||
async initiatePayment(userId: string, billId: string, dto: InitiateBillPaymentDto) {
|
|
||||||
const bill = await this.assertBill(userId, billId);
|
|
||||||
if (!ACTIVE_STATUSES.includes(bill.status)) {
|
|
||||||
throw new BadRequestException("Only pending or scheduled bills can be initiated.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = (process.env.BILL_PAY_PROVIDER ?? "sandbox").toLowerCase();
|
|
||||||
if (!SUPPORTED_PAYMENT_PROVIDERS.includes(provider)) {
|
|
||||||
throw new BadRequestException("Configured bill-payment provider is not supported by this build.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const amount = new Prisma.Decimal(dto.amount ?? Number(bill.amount));
|
|
||||||
const scheduledFor = dto.scheduledFor ? this.parseDate(dto.scheduledFor, "scheduled date") : new Date();
|
|
||||||
const method = dto.method ?? "ach";
|
|
||||||
const providerResult = await this.initiateWithProvider(provider, {
|
|
||||||
userId,
|
|
||||||
billId: bill.id,
|
|
||||||
amount: amount.toNumber(),
|
|
||||||
currency: bill.currency,
|
|
||||||
scheduledFor,
|
|
||||||
method,
|
|
||||||
fundingAccountRef: this.optionalString(dto.fundingAccountRef),
|
|
||||||
memo: this.optionalString(dto.memo),
|
|
||||||
});
|
|
||||||
|
|
||||||
const payment = await (this.prisma as any).billPayment.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
billId: bill.id,
|
|
||||||
amount,
|
|
||||||
paidAt: scheduledFor,
|
|
||||||
method,
|
|
||||||
status: providerResult.status,
|
|
||||||
provider,
|
|
||||||
providerPaymentId: providerResult.providerPaymentId,
|
|
||||||
confirmationNumber: providerResult.confirmationNumber,
|
|
||||||
notes: this.optionalString(dto.memo),
|
|
||||||
metadata: providerResult.metadata as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updated = await this.prisma.bill.update({
|
|
||||||
where: { id: bill.id },
|
|
||||||
data: {
|
|
||||||
status: providerResult.status === "completed" ? "paid" : "scheduled",
|
|
||||||
paidAt: providerResult.status === "completed" ? scheduledFor : bill.paidAt,
|
|
||||||
},
|
|
||||||
include: { payee: true, payments: { orderBy: { paidAt: "desc" } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
action: "bill_pay.payment_initiate",
|
|
||||||
metadata: {
|
|
||||||
billId: bill.id,
|
|
||||||
paymentId: payment.id,
|
|
||||||
provider,
|
|
||||||
providerPaymentId: providerResult.providerPaymentId,
|
|
||||||
status: providerResult.status,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.notifications.notifyUser(userId, {
|
|
||||||
type: "bill.payment_initiated",
|
|
||||||
severity: "info",
|
|
||||||
title: `Payment initiated: ${bill.name}`,
|
|
||||||
body: `${this.formatMoney(amount, bill.currency)} is ${providerResult.status} through ${provider}.`,
|
|
||||||
metadata: { billId: bill.id, paymentId: payment.id, provider },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
bill: this.withComputedStatus(updated),
|
|
||||||
payment,
|
|
||||||
provider: providerResult,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async summary(userId: string) {
|
|
||||||
const bills = await this.listBills(userId, "all");
|
|
||||||
const now = new Date();
|
|
||||||
const next30 = new Date(now);
|
|
||||||
next30.setDate(next30.getDate() + 30);
|
|
||||||
const active = bills.filter((bill: any) => ACTIVE_STATUSES.includes(bill.status));
|
|
||||||
const upcoming = active.filter((bill: any) => new Date(bill.dueDate) <= next30);
|
|
||||||
const overdue = bills.filter((bill: any) => bill.computedStatus === "overdue");
|
|
||||||
const totalDueNext30 = upcoming.reduce((sum: Prisma.Decimal, bill: any) => sum.plus(bill.amount), new Prisma.Decimal(0));
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeCount: active.length,
|
|
||||||
upcomingCount: upcoming.length,
|
|
||||||
overdueCount: overdue.length,
|
|
||||||
totalDueNext30: totalDueNext30.toString(),
|
|
||||||
nextBills: upcoming.slice(0, 5),
|
|
||||||
overdueBills: overdue.slice(0, 5),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertPayee(userId: string, payeeId: string) {
|
|
||||||
const payee = await this.prisma.billPayee.findFirst({ where: { id: payeeId, userId } });
|
|
||||||
if (!payee) throw new BadRequestException("Bill payee not found.");
|
|
||||||
return payee;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertBill(userId: string, billId: string) {
|
|
||||||
const bill = await this.prisma.bill.findFirst({ where: { id: billId, userId } });
|
|
||||||
if (!bill) throw new BadRequestException("Bill not found.");
|
|
||||||
return bill;
|
|
||||||
}
|
|
||||||
|
|
||||||
private withComputedStatus<T extends { dueDate: Date; status: string }>(bill: T) {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const due = new Date(bill.dueDate);
|
|
||||||
due.setHours(0, 0, 0, 0);
|
|
||||||
return {
|
|
||||||
...bill,
|
|
||||||
computedStatus: ACTIVE_STATUSES.includes(bill.status) && due < today ? "overdue" : bill.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private isDueSoon(dueDate: Date, reminderDays: number) {
|
|
||||||
const now = new Date();
|
|
||||||
const threshold = new Date(now);
|
|
||||||
threshold.setDate(threshold.getDate() + reminderDays);
|
|
||||||
return dueDate <= threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseDate(value: string, label: string) {
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) throw new BadRequestException(`Invalid ${label}.`);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
private optionalString(value?: string | null) {
|
|
||||||
const trimmed = value?.trim();
|
|
||||||
return trimmed ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatMoney(amount: Prisma.Decimal, currency: string) {
|
|
||||||
return `${currency} ${amount.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initiateWithProvider(provider: string, payload: {
|
|
||||||
userId: string;
|
|
||||||
billId: string;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
scheduledFor: Date;
|
|
||||||
method: string;
|
|
||||||
fundingAccountRef?: string | null;
|
|
||||||
memo?: string | null;
|
|
||||||
}) {
|
|
||||||
if (provider !== "sandbox") {
|
|
||||||
throw new BadRequestException("Bill-payment provider is not available.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerPaymentId = `sandbox_${randomUUID().replace(/-/g, "")}`;
|
|
||||||
return {
|
|
||||||
providerPaymentId,
|
|
||||||
confirmationNumber: providerPaymentId.slice(-12).toUpperCase(),
|
|
||||||
status: payload.scheduledFor.getTime() <= Date.now() ? "completed" : "initiated",
|
|
||||||
metadata: {
|
|
||||||
rail: payload.method,
|
|
||||||
amount: payload.amount,
|
|
||||||
currency: payload.currency,
|
|
||||||
scheduledFor: payload.scheduledFor.toISOString(),
|
|
||||||
fundingAccountRef: payload.fundingAccountRef,
|
|
||||||
memo: payload.memo,
|
|
||||||
sandbox: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
import { IsBoolean, IsDateString, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateBillPayeeDto {
|
|
||||||
@IsString()
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
nickname?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
category?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
website?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
accountNumberLast4?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateBillDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
payeeId?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.01)
|
|
||||||
amount!: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
currency?: string;
|
|
||||||
|
|
||||||
@IsDateString()
|
|
||||||
dueDate!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["pending", "scheduled"])
|
|
||||||
status?: "pending" | "scheduled";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["none", "weekly", "monthly", "quarterly", "yearly"])
|
|
||||||
recurrence?: "none" | "weekly" | "monthly" | "quarterly" | "yearly";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
autopay?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
@Max(30)
|
|
||||||
reminderDays?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateBillDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
payeeId?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.01)
|
|
||||||
amount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
currency?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
dueDate?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["pending", "scheduled", "paid", "skipped", "cancelled"])
|
|
||||||
status?: "pending" | "scheduled" | "paid" | "skipped" | "cancelled";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["none", "weekly", "monthly", "quarterly", "yearly"])
|
|
||||||
recurrence?: "none" | "weekly" | "monthly" | "quarterly" | "yearly";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
autopay?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
@Max(30)
|
|
||||||
reminderDays?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
notes?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MarkBillPaidDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.01)
|
|
||||||
amount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
paidAt?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["manual", "autopay", "bank_bill_pay", "card", "ach", "check", "cash", "other"])
|
|
||||||
method?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
confirmationNumber?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InitiateBillPaymentDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.01)
|
|
||||||
amount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
scheduledFor?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["ach", "bank_bill_pay", "card"])
|
|
||||||
method?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
fundingAccountRef?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
memo?: string;
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
|
|
||||||
import { Observable } from "rxjs";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BrowserUntrustedInterceptor implements NestInterceptor {
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
||||||
const response = context.switchToHttp().getResponse<{
|
|
||||||
setHeader?: (name: string, value: string) => void;
|
|
||||||
headersSent?: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
if (response?.setHeader && !response.headersSent) {
|
|
||||||
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, private");
|
|
||||||
response.setHeader("Pragma", "no-cache");
|
|
||||||
response.setHeader("Expires", "0");
|
|
||||||
response.setHeader("X-LedgerOne-Browser-Trust", "untrusted");
|
|
||||||
response.setHeader("X-LedgerOne-Data-Boundary", "server-authoritative");
|
|
||||||
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
|
|
||||||
}
|
|
||||||
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +1,9 @@
|
|||||||
import { Global, Module } from "@nestjs/common";
|
import { Global, Module } from "@nestjs/common";
|
||||||
import { EncryptionService } from "./encryption.service";
|
import { EncryptionService } from "./encryption.service";
|
||||||
import { OpaqueIdService } from "./opaque-id.service";
|
|
||||||
import { RawPayloadEncryptionService } from "./raw-payload-encryption.service";
|
|
||||||
import { ViewRefService } from "./view-ref.service";
|
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
|
providers: [EncryptionService],
|
||||||
exports: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
|
exports: [EncryptionService],
|
||||||
})
|
})
|
||||||
export class CommonModule {}
|
export class CommonModule {}
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { SetMetadata } from "@nestjs/common";
|
|
||||||
|
|
||||||
export const ROLES_KEY = "roles";
|
|
||||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
|
||||||
@ -7,10 +7,6 @@ import {
|
|||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import * as crypto from "crypto";
|
|
||||||
import { AbuseService } from "../../abuse/abuse.service";
|
|
||||||
import { requestContextFrom } from "../../abuse/abuse.types";
|
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
|
||||||
|
|
||||||
export const IS_PUBLIC_KEY = "isPublic";
|
export const IS_PUBLIC_KEY = "isPublic";
|
||||||
|
|
||||||
@ -19,11 +15,9 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly reflector: Reflector,
|
private readonly reflector: Reflector,
|
||||||
private readonly abuseService: AbuseService,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
@ -39,41 +33,12 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = this.jwtService.verify<{ sub: string; sid?: string }>(token, {
|
const payload = this.jwtService.verify<{ sub: string }>(token, {
|
||||||
secret: process.env.JWT_SECRET,
|
secret: process.env.JWT_SECRET,
|
||||||
});
|
});
|
||||||
if (!payload.sid) {
|
(request as Request & { user: { sub: string } }).user = payload;
|
||||||
throw new UnauthorizedException("Session-bound token required.");
|
|
||||||
}
|
|
||||||
const session = await this.prisma.session.findUnique({ where: { id: payload.sid } });
|
|
||||||
const requestContext = requestContextFrom(request);
|
|
||||||
if (
|
|
||||||
!session ||
|
|
||||||
session.userId !== payload.sub ||
|
|
||||||
session.revokedAt ||
|
|
||||||
session.expiresAt < new Date() ||
|
|
||||||
session.ipHash !== this.hashBindingValue(requestContext.ipAddress ?? "unknown-ip") ||
|
|
||||||
session.userAgentHash !== this.hashBindingValue(requestContext.userAgent ?? "unknown-user-agent") ||
|
|
||||||
!session.nonceHash ||
|
|
||||||
!requestContext.sessionNonce ||
|
|
||||||
session.nonceHash !== this.hashBindingValue(requestContext.sessionNonce)
|
|
||||||
) {
|
|
||||||
throw new UnauthorizedException("Invalid session binding.");
|
|
||||||
}
|
|
||||||
const nextNonce = crypto.randomBytes(32).toString("base64url");
|
|
||||||
const response = context.switchToHttp().getResponse<{ setHeader(name: string, value: string): void }>();
|
|
||||||
await this.prisma.session.update({
|
|
||||||
where: { id: session.id },
|
|
||||||
data: {
|
|
||||||
lastSeenAt: new Date(),
|
|
||||||
nonceHash: this.hashBindingValue(nextNonce),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
response.setHeader("X-LedgerOne-Next-Nonce", nextNonce);
|
|
||||||
(request as Request & { user: { sub: string; sid: string } }).user = { sub: payload.sub, sid: payload.sid };
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
await this.abuseService.recordInvalidToken(requestContextFrom(request));
|
|
||||||
throw new UnauthorizedException("Invalid or expired token.");
|
throw new UnauthorizedException("Invalid or expired token.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,8 +50,4 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashBindingValue(value: string): string {
|
|
||||||
return crypto.createHash("sha256").update(value).digest("hex");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { Reflector } from "@nestjs/core";
|
|
||||||
import { Request } from "express";
|
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
|
||||||
import { ROLES_KEY } from "../decorators/roles.decorator";
|
|
||||||
import { IS_PUBLIC_KEY } from "./jwt-auth.guard";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RolesGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
if (isPublic) return true;
|
|
||||||
|
|
||||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
if (!requiredRoles?.length) return true;
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest<Request & { user?: { sub: string } }>();
|
|
||||||
const userId = request.user?.sub;
|
|
||||||
if (!userId) throw new ForbiddenException("Authentication required.");
|
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { role: true },
|
|
||||||
});
|
|
||||||
if (!user || !requiredRoles.includes(user.role)) {
|
|
||||||
throw new ForbiddenException("Insufficient role.");
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
|
||||||
import { EncryptionService } from "./encryption.service";
|
|
||||||
|
|
||||||
type OpaqueIdPayload = {
|
|
||||||
kind: string;
|
|
||||||
userId: string;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OpaqueIdService {
|
|
||||||
constructor(private readonly encryption: EncryptionService) {}
|
|
||||||
|
|
||||||
encode(kind: string, userId: string, id: string) {
|
|
||||||
const encrypted = this.encryption.encrypt(JSON.stringify({ kind, userId, id }));
|
|
||||||
return Buffer.from(encrypted, "utf8").toString("base64url");
|
|
||||||
}
|
|
||||||
|
|
||||||
decode(kind: string, userId: string, token: string) {
|
|
||||||
try {
|
|
||||||
const encrypted = Buffer.from(token, "base64url").toString("utf8");
|
|
||||||
const payload = JSON.parse(this.encryption.decrypt(encrypted)) as OpaqueIdPayload;
|
|
||||||
if (payload.kind !== kind || payload.userId !== userId || !payload.id) {
|
|
||||||
throw new Error("Opaque ID scope mismatch.");
|
|
||||||
}
|
|
||||||
return payload.id;
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException("Invalid resource identifier.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { EncryptionService } from "./encryption.service";
|
|
||||||
|
|
||||||
type EncryptedRawPayload = {
|
|
||||||
encrypted: true;
|
|
||||||
version: 1;
|
|
||||||
ciphertext: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RawPayloadEncryptionService {
|
|
||||||
constructor(private readonly encryption: EncryptionService) {}
|
|
||||||
|
|
||||||
encrypt(payload: unknown): Prisma.InputJsonValue {
|
|
||||||
return {
|
|
||||||
encrypted: true,
|
|
||||||
version: 1,
|
|
||||||
ciphertext: this.encryption.encrypt(JSON.stringify(payload ?? null)),
|
|
||||||
} satisfies EncryptedRawPayload as unknown as Prisma.InputJsonValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
decrypt<T = unknown>(payload: unknown): T {
|
|
||||||
if (!this.isEncryptedPayload(payload)) return payload as T;
|
|
||||||
return JSON.parse(this.encryption.decrypt(payload.ciphertext)) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isEncryptedPayload(payload: unknown): payload is EncryptedRawPayload {
|
|
||||||
return Boolean(
|
|
||||||
payload &&
|
|
||||||
typeof payload === "object" &&
|
|
||||||
(payload as { encrypted?: unknown }).encrypted === true &&
|
|
||||||
(payload as { version?: unknown }).version === 1 &&
|
|
||||||
typeof (payload as { ciphertext?: unknown }).ciphertext === "string",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,7 +12,6 @@ import { Request, Response } from "express";
|
|||||||
@Catch()
|
@Catch()
|
||||||
export class SentryExceptionFilter implements ExceptionFilter {
|
export class SentryExceptionFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger("ExceptionFilter");
|
private readonly logger = new Logger("ExceptionFilter");
|
||||||
private readonly sentryEnabled = Boolean(process.env.SENTRY_DSN);
|
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
@ -24,35 +23,17 @@ export class SentryExceptionFilter implements ExceptionFilter {
|
|||||||
? exception.getStatus()
|
? exception.getStatus()
|
||||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
const userId = (request as Request & { user?: { sub: string } }).user?.sub;
|
// Only report 5xx errors to Sentry
|
||||||
|
if (status >= 500 && process.env.SENTRY_DSN) {
|
||||||
// Only report server-side failures to Sentry. Client errors remain local
|
Sentry.captureException(exception, {
|
||||||
// responses and do not create noisy operational alerts.
|
extra: {
|
||||||
if (status >= 500 && this.sentryEnabled) {
|
url: request.url,
|
||||||
const eventId = Sentry.withScope((scope) => {
|
|
||||||
if (userId) {
|
|
||||||
scope.setUser({ id: userId });
|
|
||||||
}
|
|
||||||
scope.setTag("http.status_code", String(status));
|
|
||||||
scope.setContext("request", {
|
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: request.path,
|
userId: (request as Request & { user?: { sub: string } }).user?.sub,
|
||||||
query: request.query,
|
},
|
||||||
ip: request.ip,
|
|
||||||
userAgent: request.headers["user-agent"],
|
|
||||||
});
|
|
||||||
return Sentry.captureException(exception);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.error(
|
|
||||||
`Captured server exception in Sentry: ${eventId}`,
|
|
||||||
exception instanceof Error ? exception.stack : undefined,
|
|
||||||
);
|
|
||||||
} else if (status >= 500) {
|
} else if (status >= 500) {
|
||||||
this.logger.error(
|
this.logger.error(exception);
|
||||||
"Unhandled server exception",
|
|
||||||
exception instanceof Error ? exception.stack : String(exception),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
@ -69,7 +50,7 @@ export class SentryExceptionFilter implements ExceptionFilter {
|
|||||||
? message
|
? message
|
||||||
: (message as { message?: string }).message ?? "Internal server error",
|
: (message as { message?: string }).message ?? "Internal server error",
|
||||||
},
|
},
|
||||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
meta: { timestamp: new Date().toISOString(), version: "1.0" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ViewRefService {
|
|
||||||
create(userId: string, type: string, id: string) {
|
|
||||||
const day = new Date().toISOString().slice(0, 10);
|
|
||||||
const secret = process.env.JWT_SECRET ?? "ledgerone-view-boundary";
|
|
||||||
return crypto
|
|
||||||
.createHmac("sha256", secret)
|
|
||||||
.update(`${day}:${userId}:${type}:${id}`)
|
|
||||||
.digest("hex")
|
|
||||||
.slice(0, 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
matches(userId: string, type: string, id: string, candidate: string) {
|
|
||||||
if (!/^[a-f0-9]{24}$/i.test(candidate)) return false;
|
|
||||||
const expected = this.create(userId, type, id);
|
|
||||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate.toLowerCase()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { Controller, Get } from "@nestjs/common";
|
|
||||||
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
||||||
import { ComplianceService } from "./compliance.service";
|
|
||||||
|
|
||||||
@Controller("compliance")
|
|
||||||
export class ComplianceController {
|
|
||||||
constructor(private readonly complianceService: ComplianceService) {}
|
|
||||||
|
|
||||||
@Get("soc2/readiness")
|
|
||||||
async soc2Readiness(@CurrentUser() userId: string) {
|
|
||||||
return this.complianceService.getSoc2Readiness(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("soc2/operations")
|
|
||||||
async soc2Operations() {
|
|
||||||
return this.complianceService.getSoc2Operations();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
|
|
||||||
import { ComplianceController } from "./compliance.controller";
|
|
||||||
import { ComplianceService } from "./compliance.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [ComplianceController],
|
|
||||||
providers: [ComplianceService],
|
|
||||||
})
|
|
||||||
export class ComplianceModule {}
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
|
|
||||||
export type ControlStatus = "implemented" | "partial" | "external_required";
|
|
||||||
|
|
||||||
export interface Soc2Control {
|
|
||||||
id: string;
|
|
||||||
trustServiceCriterion: string;
|
|
||||||
status: ControlStatus;
|
|
||||||
control: string;
|
|
||||||
evidence: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Soc2ReadinessReport {
|
|
||||||
framework: "SOC 2";
|
|
||||||
readinessStatus: "technical_baseline_ready";
|
|
||||||
certificationStatus: "not_certified";
|
|
||||||
generatedAt: string;
|
|
||||||
evidenceSummary: {
|
|
||||||
auditLogCount: number;
|
|
||||||
exportLogCount: number;
|
|
||||||
abuseEventCount: number;
|
|
||||||
activeSessionCount: number;
|
|
||||||
};
|
|
||||||
controls: Soc2Control[];
|
|
||||||
pendingExternalControls: string[];
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Soc2OperationsReport {
|
|
||||||
framework: "SOC 2";
|
|
||||||
certificationStatus: "not_certified";
|
|
||||||
generatedAt: string;
|
|
||||||
externalOperations: Array<{
|
|
||||||
id: string;
|
|
||||||
area: string;
|
|
||||||
owner: string;
|
|
||||||
status: "external_required";
|
|
||||||
requiredEvidence: string[];
|
|
||||||
systemSupport: string[];
|
|
||||||
}>;
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ComplianceService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async getSoc2Readiness(userId: string): Promise<Soc2ReadinessReport> {
|
|
||||||
const [auditLogCount, exportLogCount, abuseEventCount, activeSessionCount] = await Promise.all([
|
|
||||||
this.prisma.auditLog.count({ where: { userId } }),
|
|
||||||
this.prisma.exportLog.count({ where: { userId } }),
|
|
||||||
this.prisma.abuseEvent.count({ where: { userId } }),
|
|
||||||
this.prisma.session.count({ where: { userId, revokedAt: null, expiresAt: { gt: new Date() } } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const controls: Soc2Control[] = [
|
|
||||||
{
|
|
||||||
id: "CC6.1",
|
|
||||||
trustServiceCriterion: "Logical access",
|
|
||||||
status: "implemented",
|
|
||||||
control: "Authenticated access is bound to a server-side session with IP, user-agent, refresh token, and rotating nonce checks.",
|
|
||||||
evidence: [
|
|
||||||
"JwtAuthGuard validates session ID, IP hash, user-agent hash, and per-request nonce hash.",
|
|
||||||
`${activeSessionCount} active session(s) currently recorded for this user.`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "CC6.7",
|
|
||||||
trustServiceCriterion: "Data confidentiality",
|
|
||||||
status: "implemented",
|
|
||||||
control: "Sensitive tokens and raw transaction payloads are encrypted before storage, and browser APIs return presentation data.",
|
|
||||||
evidence: [
|
|
||||||
"Plaid tokens and raw transaction payloads use server-side encryption services.",
|
|
||||||
"Browser-facing finance routes are served through sanitized view APIs.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "CC7.2",
|
|
||||||
trustServiceCriterion: "Security monitoring",
|
|
||||||
status: "implemented",
|
|
||||||
control: "Abuse and anomaly signals are recorded for risky usage patterns.",
|
|
||||||
evidence: [`${abuseEventCount} abuse/risk event(s) currently recorded for this user.`],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "CC7.3",
|
|
||||||
trustServiceCriterion: "Auditability",
|
|
||||||
status: "implemented",
|
|
||||||
control: "Security-relevant account, export, and collaboration actions write audit evidence.",
|
|
||||||
evidence: [
|
|
||||||
`${auditLogCount} audit event(s) currently recorded for this user.`,
|
|
||||||
`${exportLogCount} export audit record(s) currently recorded for this user.`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "CC8.1",
|
|
||||||
trustServiceCriterion: "Change management",
|
|
||||||
status: "partial",
|
|
||||||
control: "Repository-level CI and committed implementation history exist, but formal approval and release controls remain operational processes.",
|
|
||||||
evidence: ["Technical CI coverage can support SOC 2 evidence, but policy attestation is outside the application runtime."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "P1.1",
|
|
||||||
trustServiceCriterion: "Privacy",
|
|
||||||
status: "implemented",
|
|
||||||
control: "User erasure, privacy inventory, and minimized personal-data export are available through authenticated account privacy flows.",
|
|
||||||
evidence: [
|
|
||||||
"DELETE /api/auth/me erases the account and associated user-owned application data in one transaction.",
|
|
||||||
"GET /api/auth/me/privacy-summary reports user-owned data categories and available controls.",
|
|
||||||
"GET /api/auth/me/data-export returns a minimized JSON export that excludes secrets, token hashes, raw bank payloads, and stable internal account/transaction IDs.",
|
|
||||||
"Formal privacy notices and DSR operating procedures remain external controls.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
framework: "SOC 2",
|
|
||||||
readinessStatus: "technical_baseline_ready",
|
|
||||||
certificationStatus: "not_certified",
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
evidenceSummary: {
|
|
||||||
auditLogCount,
|
|
||||||
exportLogCount,
|
|
||||||
abuseEventCount,
|
|
||||||
activeSessionCount,
|
|
||||||
},
|
|
||||||
controls,
|
|
||||||
pendingExternalControls: [
|
|
||||||
"Independent SOC 2 auditor engagement and examination period",
|
|
||||||
"Board/management-approved security, access control, incident response, vendor, and change management policies",
|
|
||||||
"Recurring access reviews with retained evidence",
|
|
||||||
"Vendor risk reviews for infrastructure, email, payment, and data-provider subprocessors",
|
|
||||||
"Incident response tabletop or equivalent drill evidence",
|
|
||||||
"Employee security training and onboarding/offboarding records",
|
|
||||||
],
|
|
||||||
note: "This endpoint reports application control evidence only. It does not represent SOC 2 certification or audit opinion.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getSoc2Operations(): Soc2OperationsReport {
|
|
||||||
return {
|
|
||||||
framework: "SOC 2",
|
|
||||||
certificationStatus: "not_certified",
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
externalOperations: [
|
|
||||||
{
|
|
||||||
id: "SOC2-AUDITOR",
|
|
||||||
area: "Independent examination",
|
|
||||||
owner: "Executive sponsor / auditor",
|
|
||||||
status: "external_required",
|
|
||||||
requiredEvidence: [
|
|
||||||
"Signed engagement letter with a CPA firm",
|
|
||||||
"Defined Type I or Type II examination scope",
|
|
||||||
"Auditor request list and evidence retention plan",
|
|
||||||
],
|
|
||||||
systemSupport: ["GET /api/compliance/soc2/readiness exposes runtime control evidence."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SOC2-POLICIES",
|
|
||||||
area: "Security policies",
|
|
||||||
owner: "Security / management",
|
|
||||||
status: "external_required",
|
|
||||||
requiredEvidence: [
|
|
||||||
"Approved access control policy",
|
|
||||||
"Approved change management policy",
|
|
||||||
"Approved incident response policy",
|
|
||||||
"Approved vendor risk policy",
|
|
||||||
],
|
|
||||||
systemSupport: ["Audit logs, export logs, session records, and abuse events provide technical evidence."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SOC2-ACCESS-REVIEWS",
|
|
||||||
area: "Access reviews",
|
|
||||||
owner: "Operations",
|
|
||||||
status: "external_required",
|
|
||||||
requiredEvidence: [
|
|
||||||
"Quarterly access review checklist",
|
|
||||||
"Reviewer sign-off records",
|
|
||||||
"Remediation tickets for excessive access",
|
|
||||||
],
|
|
||||||
systemSupport: ["User/session/audit data can support review evidence, but approvals occur outside the runtime."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SOC2-VENDOR-RISK",
|
|
||||||
area: "Vendor risk",
|
|
||||||
owner: "Operations / legal",
|
|
||||||
status: "external_required",
|
|
||||||
requiredEvidence: [
|
|
||||||
"Subprocessor inventory",
|
|
||||||
"Vendor security reviews for infrastructure, email, payments, bank data, and tax providers",
|
|
||||||
"Executed DPAs where required",
|
|
||||||
],
|
|
||||||
systemSupport: ["Provider integration metadata identifies Stripe, Plaid, Teller, Google, SMTP, and export storage dependencies."],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SOC2-TRAINING-DRILLS",
|
|
||||||
area: "Training and incident drills",
|
|
||||||
owner: "People / security",
|
|
||||||
status: "external_required",
|
|
||||||
requiredEvidence: [
|
|
||||||
"Employee security training records",
|
|
||||||
"Onboarding/offboarding records",
|
|
||||||
"Incident response tabletop or drill report",
|
|
||||||
],
|
|
||||||
systemSupport: ["Application audit records can support incident reconstruction after an event."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
note: "These operations cannot be completed by code alone. The application can expose evidence, but certification requires external policies, retained records, and an auditor.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ export const envValidationSchema = Joi.object({
|
|||||||
DATABASE_URL: Joi.string().required(),
|
DATABASE_URL: Joi.string().required(),
|
||||||
JWT_SECRET: Joi.string().min(32).required(),
|
JWT_SECRET: Joi.string().min(32).required(),
|
||||||
JWT_REFRESH_SECRET: Joi.string().min(32).required(),
|
JWT_REFRESH_SECRET: Joi.string().min(32).required(),
|
||||||
JWT_ACCESS_TTL_SECONDS: Joi.number().integer().min(30).max(60).default(60),
|
|
||||||
ENCRYPTION_KEY: Joi.string().length(64).required(),
|
ENCRYPTION_KEY: Joi.string().length(64).required(),
|
||||||
|
|
||||||
PLAID_CLIENT_ID: Joi.string().required(),
|
PLAID_CLIENT_ID: Joi.string().required(),
|
||||||
@ -13,26 +12,11 @@ export const envValidationSchema = Joi.object({
|
|||||||
PLAID_PRODUCTS: Joi.string().default("transactions"),
|
PLAID_PRODUCTS: Joi.string().default("transactions"),
|
||||||
PLAID_COUNTRY_CODES: Joi.string().default("US"),
|
PLAID_COUNTRY_CODES: Joi.string().default("US"),
|
||||||
PLAID_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
PLAID_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
||||||
PLAID_WEBHOOK_URL: Joi.string().uri().optional().allow(""),
|
|
||||||
PLAID_VERIFY_WEBHOOKS: Joi.boolean().default(true),
|
|
||||||
|
|
||||||
TELLER_APPLICATION_ID: Joi.string().optional().allow(""),
|
|
||||||
TELLER_ENV: Joi.string().valid("sandbox", "development", "production").default("sandbox"),
|
|
||||||
TELLER_PRODUCTS: Joi.string().default("transactions,balance"),
|
|
||||||
TELLER_API_BASE_URL: Joi.string().uri().default("https://api.teller.io"),
|
|
||||||
TELLER_CERT_PATH: Joi.string().optional().allow(""),
|
|
||||||
TELLER_KEY_PATH: Joi.string().optional().allow(""),
|
|
||||||
TELLER_CERT_PEM: Joi.string().optional().allow(""),
|
|
||||||
TELLER_KEY_PEM: Joi.string().optional().allow(""),
|
|
||||||
|
|
||||||
SUPABASE_URL: Joi.string().optional().allow(""),
|
SUPABASE_URL: Joi.string().optional().allow(""),
|
||||||
SUPABASE_SERVICE_KEY: Joi.string().optional().allow(""),
|
SUPABASE_SERVICE_KEY: Joi.string().optional().allow(""),
|
||||||
SUPABASE_ANON_KEY: Joi.string().optional().allow(""),
|
SUPABASE_ANON_KEY: Joi.string().optional().allow(""),
|
||||||
|
|
||||||
EXPORT_OBJECT_STORAGE_DRIVER: Joi.string().valid("local", "supabase").default("local"),
|
|
||||||
EXPORT_OBJECT_STORAGE_BUCKET: Joi.string().default("ledgerone-exports"),
|
|
||||||
EXPORT_OBJECT_STORAGE_DIR: Joi.string().default("data/export-objects"),
|
|
||||||
|
|
||||||
STRIPE_SECRET_KEY: Joi.string().optional().allow(""),
|
STRIPE_SECRET_KEY: Joi.string().optional().allow(""),
|
||||||
STRIPE_WEBHOOK_SECRET: Joi.string().optional().allow(""),
|
STRIPE_WEBHOOK_SECRET: Joi.string().optional().allow(""),
|
||||||
STRIPE_PRICE_PRO: Joi.string().optional().allow(""),
|
STRIPE_PRICE_PRO: Joi.string().optional().allow(""),
|
||||||
@ -43,9 +27,6 @@ export const envValidationSchema = Joi.object({
|
|||||||
SMTP_USER: Joi.string().optional().allow(""),
|
SMTP_USER: Joi.string().optional().allow(""),
|
||||||
SMTP_PASS: Joi.string().optional().allow(""),
|
SMTP_PASS: Joi.string().optional().allow(""),
|
||||||
SMTP_FROM: Joi.string().default("noreply@ledgerone.app"),
|
SMTP_FROM: Joi.string().default("noreply@ledgerone.app"),
|
||||||
VAPID_PUBLIC_KEY: Joi.string().optional().allow(""),
|
|
||||||
VAPID_PRIVATE_KEY: Joi.string().optional().allow(""),
|
|
||||||
VAPID_SUBJECT: Joi.string().default("mailto:support@ledgerone.app"),
|
|
||||||
|
|
||||||
APP_URL: Joi.string().uri().default("http://localhost:3052"),
|
APP_URL: Joi.string().uri().default("http://localhost:3052"),
|
||||||
PORT: Joi.number().default(3051),
|
PORT: Joi.number().default(3051),
|
||||||
@ -54,27 +35,11 @@ export const envValidationSchema = Joi.object({
|
|||||||
CORS_ORIGIN: Joi.string().default("http://localhost:3052"),
|
CORS_ORIGIN: Joi.string().default("http://localhost:3052"),
|
||||||
|
|
||||||
AUTO_SYNC_ENABLED: Joi.boolean().default(true),
|
AUTO_SYNC_ENABLED: Joi.boolean().default(true),
|
||||||
AUTO_SYNC_INTERVAL_MINUTES: Joi.number().integer().min(1).default(15),
|
AUTO_SYNC_INTERVAL_MINUTES: Joi.number().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.when("NODE_ENV", {
|
SENTRY_DSN: Joi.string().optional().allow(""),
|
||||||
is: "production",
|
|
||||||
then: Joi.string().uri().required(),
|
|
||||||
otherwise: Joi.string().uri().optional().allow(""),
|
|
||||||
}),
|
|
||||||
SENTRY_TRACES_SAMPLE_RATE: Joi.number().min(0).max(1).default(0),
|
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID: Joi.string().optional().allow(""),
|
GOOGLE_CLIENT_ID: Joi.string().optional().allow(""),
|
||||||
GOOGLE_CLIENT_SECRET: Joi.string().optional().allow(""),
|
GOOGLE_CLIENT_SECRET: Joi.string().optional().allow(""),
|
||||||
GOOGLE_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
GOOGLE_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
||||||
|
|
||||||
SOCIAL_AUTH_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
|
||||||
GOOGLE_AUTH_CLIENT_ID: Joi.string().optional().allow(""),
|
|
||||||
GOOGLE_AUTH_CLIENT_SECRET: Joi.string().optional().allow(""),
|
|
||||||
APPLE_CLIENT_ID: Joi.string().optional().allow(""),
|
|
||||||
APPLE_TEAM_ID: Joi.string().optional().allow(""),
|
|
||||||
APPLE_KEY_ID: Joi.string().optional().allow(""),
|
|
||||||
APPLE_PRIVATE_KEY: Joi.string().optional().allow(""),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
||||||
import { ok } from "../common/response";
|
|
||||||
import { CreditScoreService } from "./credit-score.service";
|
|
||||||
import { CreateCreditScoreEntryDto, PullCreditScoreDto } from "./dto";
|
|
||||||
|
|
||||||
@Controller("credit-score")
|
|
||||||
export class CreditScoreController {
|
|
||||||
constructor(private readonly creditScoreService: CreditScoreService) {}
|
|
||||||
|
|
||||||
@Get("summary")
|
|
||||||
async summary(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.creditScoreService.summary(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("entries")
|
|
||||||
async entries(@CurrentUser() userId: string, @Query("bureau") bureau?: string) {
|
|
||||||
return ok(await this.creditScoreService.listEntries(userId, bureau));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("entries")
|
|
||||||
async createEntry(@CurrentUser() userId: string, @Body() body: CreateCreditScoreEntryDto) {
|
|
||||||
return ok(await this.creditScoreService.createEntry(userId, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("pull")
|
|
||||||
async pull(@CurrentUser() userId: string, @Body() body: PullCreditScoreDto) {
|
|
||||||
return ok(await this.creditScoreService.pullScore(userId, body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { NotificationsModule } from "../notifications/notifications.module";
|
|
||||||
import { CreditScoreController } from "./credit-score.controller";
|
|
||||||
import { CreditScoreService } from "./credit-score.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [NotificationsModule],
|
|
||||||
controllers: [CreditScoreController],
|
|
||||||
providers: [CreditScoreService],
|
|
||||||
})
|
|
||||||
export class CreditScoreModule {}
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { CreateCreditScoreEntryDto, PullCreditScoreDto } from "./dto";
|
|
||||||
|
|
||||||
const SUPPORTED_CREDIT_PROVIDERS = ["sandbox"];
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CreditScoreService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly notifications: NotificationsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listEntries(userId: string, bureau?: string) {
|
|
||||||
return this.prisma.creditScoreEntry.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
...(bureau && bureau !== "all" ? { bureau } : {}),
|
|
||||||
},
|
|
||||||
orderBy: [{ scoreDate: "desc" }, { createdAt: "desc" }],
|
|
||||||
take: 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createEntry(userId: string, dto: CreateCreditScoreEntryDto) {
|
|
||||||
const scoreDate = new Date(dto.scoreDate);
|
|
||||||
if (Number.isNaN(scoreDate.getTime())) throw new BadRequestException("Invalid score date.");
|
|
||||||
|
|
||||||
const previous = await this.prisma.creditScoreEntry.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
bureau: dto.bureau ?? "unknown",
|
|
||||||
scoreDate: { lt: scoreDate },
|
|
||||||
},
|
|
||||||
orderBy: { scoreDate: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const entry = await this.prisma.creditScoreEntry.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
score: dto.score,
|
|
||||||
bureau: dto.bureau ?? "unknown",
|
|
||||||
source: dto.source ?? "manual",
|
|
||||||
model: dto.model ?? "vantage_score_3",
|
|
||||||
scoreDate,
|
|
||||||
factors: (dto.factors ?? {}) as Prisma.InputJsonValue,
|
|
||||||
metadata: (dto.metadata ?? {}) as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const change = previous ? entry.score - previous.score : null;
|
|
||||||
await this.prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
action: "credit_score.entry_create",
|
|
||||||
metadata: {
|
|
||||||
entryId: entry.id,
|
|
||||||
bureau: entry.bureau,
|
|
||||||
score: entry.score,
|
|
||||||
scoreDate: entry.scoreDate,
|
|
||||||
change,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (change !== null && Math.abs(change) >= 20) {
|
|
||||||
await this.notifications.notifyUser(userId, {
|
|
||||||
type: "credit_score.change",
|
|
||||||
severity: change < 0 ? "warning" : "info",
|
|
||||||
title: change < 0 ? "Credit score dropped" : "Credit score improved",
|
|
||||||
body: `${this.label(entry.bureau)} score changed by ${change > 0 ? "+" : ""}${change} points to ${entry.score}.`,
|
|
||||||
metadata: { entryId: entry.id, bureau: entry.bureau, score: entry.score, change },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...entry,
|
|
||||||
change,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async pullScore(userId: string, dto: PullCreditScoreDto) {
|
|
||||||
const provider = (dto.provider ?? process.env.CREDIT_SCORE_PROVIDER ?? "sandbox").toLowerCase();
|
|
||||||
if (!SUPPORTED_CREDIT_PROVIDERS.includes(provider)) {
|
|
||||||
throw new BadRequestException("Configured credit-score provider is not supported by this build.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const pulled = await this.pullFromProvider(provider, userId, dto.bureau ?? "experian");
|
|
||||||
return this.createEntry(userId, {
|
|
||||||
score: pulled.score,
|
|
||||||
bureau: pulled.bureau,
|
|
||||||
source: "provider",
|
|
||||||
model: pulled.model,
|
|
||||||
scoreDate: pulled.scoreDate,
|
|
||||||
factors: pulled.factors,
|
|
||||||
metadata: {
|
|
||||||
provider,
|
|
||||||
providerPullId: pulled.providerPullId,
|
|
||||||
consent: dto.consent ?? {},
|
|
||||||
sandbox: provider === "sandbox",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async summary(userId: string) {
|
|
||||||
const entries = await this.listEntries(userId, "all");
|
|
||||||
const latestByBureau = new Map<string, any>();
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!latestByBureau.has(entry.bureau)) latestByBureau.set(entry.bureau, entry);
|
|
||||||
}
|
|
||||||
const latest = entries[0] ?? null;
|
|
||||||
const previous = latest
|
|
||||||
? entries.find((entry) => entry.id !== latest.id && entry.bureau === latest.bureau) ?? null
|
|
||||||
: null;
|
|
||||||
const change = latest && previous ? latest.score - previous.score : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
latest,
|
|
||||||
previous,
|
|
||||||
change,
|
|
||||||
averageScore: entries.length
|
|
||||||
? Math.round(entries.reduce((sum, entry) => sum + entry.score, 0) / entries.length)
|
|
||||||
: null,
|
|
||||||
entryCount: entries.length,
|
|
||||||
latestByBureau: Array.from(latestByBureau.values()),
|
|
||||||
trend: entries
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((entry) => ({
|
|
||||||
id: entry.id,
|
|
||||||
score: entry.score,
|
|
||||||
bureau: entry.bureau,
|
|
||||||
scoreDate: entry.scoreDate,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private label(bureau: string) {
|
|
||||||
if (bureau === "experian") return "Experian";
|
|
||||||
if (bureau === "equifax") return "Equifax";
|
|
||||||
if (bureau === "transunion") return "TransUnion";
|
|
||||||
return "Credit";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async pullFromProvider(provider: string, userId: string, bureau: "experian" | "equifax" | "transunion") {
|
|
||||||
if (provider !== "sandbox") {
|
|
||||||
throw new BadRequestException("Credit-score provider is not available.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const seed = Array.from(`${userId}:${bureau}:${new Date().toISOString().slice(0, 10)}`)
|
|
||||||
.reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
|
||||||
const score = 660 + (seed % 90);
|
|
||||||
return {
|
|
||||||
score,
|
|
||||||
bureau,
|
|
||||||
model: "vantage_score_3",
|
|
||||||
scoreDate: new Date().toISOString(),
|
|
||||||
providerPullId: `sandbox_credit_${seed}_${Date.now()}`,
|
|
||||||
factors: {
|
|
||||||
paymentHistory: "good",
|
|
||||||
utilization: seed % 3 === 0 ? "moderate" : "low",
|
|
||||||
accountAge: "established",
|
|
||||||
inquiries: seed % 2 === 0 ? "low" : "moderate",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { IsDateString, IsIn, IsInt, IsObject, IsOptional, IsString, Max, Min } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateCreditScoreEntryDto {
|
|
||||||
@IsInt()
|
|
||||||
@Min(300)
|
|
||||||
@Max(850)
|
|
||||||
score!: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["experian", "equifax", "transunion", "unknown"])
|
|
||||||
bureau?: "experian" | "equifax" | "transunion" | "unknown";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["manual", "import", "provider"])
|
|
||||||
source?: "manual" | "import" | "provider";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
model?: string;
|
|
||||||
|
|
||||||
@IsDateString()
|
|
||||||
scoreDate!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
factors?: Record<string, unknown>;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PullCreditScoreDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["experian", "equifax", "transunion"])
|
|
||||||
bureau?: "experian" | "equifax" | "transunion";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
provider?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
consent?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@ -77,51 +77,4 @@ export class EmailService {
|
|||||||
this.logger.error(`Failed to send password reset email to ${email}`, err);
|
this.logger.error(`Failed to send password reset email to ${email}`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendHouseholdInviteEmail(email: string, householdName: string, inviterName: string, token: string): Promise<void> {
|
|
||||||
const url = `${this.appUrl}/settings/households/invite?token=${token}`;
|
|
||||||
try {
|
|
||||||
const info = await this.transporter.sendMail({
|
|
||||||
from: this.from,
|
|
||||||
to: email,
|
|
||||||
subject: `You're invited to join ${householdName} on LedgerOne`,
|
|
||||||
html: `
|
|
||||||
<h2>Join ${householdName}</h2>
|
|
||||||
<p>${inviterName} invited you to collaborate in a LedgerOne household.</p>
|
|
||||||
<p><a href="${url}" style="background:#316263;color:#B6FF3B;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Accept Invite</a></p>
|
|
||||||
<p>Or copy this link: ${url}</p>
|
|
||||||
<p>This invitation expires in 7 days.</p>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
if (!process.env.SMTP_HOST) {
|
|
||||||
this.logger.log(`[DEV] Household invite email for ${email}: ${url}`);
|
|
||||||
this.logger.debug(JSON.stringify(info));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`Failed to send household invite email to ${email}`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendNotificationEmail(email: string, title: string, body: string, actionUrl?: string, severity = "info"): Promise<void> {
|
|
||||||
const url = actionUrl ?? `${this.appUrl}/notifications`;
|
|
||||||
try {
|
|
||||||
const info = await this.transporter.sendMail({
|
|
||||||
from: this.from,
|
|
||||||
to: email,
|
|
||||||
subject: `[LedgerOne ${severity.toUpperCase()}] ${title}`,
|
|
||||||
html: `
|
|
||||||
<h2>${title}</h2>
|
|
||||||
<p>${body}</p>
|
|
||||||
<p><a href="${url}" style="background:#316263;color:#B6FF3B;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Open LedgerOne</a></p>
|
|
||||||
<p>Or copy this link: ${url}</p>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
if (!process.env.SMTP_HOST) {
|
|
||||||
this.logger.log(`[DEV] Notification email for ${email}: ${title}`);
|
|
||||||
this.logger.debug(JSON.stringify(info));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`Failed to send notification email to ${email}`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,101 +0,0 @@
|
|||||||
import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
|
|
||||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
|
|
||||||
export type ExportObjectInput = {
|
|
||||||
userId: string;
|
|
||||||
format: string;
|
|
||||||
fileName: string;
|
|
||||||
mimeType: string;
|
|
||||||
content: string | Buffer;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StoredExportObject = {
|
|
||||||
provider: "local" | "supabase";
|
|
||||||
key: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LoadedExportObject = {
|
|
||||||
content: Buffer;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ExportObjectStorageService {
|
|
||||||
private readonly driver = (process.env.EXPORT_OBJECT_STORAGE_DRIVER || "local").toLowerCase();
|
|
||||||
private readonly bucket = process.env.EXPORT_OBJECT_STORAGE_BUCKET || "ledgerone-exports";
|
|
||||||
private readonly localRoot = path.resolve(process.cwd(), process.env.EXPORT_OBJECT_STORAGE_DIR || "data/export-objects");
|
|
||||||
private supabaseClient?: SupabaseClient;
|
|
||||||
|
|
||||||
async store(input: ExportObjectInput): Promise<StoredExportObject> {
|
|
||||||
const content = Buffer.isBuffer(input.content) ? input.content : Buffer.from(input.content, "utf8");
|
|
||||||
const key = this.createObjectKey(input.userId, input.format, input.fileName);
|
|
||||||
|
|
||||||
if (this.driver === "supabase") {
|
|
||||||
const client = this.getSupabaseClient();
|
|
||||||
const { error } = await client.storage.from(this.bucket).upload(key, content, {
|
|
||||||
contentType: input.mimeType,
|
|
||||||
upsert: false,
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
throw new InternalServerErrorException(`Failed to store export object: ${error.message}`);
|
|
||||||
}
|
|
||||||
return { provider: "supabase", key, sizeBytes: content.byteLength };
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPath = this.localPathForKey(key);
|
|
||||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
||||||
await fs.writeFile(targetPath, content);
|
|
||||||
return { provider: "local", key, sizeBytes: content.byteLength };
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(provider: string | null | undefined, key: string | null | undefined): Promise<LoadedExportObject> {
|
|
||||||
if (!key) throw new NotFoundException("Stored export object not found.");
|
|
||||||
|
|
||||||
if (provider === "supabase") {
|
|
||||||
const client = this.getSupabaseClient();
|
|
||||||
const { data, error } = await client.storage.from(this.bucket).download(key);
|
|
||||||
if (error || !data) {
|
|
||||||
throw new NotFoundException("Stored export object not found.");
|
|
||||||
}
|
|
||||||
return { content: Buffer.from(await data.arrayBuffer()) };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return { content: await fs.readFile(this.localPathForKey(key)) };
|
|
||||||
} catch {
|
|
||||||
throw new NotFoundException("Stored export object not found.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createObjectKey(userId: string, format: string, fileName: string) {
|
|
||||||
const now = new Date();
|
|
||||||
const yyyy = now.getUTCFullYear();
|
|
||||||
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(now.getUTCDate()).padStart(2, "0");
|
|
||||||
const safeName = fileName.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
||||||
return `exports/${userId}/${yyyy}/${mm}/${dd}/${crypto.randomUUID()}-${format}-${safeName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private localPathForKey(key: string) {
|
|
||||||
const normalizedKey = key.replace(/\\/g, "/");
|
|
||||||
const targetPath = path.resolve(this.localRoot, normalizedKey);
|
|
||||||
if (!targetPath.startsWith(this.localRoot)) {
|
|
||||||
throw new InternalServerErrorException("Invalid export object key.");
|
|
||||||
}
|
|
||||||
return targetPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSupabaseClient() {
|
|
||||||
if (this.supabaseClient) return this.supabaseClient;
|
|
||||||
const url = process.env.SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY;
|
|
||||||
if (!url || !serviceKey) {
|
|
||||||
throw new InternalServerErrorException("Supabase export storage is not configured.");
|
|
||||||
}
|
|
||||||
this.supabaseClient = createClient(url, serviceKey, { auth: { persistSession: false } });
|
|
||||||
return this.supabaseClient;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +1,21 @@
|
|||||||
import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Post, Query } from "@nestjs/common";
|
||||||
import { Request, Response } from "express";
|
|
||||||
import { ok } from "../common/response";
|
import { ok } from "../common/response";
|
||||||
import { ExportsService } from "./exports.service";
|
import { ExportsService } from "./exports.service";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { requestContextFrom } from "../abuse/abuse.types";
|
|
||||||
import { RequiredPlan, SubscriptionGuard } from "../stripe/subscription.guard";
|
|
||||||
import { Public } from "../common/decorators/public.decorator";
|
|
||||||
|
|
||||||
@Controller("exports")
|
@Controller("exports")
|
||||||
@RequiredPlan("pro")
|
|
||||||
@UseGuards(SubscriptionGuard)
|
|
||||||
export class ExportsController {
|
export class ExportsController {
|
||||||
constructor(private readonly exportsService: ExportsService) {}
|
constructor(private readonly exportsService: ExportsService) {}
|
||||||
|
|
||||||
@Get("csv")
|
@Get("csv")
|
||||||
async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
|
||||||
const data = await this.exportsService.createSignedDownloadUrl(userId, "csv", query, requestContextFrom(req));
|
const data = await this.exportsService.exportCsv(userId, query);
|
||||||
return ok(data);
|
return ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("json")
|
|
||||||
async exportJson(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
|
||||||
const data = await this.exportsService.createSignedDownloadUrl(userId, "json", query, requestContextFrom(req));
|
|
||||||
return ok(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("xlsx")
|
|
||||||
async exportXlsx(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
|
||||||
const data = await this.exportsService.createSignedDownloadUrl(userId, "xlsx", query, requestContextFrom(req));
|
|
||||||
return ok(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("pdf")
|
|
||||||
async exportPdf(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
|
||||||
const data = await this.exportsService.createSignedDownloadUrl(userId, "pdf", query, requestContextFrom(req));
|
|
||||||
return ok(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Get("download/:token")
|
|
||||||
async downloadSignedExport(@Param("token") token: string, @Req() req: Request, @Res() res: Response) {
|
|
||||||
const file = await this.exportsService.consumeSignedDownloadUrl(token, requestContextFrom(req));
|
|
||||||
res.setHeader("Content-Type", file.mimeType);
|
|
||||||
res.setHeader("Content-Disposition", `attachment; filename="${file.fileName.replace(/"/g, "")}"`);
|
|
||||||
res.setHeader("Cache-Control", "no-store");
|
|
||||||
res.send(file.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("sheets")
|
@Post("sheets")
|
||||||
async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>, @Req() req: Request) {
|
async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
|
||||||
const data = await this.exportsService.exportSheets(userId, query, requestContextFrom(req));
|
const data = await this.exportsService.exportSheets(userId, query);
|
||||||
return ok(data);
|
return ok(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ExportsController } from "./exports.controller";
|
import { ExportsController } from "./exports.controller";
|
||||||
import { ExportsService } from "./exports.service";
|
import { ExportsService } from "./exports.service";
|
||||||
import { ExportObjectStorageService } from "./export-object-storage.service";
|
|
||||||
import { StripeModule } from "../stripe/stripe.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StripeModule],
|
|
||||||
controllers: [ExportsController],
|
controllers: [ExportsController],
|
||||||
providers: [ExportsService, ExportObjectStorageService],
|
providers: [ExportsService],
|
||||||
exports: [ExportsService],
|
|
||||||
})
|
})
|
||||||
export class ExportsModule {}
|
export class ExportsModule {}
|
||||||
|
|||||||
@ -1,32 +1,12 @@
|
|||||||
import { BadRequestException, GoneException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
import * as crypto from "crypto";
|
|
||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { AbuseService } from "../abuse/abuse.service";
|
|
||||||
import { RequestContext } from "../abuse/abuse.types";
|
|
||||||
import { ExportObjectStorageService } from "./export-object-storage.service";
|
|
||||||
|
|
||||||
type ExportWatermark = {
|
|
||||||
label: string;
|
|
||||||
userId: string;
|
|
||||||
generatedAt: string;
|
|
||||||
traceId: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportsService {
|
export class ExportsService {
|
||||||
private readonly logger = new Logger(ExportsService.name);
|
private readonly logger = new Logger(ExportsService.name);
|
||||||
private readonly syncSheetTitle = "LedgerOne Sync";
|
|
||||||
private readonly downloadTokenTtlMs = 2 * 60 * 1000;
|
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly abuseService: AbuseService,
|
|
||||||
private readonly exportObjectStorage: ExportObjectStorageService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private toCsv(rows: Array<Record<string, string>>) {
|
private toCsv(rows: Array<Record<string, string>>) {
|
||||||
if (!rows.length) return "";
|
if (!rows.length) return "";
|
||||||
@ -39,102 +19,6 @@ export class ExportsService {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
private createWatermark(userId: string): ExportWatermark {
|
|
||||||
const generatedAt = new Date().toISOString();
|
|
||||||
const traceId = crypto.randomBytes(12).toString("hex");
|
|
||||||
const label = "LedgerOne Export Watermark";
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
userId,
|
|
||||||
generatedAt,
|
|
||||||
traceId,
|
|
||||||
text: `${label} | user=${userId} | generated=${generatedAt} | trace=${traceId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyWatermarkToRows(rows: ReturnType<typeof this.toRows>, watermark: ExportWatermark) {
|
|
||||||
const watermarkedRows = rows.map((row) => ({ ...row, watermark: watermark.text }));
|
|
||||||
if (watermarkedRows.length) return watermarkedRows;
|
|
||||||
return [{
|
|
||||||
id: "",
|
|
||||||
date: "",
|
|
||||||
description: "",
|
|
||||||
amount: "",
|
|
||||||
category: "",
|
|
||||||
notes: "",
|
|
||||||
attribution: "",
|
|
||||||
splitMode: "",
|
|
||||||
splitMinePercent: "",
|
|
||||||
splitYoursPercent: "",
|
|
||||||
splitMineAmount: "",
|
|
||||||
splitYoursAmount: "",
|
|
||||||
hidden: "",
|
|
||||||
source: "",
|
|
||||||
watermark: watermark.text,
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
private escapePdfText(value: string) {
|
|
||||||
return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildPdf(rows: Array<Record<string, string>>, watermark: ExportWatermark) {
|
|
||||||
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"];
|
|
||||||
const lines = [
|
|
||||||
"LedgerOne Export",
|
|
||||||
`Generated ${new Date().toISOString()}`,
|
|
||||||
`Rows ${rows.length}`,
|
|
||||||
watermark.text,
|
|
||||||
"",
|
|
||||||
headers.join(" | "),
|
|
||||||
...rows.map((row) => headers.map((header) => row[header] ?? "").join(" | ")),
|
|
||||||
].map((line) => line.length > 118 ? `${line.slice(0, 115)}...` : line);
|
|
||||||
|
|
||||||
const pageHeight = 792;
|
|
||||||
const margin = 36;
|
|
||||||
const lineHeight = 11;
|
|
||||||
const fontSize = 8;
|
|
||||||
const linesPerPage = Math.floor((pageHeight - margin * 2) / lineHeight);
|
|
||||||
const pages: string[] = [];
|
|
||||||
for (let index = 0; index < lines.length; index += linesPerPage) {
|
|
||||||
const pageLines = lines.slice(index, index + linesPerPage);
|
|
||||||
const commands = ["BT", `/F1 ${fontSize} Tf`];
|
|
||||||
pageLines.forEach((line, lineIndex) => {
|
|
||||||
const y = pageHeight - margin - lineIndex * lineHeight;
|
|
||||||
commands.push(`1 0 0 1 ${margin} ${y} Tm (${this.escapePdfText(line)}) Tj`);
|
|
||||||
});
|
|
||||||
commands.push(`1 0 0 1 ${margin} ${margin - 14} Tm (${this.escapePdfText(watermark.text)}) Tj`);
|
|
||||||
commands.push("ET");
|
|
||||||
pages.push(commands.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const objects: string[] = [];
|
|
||||||
const pageObjectIds = pages.map((_, index) => 4 + index * 2);
|
|
||||||
objects[0] = "<< /Type /Catalog /Pages 2 0 R >>";
|
|
||||||
objects[1] = `<< /Type /Pages /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pages.length} >>`;
|
|
||||||
objects[2] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
|
|
||||||
pages.forEach((content, index) => {
|
|
||||||
const pageObjectId = 4 + index * 2;
|
|
||||||
const contentObjectId = pageObjectId + 1;
|
|
||||||
objects[pageObjectId - 1] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentObjectId} 0 R >>`;
|
|
||||||
objects[contentObjectId - 1] = `<< /Length ${Buffer.byteLength(content, "binary")} >>\nstream\n${content}\nendstream`;
|
|
||||||
});
|
|
||||||
|
|
||||||
let pdf = "%PDF-1.4\n";
|
|
||||||
const offsets = [0];
|
|
||||||
objects.forEach((object, index) => {
|
|
||||||
offsets.push(Buffer.byteLength(pdf, "binary"));
|
|
||||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
|
||||||
});
|
|
||||||
const xrefOffset = Buffer.byteLength(pdf, "binary");
|
|
||||||
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
||||||
for (let index = 1; index < offsets.length; index += 1) {
|
|
||||||
pdf += `${String(offsets[index]).padStart(10, "0")} 00000 n \n`;
|
|
||||||
}
|
|
||||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
|
||||||
return Buffer.from(pdf, "binary");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTransactions(
|
private async getTransactions(
|
||||||
userId: string,
|
userId: string,
|
||||||
filters: Record<string, string>,
|
filters: Record<string, string>,
|
||||||
@ -166,14 +50,7 @@ export class ExportsService {
|
|||||||
}
|
}
|
||||||
return this.prisma.transactionRaw.findMany({
|
return this.prisma.transactionRaw.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: { derived: true },
|
||||||
derived: true,
|
|
||||||
account: {
|
|
||||||
select: {
|
|
||||||
ownershipType: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { date: "desc" },
|
orderBy: { date: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
@ -187,356 +64,30 @@ export class ExportsService {
|
|||||||
amount: Number(tx.amount).toFixed(2),
|
amount: Number(tx.amount).toFixed(2),
|
||||||
category: tx.derived?.userCategory ?? "",
|
category: tx.derived?.userCategory ?? "",
|
||||||
notes: tx.derived?.userNotes ?? "",
|
notes: tx.derived?.userNotes ?? "",
|
||||||
attribution: tx.derived?.attribution ?? this.defaultAttributionForAccount(tx.account?.ownershipType),
|
|
||||||
splitMode: tx.derived?.splitMode ?? "none",
|
|
||||||
splitMinePercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"),
|
|
||||||
splitYoursPercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"),
|
|
||||||
splitMineAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"),
|
|
||||||
splitYoursAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"),
|
|
||||||
hidden: tx.derived?.isHidden ? "true" : "false",
|
hidden: tx.derived?.isHidden ? "true" : "false",
|
||||||
source: tx.source,
|
source: tx.source,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private defaultAttributionForAccount(ownershipType?: string | null) {
|
async exportCsv(userId: string, filters: Record<string, string> = {}) {
|
||||||
if (ownershipType === "joint") return "ours";
|
const transactions = await this.getTransactions(userId, filters);
|
||||||
if (ownershipType === "theirs") return "yours";
|
const rows = this.toRows(transactions);
|
||||||
return "mine";
|
const csv = this.toCsv(rows);
|
||||||
}
|
|
||||||
|
|
||||||
private splitPercent(mode: string | null | undefined, value: unknown, side: "mine" | "yours") {
|
|
||||||
if (!mode || mode === "none") return side === "mine" ? "100.00" : "0.00";
|
|
||||||
if (mode === "equal") return "50.00";
|
|
||||||
return Number(value ?? 0).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
private splitAmount(amount: number, mode: string | null | undefined, value: unknown, side: "mine" | "yours") {
|
|
||||||
const percent = Number(this.splitPercent(mode, value, side));
|
|
||||||
return (amount * (percent / 100)).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
private hashContent(content: string | Buffer) {
|
|
||||||
return crypto.createHash("sha256").update(content).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
private hashToken(token: string) {
|
|
||||||
return crypto.createHash("sha256").update(token).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createExportLog(
|
|
||||||
userId: string,
|
|
||||||
filters: Record<string, string>,
|
|
||||||
rowCount: number,
|
|
||||||
audit: {
|
|
||||||
format: string;
|
|
||||||
destination?: string;
|
|
||||||
fileName?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
fileContent?: string | Buffer;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
},
|
|
||||||
context?: RequestContext,
|
|
||||||
) {
|
|
||||||
await this.prisma.exportLog.create({
|
await this.prisma.exportLog.create({
|
||||||
data: {
|
data: { userId, filters, rowCount: rows.length },
|
||||||
userId,
|
|
||||||
format: audit.format,
|
|
||||||
destination: audit.destination ?? "download",
|
|
||||||
filters,
|
|
||||||
rowCount,
|
|
||||||
fileName: audit.fileName,
|
|
||||||
mimeType: audit.mimeType,
|
|
||||||
fileHash: audit.fileContent ? this.hashContent(audit.fileContent) : undefined,
|
|
||||||
ipAddress: context?.ipAddress,
|
|
||||||
userAgent: context?.userAgent,
|
|
||||||
metadata: (audit.metadata ?? {}) as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildCsvFile(userId: string, filters: Record<string, string> = {}) {
|
|
||||||
const transactions = await this.getTransactions(userId, filters);
|
|
||||||
const rows = this.toRows(transactions);
|
|
||||||
const watermark = this.createWatermark(userId);
|
|
||||||
const csv = this.toCsv(this.applyWatermarkToRows(rows, watermark));
|
|
||||||
const fileName = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: csv,
|
|
||||||
fileName,
|
|
||||||
mimeType: "text/csv",
|
|
||||||
rowCount: rows.length,
|
|
||||||
watermark,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportCsv(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
||||||
const file = await this.buildCsvFile(userId, filters);
|
|
||||||
|
|
||||||
await this.createExportLog(userId, filters, file.rowCount, {
|
|
||||||
format: "csv",
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
fileContent: file.content,
|
|
||||||
metadata: { watermark: file.watermark },
|
|
||||||
}, context);
|
|
||||||
await this.abuseService.recordExportActivity(userId, file.rowCount, filters, context);
|
|
||||||
|
|
||||||
return { status: "ready", csv: file.content, fileName: file.fileName, rowCount: file.rowCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildJsonFile(userId: string, filters: Record<string, string> = {}) {
|
|
||||||
const transactions = await this.getTransactions(userId, filters);
|
|
||||||
const rows = this.toRows(transactions);
|
|
||||||
const watermark = this.createWatermark(userId);
|
|
||||||
const payload = {
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
rowCount: rows.length,
|
|
||||||
filters,
|
|
||||||
watermark,
|
|
||||||
transactions: rows,
|
|
||||||
};
|
|
||||||
const buffer = Buffer.from(JSON.stringify(payload, null, 2), "utf8");
|
|
||||||
return {
|
|
||||||
content: buffer,
|
|
||||||
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.json`,
|
|
||||||
mimeType: "application/json",
|
|
||||||
rowCount: rows.length,
|
|
||||||
watermark,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportJson(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
||||||
const file = await this.buildJsonFile(userId, filters);
|
|
||||||
|
|
||||||
await this.createExportLog(userId, filters, file.rowCount, {
|
|
||||||
format: "json",
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
fileContent: file.content,
|
|
||||||
metadata: { watermark: file.watermark },
|
|
||||||
}, context);
|
|
||||||
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "json" }, context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: "ready",
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
base64: file.content.toString("base64"),
|
|
||||||
rowCount: file.rowCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildXlsxFile(userId: string, filters: Record<string, string> = {}) {
|
|
||||||
const transactions = await this.getTransactions(userId, filters);
|
|
||||||
const rows = this.toRows(transactions);
|
|
||||||
const watermark = this.createWatermark(userId);
|
|
||||||
const watermarkedRows = this.applyWatermarkToRows(rows, watermark);
|
|
||||||
const headers = this.getHeaders(watermarkedRows);
|
|
||||||
const worksheet = XLSX.utils.json_to_sheet(watermarkedRows, { header: headers });
|
|
||||||
const watermarkWorksheet = XLSX.utils.json_to_sheet([{
|
|
||||||
label: watermark.label,
|
|
||||||
userId: watermark.userId,
|
|
||||||
generatedAt: watermark.generatedAt,
|
|
||||||
traceId: watermark.traceId,
|
|
||||||
watermark: watermark.text,
|
|
||||||
}]);
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, "LedgerOne Export");
|
|
||||||
XLSX.utils.book_append_sheet(workbook, watermarkWorksheet, "Watermark");
|
|
||||||
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
|
||||||
return {
|
|
||||||
content: buffer,
|
|
||||||
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.xlsx`,
|
|
||||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
rowCount: rows.length,
|
|
||||||
watermark,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportXlsx(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
||||||
const file = await this.buildXlsxFile(userId, filters);
|
|
||||||
|
|
||||||
await this.createExportLog(userId, filters, file.rowCount, {
|
|
||||||
format: "xlsx",
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
fileContent: file.content,
|
|
||||||
metadata: { watermark: file.watermark },
|
|
||||||
}, context);
|
|
||||||
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "xlsx" }, context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: "ready",
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
base64: file.content.toString("base64"),
|
|
||||||
rowCount: file.rowCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildPdfFile(userId: string, filters: Record<string, string> = {}) {
|
|
||||||
const transactions = await this.getTransactions(userId, filters);
|
|
||||||
const rows = this.toRows(transactions);
|
|
||||||
const watermark = this.createWatermark(userId);
|
|
||||||
const buffer = this.buildPdf(rows, watermark);
|
|
||||||
return {
|
|
||||||
content: buffer,
|
|
||||||
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.pdf`,
|
|
||||||
mimeType: "application/pdf",
|
|
||||||
rowCount: rows.length,
|
|
||||||
watermark,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportPdf(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
||||||
const file = await this.buildPdfFile(userId, filters);
|
|
||||||
|
|
||||||
await this.createExportLog(userId, filters, file.rowCount, {
|
|
||||||
format: "pdf",
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
fileContent: file.content,
|
|
||||||
metadata: { watermark: file.watermark },
|
|
||||||
}, context);
|
|
||||||
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "pdf" }, context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: "ready",
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
base64: file.content.toString("base64"),
|
|
||||||
rowCount: file.rowCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSignedDownloadUrl(
|
|
||||||
userId: string,
|
|
||||||
format: "csv" | "json" | "xlsx" | "pdf",
|
|
||||||
filters: Record<string, string> = {},
|
|
||||||
context?: RequestContext,
|
|
||||||
) {
|
|
||||||
const token = crypto.randomBytes(32).toString("base64url");
|
|
||||||
const expiresAt = new Date(Date.now() + this.downloadTokenTtlMs);
|
|
||||||
const file = await this.buildDownloadFile(userId, format, filters);
|
|
||||||
const stored = await this.exportObjectStorage.store({
|
|
||||||
userId,
|
|
||||||
format,
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
content: file.content,
|
|
||||||
});
|
|
||||||
const fileHash = this.hashContent(file.content);
|
|
||||||
|
|
||||||
await this.prisma.exportDownloadToken.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
tokenHash: this.hashToken(token),
|
|
||||||
format,
|
|
||||||
filters,
|
|
||||||
storageProvider: stored.provider,
|
|
||||||
storageKey: stored.key,
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
rowCount: file.rowCount,
|
|
||||||
fileHash,
|
|
||||||
expiresAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { status: "ready", csv, rowCount: rows.length };
|
||||||
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) {
|
async exportSheets(userId: string, filters: Record<string, string> = {}) {
|
||||||
const tokenHash = this.hashToken(token);
|
// Get the user's Google connection
|
||||||
const record = await this.prisma.exportDownloadToken.findUnique({ where: { tokenHash } });
|
|
||||||
if (!record) throw new NotFoundException("Download link not found.");
|
|
||||||
if (record.usedAt) throw new GoneException("Download link has already been used.");
|
|
||||||
if (record.expiresAt < new Date()) throw new GoneException("Download link has expired.");
|
|
||||||
|
|
||||||
const consumed = await this.prisma.exportDownloadToken.updateMany({
|
|
||||||
where: { id: record.id, usedAt: null, expiresAt: { gt: new Date() } },
|
|
||||||
data: { usedAt: new Date() },
|
|
||||||
});
|
|
||||||
if (consumed.count !== 1) {
|
|
||||||
throw new GoneException("Download link is no longer valid.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = record.filters as unknown as Record<string, string>;
|
|
||||||
const format = record.format as "csv" | "json" | "xlsx" | "pdf";
|
|
||||||
const storedFile = await this.exportObjectStorage.load(record.storageProvider, record.storageKey);
|
|
||||||
const file = {
|
|
||||||
content: storedFile.content,
|
|
||||||
fileName: record.fileName ?? `ledgerone-export-${new Date().toISOString().slice(0, 10)}.${format}`,
|
|
||||||
mimeType: record.mimeType ?? this.mimeTypeForFormat(format),
|
|
||||||
rowCount: record.rowCount ?? 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.createExportLog(record.userId, filters, file.rowCount, {
|
|
||||||
format,
|
|
||||||
fileName: file.fileName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
fileContent: file.content,
|
|
||||||
metadata: {
|
|
||||||
signedUrl: true,
|
|
||||||
tokenId: record.id,
|
|
||||||
expiresAt: record.expiresAt.toISOString(),
|
|
||||||
storageProvider: record.storageProvider,
|
|
||||||
storageKey: record.storageKey,
|
|
||||||
precomputedFileHash: record.fileHash,
|
|
||||||
},
|
|
||||||
}, context);
|
|
||||||
await this.abuseService.recordExportActivity(record.userId, file.rowCount, { ...filters, format, signedUrl: "true" }, context);
|
|
||||||
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildDownloadFile(userId: string, format: "csv" | "json" | "xlsx" | "pdf", filters: Record<string, string>) {
|
|
||||||
if (format === "csv") return this.buildCsvFile(userId, filters);
|
|
||||||
if (format === "json") return this.buildJsonFile(userId, filters);
|
|
||||||
if (format === "xlsx") return this.buildXlsxFile(userId, filters);
|
|
||||||
if (format === "pdf") return this.buildPdfFile(userId, filters);
|
|
||||||
throw new BadRequestException("Unsupported export format.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private mimeTypeForFormat(format: "csv" | "json" | "xlsx" | "pdf") {
|
|
||||||
if (format === "csv") return "text/csv";
|
|
||||||
if (format === "json") return "application/json";
|
|
||||||
if (format === "xlsx") return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
||||||
if (format === "pdf") return "application/pdf";
|
|
||||||
return "application/octet-stream";
|
|
||||||
}
|
|
||||||
|
|
||||||
private getHeaders(rows: ReturnType<typeof this.toRows>) {
|
|
||||||
return rows.length
|
|
||||||
? Object.keys(rows[0])
|
|
||||||
: ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"];
|
|
||||||
}
|
|
||||||
|
|
||||||
private toSheetValues(rows: ReturnType<typeof this.toRows>) {
|
|
||||||
const headers = this.getHeaders(rows);
|
|
||||||
return [
|
|
||||||
headers,
|
|
||||||
...rows.map((row) => headers.map((header) => row[header as keyof typeof row] ?? "")),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createGoogleSheetsClient(userId: string, failWhenDisconnected: boolean) {
|
|
||||||
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
||||||
if (!gc || !gc.isConnected) {
|
if (!gc || !gc.isConnected) {
|
||||||
if (failWhenDisconnected) {
|
throw new BadRequestException(
|
||||||
throw new BadRequestException(
|
"Google account not connected. Please connect via /api/google/connect.",
|
||||||
"Google account not connected. Please connect via /api/google/connect.",
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauth2Client = new google.auth.OAuth2(
|
const oauth2Client = new google.auth.OAuth2(
|
||||||
@ -548,6 +99,7 @@ export class ExportsService {
|
|||||||
refresh_token: gc.refreshToken,
|
refresh_token: gc.refreshToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refresh the access token if needed
|
||||||
const { credentials } = await oauth2Client.refreshAccessToken();
|
const { credentials } = await oauth2Client.refreshAccessToken();
|
||||||
await this.prisma.googleConnection.update({
|
await this.prisma.googleConnection.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@ -558,94 +110,26 @@ export class ExportsService {
|
|||||||
});
|
});
|
||||||
oauth2Client.setCredentials(credentials);
|
oauth2Client.setCredentials(credentials);
|
||||||
|
|
||||||
return {
|
const sheets = google.sheets({ version: "v4", auth: oauth2Client });
|
||||||
gc,
|
|
||||||
sheets: google.sheets({ version: "v4", auth: oauth2Client }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureSpreadsheet(
|
|
||||||
userId: string,
|
|
||||||
sheets: ReturnType<typeof google.sheets>,
|
|
||||||
spreadsheetId?: string | null,
|
|
||||||
) {
|
|
||||||
if (spreadsheetId) {
|
|
||||||
await this.markDriveMirror(userId, spreadsheetId, "ready");
|
|
||||||
return spreadsheetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const spreadsheet = await sheets.spreadsheets.create({
|
|
||||||
requestBody: { properties: { title: "LedgerOne User-Owned Ledger" } },
|
|
||||||
});
|
|
||||||
const createdSpreadsheetId = spreadsheet.data.spreadsheetId!;
|
|
||||||
await this.markDriveMirror(userId, createdSpreadsheetId, "ready");
|
|
||||||
return createdSpreadsheetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async markDriveMirror(userId: string, spreadsheetId: string, status: string, syncedAt?: Date) {
|
|
||||||
await this.prisma.googleConnection.update({
|
|
||||||
where: { userId },
|
|
||||||
data: {
|
|
||||||
spreadsheetId,
|
|
||||||
driveMirrorEnabled: true,
|
|
||||||
driveMirrorStatus: status,
|
|
||||||
driveMirrorSpreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
|
||||||
...(syncedAt ? { driveMirrorLastSyncedAt: syncedAt, lastSyncedAt: syncedAt } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async addSheetIfMissing(
|
|
||||||
sheets: ReturnType<typeof google.sheets>,
|
|
||||||
spreadsheetId: string,
|
|
||||||
sheetTitle: string,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await sheets.spreadsheets.batchUpdate({
|
|
||||||
spreadsheetId,
|
|
||||||
requestBody: {
|
|
||||||
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : "";
|
|
||||||
if (!/already exists|duplicate/i.test(message)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async writeSheetValues(
|
|
||||||
sheets: ReturnType<typeof google.sheets>,
|
|
||||||
spreadsheetId: string,
|
|
||||||
sheetTitle: string,
|
|
||||||
values: string[][],
|
|
||||||
clearFirst = false,
|
|
||||||
) {
|
|
||||||
if (clearFirst) {
|
|
||||||
await sheets.spreadsheets.values.clear({
|
|
||||||
spreadsheetId,
|
|
||||||
range: `'${sheetTitle}'!A:Z`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await sheets.spreadsheets.values.update({
|
|
||||||
spreadsheetId,
|
|
||||||
range: `'${sheetTitle}'!A1`,
|
|
||||||
valueInputOption: "RAW",
|
|
||||||
requestBody: { values },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportSheets(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
||||||
const client = await this.createGoogleSheetsClient(userId, true);
|
|
||||||
const { gc, sheets } = client!;
|
|
||||||
const transactions = await this.getTransactions(userId, filters);
|
const transactions = await this.getTransactions(userId, filters);
|
||||||
const rows = this.toRows(transactions);
|
const rows = this.toRows(transactions);
|
||||||
|
|
||||||
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`;
|
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`;
|
||||||
const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId);
|
|
||||||
|
|
||||||
|
let spreadsheetId = gc.spreadsheetId;
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
// Create a new spreadsheet
|
||||||
|
const spreadsheet = await sheets.spreadsheets.create({
|
||||||
|
requestBody: { properties: { title: "LedgerOne" } },
|
||||||
|
});
|
||||||
|
spreadsheetId = spreadsheet.data.spreadsheetId!;
|
||||||
|
await this.prisma.googleConnection.update({
|
||||||
|
where: { userId },
|
||||||
|
data: { spreadsheetId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new sheet tab
|
||||||
await sheets.spreadsheets.batchUpdate({
|
await sheets.spreadsheets.batchUpdate({
|
||||||
spreadsheetId,
|
spreadsheetId,
|
||||||
requestBody: {
|
requestBody: {
|
||||||
@ -653,15 +137,23 @@ export class ExportsService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.writeSheetValues(sheets, spreadsheetId, sheetTitle, this.toSheetValues(rows));
|
// Build values: header + data rows
|
||||||
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
|
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"];
|
||||||
|
const values = [
|
||||||
|
headers,
|
||||||
|
...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")),
|
||||||
|
];
|
||||||
|
|
||||||
await this.createExportLog(userId, filters, rows.length, {
|
await sheets.spreadsheets.values.update({
|
||||||
format: "google_sheets",
|
spreadsheetId,
|
||||||
destination: "google_sheets",
|
range: `'${sheetTitle}'!A1`,
|
||||||
metadata: { spreadsheetId, sheetTitle, url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}` },
|
valueInputOption: "RAW",
|
||||||
}, context);
|
requestBody: { values },
|
||||||
await this.abuseService.recordExportActivity(userId, rows.length, { ...filters, destination: "google_sheets" }, context);
|
});
|
||||||
|
|
||||||
|
await this.prisma.exportLog.create({
|
||||||
|
data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length },
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`);
|
this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`);
|
||||||
|
|
||||||
@ -673,38 +165,4 @@ export class ExportsService {
|
|||||||
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncGoogleSheets(userId: string, reason = "transaction_change") {
|
|
||||||
const client = await this.createGoogleSheetsClient(userId, false);
|
|
||||||
if (!client) {
|
|
||||||
return { status: "skipped", reason: "not_connected" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { gc, sheets } = client;
|
|
||||||
const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId);
|
|
||||||
const transactions = await this.getTransactions(userId, {}, 5000);
|
|
||||||
const rows = this.toRows(transactions);
|
|
||||||
|
|
||||||
await this.addSheetIfMissing(sheets, spreadsheetId, this.syncSheetTitle);
|
|
||||||
await this.writeSheetValues(
|
|
||||||
sheets,
|
|
||||||
spreadsheetId,
|
|
||||||
this.syncSheetTitle,
|
|
||||||
this.toSheetValues(rows),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
|
|
||||||
|
|
||||||
this.logger.log(`Synced ${rows.length} rows to Google Sheets for user ${userId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: "synced",
|
|
||||||
reason,
|
|
||||||
rowCount: rows.length,
|
|
||||||
spreadsheetId,
|
|
||||||
sheetTitle: this.syncSheetTitle,
|
|
||||||
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,10 +30,4 @@ export class GoogleController {
|
|||||||
status(@CurrentUser() userId: string) {
|
status(@CurrentUser() userId: string) {
|
||||||
return this.googleService.getStatus(userId);
|
return this.googleService.getStatus(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("data-system-mode")
|
|
||||||
@HttpCode(200)
|
|
||||||
dataSystemMode(@CurrentUser() userId: string, @Body() body: { mode: "backend_db" | "google_sheets_mirror" }) {
|
|
||||||
return this.googleService.updateDataSystemMode(userId, body.mode);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
|
import { Credentials } from "google-auth-library";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
const SCOPES = [
|
const SCOPES = [
|
||||||
@ -30,18 +31,19 @@ export class GoogleService {
|
|||||||
const authUrl = client.generateAuthUrl({
|
const authUrl = client.generateAuthUrl({
|
||||||
access_type: "offline",
|
access_type: "offline",
|
||||||
scope: SCOPES,
|
scope: SCOPES,
|
||||||
prompt: "consent", // always request a refresh_token
|
prompt: "consent",
|
||||||
state: userId, // passed back in callback to identify the user
|
state: userId,
|
||||||
});
|
});
|
||||||
return { authUrl };
|
return { authUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
async exchangeCode(userId: string, code: string) {
|
async exchangeCode(userId: string, code: string) {
|
||||||
const client = this.createClient();
|
const client = this.createClient();
|
||||||
let tokens: import("google-auth-library").Credentials;
|
let tokens: Credentials;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await client.getToken(code);
|
const { tokens: t } = await client.getToken(code);
|
||||||
tokens = result.tokens;
|
tokens = t;
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException("Invalid or expired authorization code.");
|
throw new BadRequestException("Invalid or expired authorization code.");
|
||||||
}
|
}
|
||||||
@ -52,13 +54,12 @@ export class GoogleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the Google account email
|
|
||||||
client.setCredentials(tokens);
|
client.setCredentials(tokens);
|
||||||
const oauth2 = google.oauth2({ version: "v2", auth: client });
|
const oauth2 = google.oauth2({ version: "v2", auth: client });
|
||||||
const { data } = await oauth2.userinfo.get();
|
const { data } = await oauth2.userinfo.get();
|
||||||
const googleEmail = data.email ?? "";
|
const googleEmail = data.email ?? "";
|
||||||
|
|
||||||
await (this.prisma as any).googleConnection.upsert({
|
await this.prisma.googleConnection.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
update: {
|
update: {
|
||||||
googleEmail,
|
googleEmail,
|
||||||
@ -66,12 +67,7 @@ export class GoogleService {
|
|||||||
accessToken: tokens.access_token ?? null,
|
accessToken: tokens.access_token ?? null,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
spreadsheetId: null, // reset so a new spreadsheet is created on next export
|
spreadsheetId: null,
|
||||||
driveMirrorEnabled: false,
|
|
||||||
driveMirrorStatus: "pending_first_sync",
|
|
||||||
driveMirrorSpreadsheetUrl: null,
|
|
||||||
driveMirrorLastSyncedAt: null,
|
|
||||||
dataSystemMode: "backend_db",
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId,
|
userId,
|
||||||
@ -79,9 +75,6 @@ export class GoogleService {
|
|||||||
refreshToken: tokens.refresh_token,
|
refreshToken: tokens.refresh_token,
|
||||||
accessToken: tokens.access_token ?? null,
|
accessToken: tokens.access_token ?? null,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
driveMirrorEnabled: false,
|
|
||||||
driveMirrorStatus: "pending_first_sync",
|
|
||||||
dataSystemMode: "backend_db",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -95,55 +88,8 @@ export class GoogleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(userId: string) {
|
async getStatus(userId: string) {
|
||||||
const gc = await (this.prisma as any).googleConnection.findUnique({ where: { userId } });
|
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
||||||
if (!gc || !gc.isConnected) return { connected: false };
|
if (!gc || !gc.isConnected) return { connected: false };
|
||||||
return {
|
return { connected: true, googleEmail: gc.googleEmail, connectedAt: gc.connectedAt };
|
||||||
connected: true,
|
|
||||||
googleEmail: gc.googleEmail,
|
|
||||||
connectedAt: gc.connectedAt,
|
|
||||||
driveMirror: {
|
|
||||||
enabled: gc.driveMirrorEnabled,
|
|
||||||
status: gc.driveMirrorStatus,
|
|
||||||
spreadsheetId: gc.spreadsheetId,
|
|
||||||
url: gc.driveMirrorSpreadsheetUrl,
|
|
||||||
lastSyncedAt: gc.driveMirrorLastSyncedAt ?? gc.lastSyncedAt,
|
|
||||||
ownership: "user_google_drive",
|
|
||||||
},
|
|
||||||
dataSystem: {
|
|
||||||
mode: gc.dataSystemMode,
|
|
||||||
operationalSystemOfRecord: "ledgerone_backend_db",
|
|
||||||
userOwnedMirror: gc.dataSystemMode === "google_sheets_mirror",
|
|
||||||
note: gc.dataSystemMode === "google_sheets_mirror"
|
|
||||||
? "LedgerOne writes a best-effort user-owned Google Sheets mirror while the application database remains the operational store."
|
|
||||||
: "LedgerOne uses the application database as the operational store. Google Sheets can be enabled as a user-owned mirror.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
async updateDataSystemMode(userId: string, mode: "backend_db" | "google_sheets_mirror") {
|
|
||||||
if (!["backend_db", "google_sheets_mirror"].includes(mode)) {
|
|
||||||
throw new BadRequestException("Unsupported data system mode.");
|
|
||||||
}
|
|
||||||
const connection = await (this.prisma as any).googleConnection.findUnique({ where: { userId } });
|
|
||||||
if (!connection || !connection.isConnected) {
|
|
||||||
throw new BadRequestException("Google account must be connected before enabling Sheets-first mirror mode.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await (this.prisma as any).googleConnection.update({
|
|
||||||
where: { userId },
|
|
||||||
data: {
|
|
||||||
dataSystemMode: mode,
|
|
||||||
driveMirrorEnabled: mode === "google_sheets_mirror" ? true : connection.driveMirrorEnabled,
|
|
||||||
driveMirrorStatus: mode === "google_sheets_mirror" && !connection.spreadsheetId ? "pending_first_sync" : connection.driveMirrorStatus,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: updated.dataSystemMode,
|
|
||||||
driveMirrorEnabled: updated.driveMirrorEnabled,
|
|
||||||
status: updated.driveMirrorStatus,
|
|
||||||
operationalSystemOfRecord: "ledgerone_backend_db",
|
|
||||||
userOwnedMirror: updated.dataSystemMode === "google_sheets_mirror",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { IsString, MinLength } from "class-validator";
|
|
||||||
|
|
||||||
export class AcceptHouseholdInviteDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(32)
|
|
||||||
token!: string;
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
export class CreateAccountantTaskDto {
|
|
||||||
title!: string;
|
|
||||||
description?: string;
|
|
||||||
taskType?: string;
|
|
||||||
assignedToUserId?: string;
|
|
||||||
priority?: string;
|
|
||||||
dueDate?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateAccountantTaskDto {
|
|
||||||
title?: string;
|
|
||||||
description?: string | null;
|
|
||||||
taskType?: string;
|
|
||||||
assignedToUserId?: string | null;
|
|
||||||
priority?: string;
|
|
||||||
dueDate?: string | null;
|
|
||||||
status?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateHouseholdGoalDto {
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(500)
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.01)
|
|
||||||
targetAmount!: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
currentAmount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(3)
|
|
||||||
isoCurrencyCode?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
targetDate?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["low", "medium", "high"])
|
|
||||||
priority?: "low" | "medium" | "high";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { IsEmail, IsIn, IsOptional } from "class-validator";
|
|
||||||
import { HOUSEHOLD_ROLES, HouseholdRole } from "./update-household-member.dto";
|
|
||||||
|
|
||||||
export class CreateHouseholdInviteDto {
|
|
||||||
@IsEmail()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(HOUSEHOLD_ROLES)
|
|
||||||
role?: HouseholdRole;
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { IsObject, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateHouseholdDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(2)
|
|
||||||
@MaxLength(120)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
export type DebtPayoffStrategy = "avalanche" | "snowball" | "custom";
|
|
||||||
export type DebtPayoffSplitMode = "equal" | "income_weighted" | "custom";
|
|
||||||
|
|
||||||
export class DebtPayoffItemDto {
|
|
||||||
name!: string;
|
|
||||||
balance!: number;
|
|
||||||
annualPercentageRate?: number;
|
|
||||||
minimumPayment!: number;
|
|
||||||
priority?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DebtPayoffPlannerDto {
|
|
||||||
debts!: DebtPayoffItemDto[];
|
|
||||||
monthlyExtraPayment?: number;
|
|
||||||
strategy?: DebtPayoffStrategy;
|
|
||||||
splitMode?: DebtPayoffSplitMode;
|
|
||||||
mineMonthlyIncome?: number;
|
|
||||||
yoursMonthlyIncome?: number;
|
|
||||||
customMinePercent?: number;
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
export type FairSplitMode = "equal" | "income_weighted" | "custom";
|
|
||||||
|
|
||||||
export class FairSplitCalculatorDto {
|
|
||||||
expenseAmount!: number;
|
|
||||||
mineMonthlyIncome?: number;
|
|
||||||
yoursMonthlyIncome?: number;
|
|
||||||
splitMode?: FairSplitMode;
|
|
||||||
customMinePercent?: number;
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
export type FutureScenarioType = "goal" | "net_worth" | "income_change" | "expense_change";
|
|
||||||
|
|
||||||
export class FuturePlanningEventDto {
|
|
||||||
month!: number;
|
|
||||||
label!: string;
|
|
||||||
amount!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FuturePlanningScenarioDto {
|
|
||||||
name!: string;
|
|
||||||
type?: FutureScenarioType;
|
|
||||||
startingBalance!: number;
|
|
||||||
monthlyContribution!: number;
|
|
||||||
monthlyIncome?: number;
|
|
||||||
monthlyExpenses?: number;
|
|
||||||
targetAmount?: number;
|
|
||||||
horizonMonths?: number;
|
|
||||||
annualGrowthRate?: number;
|
|
||||||
events?: FuturePlanningEventDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FuturePlanningScenariosDto {
|
|
||||||
scenarios!: FuturePlanningScenarioDto[];
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
|
|
||||||
|
|
||||||
export class UpdateHouseholdGoalDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(500)
|
|
||||||
description?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.01)
|
|
||||||
targetAmount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
currentAmount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(3)
|
|
||||||
isoCurrencyCode?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
targetDate?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["low", "medium", "high"])
|
|
||||||
priority?: "low" | "medium" | "high";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["active", "paused", "completed", "archived"])
|
|
||||||
status?: "active" | "paused" | "completed" | "archived";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { IsIn, IsOptional } from "class-validator";
|
|
||||||
|
|
||||||
export const HOUSEHOLD_ROLES = ["owner", "admin", "member", "viewer", "accountant", "advisor"] as const;
|
|
||||||
export const HOUSEHOLD_MEMBER_STATUSES = ["active", "inactive", "removed"] as const;
|
|
||||||
|
|
||||||
export type HouseholdRole = typeof HOUSEHOLD_ROLES[number];
|
|
||||||
export type HouseholdMemberStatus = typeof HOUSEHOLD_MEMBER_STATUSES[number];
|
|
||||||
|
|
||||||
export class UpdateHouseholdMemberDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(HOUSEHOLD_ROLES)
|
|
||||||
role?: HouseholdRole;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(HOUSEHOLD_MEMBER_STATUSES)
|
|
||||||
status?: HouseholdMemberStatus;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export class UpdateHouseholdPrivacyDto {
|
|
||||||
enabled!: boolean;
|
|
||||||
hideIndividualBalances?: boolean;
|
|
||||||
hideIndividualTransactions?: boolean;
|
|
||||||
}
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
|
||||||
import { ok } from "../common/response";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
||||||
import { CreateAccountantTaskDto, UpdateAccountantTaskDto } from "./dto/accountant-task.dto";
|
|
||||||
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
|
||||||
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
|
||||||
import { CreateHouseholdDto } from "./dto/create-household.dto";
|
|
||||||
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
|
|
||||||
import { DebtPayoffPlannerDto } from "./dto/debt-payoff-planner.dto";
|
|
||||||
import { FairSplitCalculatorDto } from "./dto/fair-split-calculator.dto";
|
|
||||||
import { FuturePlanningScenariosDto } from "./dto/future-planning-scenario.dto";
|
|
||||||
import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
|
|
||||||
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
|
|
||||||
import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.dto";
|
|
||||||
import { HouseholdsService } from "./households.service";
|
|
||||||
|
|
||||||
@Controller("households")
|
|
||||||
export class HouseholdsController {
|
|
||||||
constructor(private readonly householdsService: HouseholdsService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async list(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.householdsService.listForUser(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
async create(@CurrentUser() userId: string, @Body() payload: CreateHouseholdDto) {
|
|
||||||
return ok(await this.householdsService.create(userId, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id/dashboard")
|
|
||||||
async dashboard(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
||||||
return ok(await this.householdsService.getDashboard(userId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id")
|
|
||||||
async get(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
||||||
return ok(await this.householdsService.getForUser(userId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(":id/privacy")
|
|
||||||
async updatePrivacy(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() payload: UpdateHouseholdPrivacyDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.updatePrivacyMode(userId, id, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id/members")
|
|
||||||
async members(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
||||||
return ok(await this.householdsService.listMembers(userId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/fair-split")
|
|
||||||
async fairSplit(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() payload: FairSplitCalculatorDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.calculateFairSplit(userId, id, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id/money-date-prompts")
|
|
||||||
async moneyDatePrompts(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
||||||
return ok(await this.householdsService.listMoneyDatePrompts(userId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/debt-payoff")
|
|
||||||
async debtPayoff(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() payload: DebtPayoffPlannerDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.calculateDebtPayoff(userId, id, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/future-scenarios")
|
|
||||||
async futureScenarios(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() payload: FuturePlanningScenariosDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.calculateFutureScenarios(userId, id, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id/accountant-tasks")
|
|
||||||
async accountantTasks(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
||||||
return ok(await this.householdsService.listAccountantTasks(userId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/accountant-tasks")
|
|
||||||
async createAccountantTask(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() payload: CreateAccountantTaskDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.createAccountantTask(userId, id, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(":id/accountant-tasks/:taskId")
|
|
||||||
async updateAccountantTask(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Param("taskId") taskId: string,
|
|
||||||
@Body() payload: UpdateAccountantTaskDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.updateAccountantTask(userId, id, taskId, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id/goals")
|
|
||||||
async goals(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
||||||
return ok(await this.householdsService.listGoals(userId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/goals")
|
|
||||||
async createGoal(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() payload: CreateHouseholdGoalDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.createGoal(userId, id, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(":id/goals/:goalId")
|
|
||||||
async updateGoal(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Param("goalId") goalId: string,
|
|
||||||
@Body() payload: UpdateHouseholdGoalDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.updateGoal(userId, id, goalId, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(":id/members/:memberId")
|
|
||||||
async updateMember(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Param("memberId") memberId: string,
|
|
||||||
@Body() payload: UpdateHouseholdMemberDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.updateMember(userId, id, memberId, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id/invites")
|
|
||||||
async invites(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
||||||
return ok(await this.householdsService.listInvites(userId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/invites")
|
|
||||||
async invite(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body() payload: CreateHouseholdInviteDto,
|
|
||||||
) {
|
|
||||||
return ok(await this.householdsService.invite(userId, id, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("invites/accept")
|
|
||||||
async acceptInvite(@CurrentUser() userId: string, @Body() payload: AcceptHouseholdInviteDto) {
|
|
||||||
return ok(await this.householdsService.acceptInvite(userId, payload));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
|
||||||
import { HouseholdsController } from "./households.controller";
|
|
||||||
import { HouseholdsService } from "./households.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule],
|
|
||||||
controllers: [HouseholdsController],
|
|
||||||
providers: [HouseholdsService],
|
|
||||||
})
|
|
||||||
export class HouseholdsModule {}
|
|
||||||
File diff suppressed because it is too large
Load Diff
12
src/main.ts
12
src/main.ts
@ -9,14 +9,11 @@ import { AppModule } from "./app.module";
|
|||||||
import { SentryExceptionFilter } from "./common/sentry.filter";
|
import { SentryExceptionFilter } from "./common/sentry.filter";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
|
||||||
|
|
||||||
// ─── Sentry initialization (before app creation) ──────────────────────────
|
// ─── Sentry initialization (before app creation) ──────────────────────────
|
||||||
if (process.env.SENTRY_DSN) {
|
if (process.env.SENTRY_DSN) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: process.env.SENTRY_DSN,
|
dsn: process.env.SENTRY_DSN,
|
||||||
environment: process.env.NODE_ENV ?? "development",
|
environment: process.env.NODE_ENV ?? "development",
|
||||||
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,20 +23,15 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Security headers ─────────────────────────────────────────────────────
|
// ─── Security headers ─────────────────────────────────────────────────────
|
||||||
const scriptSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
|
|
||||||
const styleSrc = isProduction ? ["'self'"] : ["'self'", "'unsafe-inline'"];
|
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
crossOriginEmbedderPolicy: false,
|
crossOriginEmbedderPolicy: false,
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc,
|
scriptSrc: ["'self'", "'unsafe-inline'"], // Swagger UI needs inline scripts
|
||||||
styleSrc,
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
imgSrc: ["'self'", "data:", "https:"],
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
objectSrc: ["'none'"],
|
|
||||||
baseUri: ["'self'"],
|
|
||||||
frameAncestors: ["'none'"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from "@nestjs/common";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
||||||
import { ok } from "../common/response";
|
|
||||||
import { SavePushSubscriptionDto, UpdateNotificationPreferencesDto } from "./notifications.dto";
|
|
||||||
import { NotificationsService } from "./notifications.service";
|
|
||||||
|
|
||||||
@Controller("notifications")
|
|
||||||
export class NotificationsController {
|
|
||||||
constructor(private readonly notificationsService: NotificationsService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async list(@CurrentUser() userId: string, @Query("unreadOnly") unreadOnly?: string) {
|
|
||||||
return ok(await this.notificationsService.list(userId, unreadOnly === "true"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("preferences")
|
|
||||||
async preferences(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.notificationsService.getPreferences(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch("preferences")
|
|
||||||
async updatePreferences(@CurrentUser() userId: string, @Body() body: UpdateNotificationPreferencesDto) {
|
|
||||||
return ok(await this.notificationsService.updatePreferences(userId, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("vapid-public-key")
|
|
||||||
vapidPublicKey() {
|
|
||||||
return ok(this.notificationsService.getVapidStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("push-subscriptions")
|
|
||||||
async savePushSubscription(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Body() body: SavePushSubscriptionDto,
|
|
||||||
@Headers("user-agent") userAgent?: string,
|
|
||||||
) {
|
|
||||||
return ok(await this.notificationsService.savePushSubscription(userId, body, userAgent));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(":id/read")
|
|
||||||
async markRead(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
||||||
return ok(await this.notificationsService.markRead(userId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("read-all")
|
|
||||||
async markAllRead(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.notificationsService.markAllRead(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("test")
|
|
||||||
async test(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.notificationsService.sendTestNotification(userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { IsBoolean, IsIn, IsOptional, IsString, IsUrl, ValidateNested } from "class-validator";
|
|
||||||
import { Type } from "class-transformer";
|
|
||||||
|
|
||||||
export class UpdateNotificationPreferencesDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
emailEnabled?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
pushEnabled?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["info", "warning", "critical"])
|
|
||||||
minSeverity?: "info" | "warning" | "critical";
|
|
||||||
}
|
|
||||||
|
|
||||||
class PushKeysDto {
|
|
||||||
@IsString()
|
|
||||||
p256dh!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
auth!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SavePushSubscriptionDto {
|
|
||||||
@IsUrl({ require_tld: false })
|
|
||||||
endpoint!: string;
|
|
||||||
|
|
||||||
@ValidateNested()
|
|
||||||
@Type(() => PushKeysDto)
|
|
||||||
keys!: PushKeysDto;
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { NotificationsController } from "./notifications.controller";
|
|
||||||
import { NotificationsService } from "./notifications.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [NotificationsController],
|
|
||||||
providers: [NotificationsService],
|
|
||||||
exports: [NotificationsService],
|
|
||||||
})
|
|
||||||
export class NotificationsModule {}
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import * as webPush from "web-push";
|
|
||||||
import { EmailService } from "../email/email.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { SavePushSubscriptionDto, UpdateNotificationPreferencesDto } from "./notifications.dto";
|
|
||||||
|
|
||||||
type NotificationSeverity = "info" | "warning" | "critical";
|
|
||||||
|
|
||||||
type NotifyUserInput = {
|
|
||||||
type: string;
|
|
||||||
severity?: NotificationSeverity;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEVERITY_RANK: Record<NotificationSeverity, number> = {
|
|
||||||
info: 1,
|
|
||||||
warning: 2,
|
|
||||||
critical: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class NotificationsService {
|
|
||||||
private readonly logger = new Logger(NotificationsService.name);
|
|
||||||
private readonly pushEnabled: boolean;
|
|
||||||
private readonly appUrl = process.env.APP_URL ?? "http://localhost:3052";
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly emailService: EmailService,
|
|
||||||
) {
|
|
||||||
const publicKey = process.env.VAPID_PUBLIC_KEY;
|
|
||||||
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
|
||||||
const subject = process.env.VAPID_SUBJECT ?? "mailto:support@ledgerone.app";
|
|
||||||
this.pushEnabled = Boolean(publicKey && privateKey);
|
|
||||||
|
|
||||||
if (this.pushEnabled) {
|
|
||||||
webPush.setVapidDetails(subject, publicKey as string, privateKey as string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getVapidStatus() {
|
|
||||||
return {
|
|
||||||
enabled: this.pushEnabled,
|
|
||||||
publicKey: this.pushEnabled ? process.env.VAPID_PUBLIC_KEY : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async list(userId: string, unreadOnly = false) {
|
|
||||||
return this.prisma.notification.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
...(unreadOnly ? { readAt: null } : {}),
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 50,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPreferences(userId: string) {
|
|
||||||
const existing = await this.prisma.notificationPreference.findUnique({ where: { userId } });
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
return this.prisma.notificationPreference.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
emailEnabled: true,
|
|
||||||
pushEnabled: false,
|
|
||||||
minSeverity: "info",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePreferences(userId: string, dto: UpdateNotificationPreferencesDto) {
|
|
||||||
return this.prisma.notificationPreference.upsert({
|
|
||||||
where: { userId },
|
|
||||||
create: {
|
|
||||||
userId,
|
|
||||||
emailEnabled: dto.emailEnabled ?? true,
|
|
||||||
pushEnabled: dto.pushEnabled ?? false,
|
|
||||||
minSeverity: dto.minSeverity ?? "info",
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
...(dto.emailEnabled !== undefined ? { emailEnabled: dto.emailEnabled } : {}),
|
|
||||||
...(dto.pushEnabled !== undefined ? { pushEnabled: dto.pushEnabled } : {}),
|
|
||||||
...(dto.minSeverity ? { minSeverity: dto.minSeverity } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async savePushSubscription(userId: string, dto: SavePushSubscriptionDto, userAgent?: string) {
|
|
||||||
const subscription = await this.prisma.pushSubscription.upsert({
|
|
||||||
where: { endpoint: dto.endpoint },
|
|
||||||
create: {
|
|
||||||
userId,
|
|
||||||
endpoint: dto.endpoint,
|
|
||||||
p256dh: dto.keys.p256dh,
|
|
||||||
auth: dto.keys.auth,
|
|
||||||
userAgent,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
userId,
|
|
||||||
p256dh: dto.keys.p256dh,
|
|
||||||
auth: dto.keys.auth,
|
|
||||||
userAgent,
|
|
||||||
revokedAt: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.updatePreferences(userId, { pushEnabled: true });
|
|
||||||
return subscription;
|
|
||||||
}
|
|
||||||
|
|
||||||
async markRead(userId: string, notificationId: string) {
|
|
||||||
return this.prisma.notification.updateMany({
|
|
||||||
where: { id: notificationId, userId },
|
|
||||||
data: { readAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async markAllRead(userId: string) {
|
|
||||||
return this.prisma.notification.updateMany({
|
|
||||||
where: { userId, readAt: null },
|
|
||||||
data: { readAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendTestNotification(userId: string) {
|
|
||||||
return this.notifyUser(userId, {
|
|
||||||
type: "notification.test",
|
|
||||||
severity: "info",
|
|
||||||
title: "LedgerOne notification test",
|
|
||||||
body: "SMTP and push notification delivery are configured for your account.",
|
|
||||||
metadata: { source: "settings_test" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async notifyUser(userId: string, input: NotifyUserInput) {
|
|
||||||
const severity = input.severity ?? "info";
|
|
||||||
const channels = ["in_app"];
|
|
||||||
const notification = await this.prisma.notification.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
type: input.type,
|
|
||||||
severity,
|
|
||||||
title: input.title,
|
|
||||||
body: input.body,
|
|
||||||
metadata: (input.metadata ?? {}) as Prisma.InputJsonValue,
|
|
||||||
channels: [...channels],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [preferences, user] = await Promise.all([
|
|
||||||
this.getPreferences(userId),
|
|
||||||
this.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (this.shouldSend(preferences.minSeverity as NotificationSeverity, severity)) {
|
|
||||||
if (preferences.emailEnabled && user?.email) {
|
|
||||||
await this.emailService.sendNotificationEmail(
|
|
||||||
user.email,
|
|
||||||
input.title,
|
|
||||||
input.body,
|
|
||||||
`${this.appUrl}/notifications`,
|
|
||||||
severity,
|
|
||||||
);
|
|
||||||
channels.push("email");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preferences.pushEnabled && this.pushEnabled) {
|
|
||||||
const sent = await this.sendPushNotifications(userId, notification.id, input, severity);
|
|
||||||
if (sent > 0) channels.push("push");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.notification.update({
|
|
||||||
where: { id: notification.id },
|
|
||||||
data: { channels },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldSend(minSeverity: NotificationSeverity, severity: NotificationSeverity) {
|
|
||||||
return SEVERITY_RANK[severity] >= SEVERITY_RANK[minSeverity ?? "info"];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendPushNotifications(
|
|
||||||
userId: string,
|
|
||||||
notificationId: string,
|
|
||||||
input: NotifyUserInput,
|
|
||||||
severity: NotificationSeverity,
|
|
||||||
) {
|
|
||||||
const subscriptions = await this.prisma.pushSubscription.findMany({
|
|
||||||
where: { userId, revokedAt: null },
|
|
||||||
});
|
|
||||||
let sent = 0;
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
title: input.title,
|
|
||||||
body: input.body,
|
|
||||||
url: "/notifications",
|
|
||||||
notificationId,
|
|
||||||
type: input.type,
|
|
||||||
severity,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const subscription of subscriptions) {
|
|
||||||
try {
|
|
||||||
await webPush.sendNotification(
|
|
||||||
{
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
keys: {
|
|
||||||
p256dh: subscription.p256dh,
|
|
||||||
auth: subscription.auth,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
sent += 1;
|
|
||||||
await this.prisma.pushSubscription.update({
|
|
||||||
where: { id: subscription.id },
|
|
||||||
data: { lastUsedAt: new Date() },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const statusCode = typeof err === "object" && err && "statusCode" in err
|
|
||||||
? Number((err as { statusCode?: number }).statusCode)
|
|
||||||
: 0;
|
|
||||||
if (statusCode === 404 || statusCode === 410) {
|
|
||||||
await this.prisma.pushSubscription.update({
|
|
||||||
where: { id: subscription.id },
|
|
||||||
data: { revokedAt: new Date() },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`Push notification failed for subscription ${subscription.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +1,11 @@
|
|||||||
import { BadRequestException, Body, Controller, Headers, Post, Req } from "@nestjs/common";
|
import { Body, Controller, Post } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
|
||||||
import { ok } from "../common/response";
|
import { ok } from "../common/response";
|
||||||
import { PlaidService } from "./plaid.service";
|
import { PlaidService } from "./plaid.service";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { Public } from "../common/decorators/public.decorator";
|
|
||||||
import { OpaqueIdService } from "../common/opaque-id.service";
|
|
||||||
import { ViewRefService } from "../common/view-ref.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
|
|
||||||
@Controller("plaid")
|
@Controller("plaid")
|
||||||
export class PlaidController {
|
export class PlaidController {
|
||||||
constructor(
|
constructor(private readonly plaidService: PlaidService) {}
|
||||||
private readonly plaidService: PlaidService,
|
|
||||||
private readonly opaqueIds: OpaqueIdService,
|
|
||||||
private readonly viewRefs: ViewRefService,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post("link-token")
|
@Post("link-token")
|
||||||
async createLinkToken(@CurrentUser() userId: string) {
|
async createLinkToken(@CurrentUser() userId: string) {
|
||||||
@ -31,49 +21,4 @@ export class PlaidController {
|
|||||||
const data = await this.plaidService.exchangePublicTokenForUser(userId, payload.publicToken);
|
const data = await this.plaidService.exchangePublicTokenForUser(userId, payload.publicToken);
|
||||||
return ok(data);
|
return ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("update-link-token")
|
|
||||||
async createUpdateLinkToken(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Body() payload: { accountId: string },
|
|
||||||
) {
|
|
||||||
const accountId = await this.resolveAccountHandle(userId, payload.accountId);
|
|
||||||
const data = await this.plaidService.createUpdateModeLinkToken(userId, accountId);
|
|
||||||
return ok(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("repair-complete")
|
|
||||||
async repairComplete(
|
|
||||||
@CurrentUser() userId: string,
|
|
||||||
@Body() payload: { accountId: string },
|
|
||||||
) {
|
|
||||||
const accountId = await this.resolveAccountHandle(userId, payload.accountId);
|
|
||||||
const data = await this.plaidService.markItemRepairComplete(userId, accountId);
|
|
||||||
return ok(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Post("webhook")
|
|
||||||
async webhook(
|
|
||||||
@Body() payload: Record<string, unknown>,
|
|
||||||
@Headers("plaid-verification") verification: string | undefined,
|
|
||||||
@Req() request: Request & { rawBody?: Buffer },
|
|
||||||
) {
|
|
||||||
const data = await this.plaidService.handleWebhook(payload, verification, request.rawBody);
|
|
||||||
return ok(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveAccountHandle(userId: string, handle: string) {
|
|
||||||
try {
|
|
||||||
return this.opaqueIds.decode("account", userId, handle);
|
|
||||||
} catch {
|
|
||||||
const accounts = await this.prisma.account.findMany({
|
|
||||||
where: { userId, isActive: true },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const match = accounts.find((account) => this.viewRefs.matches(userId, "account", account.id, handle));
|
|
||||||
if (!match) throw new BadRequestException("Invalid resource identifier.");
|
|
||||||
return match.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { StripeModule } from "../stripe/stripe.module";
|
|
||||||
import { PlaidController } from "./plaid.controller";
|
import { PlaidController } from "./plaid.controller";
|
||||||
import { PlaidService } from "./plaid.service";
|
import { PlaidService } from "./plaid.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StripeModule],
|
|
||||||
controllers: [PlaidController],
|
controllers: [PlaidController],
|
||||||
providers: [PlaidService],
|
providers: [PlaidService],
|
||||||
exports: [PlaidService]
|
exports: [PlaidService]
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
Configuration,
|
Configuration,
|
||||||
CountryCode,
|
CountryCode,
|
||||||
@ -10,20 +10,14 @@ import * as crypto from "crypto";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { EncryptionService } from "../common/encryption.service";
|
import { EncryptionService } from "../common/encryption.service";
|
||||||
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
|
|
||||||
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlaidService {
|
export class PlaidService {
|
||||||
private readonly logger = new Logger(PlaidService.name);
|
|
||||||
private readonly client: PlaidApi;
|
private readonly client: PlaidApi;
|
||||||
private readonly webhookKeys = new Map<string, JsonWebKey>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly encryption: EncryptionService,
|
private readonly encryption: EncryptionService,
|
||||||
private readonly planLimits: PlanLimitsService,
|
|
||||||
private readonly rawPayloads: RawPayloadEncryptionService,
|
|
||||||
) {
|
) {
|
||||||
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
|
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
|
||||||
const clientId = this.requireEnv("PLAID_CLIENT_ID");
|
const clientId = this.requireEnv("PLAID_CLIENT_ID");
|
||||||
@ -61,7 +55,6 @@ export class PlaidService {
|
|||||||
country_codes: countryCodes,
|
country_codes: countryCodes,
|
||||||
language: "en",
|
language: "en",
|
||||||
redirect_uri: redirectUri || undefined,
|
redirect_uri: redirectUri || undefined,
|
||||||
webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -93,14 +86,6 @@ export class PlaidService {
|
|||||||
const institutionName = institutionId
|
const institutionName = institutionId
|
||||||
? await this.getInstitutionName(institutionId)
|
? await this.getInstitutionName(institutionId)
|
||||||
: "Plaid institution";
|
: "Plaid institution";
|
||||||
const incomingAccountIds = accountsResponse.data.accounts.map((account) => account.account_id);
|
|
||||||
const existingAccounts = await this.prisma.account.findMany({
|
|
||||||
where: { userId, plaidAccountId: { in: incomingAccountIds } },
|
|
||||||
select: { plaidAccountId: true },
|
|
||||||
});
|
|
||||||
const existingIds = new Set(existingAccounts.map((account) => account.plaidAccountId));
|
|
||||||
const newAccountCount = incomingAccountIds.filter((id) => !existingIds.has(id)).length;
|
|
||||||
await this.planLimits.assertCanAddAccounts(userId, newAccountCount);
|
|
||||||
|
|
||||||
for (const account of accountsResponse.data.accounts) {
|
for (const account of accountsResponse.data.accounts) {
|
||||||
await this.prisma.account.upsert({
|
await this.prisma.account.upsert({
|
||||||
@ -177,7 +162,7 @@ export class PlaidService {
|
|||||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build map: raw decrypted token → Plaid account ids for one item.
|
// Build map: raw decrypted token → plaidAccountIds
|
||||||
const tokenMap = new Map<string, string[]>();
|
const tokenMap = new Map<string, string[]>();
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
if (!account.plaidAccessToken || !account.plaidAccountId) continue;
|
if (!account.plaidAccessToken || !account.plaidAccountId) continue;
|
||||||
@ -187,370 +172,48 @@ export class PlaidService {
|
|||||||
tokenMap.set(raw, list);
|
tokenMap.set(raw, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await this.prisma.account.updateMany({
|
|
||||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
|
||||||
data: { syncStatus: "syncing", lastSyncAttemptAt: now, lastSyncError: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
for (const [rawToken, plaidAccountIds] of tokenMap) {
|
for (const [rawToken] of tokenMap) {
|
||||||
try {
|
const response = await this.client.transactionsGet({
|
||||||
const response = await this.client.transactionsGet({
|
access_token: rawToken,
|
||||||
access_token: rawToken,
|
start_date: startDate,
|
||||||
start_date: startDate,
|
end_date: endDate,
|
||||||
end_date: endDate,
|
options: { count: 500, offset: 0 },
|
||||||
options: { count: 500, offset: 0 },
|
});
|
||||||
});
|
|
||||||
|
|
||||||
for (const tx of response.data.transactions) {
|
for (const tx of response.data.transactions) {
|
||||||
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
|
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
|
||||||
if (!account) continue;
|
if (!account) continue;
|
||||||
const rawPayload = this.rawPayloads.encrypt(tx);
|
const rawPayload = tx as unknown as Prisma.InputJsonValue;
|
||||||
await this.prisma.transactionRaw.upsert({
|
await this.prisma.transactionRaw.upsert({
|
||||||
where: { bankTransactionId: tx.transaction_id },
|
where: { bankTransactionId: tx.transaction_id },
|
||||||
update: {
|
update: {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
date: new Date(tx.date),
|
date: new Date(tx.date),
|
||||||
amount: tx.amount,
|
amount: tx.amount,
|
||||||
description: tx.name ?? "Plaid transaction",
|
description: tx.name ?? "Plaid transaction",
|
||||||
rawPayload,
|
rawPayload,
|
||||||
source: "plaid",
|
source: "plaid",
|
||||||
ingestedAt: new Date(),
|
ingestedAt: new Date(),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
bankTransactionId: tx.transaction_id,
|
bankTransactionId: tx.transaction_id,
|
||||||
date: new Date(tx.date),
|
date: new Date(tx.date),
|
||||||
amount: tx.amount,
|
amount: tx.amount,
|
||||||
description: tx.name ?? "Plaid transaction",
|
description: tx.name ?? "Plaid transaction",
|
||||||
rawPayload,
|
rawPayload,
|
||||||
ingestedAt: new Date(),
|
ingestedAt: new Date(),
|
||||||
source: "plaid",
|
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 };
|
return { created };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUpdateModeLinkToken(userId: string, accountId: string) {
|
|
||||||
const account = await this.prisma.account.findFirst({
|
|
||||||
where: {
|
|
||||||
id: accountId,
|
|
||||||
userId,
|
|
||||||
plaidAccessToken: { not: null },
|
|
||||||
plaidItemId: { not: null },
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
plaidAccessToken: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!account?.plaidAccessToken) {
|
|
||||||
throw new BadRequestException("Plaid account not found for update mode.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawAccessToken = this.encryption.decrypt(account.plaidAccessToken);
|
|
||||||
try {
|
|
||||||
const response = await this.client.linkTokenCreate({
|
|
||||||
user: { client_user_id: userId },
|
|
||||||
client_name: "LedgerOne",
|
|
||||||
country_codes: this.getCountryCodes(),
|
|
||||||
language: "en",
|
|
||||||
access_token: rawAccessToken,
|
|
||||||
redirect_uri: process.env.PLAID_REDIRECT_URI?.trim() || undefined,
|
|
||||||
webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
linkToken: response.data.link_token,
|
|
||||||
expiration: response.data.expiration,
|
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as { response?: { data?: { error_message?: string } } };
|
|
||||||
const message =
|
|
||||||
err.response?.data?.error_message ?? "Plaid update-mode link token request failed.";
|
|
||||||
throw new BadRequestException(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async markItemRepairComplete(userId: string, accountId: string) {
|
|
||||||
const account = await this.prisma.account.findFirst({
|
|
||||||
where: {
|
|
||||||
id: accountId,
|
|
||||||
userId,
|
|
||||||
plaidItemId: { not: null },
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
plaidItemId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!account?.plaidItemId) {
|
|
||||||
throw new BadRequestException("Plaid account not found for repair completion.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await this.prisma.account.updateMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
plaidItemId: account.plaidItemId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
syncStatus: "idle",
|
|
||||||
lastSyncError: null,
|
|
||||||
syncConsecutiveFailures: 0,
|
|
||||||
plaidWebhookCode: "UPDATE_MODE_COMPLETED",
|
|
||||||
plaidWebhookAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { updated: updated.count };
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleWebhook(payload: PlaidWebhookPayload, verificationHeader?: string, rawBody?: Buffer) {
|
|
||||||
if (this.shouldVerifyWebhooks()) {
|
|
||||||
const valid = await this.verifyWebhook(verificationHeader, rawBody);
|
|
||||||
if (!valid) {
|
|
||||||
throw new BadRequestException("Invalid Plaid webhook signature.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhookType = payload.webhook_type ?? "UNKNOWN";
|
|
||||||
const webhookCode = payload.webhook_code ?? "UNKNOWN";
|
|
||||||
const itemId = payload.item_id ?? null;
|
|
||||||
const event = await this.prisma.plaidWebhookEvent.create({
|
|
||||||
data: {
|
|
||||||
itemId,
|
|
||||||
webhookType,
|
|
||||||
webhookCode,
|
|
||||||
payload: payload as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.processWebhook(payload);
|
|
||||||
await this.prisma.plaidWebhookEvent.update({
|
|
||||||
where: { id: event.id },
|
|
||||||
data: {
|
|
||||||
status: "processed",
|
|
||||||
processedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { received: true, processed: true, webhookType, webhookCode, ...result };
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : "Plaid webhook processing failed.";
|
|
||||||
this.logger.error(`Plaid webhook ${webhookType}:${webhookCode} failed: ${message}`);
|
|
||||||
await this.prisma.plaidWebhookEvent.update({
|
|
||||||
where: { id: event.id },
|
|
||||||
data: {
|
|
||||||
status: "error",
|
|
||||||
error: message.slice(0, 500),
|
|
||||||
processedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processWebhook(payload: PlaidWebhookPayload) {
|
|
||||||
if (!payload.item_id) {
|
|
||||||
return { skipped: true, reason: "missing_item_id" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = await this.prisma.account.findMany({
|
|
||||||
where: { plaidItemId: payload.item_id },
|
|
||||||
select: { userId: true },
|
|
||||||
distinct: ["userId"],
|
|
||||||
});
|
|
||||||
const userIds = accounts.map((account) => account.userId);
|
|
||||||
if (userIds.length === 0) {
|
|
||||||
return { skipped: true, reason: "item_not_found" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhookType = payload.webhook_type ?? "UNKNOWN";
|
|
||||||
const webhookCode = payload.webhook_code ?? "UNKNOWN";
|
|
||||||
await this.prisma.account.updateMany({
|
|
||||||
where: { plaidItemId: payload.item_id },
|
|
||||||
data: {
|
|
||||||
plaidWebhookCode: webhookCode,
|
|
||||||
plaidWebhookAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
webhookType === "TRANSACTIONS" &&
|
|
||||||
["SYNC_UPDATES_AVAILABLE", "INITIAL_UPDATE", "HISTORICAL_UPDATE", "DEFAULT_UPDATE"].includes(webhookCode)
|
|
||||||
) {
|
|
||||||
const days = Number(process.env.PLAID_WEBHOOK_LOOKBACK_DAYS ?? process.env.AUTO_SYNC_LOOKBACK_DAYS ?? 30);
|
|
||||||
const endDate = new Date().toISOString().slice(0, 10);
|
|
||||||
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
||||||
let created = 0;
|
|
||||||
for (const userId of userIds) {
|
|
||||||
const result = await this.syncTransactionsForUser(userId, startDate, endDate);
|
|
||||||
created += result.created;
|
|
||||||
}
|
|
||||||
return { usersSynced: userIds.length, created };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (webhookType === "ITEM") {
|
|
||||||
return this.handleItemWebhook(payload, userIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { skipped: true, reason: "unsupported_webhook" };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleItemWebhook(payload: PlaidWebhookPayload, userIds: string[]) {
|
|
||||||
if (!payload.item_id) return { skipped: true, reason: "missing_item_id" };
|
|
||||||
const webhookCode = payload.webhook_code ?? "UNKNOWN";
|
|
||||||
const errorCode = payload.error?.error_code ?? webhookCode;
|
|
||||||
const errorMessage = payload.error?.error_message ?? webhookCode;
|
|
||||||
|
|
||||||
if (webhookCode === "ERROR" || errorCode === "ITEM_LOGIN_REQUIRED") {
|
|
||||||
await this.prisma.account.updateMany({
|
|
||||||
where: { plaidItemId: payload.item_id },
|
|
||||||
data: {
|
|
||||||
syncStatus: "needs_reauth",
|
|
||||||
lastSyncError: errorMessage.slice(0, 500),
|
|
||||||
syncConsecutiveFailures: { increment: 1 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { usersMarked: userIds.length, status: "needs_reauth" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (["PENDING_EXPIRATION", "PENDING_DISCONNECT", "NEW_ACCOUNTS_AVAILABLE"].includes(webhookCode)) {
|
|
||||||
await this.prisma.account.updateMany({
|
|
||||||
where: { plaidItemId: payload.item_id },
|
|
||||||
data: {
|
|
||||||
syncStatus: "attention_required",
|
|
||||||
lastSyncError: webhookCode,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { usersMarked: userIds.length, status: "attention_required" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (["USER_PERMISSION_REVOKED", "USER_ACCOUNT_REVOKED"].includes(webhookCode)) {
|
|
||||||
await this.prisma.account.updateMany({
|
|
||||||
where: { plaidItemId: payload.item_id },
|
|
||||||
data: {
|
|
||||||
isActive: false,
|
|
||||||
syncStatus: "revoked",
|
|
||||||
lastSyncError: webhookCode,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { usersMarked: userIds.length, status: "revoked" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (webhookCode === "LOGIN_REPAIRED") {
|
|
||||||
await this.prisma.account.updateMany({
|
|
||||||
where: { plaidItemId: payload.item_id },
|
|
||||||
data: {
|
|
||||||
syncStatus: "idle",
|
|
||||||
lastSyncError: null,
|
|
||||||
syncConsecutiveFailures: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { usersMarked: userIds.length, status: "repaired" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { skipped: true, reason: "unsupported_item_webhook" };
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldVerifyWebhooks() {
|
|
||||||
if (process.env.NODE_ENV === "test") return false;
|
|
||||||
return process.env.PLAID_VERIFY_WEBHOOKS !== "false";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async verifyWebhook(verificationHeader?: string, rawBody?: Buffer) {
|
|
||||||
if (!verificationHeader || !rawBody) return false;
|
|
||||||
const parts = verificationHeader.split(".");
|
|
||||||
if (parts.length !== 3) return false;
|
|
||||||
|
|
||||||
const header = this.decodeJwtPart<{ alg?: string; kid?: string }>(parts[0]);
|
|
||||||
if (header.alg !== "ES256" || !header.kid) return false;
|
|
||||||
|
|
||||||
const key = await this.getWebhookKey(header.kid);
|
|
||||||
const publicKey = crypto.createPublicKey({ key, format: "jwk" } as crypto.JsonWebKeyInput);
|
|
||||||
const verifier = crypto.createVerify("SHA256");
|
|
||||||
verifier.update(`${parts[0]}.${parts[1]}`);
|
|
||||||
verifier.end();
|
|
||||||
|
|
||||||
const signature = this.ecJoseSignatureToDer(Buffer.from(parts[2], "base64url"));
|
|
||||||
if (!verifier.verify(publicKey, signature)) return false;
|
|
||||||
|
|
||||||
const claims = this.decodeJwtPart<{ iat?: number; request_body_sha256?: string }>(parts[1]);
|
|
||||||
if (!claims.iat || Math.abs(Date.now() / 1000 - claims.iat) > 300) return false;
|
|
||||||
if (!claims.request_body_sha256) return false;
|
|
||||||
|
|
||||||
const actualHash = crypto.createHash("sha256").update(rawBody).digest("hex");
|
|
||||||
return this.timingSafeEqual(actualHash, claims.request_body_sha256);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getWebhookKey(keyId: string) {
|
|
||||||
const cached = this.webhookKeys.get(keyId);
|
|
||||||
if (cached) return cached;
|
|
||||||
const response = await (this.client as unknown as {
|
|
||||||
webhookVerificationKeyGet(request: { key_id: string }): Promise<{ data: { key: JsonWebKey } }>;
|
|
||||||
}).webhookVerificationKeyGet({ key_id: keyId });
|
|
||||||
this.webhookKeys.set(keyId, response.data.key);
|
|
||||||
return response.data.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeJwtPart<T>(part: string): T {
|
|
||||||
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
private timingSafeEqual(left: string, right: string) {
|
|
||||||
const leftBuffer = Buffer.from(left);
|
|
||||||
const rightBuffer = Buffer.from(right);
|
|
||||||
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ecJoseSignatureToDer(signature: Buffer) {
|
|
||||||
if (signature.length !== 64) return signature;
|
|
||||||
const r = this.derInteger(signature.subarray(0, 32));
|
|
||||||
const s = this.derInteger(signature.subarray(32));
|
|
||||||
const length = r.length + s.length;
|
|
||||||
return Buffer.concat([Buffer.from([0x30, length]), r, s]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private derInteger(bytes: Buffer) {
|
|
||||||
let value = bytes;
|
|
||||||
while (value.length > 1 && value[0] === 0) {
|
|
||||||
value = value.subarray(1);
|
|
||||||
}
|
|
||||||
if (value[0] & 0x80) {
|
|
||||||
value = Buffer.concat([Buffer.from([0]), value]);
|
|
||||||
}
|
|
||||||
return Buffer.concat([Buffer.from([0x02, value.length]), value]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireEnv(name: string) {
|
private requireEnv(name: string) {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@ -559,13 +222,6 @@ export class PlaidService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCountryCodes() {
|
|
||||||
return (process.env.PLAID_COUNTRY_CODES ?? "US")
|
|
||||||
.split(",")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean) as CountryCode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getInstitutionName(institutionId: string) {
|
private async getInstitutionName(institutionId: string) {
|
||||||
try {
|
try {
|
||||||
const response = await this.client.institutionsGetById({
|
const response = await this.client.institutionsGetById({
|
||||||
@ -578,15 +234,3 @@ export class PlaidService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaidWebhookPayload = {
|
|
||||||
webhook_type?: string;
|
|
||||||
webhook_code?: string;
|
|
||||||
item_id?: string;
|
|
||||||
environment?: string;
|
|
||||||
error?: {
|
|
||||||
error_code?: string;
|
|
||||||
error_message?: string;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,188 +0,0 @@
|
|||||||
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateHouseholdBudgetDto {
|
|
||||||
@IsString()
|
|
||||||
householdId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(80)
|
|
||||||
category?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["weekly", "monthly", "quarterly", "annual"])
|
|
||||||
period?: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.01)
|
|
||||||
limitAmount!: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
spentAmount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(3)
|
|
||||||
isoCurrencyCode?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
startDate?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
endDate?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateHouseholdBudgetDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(80)
|
|
||||||
category?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["weekly", "monthly", "quarterly", "annual"])
|
|
||||||
period?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.01)
|
|
||||||
limitAmount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
spentAmount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["active", "paused", "archived"])
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreatePersonalGoalDto {
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(500)
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.01)
|
|
||||||
targetAmount!: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
currentAmount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(3)
|
|
||||||
isoCurrencyCode?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
targetDate?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["low", "medium", "high"])
|
|
||||||
priority?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
automation?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdatePersonalGoalDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
currentAmount?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["active", "paused", "completed", "archived"])
|
|
||||||
status?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
automation?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateInvestmentHoldingDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
accountId?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
symbol!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["stock", "etf", "mutual_fund", "bond", "crypto", "cash", "other"])
|
|
||||||
assetClass?: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
quantity!: number;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
price!: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
costBasis?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(3)
|
|
||||||
isoCurrencyCode?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
asOfDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateNetWorthSnapshotDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
snapshotDate?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
assets?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
liabilities?: number;
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
||||||
import { ok } from "../common/response";
|
|
||||||
import {
|
|
||||||
CreateHouseholdBudgetDto,
|
|
||||||
CreateInvestmentHoldingDto,
|
|
||||||
CreateNetWorthSnapshotDto,
|
|
||||||
CreatePersonalGoalDto,
|
|
||||||
UpdateHouseholdBudgetDto,
|
|
||||||
UpdatePersonalGoalDto,
|
|
||||||
} from "./dto";
|
|
||||||
import { PlanningService } from "./planning.service";
|
|
||||||
|
|
||||||
@Controller("planning")
|
|
||||||
export class PlanningController {
|
|
||||||
constructor(private readonly planningService: PlanningService) {}
|
|
||||||
|
|
||||||
@Get("budgets")
|
|
||||||
async budgets(@CurrentUser() userId: string, @Query("householdId") householdId?: string) {
|
|
||||||
return ok(await this.planningService.listBudgets(userId, householdId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("budgets")
|
|
||||||
async createBudget(@CurrentUser() userId: string, @Body() body: CreateHouseholdBudgetDto) {
|
|
||||||
return ok(await this.planningService.createBudget(userId, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch("budgets/:id")
|
|
||||||
async updateBudget(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: UpdateHouseholdBudgetDto) {
|
|
||||||
return ok(await this.planningService.updateBudget(userId, id, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("goals")
|
|
||||||
async goals(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.planningService.listPersonalGoals(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("goals")
|
|
||||||
async createGoal(@CurrentUser() userId: string, @Body() body: CreatePersonalGoalDto) {
|
|
||||||
return ok(await this.planningService.createPersonalGoal(userId, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch("goals/:id")
|
|
||||||
async updateGoal(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: UpdatePersonalGoalDto) {
|
|
||||||
return ok(await this.planningService.updatePersonalGoal(userId, id, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("investments")
|
|
||||||
async investments(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.planningService.listInvestments(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("investments")
|
|
||||||
async createInvestment(@CurrentUser() userId: string, @Body() body: CreateInvestmentHoldingDto) {
|
|
||||||
return ok(await this.planningService.createInvestment(userId, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("net-worth")
|
|
||||||
async netWorth(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.planningService.netWorthSummary(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("net-worth/snapshots")
|
|
||||||
async createNetWorthSnapshot(@CurrentUser() userId: string, @Body() body: CreateNetWorthSnapshotDto) {
|
|
||||||
return ok(await this.planningService.createNetWorthSnapshot(userId, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("recurring")
|
|
||||||
async recurring(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.planningService.listRecurring(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("recurring/detect")
|
|
||||||
async detectRecurring(@CurrentUser() userId: string) {
|
|
||||||
return ok(await this.planningService.detectRecurring(userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { NotificationsModule } from "../notifications/notifications.module";
|
|
||||||
import { PlanningController } from "./planning.controller";
|
|
||||||
import { PlanningService } from "./planning.service";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [NotificationsModule],
|
|
||||||
controllers: [PlanningController],
|
|
||||||
providers: [PlanningService],
|
|
||||||
})
|
|
||||||
export class PlanningModule {}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user