From 375e5ff293fc7c20f5072d2557c8d0a9a46a3527 Mon Sep 17 00:00:00 2001 From: shogun444 Date: Mon, 30 Mar 2026 01:34:53 +0530 Subject: [PATCH] =?UTF-8?q?feat(reports):=20savings=20tax=20report=20gener?= =?UTF-8?q?ator=20=E2=80=94=20CSV/PDF/1099,=20encryption,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package-lock.json | 609 +++++++++++++++++- backend/package.json | 1 + backend/src/app.module.ts | 2 + .../src/modules/reports/reports.controller.ts | 42 ++ backend/src/modules/reports/reports.module.ts | 15 + .../modules/reports/reports.service.spec.ts | 133 ++++ .../src/modules/reports/reports.service.ts | 346 ++++++++++ 7 files changed, 1147 insertions(+), 1 deletion(-) create mode 100644 backend/src/modules/reports/reports.controller.ts create mode 100644 backend/src/modules/reports/reports.module.ts create mode 100644 backend/src/modules/reports/reports.service.spec.ts create mode 100644 backend/src/modules/reports/reports.service.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 41c8a4557..e0e074202 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -38,6 +38,7 @@ "nodemailer": "^8.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdfkit": "^0.13.0", "pg": "^8.18.0", "pino-http": "^11.0.0", "reflect-metadata": "^0.2.2", @@ -62,8 +63,10 @@ "@types/node": "^22.10.7", "@types/nodemailer": "^7.0.11", "@types/passport-jwt": "^4.0.1", + "@types/superagent": "^8.1.9", "@types/supertest": "^6.0.2", "@types/uuid": "^11.0.0", + "@types/validator": "^13.15.10", "eslint": "^9.39.3", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", @@ -3985,6 +3988,18 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@swc/helpers": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz", + "integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@swc/types": { "version": "0.1.26", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", @@ -5665,6 +5680,22 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -6248,6 +6279,15 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -7101,6 +7141,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/css-declaration-sorter": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz", @@ -7380,6 +7426,38 @@ } } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -7447,6 +7525,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7503,6 +7598,12 @@ "wrappy": "1" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -7837,6 +7938,26 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -8687,6 +8808,41 @@ } } }, + "node_modules/fontkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz", + "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.3.13", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "deep-equal": "^2.0.5", + "dfa": "^1.2.0", + "restructure": "^2.0.1", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/fontkit/node_modules/@swc/helpers": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz", + "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/fontkit/node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -8881,6 +9037,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -9136,6 +9301,18 @@ "node": ">=0.10.0" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -9572,6 +9749,20 @@ "kind-of": "^6.0.2" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9581,6 +9772,39 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -9588,6 +9812,21 @@ "devOptional": true, "license": "MIT" }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -9601,6 +9840,22 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -9629,6 +9884,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -9728,6 +9999,18 @@ "license": "ISC", "optional": true }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -9738,6 +10021,22 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -9760,7 +10059,6 @@ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "license": "MIT", - "optional": true, "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", @@ -9786,6 +10084,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -9799,6 +10124,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -9827,6 +10185,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -10966,6 +11352,25 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -12331,6 +12736,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -12549,6 +12999,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12779,6 +13235,18 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/pdfkit": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.13.0.tgz", + "integrity": "sha512-AW79eHU5eLd2vgRDS9z3bSoi0FA+gYm+100LLosrQQMLUzOBGVOhG7ABcMFpJu7Bpg+MT74XYHi4k9EuU/9EZw==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.0.0", + "fontkit": "^1.8.1", + "linebreak": "^1.0.2", + "png-js": "^1.0.0" + } + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -13091,6 +13559,11 @@ "node": ">=4" } }, + "node_modules/png-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", + "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -14276,6 +14749,26 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -14403,6 +14896,12 @@ "dev": true, "license": "ISC" }, + "node_modules/restructure": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz", + "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -14634,6 +15133,23 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -14835,6 +15351,21 @@ "node": ">= 0.4" } }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -15133,6 +15664,19 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -15733,6 +16277,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -16437,6 +16987,26 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -16908,6 +17478,43 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/backend/package.json b/backend/package.json index 8fbb3d654..f62eb965c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -53,6 +53,7 @@ "pg": "^8.18.0", "pino-http": "^11.0.0", "reflect-metadata": "^0.2.2", + "pdfkit": "^0.13.0", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.28", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a1e206a52..1a7d4f13e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -29,6 +29,7 @@ import { SavingsModule } from './modules/savings/savings.module'; import { GovernanceModule } from './modules/governance/governance.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { TransactionsModule } from './modules/transactions/transactions.module'; +import { ReportsModule } from './modules/reports/reports.module'; import { TestRbacModule } from './test-rbac/test-rbac.module'; import { TestThrottlingModule } from './test-throttling/test-throttling.module'; @@ -165,6 +166,7 @@ const envValidationSchema = Joi.object({ GovernanceModule, NotificationsModule, TransactionsModule, + ReportsModule, TestRbacModule, TestThrottlingModule, CommonModule, diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts new file mode 100644 index 000000000..d0444b2df --- /dev/null +++ b/backend/src/modules/reports/reports.controller.ts @@ -0,0 +1,42 @@ +import { + Controller, + Get, + Param, + Query, + Res, + BadRequestException, + UseGuards, +} from '@nestjs/common'; +import { ReportsService } from './reports.service'; +import { Response } from 'express'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; + +@Controller('reports') +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @UseGuards(JwtAuthGuard) + @Get('tax/:year') + async getTaxReport( + @Param('year') yearParam: string, + @Query('format') format = 'csv', + @Query('irs1099') irs1099 = 'false', + @CurrentUser() user: any, + @Res() res: Response, + ) { + const year = Number(yearParam); + if (!user || !user.id) + throw new BadRequestException('authenticated user required'); + if (Number.isNaN(year)) throw new BadRequestException('invalid year'); + const irsFlag = irs1099 === 'true'; + + const result = await this.reportsService.buildAndStoreTaxReport( + user.id, + year, + { format, irs1099: irsFlag }, + ); + + return res.json({ storedPath: result.path, filename: result.filename }); + } +} diff --git a/backend/src/modules/reports/reports.module.ts b/backend/src/modules/reports/reports.module.ts new file mode 100644 index 000000000..9f610ddcc --- /dev/null +++ b/backend/src/modules/reports/reports.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; +import { Transaction } from '../transactions/entities/transaction.entity'; +import { SavingsProduct } from '../savings/entities/savings-product.entity'; +import { User } from '../user/entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Transaction, SavingsProduct, User])], + controllers: [ReportsController], + providers: [ReportsService], + exports: [ReportsService], +}) +export class ReportsModule {} diff --git a/backend/src/modules/reports/reports.service.spec.ts b/backend/src/modules/reports/reports.service.spec.ts new file mode 100644 index 000000000..6f85afe1b --- /dev/null +++ b/backend/src/modules/reports/reports.service.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReportsService } from './reports.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { + Transaction, + TxType, +} from '../transactions/entities/transaction.entity'; +import { SavingsProduct } from '../savings/entities/savings-product.entity'; +import { User } from '../user/entities/user.entity'; + +describe('ReportsService', () => { + let service: ReportsService; + const mockTxRepo = { find: jest.fn() }; + const mockProductRepo = { findOneBy: jest.fn() }; + const mockUserRepo = { findOne: jest.fn() }; + const mockConfig = { get: jest.fn().mockReturnValue('test-key') }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReportsService, + { provide: getRepositoryToken(Transaction), useValue: mockTxRepo }, + { + provide: getRepositoryToken(SavingsProduct), + useValue: mockProductRepo, + }, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: ConfigService, useValue: mockConfig }, + ], + }).compile(); + + service = module.get(ReportsService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('generateTaxReportCSV aggregates transactions into CSV', async () => { + const txs = [ + { + userId: 'u1', + type: TxType.DEPOSIT, + amount: '100', + poolId: 'p1', + createdAt: new Date('2025-03-01'), + }, + { + userId: 'u1', + type: TxType.YIELD, + amount: '5', + poolId: 'p1', + createdAt: new Date('2025-04-01'), + }, + { + userId: 'u1', + type: TxType.WITHDRAW, + amount: '120', + poolId: 'p1', + createdAt: new Date('2025-12-01'), + }, + { + userId: 'u1', + type: TxType.DEPOSIT, + amount: '50', + poolId: 'p2', + createdAt: new Date('2025-06-01'), + }, + ]; + + mockTxRepo.find.mockResolvedValue(txs); + mockProductRepo.findOneBy.mockResolvedValue({ id: 'p1', name: 'Pool One' }); + + const buf = await service.generateTaxReportCSV('u1', 2025); + const csv = buf.toString(); + expect(csv).toContain( + 'productId,productName,interest,deposits,withdrawals,gains', + ); + expect(csv).toContain('Pool One'); + expect(csv).toContain('5'); + expect(csv).toContain('100'); + expect(csv).toContain('120'); + }); + + it('generate1099CSV sums interest and includes user info', async () => { + const txs = [ + { + userId: 'u1', + type: TxType.YIELD, + amount: '2.5', + createdAt: new Date('2025-01-02'), + }, + { + userId: 'u1', + type: TxType.YIELD, + amount: '1.5', + createdAt: new Date('2025-05-02'), + }, + ]; + mockTxRepo.find.mockResolvedValue(txs); + mockUserRepo.findOne.mockResolvedValue({ + id: 'u1', + email: 'a@b.com', + name: 'Alice', + tin: '123-45-6789', + accountNumber: 'ACC123', + }); + + const buf = await service.generate1099CSV('u1', 2025); + const csv = buf.toString(); + expect(csv).toContain( + 'payer_name,payer_tin,recipient_name,recipient_tin,recipient_account_number,year,interest_income,federal_tax_withheld', + ); + expect(csv).toContain('Alice'); + expect(csv).toContain('123-45-6789'); + expect(csv).toContain('4'); + }); + + it('generatePdfReport returns a Buffer', async () => { + const products = new Map(); + products.set('p1', { + productName: 'Pool One', + interest: 1.23, + deposits: 100, + withdrawals: 110, + gains: 10, + }); + const buf = await service.generatePdfReport('u1', 2025, products as any); + expect(Buffer.isBuffer(buf)).toBe(true); + expect(buf.length).toBeGreaterThan(0); + }); +}); diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts new file mode 100644 index 000000000..7f24509b4 --- /dev/null +++ b/backend/src/modules/reports/reports.service.ts @@ -0,0 +1,346 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + Transaction, + TxType, +} from '../transactions/entities/transaction.entity'; +import { SavingsProduct } from '../savings/entities/savings-product.entity'; +import { User } from '../user/entities/user.entity'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +const PDFDocument = require('pdfkit'); + +interface ProductBreakdown { + productId: string | null; + productName: string; + interest: number; + deposits: number; + withdrawals: number; + gains: number; +} + +@Injectable() +export class ReportsService { + private readonly logger = new Logger(ReportsService.name); + + constructor( + @InjectRepository(Transaction) + private readonly txRepo: Repository, + @InjectRepository(SavingsProduct) + private readonly productRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly configService: ConfigService, + ) {} + + private ensureStorageDir() { + const base = path.resolve( + __dirname, + '..', + '..', + '..', + 'uploads', + 'tax-reports', + ); + if (!fs.existsSync(base)) fs.mkdirSync(base, { recursive: true }); + return base; + } + + private encryptBuffer(buffer: Buffer): { data: Buffer; iv: Buffer } { + const key = this.configService.get('TAX_REPORT_KEY'); + if (!key) throw new BadRequestException('TAX_REPORT_KEY not set'); + const keyBuf = crypto.createHash('sha256').update(key).digest(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', keyBuf, iv); + const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); + const tag = cipher.getAuthTag(); + return { data: Buffer.concat([iv, tag, encrypted]), iv }; + } + + async generateTaxReportCSV(userId: string, year: number): Promise { + const start = new Date(Date.UTC(year, 0, 1, 0, 0, 0)); + const end = new Date(Date.UTC(year + 1, 0, 1, 0, 0, 0)); + + const txs = await this.txRepo.find({ + where: { userId }, + order: { createdAt: 'ASC' }, + }); + + const filtered = txs.filter( + (t) => t.createdAt >= start && t.createdAt < end, + ); + + const products = new Map(); + + for (const t of filtered) { + const pid = t.poolId ?? 'unspecified'; + if (!products.has(pid)) { + const prod = await this.productRepo + .findOneBy({ id: pid }) + .catch(() => null as any); + products.set(pid, { + productId: pid === 'unspecified' ? null : pid, + productName: prod?.name ?? pid, + interest: 0, + deposits: 0, + withdrawals: 0, + gains: 0, + }); + } + const entry = products.get(pid)!; + const amt = Number(t.amount || 0); + if (t.type === TxType.YIELD) entry.interest += amt; + else if (t.type === TxType.DEPOSIT) entry.deposits += amt; + else if (t.type === TxType.WITHDRAW) entry.withdrawals += amt; + } + + // Basic capital gains calculation per product: realized gains = withdrawals - deposits + for (const entry of products.values()) { + entry.gains = Number((entry.withdrawals - entry.deposits).toFixed(7)); + } + + // Build CSV + const rows: string[] = []; + rows.push('productId,productName,interest,deposits,withdrawals,gains'); + for (const v of products.values()) { + rows.push( + `${v.productId ?? ''},"${v.productName.replace(/"/g, '""')}",${v.interest},${v.deposits},${v.withdrawals},${v.gains}`, + ); + } + + return Buffer.from(rows.join('\n')); + } + + async generate1099CSV(userId: string, year: number): Promise { + // Build a more realistic 1099-INT style CSV with common fields. + // We keep this simplified and non-authoritative, but structured. + const start = new Date(Date.UTC(year, 0, 1)); + const end = new Date(Date.UTC(year + 1, 0, 1)); + const txs = await this.txRepo.find({ where: { userId } }); + const filtered = txs.filter( + (t) => + t.createdAt >= start && t.createdAt < end && t.type === TxType.YIELD, + ); + const interest = filtered.reduce((s, t) => s + Number(t.amount || 0), 0); + + const user = await this.userRepo + .findOne({ where: { id: userId } }) + .catch(() => null as any); + + // Payer details can be supplied via config for realistic CSVs + const payerName = + this.configService.get('TAX_PAYER_NAME') ?? 'Nestera Inc'; + const payerTin = this.configService.get('TAX_PAYER_TIN') ?? ''; + + const recipientName = (user && (user.name || user.email)) ?? ''; + const recipientTin = (user && (user.tin || '')) ?? ''; + const accountNumber = (user && (user.accountNumber || '')) ?? ''; + + const headers = [ + 'payer_name', + 'payer_tin', + 'recipient_name', + 'recipient_tin', + 'recipient_account_number', + 'year', + 'interest_income', + 'federal_tax_withheld', + ]; + + // We'll leave federal_tax_withheld empty for now (could be added later) + const row = [ + payerName, + payerTin, + recipientName, + recipientTin, + accountNumber, + String(year), + String(interest), + '', + ]; + + return Buffer.from( + headers.join(',') + + '\n' + + row.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(',') + + '\n', + ); + } + + async generatePdfBufferFromText( + title: string, + lines: string[], + ): Promise { + // kept for backward compatibility; create simple PDF + const doc = new PDFDocument({ size: 'A4', margin: 40 }); + const chunks: Buffer[] = []; + doc.on('data', (c) => chunks.push(c)); + doc.fontSize(16).text(title, { align: 'center' }); + doc.moveDown(); + doc.fontSize(10); + for (const line of lines) { + doc.text(line); + } + doc.end(); + return new Promise((res) => + doc.on('end', () => res(Buffer.concat(chunks))), + ); + } + + async generatePdfReport( + userId: string, + year: number, + products: Map, + ): Promise { + const doc = new PDFDocument({ size: 'A4', margin: 40 }); + const chunks: Buffer[] = []; + doc.on('data', (c) => chunks.push(c)); + + // Title + doc.fontSize(18).text(`Savings Tax Report — ${year}`, { align: 'center' }); + doc.moveDown(0.5); + + // Summary + let totalInterest = 0; + let totalDeposits = 0; + let totalWithdrawals = 0; + let totalGains = 0; + for (const v of products.values()) { + totalInterest += v.interest; + totalDeposits += v.deposits; + totalWithdrawals += v.withdrawals; + totalGains += v.gains ?? 0; + } + + doc.fontSize(12).text('Summary', { underline: true }); + doc.moveDown(0.2); + doc.fontSize(10).text(`Total interest: ${totalInterest.toFixed(7)}`); + doc + .fontSize(10) + .text(`Total deposits (cost basis): ${totalDeposits.toFixed(7)}`); + doc + .fontSize(10) + .text(`Total withdrawals (realized): ${totalWithdrawals.toFixed(7)}`); + doc.fontSize(10).text(`Estimated capital gains: ${totalGains.toFixed(7)}`); + doc.moveDown(0.6); + + // Breakdown table header + doc.fontSize(12).text('Breakdown by Savings Product', { underline: true }); + doc.moveDown(0.2); + + // table columns + const tableTop = doc.y; + const colWidths = [150, 80, 80, 80, 80]; + doc.fontSize(10); + // Header row + doc.text('Product', { continued: true, width: colWidths[0] }); + doc.text('Interest', { + continued: true, + width: colWidths[1], + align: 'right', + }); + doc.text('Deposits', { + continued: true, + width: colWidths[2], + align: 'right', + }); + doc.text('Withdrawals', { + continued: true, + width: colWidths[3], + align: 'right', + }); + doc.text('Gains', { width: colWidths[4], align: 'right' }); + doc.moveDown(0.2); + + for (const v of products.values()) { + doc.text(v.productName, { continued: true, width: colWidths[0] }); + doc.text(v.interest.toFixed(7), { + continued: true, + width: colWidths[1], + align: 'right', + }); + doc.text(v.deposits.toFixed(7), { + continued: true, + width: colWidths[2], + align: 'right', + }); + doc.text(v.withdrawals.toFixed(7), { + continued: true, + width: colWidths[3], + align: 'right', + }); + doc.text((v.gains ?? 0).toFixed(7), { + width: colWidths[4], + align: 'right', + }); + doc.moveDown(0.1); + } + + doc.end(); + return new Promise((res) => + doc.on('end', () => res(Buffer.concat(chunks))), + ); + } + + async saveEncrypted(buffer: Buffer, filenameBase: string): Promise { + const dir = this.ensureStorageDir(); + const { data } = this.encryptBuffer(buffer); + const filePath = path.join(dir, `${filenameBase}.enc`); + fs.writeFileSync(filePath, data); + this.logger.log(`Saved encrypted tax report to ${filePath}`); + return filePath; + } + + // Public method used by controller + async buildAndStoreTaxReport( + userId: string, + year: number, + opts: { format?: string; irs1099?: boolean }, + ) { + const fmt = opts.format ?? 'csv'; + let buffer: Buffer; + let filenameBase = `taxreport_${userId}_${year}_${Date.now()}`; + + if (opts.irs1099) { + buffer = await this.generate1099CSV(userId, year); + filenameBase += '_1099'; + } else if (fmt === 'csv') { + buffer = await this.generateTaxReportCSV(userId, year); + } else if (fmt === 'pdf') { + // For PDF, build structured data and render a formatted PDF + const csvBuf = await this.generateTaxReportCSV(userId, year); + // Reconstruct products map by parsing the CSV (cheap reuse) - header + rows + const csv = csvBuf.toString().split('\n'); + const header = csv[0] ?? ''; + const rows = csv.slice(1).filter(Boolean); + const products = new Map(); + for (const r of rows) { + // naive CSV split by comma taking into account quoted name + const parts = r.match( + /^(.*?),"?(.*?)"?,([0-9.+-eE]+),([0-9.+-eE]+),([0-9.+-eE]+),([0-9.+-eE]+)$/, + ); + if (!parts) continue; + const pid = parts[1] || 'unspecified'; + products.set(pid, { + productId: pid || null, + productName: parts[2], + interest: Number(parts[3]), + deposits: Number(parts[4]), + withdrawals: Number(parts[5]), + gains: Number(parts[6]), + }); + } + buffer = await this.generatePdfReport(userId, year, products); + filenameBase += '.pdf'; + } else { + // default to CSV + buffer = await this.generateTaxReportCSV(userId, year); + } + + const storedPath = await this.saveEncrypted(buffer, filenameBase); + return { path: storedPath, filename: filenameBase }; + } +}