From c7f4f4a8d7830a519c76c47be75d9992b776e8b6 Mon Sep 17 00:00:00 2001 From: maikNeta Date: Sat, 17 Oct 2026 15:43:37 +0100 Subject: [PATCH] feat: implement issuer analytics, document bulk export, Stellar network selector, and auto-invest rule engine Closes #587 Closes #588 Closes #589 Closes #596 --- package-lock.json | 496 +++++++++++++++++- package.json | 2 + src/app.ts | 35 ++ src/config/env.ts | 15 + src/config/stellar-networks.ts | 44 ++ src/controllers/auto-invest.controller.ts | 104 ++++ src/controllers/document-export.controller.ts | 75 +++ .../investor-risk-profile.controller.ts | 1 - src/controllers/invoice-draft.controller.ts | 3 +- .../issuer-analytics.controller.ts | 40 ++ src/index.ts | 65 +++ src/lib/signed-url.ts | 58 ++ src/middleware/network-context.middleware.ts | 47 ++ .../1731900000000-AddInvoiceDocuments.ts | 32 ++ src/migrations/1731910000000-AddAutoInvest.ts | 71 +++ src/models/AutoInvestExecution.model.ts | 63 +++ src/models/AutoInvestRule.model.ts | 66 +++ src/models/InvestorAccreditation.model.ts | 14 +- src/models/Invoice.model.ts | 4 + src/models/InvoiceDocument.model.ts | 49 ++ .../SecondaryMarketPriceHistory.model.ts | 12 +- src/routes/accreditation.routes.ts | 2 +- src/routes/auto-invest.routes.ts | 33 ++ src/routes/document-export.routes.ts | 30 ++ src/routes/investor.routes.ts | 3 - src/routes/issuer-analytics.routes.ts | 20 + .../auto-invest-rule-engine.service.ts | 150 ++++++ src/services/auto-invest-rules.service.ts | 149 ++++++ src/services/document-export.service.ts | 235 +++++++++ .../investor-accreditation.service.ts | 1 - src/services/invoice-refund.service.ts | 2 +- src/services/issuer-analytics.service.ts | 257 +++++++++ src/services/issuer-reputation.service.ts | 5 +- .../secondary-market-price.service.ts | 2 +- .../stellar/account-balance.service.ts | 27 + src/types/enums.ts | 14 + src/types/network.ts | 19 + 37 files changed, 2215 insertions(+), 30 deletions(-) create mode 100644 src/config/stellar-networks.ts create mode 100644 src/controllers/auto-invest.controller.ts create mode 100644 src/controllers/document-export.controller.ts create mode 100644 src/controllers/issuer-analytics.controller.ts create mode 100644 src/lib/signed-url.ts create mode 100644 src/middleware/network-context.middleware.ts create mode 100644 src/migrations/1731900000000-AddInvoiceDocuments.ts create mode 100644 src/migrations/1731910000000-AddAutoInvest.ts create mode 100644 src/models/AutoInvestExecution.model.ts create mode 100644 src/models/AutoInvestRule.model.ts create mode 100644 src/models/InvoiceDocument.model.ts create mode 100644 src/routes/auto-invest.routes.ts create mode 100644 src/routes/document-export.routes.ts create mode 100644 src/routes/issuer-analytics.routes.ts create mode 100644 src/services/auto-invest-rule-engine.service.ts create mode 100644 src/services/auto-invest-rules.service.ts create mode 100644 src/services/document-export.service.ts create mode 100644 src/services/issuer-analytics.service.ts create mode 100644 src/services/stellar/account-balance.service.ts create mode 100644 src/types/network.ts diff --git a/package-lock.json b/package-lock.json index 58ccf1a4..43b2356c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@types/archiver": "^8.0.0", + "archiver": "^8.0.0", "bcrypt": "^5.1.1", "cors": "^2.8.5", "decimal.js": "^10.6.0", @@ -1934,6 +1936,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/readdir-glob": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2180,7 +2192,6 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2207,6 +2218,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sanitize-html": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", @@ -2510,6 +2530,18 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2712,6 +2744,66 @@ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", "license": "ISC" }, + "node_modules/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/archiver/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" + } + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -2802,6 +2894,20 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2953,6 +3059,44 @@ } } }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, "node_modules/bare-module-resolve": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz", @@ -2971,6 +3115,12 @@ } } }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, "node_modules/bare-semver": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.2.tgz", @@ -2978,6 +3128,42 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.5.tgz", + "integrity": "sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base32.js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", @@ -3227,6 +3413,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3660,6 +3855,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/compress-commons/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3784,6 +4023,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -3846,6 +4091,47 @@ "typescript": ">=5" } }, + "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/crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4650,6 +4936,33 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -4783,6 +5096,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -6959,6 +7278,54 @@ "dayjs": "^1.11.7" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7901,7 +8268,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8559,6 +8925,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -8780,6 +9161,57 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9654,6 +10086,17 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9895,6 +10338,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9934,6 +10386,15 @@ "node": "*" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -10469,7 +10930,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unique-filename": { @@ -10852,6 +11312,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "license": "MIT", + "dependencies": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index 5d9b39d3..f2818be6 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "delay": "500" }, "dependencies": { + "@types/archiver": "^8.0.0", + "archiver": "^8.0.0", "bcrypt": "^5.1.1", "cors": "^2.8.5", "decimal.js": "^10.6.0", diff --git a/src/app.ts b/src/app.ts index 5a7ddb01..273adbf4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -49,6 +49,10 @@ import { createRoyaltiesRouter } from "./routes/royalties.routes"; import { createSubscriptionsRouter } from "./routes/subscriptions.routes"; import { createOnboardingRouter } from "./routes/onboarding.routes"; import { createIssuerReputationRouter } from "./routes/issuer-reputation.routes"; +import { createIssuerAnalyticsRouter } from "./routes/issuer-analytics.routes"; +import { createDocumentExportRouter } from "./routes/document-export.routes"; +import { createAutoInvestRouter } from "./routes/auto-invest.routes"; +import { createNetworkContextMiddleware } from "./middleware/network-context.middleware"; import type { AuthService } from "./services/auth.service"; import type { NotificationService } from "./services/notification.service"; @@ -71,6 +75,10 @@ import type { FeeScheduleService } from "./services/fee-schedule.service"; import type { IssuerReputationService } from "./services/issuer-reputation.service"; import type { InvestorRiskProfileService } from "./services/investor-risk-profile.service"; import type { InvoiceDraftService } from "./services/invoice-draft.service"; +import type { IssuerAnalyticsService } from "./services/issuer-analytics.service"; +import type { DocumentExportService } from "./services/document-export.service"; +import type { AutoInvestRulesService } from "./services/auto-invest-rules.service"; +import type { StellarNetworksConfig } from "./config/stellar-networks"; import dataSource from "./config/database"; import { getRedisClient } from "./config/redis"; @@ -211,6 +219,10 @@ export interface AppDependencies { investorRiskProfileService?: InvestorRiskProfileService; draftService?: InvoiceDraftService; invoiceEscrowContractService?: import("./services/stellar/invoice-escrow-contract.service").InvoiceEscrowContractService; + issuerAnalyticsService?: IssuerAnalyticsService; + documentExportService?: DocumentExportService; + autoInvestRulesService?: AutoInvestRulesService; + stellarNetworksConfig?: StellarNetworksConfig; logger?: AppLogger; metricsEnabled?: boolean; metricsRegistry?: MetricsRegistry; @@ -265,6 +277,10 @@ export function createApp({ investorRiskProfileService, draftService, invoiceEscrowContractService, + issuerAnalyticsService, + documentExportService, + autoInvestRulesService, + stellarNetworksConfig, logger: appLogger = logger, metricsEnabled = true, metricsRegistry = new MetricsRegistry(), @@ -395,6 +411,10 @@ export function createApp({ }); } + if (stellarNetworksConfig) { + app.use(createNetworkContextMiddleware(stellarNetworksConfig)); + } + // Every request with a bearer token for a suspended wallet gets a 403, // on all authenticated routes. app.use( @@ -552,6 +572,21 @@ export function createApp({ ); } + // ---- Issuer Analytics (issue #587) ---- + if (issuerAnalyticsService) { + app.use("/api/v1/issuers", createIssuerAnalyticsRouter({ issuerAnalyticsService })); + } + + // ---- Document Bulk Export (issue #589) ---- + if (documentExportService) { + app.use("/api/v1/invoices", createDocumentExportRouter({ documentExportService, authService })); + } + + // ---- Auto-Invest Rules (issue #596) ---- + if (autoInvestRulesService) { + app.use("/api/v1/auto-invest", createAutoInvestRouter({ autoInvestRulesService, authService })); + } + // ---- Issuer Reputation (issue #593) ---- if (issuerReputationService && authService) { app.use( diff --git a/src/config/env.ts b/src/config/env.ts index 497f825d..01e78fc0 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -99,6 +99,11 @@ export interface AppConfig { }; /** Investor accreditation terms version (issue #473). Bumping forces re-ack. */ termsVersion: string; + documentExport: { + tempDir: string; + signingSecret: string; + urlTtlMinutes: number; + }; cache: { redisUrl?: string; invoicesListTtlSeconds: number; @@ -478,6 +483,16 @@ export function getConfig(): AppConfig { termsVersion: (process.env.TERMS_VERSION?.trim() || "1"), + documentExport: { + tempDir: process.env.DOCUMENT_EXPORT_TEMP_DIR || "/tmp/ss-document-exports", + signingSecret: process.env.DOCUMENT_EXPORT_SIGNING_SECRET || "change-me-in-production", + urlTtlMinutes: parsePositiveInteger( + process.env.DOCUMENT_EXPORT_URL_TTL_MINUTES, + 60, + "DOCUMENT_EXPORT_URL_TTL_MINUTES" + ), + }, + cache: { redisUrl: process.env.REDIS_URL || undefined, invoicesListTtlSeconds: parsePositiveInteger( diff --git a/src/config/stellar-networks.ts b/src/config/stellar-networks.ts new file mode 100644 index 00000000..c3d5eb93 --- /dev/null +++ b/src/config/stellar-networks.ts @@ -0,0 +1,44 @@ +import { Networks } from "stellar-sdk"; +import type { StellarNetworkEndpoints, StellarNetworkName } from "../types/network"; + +/** + * Per-network Horizon/contract endpoints (issue #588), kept separate from + * `AppConfig["stellar"]` -- that field is the single network the process + * started with; this is the full mainnet/testnet table a request can select + * between via `X-Stellar-Network`. + */ +export interface StellarNetworksConfig { + mainnet: StellarNetworkEndpoints; + testnet: StellarNetworkEndpoints; +} + +function requireEnv(value: string | undefined, name: string): string { + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +export function getStellarNetworksConfig(): StellarNetworksConfig { + return { + mainnet: { + network: "mainnet", + horizonUrl: requireEnv(process.env.MAINNET_HORIZON_URL, "MAINNET_HORIZON_URL"), + networkPassphrase: Networks.PUBLIC, + contractId: process.env.MAINNET_CONTRACT_ID ?? null, + }, + testnet: { + network: "testnet", + horizonUrl: requireEnv(process.env.TESTNET_HORIZON_URL, "TESTNET_HORIZON_URL"), + networkPassphrase: Networks.TESTNET, + contractId: process.env.TESTNET_CONTRACT_ID ?? null, + }, + }; +} + +export function resolveStellarNetworkEndpoints( + config: StellarNetworksConfig, + network: StellarNetworkName, +): StellarNetworkEndpoints { + return config[network]; +} diff --git a/src/controllers/auto-invest.controller.ts b/src/controllers/auto-invest.controller.ts new file mode 100644 index 00000000..59075d9d --- /dev/null +++ b/src/controllers/auto-invest.controller.ts @@ -0,0 +1,104 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { AutoInvestRulesService } from "../services/auto-invest-rules.service"; +import { RiskTier } from "../types/enums"; + +export class AutoInvestController { + constructor(private readonly autoInvestRulesService: AutoInvestRulesService) {} + + createRule = async (req: AuthenticatedRequest, res: Response) => { + try { + const user = req.user; + if (!user) { + return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }); + } + + const { category, minYield, maxFaceValue, riskTier, maxPerInvoice, minWalletBalance } = req.body; + + if (!minYield || !maxFaceValue || !maxPerInvoice) { + return res.status(400).json({ + error: { + code: "MISSING_FIELDS", + message: "minYield, maxFaceValue and maxPerInvoice are required", + }, + }); + } + + if (riskTier && !Object.values(RiskTier).includes(riskTier)) { + return res.status(400).json({ + error: { + code: "INVALID_RISK_TIER", + message: `riskTier must be one of: ${Object.values(RiskTier).join(", ")}`, + }, + }); + } + + const rule = await this.autoInvestRulesService.createRule({ + investorId: user.id, + category: category ?? null, + minYield, + maxFaceValue, + riskTier: riskTier ?? null, + maxPerInvoice, + minWalletBalance, + }); + + return res.status(201).json({ success: true, data: rule }); + } catch (err: unknown) { + return respondWithError(res, err); + } + }; + + listRules = async (req: AuthenticatedRequest, res: Response) => { + try { + const user = req.user; + if (!user) { + return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }); + } + + const rules = await this.autoInvestRulesService.listRules(user.id); + return res.status(200).json({ success: true, data: rules }); + } catch (err: unknown) { + return respondWithError(res, err); + } + }; + + updateRule = async (req: AuthenticatedRequest, res: Response) => { + try { + const user = req.user; + if (!user) { + return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }); + } + + const rule = await this.autoInvestRulesService.updateRule(user.id, req.params.ruleId as string, req.body); + return res.status(200).json({ success: true, data: rule }); + } catch (err: unknown) { + return respondWithError(res, err); + } + }; + + deleteRule = async (req: AuthenticatedRequest, res: Response) => { + try { + const user = req.user; + if (!user) { + return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }); + } + + await this.autoInvestRulesService.deleteRule(user.id, req.params.ruleId as string); + return res.status(204).send(); + } catch (err: unknown) { + return respondWithError(res, err); + } + }; +} + +function respondWithError(res: Response, err: unknown) { + const statusCode = + (err as { statusCode?: number }).statusCode || (err as { status?: number }).status || 500; + return res.status(statusCode).json({ + error: { + code: (err as { code?: string }).code || "INTERNAL_ERROR", + message: (err as { message?: string }).message || "Internal server error", + }, + }); +} diff --git a/src/controllers/document-export.controller.ts b/src/controllers/document-export.controller.ts new file mode 100644 index 00000000..dd931f3a --- /dev/null +++ b/src/controllers/document-export.controller.ts @@ -0,0 +1,75 @@ +import { Request, Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { DocumentExportService } from "../services/document-export.service"; + +export class DocumentExportController { + constructor(private readonly documentExportService: DocumentExportService) {} + + requestExport = async (req: AuthenticatedRequest, res: Response) => { + try { + const invoiceId = req.params.id as string; + const user = req.user; + if (!user) { + return res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Unauthorized" } }); + } + + const { jobId } = await this.documentExportService.requestExport(invoiceId, user.id); + + return res.status(202).json({ + success: true, + data: { jobId, status: "processing" }, + }); + } catch (err: unknown) { + return respondWithError(res, err); + } + }; + + getExportStatus = async (req: Request, res: Response) => { + try { + const jobId = req.params.jobId as string; + const job = this.documentExportService.getJobStatus(jobId); + if (!job) { + return res.status(404).json({ error: { code: "export_job_not_found", message: "Export job not found" } }); + } + + const data: Record = { jobId: job.id, status: job.status }; + if (job.status === "completed" && job.downloadToken) { + data.downloadUrl = `/api/v1/invoices/${job.invoiceId}/documents/export/${job.id}/download?token=${job.downloadToken}`; + data.expiresAt = new Date(job.downloadTokenExpiresAt!).toISOString(); + } + if (job.status === "failed") { + data.error = job.error; + } + + return res.status(200).json({ success: true, data }); + } catch (err: unknown) { + return respondWithError(res, err); + } + }; + + downloadExport = async (req: Request, res: Response) => { + try { + const jobId = req.params.jobId as string; + const token = req.query.token as string | undefined; + if (!token) { + return res.status(400).json({ error: { code: "missing_token", message: "token query param is required" } }); + } + + const zipPath = this.documentExportService.getDownloadPath(jobId, token); + return res.download(zipPath, `${jobId}.zip`); + } catch (err: unknown) { + return respondWithError(res, err); + } + }; +} + +function respondWithError(res: Response, err: unknown) { + const statusCode = + (err as { statusCode?: number }).statusCode || (err as { status?: number }).status || 500; + return res.status(statusCode).json({ + error: { + code: (err as { code?: string }).code || "INTERNAL_ERROR", + message: (err as { message?: string }).message || "Internal server error", + }, + }); +} diff --git a/src/controllers/investor-risk-profile.controller.ts b/src/controllers/investor-risk-profile.controller.ts index 2ffe9a9b..c9db744c 100644 --- a/src/controllers/investor-risk-profile.controller.ts +++ b/src/controllers/investor-risk-profile.controller.ts @@ -1,7 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { InvestorRiskProfileService, RiskProfileInput } from "../services/investor-risk-profile.service"; import { AppError } from "../utils/http-error"; -import { logger } from "../observability/logger"; import type { AuthenticatedRequest } from "../types/auth"; export class InvestorRiskProfileController { diff --git a/src/controllers/invoice-draft.controller.ts b/src/controllers/invoice-draft.controller.ts index f6b20442..77ed81f1 100644 --- a/src/controllers/invoice-draft.controller.ts +++ b/src/controllers/invoice-draft.controller.ts @@ -1,7 +1,6 @@ -import { Request, Response, NextFunction } from "express"; +import { Response, NextFunction } from "express"; import { InvoiceDraftService, DraftData } from "../services/invoice-draft.service"; import { AppError } from "../utils/http-error"; -import { logger } from "../observability/logger"; import type { AuthenticatedRequest } from "../types/auth"; export class InvoiceDraftController { diff --git a/src/controllers/issuer-analytics.controller.ts b/src/controllers/issuer-analytics.controller.ts new file mode 100644 index 00000000..af88a9eb --- /dev/null +++ b/src/controllers/issuer-analytics.controller.ts @@ -0,0 +1,40 @@ +import { Request, Response } from "express"; +import { IssuerAnalyticsService, isValidAnalyticsRange } from "../services/issuer-analytics.service"; + +export class IssuerAnalyticsController { + constructor(private readonly issuerAnalyticsService: IssuerAnalyticsService) {} + + getAnalytics = async (req: Request, res: Response) => { + try { + const wallet = req.params.wallet as string; + const rangeParam = (req.query.range as string | undefined) ?? "30d"; + + if (!isValidAnalyticsRange(rangeParam)) { + return res.status(400).json({ + error: { + code: "INVALID_RANGE", + message: `range must be one of: 30d, 90d, all. Got "${rangeParam}".`, + }, + }); + } + + const analytics = await this.issuerAnalyticsService.getAnalyticsByWallet(wallet, rangeParam); + + return res.status(200).json({ + success: true, + data: analytics, + }); + } catch (err: unknown) { + const statusCode = + (err as { statusCode?: number }).statusCode || + (err as { status?: number }).status || + 500; + return res.status(statusCode).json({ + error: { + code: (err as { code?: string }).code || "INTERNAL_ERROR", + message: (err as { message?: string }).message || "Internal server error", + }, + }); + } + }; +} diff --git a/src/index.ts b/src/index.ts index fe3a24fc..92321f50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { } from "./lib/notification-dispatcher"; import { createInvestmentNotifier, createInvestorDirectory } from "./lib/invoice-notifications"; import { Invoice } from "./models/Invoice.model"; +import { InvoiceStatus } from "./types/enums"; import { createIPFSService } from "./services/ipfs.service"; import { createInvestmentService } from "./services/investment.service"; import { createSettlementService } from "./services/settlement.service"; @@ -53,6 +54,13 @@ import { getRedisClient } from "./config/redis"; import { createIssuerReputationService } from "./services/issuer-reputation.service"; import { createInvestorRiskProfileService } from "./services/investor-risk-profile.service"; import { createInvoiceDraftService } from "./services/invoice-draft.service"; +import { createIssuerAnalyticsService } from "./services/issuer-analytics.service"; +import { createDocumentExportService } from "./services/document-export.service"; +import { createAutoInvestRuleEngineService } from "./services/auto-invest-rule-engine.service"; +import { createAutoInvestRulesService } from "./services/auto-invest-rules.service"; +import { HorizonAccountBalanceService } from "./services/stellar/account-balance.service"; +import { getStellarNetworksConfig } from "./config/stellar-networks"; +import { getPaymentVerificationConfig } from "./config/stellar"; export async function bootstrap(): Promise<{ server: Server; @@ -79,10 +87,19 @@ export async function bootstrap(): Promise<{ deadLetters: new InMemoryDeadLetterSink(), logger, }); + // Late-bound auto-invest hook: the rule engine needs investmentService which + // isn't created yet. We assign the callback after investmentService exists. + let autoInvestOnPublish: ((invoice: Invoice) => void) | undefined; + const invoiceStateMachine = createInvoiceStateMachine({ effects: [ unlessDispatched(createSellerNotificationEffect(notificationService)), createNotificationDispatchEffect(notificationDispatcher), + (transition) => { + if (transition.to === InvoiceStatus.PUBLISHED) { + autoInvestOnPublish?.(transition.invoice); + } + }, ], }); const invoiceService = new InvoiceService({ @@ -91,6 +108,26 @@ export async function bootstrap(): Promise<{ dataSource, stateMachine: invoiceStateMachine, }); + // ---- Feature: Issuer Analytics (issue #587) ---- + const issuerAnalyticsService = createIssuerAnalyticsService(dataSource); + + // ---- Feature: Document Export (issue #589) ---- + const documentExportService = createDocumentExportService({ + dataSource, + ipfsService, + tempDir: config.documentExport.tempDir, + signingSecret: config.documentExport.signingSecret, + urlTtlMinutes: config.documentExport.urlTtlMinutes, + }); + + // ---- Feature: Network Selector (issue #588) ---- + let stellarNetworksConfig; + try { + stellarNetworksConfig = getStellarNetworksConfig(); + } catch { + logger.warn("Stellar network selector disabled: networks config incomplete."); + } + // ---- Feature: Issuer Reputation (issue #593) ---- const issuerReputationService = createIssuerReputationService(dataSource); @@ -107,6 +144,30 @@ export async function bootstrap(): Promise<{ // ---- Feature: Invoice Draft Autosave (issue #594) ---- const draftService = createInvoiceDraftService(dataSource); + const autoInvestRulesService = createAutoInvestRulesService(dataSource); + + // ---- Feature: Auto-Invest Rule Engine (issue #596) ---- + try { + const horizonUrl = stellarNetworksConfig?.mainnet.horizonUrl ?? getPaymentVerificationConfig().horizonUrl; + const balanceChecker = new HorizonAccountBalanceService(horizonUrl); + const autoInvestRuleEngine = createAutoInvestRuleEngineService({ + dataSource, + investmentService, + balanceChecker, + logger, + }); + autoInvestOnPublish = (invoice) => { + void autoInvestRuleEngine.evaluateInvoice(invoice).catch((error) => { + logger.error("Auto-invest evaluation failed", { + invoice_id: invoice.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + } catch { + logger.warn("Auto-invest rule engine disabled: no Horizon URL configured."); + } + const sorobanConfig = getSorobanConfig(); const distributor = @@ -234,6 +295,10 @@ export async function bootstrap(): Promise<{ issuerReputationService, investorRiskProfileService, draftService, + issuerAnalyticsService, + documentExportService, + autoInvestRulesService, + stellarNetworksConfig, config, logger, metricsEnabled: config.observability.metricsEnabled, diff --git a/src/lib/signed-url.ts b/src/lib/signed-url.ts new file mode 100644 index 00000000..a0ee5379 --- /dev/null +++ b/src/lib/signed-url.ts @@ -0,0 +1,58 @@ +import { createHmac, timingSafeEqual } from "crypto"; + +/** + * Minimal HMAC-signed, time-limited download token (issue #589's "signed + * download URL"). There is no S3/cloud storage in this codebase to issue a + * presigned URL from, so this signs `${resource}.${expiresAt}` with a + * server-held secret instead -- same guarantee (can't be forged, can't be + * used past its expiry), served from a local route rather than object + * storage. + */ +export interface SignedToken { + token: string; + expiresAt: number; +} + +function sign(secret: string, resource: string, expiresAt: number): string { + return createHmac("sha256", secret).update(`${resource}.${expiresAt}`).digest("hex"); +} + +export function createSignedDownloadToken( + secret: string, + resource: string, + ttlMinutes: number, + now: Date = new Date(), +): SignedToken { + const expiresAt = now.getTime() + ttlMinutes * 60 * 1000; + const signature = sign(secret, resource, expiresAt); + return { token: `${expiresAt}.${signature}`, expiresAt }; +} + +/** + * Verifies a token produced by `createSignedDownloadToken` for the same + * `resource`. Returns `false` for a malformed, expired, or mismatched-secret + * token -- callers should not distinguish why. + */ +export function verifySignedDownloadToken( + secret: string, + resource: string, + token: string, + now: Date = new Date(), +): boolean { + const dot = token.indexOf("."); + if (dot <= 0) return false; + + const expiresAtPart = token.slice(0, dot); + const signaturePart = token.slice(dot + 1); + const expiresAt = Number(expiresAtPart); + if (!Number.isFinite(expiresAt)) return false; + if (expiresAt <= now.getTime()) return false; + + const expectedSignature = sign(secret, resource, expiresAt); + + const expectedBuf = Buffer.from(expectedSignature, "hex"); + const actualBuf = Buffer.from(signaturePart, "hex"); + if (expectedBuf.length !== actualBuf.length) return false; + + return timingSafeEqual(expectedBuf, actualBuf); +} diff --git a/src/middleware/network-context.middleware.ts b/src/middleware/network-context.middleware.ts new file mode 100644 index 00000000..279aca8a --- /dev/null +++ b/src/middleware/network-context.middleware.ts @@ -0,0 +1,47 @@ +import type { NextFunction, Response } from "express"; +import { HttpError } from "../utils/http-error"; +import { + STELLAR_NETWORK_NAMES, + type NetworkAwareRequest, + type StellarNetworkName, +} from "../types/network"; +import type { StellarNetworksConfig } from "../config/stellar-networks"; + +const HEADER_NAME = "x-stellar-network"; +const DEFAULT_NETWORK: StellarNetworkName = "mainnet"; + +function isStellarNetworkName(value: string): value is StellarNetworkName { + return (STELLAR_NETWORK_NAMES as readonly string[]).includes(value); +} + +/** + * Reads `X-Stellar-Network` (`mainnet` | `testnet`) and attaches the + * matching Horizon/contract endpoints to `req.stellarNetwork`, so every + * downstream handler and service call in this request routes against the + * same, explicitly selected network (issue #588). + * + * Missing header defaults to `mainnet`; an unrecognized value is a 400, not + * a silent fallback, since routing a mainnet investor's request at testnet + * (or vice versa) by accident is the exact failure mode this middleware + * exists to prevent. + */ +export function createNetworkContextMiddleware(networksConfig: StellarNetworksConfig) { + return (req: NetworkAwareRequest, _res: Response, next: NextFunction): void => { + const headerValue = req.headers[HEADER_NAME]; + const raw = Array.isArray(headerValue) ? headerValue[0] : headerValue; + const network = raw === undefined ? DEFAULT_NETWORK : raw.trim().toLowerCase(); + + if (!isStellarNetworkName(network)) { + next( + new HttpError( + 400, + `Invalid X-Stellar-Network header "${raw}". Expected one of: ${STELLAR_NETWORK_NAMES.join(", ")}.`, + ), + ); + return; + } + + req.stellarNetwork = networksConfig[network]; + next(); + }; +} diff --git a/src/migrations/1731900000000-AddInvoiceDocuments.ts b/src/migrations/1731900000000-AddInvoiceDocuments.ts new file mode 100644 index 00000000..ca171af3 --- /dev/null +++ b/src/migrations/1731900000000-AddInvoiceDocuments.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddInvoiceDocuments1731900000000 implements MigrationInterface { + name = "AddInvoiceDocuments1731900000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "invoice_documents" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "invoice_id" uuid NOT NULL, + "cid" character varying(128) NOT NULL, + "doc_type" character varying(64) NOT NULL DEFAULT 'general', + "filename" character varying(255) NOT NULL, + "mime_type" character varying(128) NOT NULL, + "file_size" integer NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_invoice_documents" PRIMARY KEY ("id"), + CONSTRAINT "FK_invoice_documents_invoice_id" FOREIGN KEY ("invoice_id") + REFERENCES "invoices" ("id") ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE INDEX "idx_invoice_documents_invoice_id" ON "invoice_documents" ("invoice_id"); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."idx_invoice_documents_invoice_id"`); + await queryRunner.query(`DROP TABLE "invoice_documents"`); + } +} diff --git a/src/migrations/1731910000000-AddAutoInvest.ts b/src/migrations/1731910000000-AddAutoInvest.ts new file mode 100644 index 00000000..8155d98a --- /dev/null +++ b/src/migrations/1731910000000-AddAutoInvest.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAutoInvest1731910000000 implements MigrationInterface { + name = "AddAutoInvest1731910000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "invoices" ADD "category" character varying(64); + CREATE INDEX "idx_invoices_category" ON "invoices" ("category"); + `); + + await queryRunner.query(` + CREATE TYPE "public"."auto_invest_rules_risktier_enum" AS ENUM('low', 'medium', 'high'); + + CREATE TABLE "auto_invest_rules" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "investor_id" uuid NOT NULL, + "category" character varying(64), + "min_yield" decimal(5,2) NOT NULL, + "max_face_value" decimal(18,4) NOT NULL, + "risk_tier" "public"."auto_invest_rules_risktier_enum", + "max_per_invoice" decimal(18,4) NOT NULL, + "min_wallet_balance" decimal(18,4) NOT NULL DEFAULT 0, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_auto_invest_rules" PRIMARY KEY ("id"), + CONSTRAINT "FK_auto_invest_rules_investor_id" FOREIGN KEY ("investor_id") + REFERENCES "users" ("id") ON DELETE CASCADE + ); + + CREATE INDEX "idx_auto_invest_rules_investor_id" ON "auto_invest_rules" ("investor_id"); + CREATE INDEX "idx_auto_invest_rules_enabled" ON "auto_invest_rules" ("enabled"); + `); + + await queryRunner.query(` + CREATE TYPE "public"."auto_invest_executions_status_enum" AS ENUM('executed', 'skipped_insufficient_balance', 'failed'); + + CREATE TABLE "auto_invest_executions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "rule_id" uuid NOT NULL, + "investor_id" uuid NOT NULL, + "invoice_id" uuid NOT NULL, + "investment_id" uuid, + "amount" decimal(18,4) NOT NULL, + "status" "public"."auto_invest_executions_status_enum" NOT NULL, + "reason" character varying(255), + "executed_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_auto_invest_executions" PRIMARY KEY ("id"), + CONSTRAINT "FK_auto_invest_executions_rule_id" FOREIGN KEY ("rule_id") + REFERENCES "auto_invest_rules" ("id") ON DELETE CASCADE, + CONSTRAINT "FK_auto_invest_executions_invoice_id" FOREIGN KEY ("invoice_id") + REFERENCES "invoices" ("id") ON DELETE CASCADE + ); + + CREATE INDEX "idx_auto_invest_executions_rule_id" ON "auto_invest_executions" ("rule_id"); + CREATE INDEX "idx_auto_invest_executions_investor_id" ON "auto_invest_executions" ("investor_id"); + CREATE INDEX "idx_auto_invest_executions_invoice_id" ON "auto_invest_executions" ("invoice_id"); + CREATE INDEX "idx_auto_invest_executions_status" ON "auto_invest_executions" ("status"); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "auto_invest_executions"`); + await queryRunner.query(`DROP TYPE "public"."auto_invest_executions_status_enum"`); + await queryRunner.query(`DROP TABLE "auto_invest_rules"`); + await queryRunner.query(`DROP TYPE "public"."auto_invest_rules_risktier_enum"`); + await queryRunner.query(`DROP INDEX "public"."idx_invoices_category"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP COLUMN "category"`); + } +} diff --git a/src/models/AutoInvestExecution.model.ts b/src/models/AutoInvestExecution.model.ts new file mode 100644 index 00000000..1cc6804b --- /dev/null +++ b/src/models/AutoInvestExecution.model.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from "typeorm"; +import { AutoInvestExecutionStatus } from "../types/enums"; + +/** + * The "AutoInvestExecuted" event record the issue's acceptance criteria + * asks for: one row per rule whose match criteria passed for one invoice, + * whatever happened next. Recording the insufficient-balance/failed cases + * too (not only successful executions) is deliberate -- an investor needs + * to see *why* a rule didn't fire, not just silence. A rule whose criteria + * simply didn't match the invoice produces no row at all. + */ +@Entity("auto_invest_executions") +export class AutoInvestExecution { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ name: "rule_id", type: "uuid" }) + @Index("idx_auto_invest_executions_rule_id") + ruleId!: string; + + @Column({ name: "investor_id", type: "uuid" }) + @Index("idx_auto_invest_executions_investor_id") + investorId!: string; + + @Column({ name: "invoice_id", type: "uuid" }) + @Index("idx_auto_invest_executions_invoice_id") + invoiceId!: string; + + /** Set only when `status` is EXECUTED. */ + @Column({ name: "investment_id", type: "uuid", nullable: true }) + investmentId!: string | null; + + /** Amount invested when EXECUTED; the amount that would have been invested, for FAILED/SKIPPED. */ + @Column({ type: "decimal", precision: 18, scale: 4 }) + amount!: string; + + @Column({ type: "enum", enum: AutoInvestExecutionStatus }) + @Index("idx_auto_invest_executions_status") + status!: AutoInvestExecutionStatus; + + /** Why a SKIPPED/FAILED execution didn't invest; `null` for EXECUTED. */ + @Column({ type: "varchar", length: 255, nullable: true }) + reason!: string | null; + + @CreateDateColumn({ name: "executed_at" }) + executedAt!: Date; + + @ManyToOne("AutoInvestRule", { onDelete: "CASCADE" }) + @JoinColumn({ name: "rule_id" }) + rule!: import("./AutoInvestRule.model").AutoInvestRule; + + @ManyToOne("Invoice", { onDelete: "CASCADE" }) + @JoinColumn({ name: "invoice_id" }) + invoice!: import("./Invoice.model").Invoice; +} diff --git a/src/models/AutoInvestRule.model.ts b/src/models/AutoInvestRule.model.ts new file mode 100644 index 00000000..5fcc5319 --- /dev/null +++ b/src/models/AutoInvestRule.model.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from "typeorm"; +import { RiskTier } from "../types/enums"; + +/** + * An investor-configured auto-invest rule (issue #596). On every invoice + * that goes PUBLISHED, every enabled rule is evaluated against it; a match + * invests `min(maxPerInvoice, invoice's remaining capacity)` on the + * investor's behalf, provided their wallet balance clears + * `minWalletBalance`. + */ +@Entity("auto_invest_rules") +export class AutoInvestRule { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ name: "investor_id", type: "uuid" }) + @Index("idx_auto_invest_rules_investor_id") + investorId!: string; + + /** `null` matches any category. */ + @Column({ name: "category", type: "varchar", length: 64, nullable: true }) + category!: string | null; + + /** Minimum acceptable yield, as a percentage matched against `invoice.discountRate`. */ + @Column({ name: "min_yield", type: "decimal", precision: 5, scale: 2 }) + minYield!: string; + + @Column({ name: "max_face_value", type: "decimal", precision: 18, scale: 4 }) + maxFaceValue!: string; + + /** `null` matches any risk tier. */ + @Column({ name: "risk_tier", type: "enum", enum: RiskTier, nullable: true }) + riskTier!: RiskTier | null; + + /** Cap on how much this rule will commit to a single invoice. */ + @Column({ name: "max_per_invoice", type: "decimal", precision: 18, scale: 4 }) + maxPerInvoice!: string; + + /** Rule is skipped (not disabled, just not executed this time) while the + * investor's wallet balance is below this. */ + @Column({ name: "min_wallet_balance", type: "decimal", precision: 18, scale: 4, default: 0 }) + minWalletBalance!: string; + + @Column({ name: "enabled", type: "boolean", default: true }) + @Index("idx_auto_invest_rules_enabled") + enabled!: boolean; + + @CreateDateColumn({ name: "created_at" }) + createdAt!: Date; + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt!: Date; + + @ManyToOne("User", { onDelete: "CASCADE" }) + @JoinColumn({ name: "investor_id" }) + investor!: import("./User.model").User; +} diff --git a/src/models/InvestorAccreditation.model.ts b/src/models/InvestorAccreditation.model.ts index 68989bf8..fa0d1e66 100644 --- a/src/models/InvestorAccreditation.model.ts +++ b/src/models/InvestorAccreditation.model.ts @@ -23,27 +23,27 @@ export const ACCREDITATION_LIMITS: Record = { @Index("idx_investor_accreditation_wallet", ["walletAddress"], { unique: true }) export class InvestorAccreditation { @PrimaryGeneratedColumn("uuid") - id: string; + id!: string; @Column({ type: "varchar", length: 256, unique: true }) - walletAddress: string; + walletAddress!: string; @Column({ type: "enum", enum: AccreditationTier, default: AccreditationTier.UNACCREDITED, }) - tier: AccreditationTier; + tier!: AccreditationTier; @Column({ type: "timestamp", nullable: true }) - verificationDate: Date | null; + verificationDate!: Date | null; @Column({ type: "varchar", length: 255, nullable: true }) - verificationSource: string | null; + verificationSource!: string | null; @CreateDateColumn() - createdAt: Date; + createdAt!: Date; @UpdateDateColumn() - updatedAt: Date; + updatedAt!: Date; } diff --git a/src/models/Invoice.model.ts b/src/models/Invoice.model.ts index 5175f9ef..e189e8c4 100644 --- a/src/models/Invoice.model.ts +++ b/src/models/Invoice.model.ts @@ -136,6 +136,10 @@ export class Invoice { @Column({ name: "risk_score", type: "decimal", precision: 5, scale: 2, nullable: true }) riskScore!: string | null; + @Column({ name: "category", type: "varchar", length: 64, nullable: true }) + @Index("idx_invoices_category") + category!: string | null; + @Column({ type: "enum", enum: InvoiceStatus, diff --git a/src/models/InvoiceDocument.model.ts b/src/models/InvoiceDocument.model.ts new file mode 100644 index 00000000..6bf2344b --- /dev/null +++ b/src/models/InvoiceDocument.model.ts @@ -0,0 +1,49 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from "typeorm"; + +/** + * One uploaded document for an invoice (issue #589). + * + * `Invoice.ipfsHash` only ever held a single CID -- each new upload + * overwrote it. This table lets an invoice carry several typed documents + * (invoice PDF, proof of delivery, purchase order, ...) so the bulk export + * endpoint has more than one file to package. + */ +@Entity("invoice_documents") +export class InvoiceDocument { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ name: "invoice_id", type: "uuid" }) + @Index("idx_invoice_documents_invoice_id") + invoiceId!: string; + + @Column({ name: "cid", type: "varchar", length: 128 }) + cid!: string; + + @Column({ name: "doc_type", type: "varchar", length: 64, default: "general" }) + docType!: string; + + @Column({ name: "filename", type: "varchar", length: 255 }) + filename!: string; + + @Column({ name: "mime_type", type: "varchar", length: 128 }) + mimeType!: string; + + @Column({ name: "file_size", type: "integer" }) + fileSize!: number; + + @CreateDateColumn({ name: "created_at" }) + createdAt!: Date; + + @ManyToOne("Invoice", { onDelete: "CASCADE" }) + @JoinColumn({ name: "invoice_id" }) + invoice!: import("./Invoice.model").Invoice; +} diff --git a/src/models/SecondaryMarketPriceHistory.model.ts b/src/models/SecondaryMarketPriceHistory.model.ts index d147e845..fec2fa3a 100644 --- a/src/models/SecondaryMarketPriceHistory.model.ts +++ b/src/models/SecondaryMarketPriceHistory.model.ts @@ -15,23 +15,23 @@ import { Invoice } from "./Invoice.model"; @Index("idx_price_history_invoice_date_range", ["invoiceId", "createdAt"]) export class SecondaryMarketPriceHistory { @PrimaryGeneratedColumn("uuid") - id: string; + id!: string; @Column({ type: "uuid" }) - invoiceId: string; + invoiceId!: string; @ManyToOne(() => Invoice) @JoinColumn({ name: "invoiceId" }) - invoice: Invoice; + invoice!: Invoice; @Column({ type: "decimal", precision: 18, scale: 8 }) - askPrice: Decimal; + askPrice!: Decimal; @Column({ type: "integer", default: 1 }) - listingCount: number; + listingCount!: number; @CreateDateColumn() - createdAt: Date; + createdAt!: Date; } export interface PriceHistoryPoint { diff --git a/src/routes/accreditation.routes.ts b/src/routes/accreditation.routes.ts index 5136436f..38c23397 100644 --- a/src/routes/accreditation.routes.ts +++ b/src/routes/accreditation.routes.ts @@ -1,5 +1,5 @@ import { Router, type Response, type NextFunction } from "express"; -import { createAuthMiddleware, requireInvestor, requireAdmin } from "../middleware/auth.middleware"; +import { createAuthMiddleware, requireAdmin } from "../middleware/auth.middleware"; import type { AuthService } from "../services/auth.service"; import type { InvestorAccreditationService } from "../services/investor-accreditation.service"; import type { AuthenticatedRequest } from "../types/auth"; diff --git a/src/routes/auto-invest.routes.ts b/src/routes/auto-invest.routes.ts new file mode 100644 index 00000000..33fec096 --- /dev/null +++ b/src/routes/auto-invest.routes.ts @@ -0,0 +1,33 @@ +import { Router } from "express"; +import { AutoInvestController } from "../controllers/auto-invest.controller"; +import type { AutoInvestRulesService } from "../services/auto-invest-rules.service"; +import { createAuthMiddleware } from "../middleware/auth.middleware"; +import type { AuthService } from "../services/auth.service"; + +export interface AutoInvestRouterDependencies { + autoInvestRulesService: AutoInvestRulesService; + authService: AuthService; +} + +export function createAutoInvestRouter({ + autoInvestRulesService, + authService, +}: AutoInvestRouterDependencies): Router { + const router = Router(); + const controller = new AutoInvestController(autoInvestRulesService); + const authMiddleware = createAuthMiddleware(authService); + + // POST /api/v1/auto-invest/rules - create a rule + router.post("/rules", authMiddleware, controller.createRule); + + // GET /api/v1/auto-invest/rules - list my rules + router.get("/rules", authMiddleware, controller.listRules); + + // PATCH /api/v1/auto-invest/rules/:ruleId - update a rule + router.patch("/rules/:ruleId", authMiddleware, controller.updateRule); + + // DELETE /api/v1/auto-invest/rules/:ruleId - remove a rule + router.delete("/rules/:ruleId", authMiddleware, controller.deleteRule); + + return router; +} diff --git a/src/routes/document-export.routes.ts b/src/routes/document-export.routes.ts new file mode 100644 index 00000000..675cb9f8 --- /dev/null +++ b/src/routes/document-export.routes.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { DocumentExportController } from "../controllers/document-export.controller"; +import type { DocumentExportService } from "../services/document-export.service"; +import { createAuthMiddleware } from "../middleware/auth.middleware"; +import type { AuthService } from "../services/auth.service"; + +export interface DocumentExportRouterDependencies { + documentExportService: DocumentExportService; + authService: AuthService; +} + +export function createDocumentExportRouter({ + documentExportService, + authService, +}: DocumentExportRouterDependencies): Router { + const router = Router(); + const controller = new DocumentExportController(documentExportService); + const authMiddleware = createAuthMiddleware(authService); + + // POST /api/v1/invoices/:id/documents/export - kick off async ZIP generation + router.post("/:id/documents/export", authMiddleware, controller.requestExport); + + // GET /api/v1/invoices/:id/documents/export/:jobId - poll job status / get the signed download URL + router.get("/:id/documents/export/:jobId", authMiddleware, controller.getExportStatus); + + // GET /api/v1/invoices/:id/documents/export/:jobId/download?token=... - stream the ZIP + router.get("/:id/documents/export/:jobId/download", controller.downloadExport); + + return router; +} diff --git a/src/routes/investor.routes.ts b/src/routes/investor.routes.ts index 87962385..0a76623c 100644 --- a/src/routes/investor.routes.ts +++ b/src/routes/investor.routes.ts @@ -5,7 +5,6 @@ import type { InvestorAcknowledgementService } from "../services/investor-acknow import type { AuthenticatedRequest } from "../types/auth"; import { ServiceError } from "../utils/service-error"; import { HttpError, PublicAppError } from "../utils/http-error"; -import { createInvestorRiskProfileController } from "@/controllers/investor-risk-profile.controller"; import type { InvestorRiskProfileService } from "@/services/investor-risk-profile.service"; export interface InvestorRouterDependencies { @@ -72,8 +71,6 @@ export function createInvestorRouter({ // ---- Risk Profile Management (issue #595) ---- if (riskProfileService) { - const riskProfileController = createInvestorRiskProfileController(riskProfileService); - router.post( "/risk-profile", auth, diff --git a/src/routes/issuer-analytics.routes.ts b/src/routes/issuer-analytics.routes.ts new file mode 100644 index 00000000..dbb97882 --- /dev/null +++ b/src/routes/issuer-analytics.routes.ts @@ -0,0 +1,20 @@ +import { Router } from "express"; +import { IssuerAnalyticsController } from "../controllers/issuer-analytics.controller"; +import type { IssuerAnalyticsService } from "../services/issuer-analytics.service"; +import { authenticateJWT } from "../middleware/auth.middleware"; + +export interface IssuerAnalyticsRouterDependencies { + issuerAnalyticsService: IssuerAnalyticsService; +} + +export function createIssuerAnalyticsRouter({ + issuerAnalyticsService, +}: IssuerAnalyticsRouterDependencies): Router { + const router = Router(); + const controller = new IssuerAnalyticsController(issuerAnalyticsService); + + // GET /api/v1/issuers/:wallet/analytics?range=30d|90d|all + router.get("/:wallet/analytics", authenticateJWT, controller.getAnalytics); + + return router; +} diff --git a/src/services/auto-invest-rule-engine.service.ts b/src/services/auto-invest-rule-engine.service.ts new file mode 100644 index 00000000..9040bf56 --- /dev/null +++ b/src/services/auto-invest-rule-engine.service.ts @@ -0,0 +1,150 @@ +import { DataSource, In } from "typeorm"; +import { Decimal } from "decimal.js"; +import { Invoice } from "../models/Invoice.model"; +import { AutoInvestRule } from "../models/AutoInvestRule.model"; +import { AutoInvestExecution } from "../models/AutoInvestExecution.model"; +import { Investment } from "../models/Investment.model"; +import { User } from "../models/User.model"; +import { AutoInvestExecutionStatus, RiskTier, InvestmentStatus } from "../types/enums"; +import { ServiceError } from "../utils/service-error"; +import type { AppLogger } from "../observability/logger"; +import type { InvestmentService } from "./investment.service"; +import type { AccountBalanceChecker } from "./stellar/account-balance.service"; + +/** `Invoice.riskScore` (0-100, lower is safer) banded into the tiers a rule can require. */ +function riskTierFor(riskScore: string | null): RiskTier | null { + if (riskScore === null) return null; + const score = new Decimal(riskScore); + if (score.lte(30)) return RiskTier.LOW; + if (score.lte(70)) return RiskTier.MEDIUM; + return RiskTier.HIGH; +} + +function matches(rule: AutoInvestRule, invoice: Invoice): boolean { + if (rule.category !== null && rule.category !== invoice.category) return false; + if (new Decimal(invoice.discountRate).lt(rule.minYield)) return false; + if (new Decimal(invoice.amount).gt(rule.maxFaceValue)) return false; + if (rule.riskTier !== null && rule.riskTier !== riskTierFor(invoice.riskScore)) return false; + return true; +} + +export interface AutoInvestRuleEngineDependencies { + dataSource: DataSource; + investmentService: InvestmentService; + balanceChecker: AccountBalanceChecker; + logger: AppLogger; +} + +/** + * Evaluates every enabled `AutoInvestRule` against an invoice that just went + * PUBLISHED (issue #596), investing on a matching rule's behalf unless the + * investor's wallet balance is below that rule's `minWalletBalance`. + * + * Each matching rule executes independently -- two rules belonging to the + * same investor (or different investors) that both match this invoice each + * get their own `createInvestment` call and their own `AutoInvestExecution` + * row, so one failing (e.g. the invoice became fully funded partway + * through) never prevents the other from running. + */ +export class AutoInvestRuleEngineService { + constructor(private readonly dependencies: AutoInvestRuleEngineDependencies) {} + + async evaluateInvoice(invoice: Invoice): Promise { + const rules = await this.dependencies.dataSource + .getRepository(AutoInvestRule) + .find({ where: { enabled: true } }); + + const matching = rules.filter((rule) => matches(rule, invoice)); + + for (const rule of matching) { + await this.executeRule(rule, invoice); + } + } + + private async executeRule(rule: AutoInvestRule, invoice: Invoice): Promise { + const existingInvestments = await this.dependencies.dataSource + .getRepository(Investment) + .find({ where: { invoiceId: invoice.id, status: In([InvestmentStatus.CONFIRMED, InvestmentStatus.SETTLED]) } }); + + const investedAmount = existingInvestments.reduce( + (sum, inv) => sum.plus(new Decimal(inv.investmentAmount)), + new Decimal(0), + ); + + const availableCapacity = new Decimal(invoice.amount).minus(investedAmount); + const amount = Decimal.min(new Decimal(rule.maxPerInvoice), availableCapacity); + + try { + const investor = await this.dependencies.dataSource.getRepository(User).findOneBy({ id: rule.investorId }); + if (!investor) { + await this.recordExecution(rule, invoice, amount, AutoInvestExecutionStatus.FAILED, "Investor not found"); + return; + } + + const balance = await this.dependencies.balanceChecker.getBalance(investor.stellarAddress); + if (balance.lt(rule.minWalletBalance)) { + await this.recordExecution( + rule, + invoice, + amount, + AutoInvestExecutionStatus.SKIPPED_INSUFFICIENT_BALANCE, + `Wallet balance ${balance.toString()} is below the rule's minimum ${rule.minWalletBalance}`, + ); + return; + } + + const investment = await this.dependencies.investmentService.createInvestment({ + invoiceId: invoice.id, + investorId: rule.investorId, + investmentAmount: amount.toFixed(4), + investorWallet: investor.stellarAddress, + }); + + await this.recordExecution(rule, invoice, amount, AutoInvestExecutionStatus.EXECUTED, null, investment.id); + + this.dependencies.logger.info("AutoInvestExecuted", { + rule_id: rule.id, + invoice_id: invoice.id, + investment_id: investment.id, + investor_id: rule.investorId, + amount: amount.toFixed(4), + }); + } catch (error) { + const message = + error instanceof ServiceError || error instanceof Error ? error.message : "Unknown error"; + await this.recordExecution(rule, invoice, amount, AutoInvestExecutionStatus.FAILED, message); + + this.dependencies.logger.warn("Auto-invest execution failed", { + rule_id: rule.id, + invoice_id: invoice.id, + error_reason: message, + }); + } + } + + private async recordExecution( + rule: AutoInvestRule, + invoice: Invoice, + amount: Decimal, + status: AutoInvestExecutionStatus, + reason: string | null, + investmentId: string | null = null, + ): Promise { + const execution = this.dependencies.dataSource.getRepository(AutoInvestExecution).create({ + ruleId: rule.id, + investorId: rule.investorId, + invoiceId: invoice.id, + investmentId, + amount: amount.toFixed(4), + status, + reason, + }); + await this.dependencies.dataSource.getRepository(AutoInvestExecution).save(execution); + } +} + +export function createAutoInvestRuleEngineService( + dependencies: AutoInvestRuleEngineDependencies, +): AutoInvestRuleEngineService { + return new AutoInvestRuleEngineService(dependencies); +} diff --git a/src/services/auto-invest-rules.service.ts b/src/services/auto-invest-rules.service.ts new file mode 100644 index 00000000..02029f52 --- /dev/null +++ b/src/services/auto-invest-rules.service.ts @@ -0,0 +1,149 @@ +import { DataSource } from "typeorm"; +import { Decimal } from "decimal.js"; +import { AutoInvestRule } from "../models/AutoInvestRule.model"; +import { RiskTier } from "../types/enums"; +import { ServiceError } from "../utils/service-error"; + +export interface CreateAutoInvestRuleInput { + investorId: string; + category?: string | null; + minYield: string; + maxFaceValue: string; + riskTier?: RiskTier | null; + maxPerInvoice: string; + minWalletBalance?: string; +} + +export interface UpdateAutoInvestRuleInput { + category?: string | null; + minYield?: string; + maxFaceValue?: string; + riskTier?: RiskTier | null; + maxPerInvoice?: string; + minWalletBalance?: string; + enabled?: boolean; +} + +/** + * CRUD for investor-configured `AutoInvestRule`s (issue #596). Evaluating + * and executing rules against a published invoice is + * `AutoInvestRuleEngineService`'s job, not this one's -- this is only the + * management surface a rule's owner uses to create, list and remove them. + */ +export class AutoInvestRulesService { + constructor(private readonly dataSource: DataSource) {} + + async createRule(input: CreateAutoInvestRuleInput): Promise { + const minYield = new Decimal(input.minYield); + const maxFaceValue = new Decimal(input.maxFaceValue); + const maxPerInvoice = new Decimal(input.maxPerInvoice); + const minWalletBalance = new Decimal(input.minWalletBalance ?? "0"); + + if (minYield.isNegative()) { + throw new ServiceError("INVALID_MIN_YIELD", "minYield must not be negative"); + } + if (maxFaceValue.isNegative() || maxFaceValue.isZero()) { + throw new ServiceError("INVALID_MAX_FACE_VALUE", "maxFaceValue must be greater than zero"); + } + if (maxPerInvoice.isNegative() || maxPerInvoice.isZero()) { + throw new ServiceError("INVALID_MAX_PER_INVOICE", "maxPerInvoice must be greater than zero"); + } + if (minWalletBalance.isNegative()) { + throw new ServiceError("INVALID_MIN_WALLET_BALANCE", "minWalletBalance must not be negative"); + } + + const repo = this.dataSource.getRepository(AutoInvestRule); + const rule = repo.create({ + investorId: input.investorId, + category: input.category ?? null, + minYield: minYield.toFixed(2), + maxFaceValue: maxFaceValue.toFixed(4), + riskTier: input.riskTier ?? null, + maxPerInvoice: maxPerInvoice.toFixed(4), + minWalletBalance: minWalletBalance.toFixed(4), + enabled: true, + }); + return repo.save(rule); + } + + async listRules(investorId: string): Promise { + return this.dataSource.getRepository(AutoInvestRule).find({ where: { investorId } }); + } + + async updateRule( + investorId: string, + ruleId: string, + input: UpdateAutoInvestRuleInput, + ): Promise { + const repo = this.dataSource.getRepository(AutoInvestRule); + const rule = await repo.findOneBy({ id: ruleId }); + if (!rule) { + throw new ServiceError("RULE_NOT_FOUND", "Auto-invest rule not found", 404); + } + if (rule.investorId !== investorId) { + throw new ServiceError("RULE_FORBIDDEN", "You can only update your own auto-invest rules", 403); + } + + if (input.minYield !== undefined) { + const minYield = new Decimal(input.minYield); + if (minYield.isNegative()) { + throw new ServiceError("INVALID_MIN_YIELD", "minYield must not be negative"); + } + rule.minYield = minYield.toFixed(2); + } + + if (input.maxFaceValue !== undefined) { + const maxFaceValue = new Decimal(input.maxFaceValue); + if (maxFaceValue.isNegative() || maxFaceValue.isZero()) { + throw new ServiceError("INVALID_MAX_FACE_VALUE", "maxFaceValue must be greater than zero"); + } + rule.maxFaceValue = maxFaceValue.toFixed(4); + } + + if (input.maxPerInvoice !== undefined) { + const maxPerInvoice = new Decimal(input.maxPerInvoice); + if (maxPerInvoice.isNegative() || maxPerInvoice.isZero()) { + throw new ServiceError("INVALID_MAX_PER_INVOICE", "maxPerInvoice must be greater than zero"); + } + rule.maxPerInvoice = maxPerInvoice.toFixed(4); + } + + if (input.minWalletBalance !== undefined) { + const minWalletBalance = new Decimal(input.minWalletBalance); + if (minWalletBalance.isNegative()) { + throw new ServiceError("INVALID_MIN_WALLET_BALANCE", "minWalletBalance must not be negative"); + } + rule.minWalletBalance = minWalletBalance.toFixed(4); + } + + if (input.category !== undefined) { + rule.category = input.category ?? null; + } + + if (input.riskTier !== undefined) { + rule.riskTier = input.riskTier ?? null; + } + + if (input.enabled !== undefined) { + rule.enabled = input.enabled; + } + + return repo.save(rule); + } + + async deleteRule(investorId: string, ruleId: string): Promise { + const repo = this.dataSource.getRepository(AutoInvestRule); + const rule = await repo.findOneBy({ id: ruleId }); + if (!rule) { + throw new ServiceError("RULE_NOT_FOUND", "Auto-invest rule not found", 404); + } + if (rule.investorId !== investorId) { + throw new ServiceError("RULE_FORBIDDEN", "You can only delete your own auto-invest rules", 403); + } + await repo.remove(rule); + } +} + +export function createAutoInvestRulesService(dataSource: DataSource): AutoInvestRulesService { + return new AutoInvestRulesService(dataSource); +} diff --git a/src/services/document-export.service.ts b/src/services/document-export.service.ts new file mode 100644 index 00000000..347fe9f9 --- /dev/null +++ b/src/services/document-export.service.ts @@ -0,0 +1,235 @@ +import { DataSource } from "typeorm"; +import { createWriteStream, promises as fs } from "fs"; +import path from "path"; +import { ZipArchive } from "archiver"; +import { Invoice } from "../models/Invoice.model"; +import { InvoiceDocument } from "../models/InvoiceDocument.model"; +import { Investment } from "../models/Investment.model"; +import { User } from "../models/User.model"; +import { KYCStatus } from "../types/enums"; +import { ServiceError } from "../utils/service-error"; +import type { IPFSService } from "./ipfs.service"; +import { createSignedDownloadToken, verifySignedDownloadToken } from "../lib/signed-url"; + +export type ExportJobStatus = "processing" | "completed" | "failed"; + +export interface ExportJob { + id: string; + invoiceId: string; + status: ExportJobStatus; + zipPath?: string; + downloadToken?: string; + downloadTokenExpiresAt?: number; + error?: string; + createdAt: number; + completedAt?: number; +} + +export interface DocumentExportServiceDependencies { + dataSource: DataSource; + ipfsService: IPFSService; + tempDir: string; + signingSecret: string; + urlTtlMinutes: number; +} + +/** Characters outside `[A-Za-z0-9._-]` are not safe in a ZIP entry/file name across platforms. */ +function sanitizeForFilename(value: string): string { + return value.replace(/[^A-Za-z0-9._-]/g, "_"); +} + +function extensionFor(filename: string, mimeType: string): string { + const existing = path.extname(filename); + if (existing) return existing; + const byMime: Record = { + "application/pdf": ".pdf", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + }; + return byMime[mimeType] ?? ""; +} + +/** + * Bulk document export (issue #589): fetches every document pinned for an + * invoice, packages them into a ZIP, and hands back a signed download URL. + * + * Generation runs as a background job (`requestExport` returns immediately + * with a job id; `getJobStatus` polls it) because fetching N documents from + * an IPFS gateway is network-bound and the issue's own acceptance criteria + * calls out "Large document sets (10+ files) handled without timeout" -- + * exactly the case a synchronous request handler would be most likely to + * blow a client or proxy timeout on. + */ +export class DocumentExportService { + private readonly jobs = new Map(); + + constructor(private readonly dependencies: DocumentExportServiceDependencies) {} + + /** Starts generating the ZIP in the background and returns a job id immediately. */ + async requestExport(invoiceId: string, requestingUserId: string): Promise<{ jobId: string }> { + const invoice = await this.dependencies.dataSource + .getRepository(Invoice) + .findOneBy({ id: invoiceId }); + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } + + await this.assertEligible(invoice, requestingUserId); + + const jobId = `exp_${invoiceId}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + const job: ExportJob = { + id: jobId, + invoiceId, + status: "processing", + createdAt: Date.now(), + }; + this.jobs.set(jobId, job); + + // Fire-and-forget: the caller polls `getJobStatus`, it does not await this. + void this.runExport(job, invoice.invoiceNumber); + + return { jobId }; + } + + getJobStatus(jobId: string): ExportJob | undefined { + return this.jobs.get(jobId); + } + + /** + * Streams the ZIP for a completed job, after verifying `token` against + * the signed URL issued when the job completed. Returns the file path on + * success; throws on an unknown job, a job that is not done, or an + * invalid/expired token. + */ + getDownloadPath(jobId: string, token: string): string { + const job = this.jobs.get(jobId); + if (!job || job.status !== "completed" || !job.zipPath) { + throw new ServiceError("export_not_ready", "Export job is not ready for download", 404); + } + + const valid = verifySignedDownloadToken( + this.dependencies.signingSecret, + jobId, + token, + ); + if (!valid) { + throw new ServiceError("invalid_download_token", "Download link is invalid or has expired", 403); + } + + return job.zipPath; + } + + private async assertEligible(invoice: Invoice, requestingUserId: string): Promise { + const user = await this.dependencies.dataSource + .getRepository(User) + .findOneBy({ id: requestingUserId }); + if (!user) { + throw new ServiceError("user_not_found", "User not found", 404); + } + if (user.kycStatus !== KYCStatus.APPROVED) { + throw new ServiceError("kyc_not_approved", "KYC approval required to export documents", 403); + } + + const isIssuer = invoice.sellerId === requestingUserId; + if (isIssuer) return; + + const hasInvested = await this.dependencies.dataSource.getRepository(Investment).exists({ + where: { invoiceId: invoice.id, investorId: requestingUserId }, + }); + if (!hasInvested) { + throw new ServiceError( + "export_forbidden", + "Only the issuer or an investor in this invoice may export its documents", + 403, + ); + } + } + + private async runExport(job: ExportJob, invoiceNumber: string): Promise { + try { + const documents = await this.dependencies.dataSource + .getRepository(InvoiceDocument) + .find({ where: { invoiceId: job.invoiceId }, order: { createdAt: "ASC" } }); + + if (documents.length === 0) { + throw new ServiceError("no_documents", "This invoice has no documents to export", 404); + } + + await fs.mkdir(this.dependencies.tempDir, { recursive: true }); + const zipPath = path.join(this.dependencies.tempDir, `${job.id}.zip`); + + await this.writeZip(zipPath, job.invoiceId, invoiceNumber, documents); + + const { token, expiresAt } = createSignedDownloadToken( + this.dependencies.signingSecret, + job.id, + this.dependencies.urlTtlMinutes, + ); + + job.status = "completed"; + job.zipPath = zipPath; + job.downloadToken = token; + job.downloadTokenExpiresAt = expiresAt; + job.completedAt = Date.now(); + } catch (error) { + job.status = "failed"; + job.error = error instanceof Error ? error.message : "Unknown error"; + job.completedAt = Date.now(); + } + } + + private async writeZip( + zipPath: string, + invoiceId: string, + invoiceNumber: string, + documents: InvoiceDocument[], + ): Promise { + const output = createWriteStream(zipPath); + const archive = new ZipArchive({ zlib: { level: 9 } }); + + const done = new Promise((resolve, reject) => { + output.on("close", resolve); + archive.on("error", reject); + }); + archive.pipe(output); + + const invoiceLabel = sanitizeForFilename(invoiceNumber || invoiceId); + const seenNames = new Map(); + + for (const doc of documents) { + const signedUrl = await this.dependencies.ipfsService.generateSignedUrl(doc.cid); + const fetchRes = await fetch(signedUrl); + if (!fetchRes.ok) { + throw new ServiceError("IPFS_FETCH_FAILED", `Failed to fetch document ${doc.cid}: ${fetchRes.status}`); + } + const fileBuffer = Buffer.from(await fetchRes.arrayBuffer()); + + const dateStamp = doc.createdAt.toISOString().slice(0, 10); + const docType = sanitizeForFilename(doc.docType); + const extension = extensionFor(doc.filename, doc.mimeType); + let entryName = `${invoiceLabel}_${docType}_${dateStamp}${extension}`; + + // Structured naming can collide when an invoice has two documents of + // the same type uploaded the same day; disambiguate rather than let + // archiver silently overwrite one entry with the other. + const count = seenNames.get(entryName) ?? 0; + seenNames.set(entryName, count + 1); + if (count > 0) { + entryName = `${invoiceLabel}_${docType}_${dateStamp}_${count + 1}${extension}`; + } + + archive.append(fileBuffer, { name: entryName }); + } + + await archive.finalize(); + await done; + } +} + +export function createDocumentExportService( + dependencies: DocumentExportServiceDependencies, +): DocumentExportService { + return new DocumentExportService(dependencies); +} diff --git a/src/services/investor-accreditation.service.ts b/src/services/investor-accreditation.service.ts index 6d452df8..a10435e8 100644 --- a/src/services/investor-accreditation.service.ts +++ b/src/services/investor-accreditation.service.ts @@ -5,7 +5,6 @@ import { ACCREDITATION_LIMITS, } from "../models/InvestorAccreditation.model"; import { Invoice } from "../models/Invoice.model"; -import { ServiceError } from "../utils/service-error"; import Decimal from "decimal.js"; export class InvestorAccreditationService { diff --git a/src/services/invoice-refund.service.ts b/src/services/invoice-refund.service.ts index 3899cec4..bc38a20d 100644 --- a/src/services/invoice-refund.service.ts +++ b/src/services/invoice-refund.service.ts @@ -81,7 +81,7 @@ export class InvoiceRefundService { } } - async getRefundableInvoices(walletAddress: string): Promise { + async getRefundableInvoices(_walletAddress: string): Promise { const now = new Date(); return this.invoiceRepo.find({ diff --git a/src/services/issuer-analytics.service.ts b/src/services/issuer-analytics.service.ts new file mode 100644 index 00000000..119c1846 --- /dev/null +++ b/src/services/issuer-analytics.service.ts @@ -0,0 +1,257 @@ +import { DataSource } from "typeorm"; +import { Decimal } from "decimal.js"; +import { Invoice } from "../models/Invoice.model"; +import { Investment } from "../models/Investment.model"; +import { User } from "../models/User.model"; +import { InvoiceStatus, InvestmentStatus } from "../types/enums"; +import { ServiceError } from "../utils/service-error"; +import { TtlCache } from "../lib/ttl-cache"; + +export type AnalyticsRange = "30d" | "90d" | "all"; + +const VALID_RANGES: readonly AnalyticsRange[] = ["30d", "90d", "all"]; + +export function isValidAnalyticsRange(value: string): value is AnalyticsRange { + return (VALID_RANGES as readonly string[]).includes(value); +} + +const CAPITAL_RAISED_STATUSES = [InvestmentStatus.CONFIRMED, InvestmentStatus.SETTLED]; + +export interface InvestorCountPoint { + /** ISO date (daily) or ISO week/month key, depending on granularity. */ + period: string; + investorCount: number; +} + +export interface IssuerAnalytics { + issuerId: string; + range: AnalyticsRange; + granularity: "day" | "week" | "month"; + totalCapitalRaised: string; + /** Average calendar days from an invoice's creation to it becoming fully + * funded; `null` when no invoice in range has been funded yet. */ + avgDaysToFullFunding: number | null; + /** 0-100; `null` when no invoice in range has settled yet. */ + onTimeRepaymentRate: number | null; + /** 0-100, currently just the on-time repayment rate (0 when nothing has + * settled yet -- a new issuer has no track record, not a bad one). */ + reputationScore: number; + investorCountTrend: InvestorCountPoint[]; + generatedAt: string; +} + +const CACHE_TTL_SECONDS = 5 * 60; + +function cacheKey(issuerId: string, range: AnalyticsRange): string { + return `${issuerId}:${range}`; +} + +function rangeStart(range: AnalyticsRange, now: Date): Date | null { + if (range === "all") return null; + const days = range === "30d" ? 30 : 90; + return new Date(now.getTime() - days * 24 * 60 * 60 * 1000); +} + +function granularityFor(range: AnalyticsRange): "day" | "week" | "month" { + if (range === "30d") return "day"; + if (range === "90d") return "week"; + return "month"; +} + +/** Monday-anchored ISO week key, e.g. "2026-W07". */ +function isoWeekKey(date: Date): string { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const dayNum = (d.getUTCDay() + 6) % 7; // Monday = 0 + d.setUTCDate(d.getUTCDate() - dayNum + 3); // nearest Thursday + const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4)); + const week = 1 + Math.round(((d.getTime() - firstThursday.getTime()) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +function periodKey(date: Date, granularity: "day" | "week" | "month"): string { + if (granularity === "day") return date.toISOString().slice(0, 10); + if (granularity === "month") return date.toISOString().slice(0, 7); + return isoWeekKey(date); +} + +function daysBetween(from: Date, to: Date): number { + return (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000); +} + +/** + * Issuer analytics (issue #587): funding speed, investor-count trend, + * on-time repayment rate and a reputation score, aggregated across one + * issuer's invoices and the investments/settlements against them. + * + * `avgDaysToFullFunding` and `onTimeRepaymentRate` are derived from the + * timestamps already on `Invoice`/`Investment` rather than from a dedicated + * "funded_at"/"settled_at" column, which this schema doesn't have: + * + * - "Became fully funded at" is approximated as the `createdAt` of the last + * CONFIRMED/SETTLED investment on an invoice that is FUNDED or SETTLED -- + * accurate, because `InvestmentService.createInvestment` flips the + * invoice to FUNDED in the same transaction as saving the investment that + * completed it. + * - "Settled at" is approximated as the invoice's `updatedAt` while its + * `status` is SETTLED. This is the one soft spot: `updatedAt` also moves + * on any other field edit to a settled invoice (there is currently none in + * this codebase, but nothing enforces that). A dedicated `settledAt` + * column would remove the ambiguity; out of scope here. + */ +export class IssuerAnalyticsService { + private readonly cache = new TtlCache({ ttlSeconds: CACHE_TTL_SECONDS }); + + constructor(private readonly dataSource: DataSource) {} + + /** Resolves `wallet` (a Stellar address) to the issuer's internal id, then delegates to `getAnalytics`. */ + async getAnalyticsByWallet( + wallet: string, + range: AnalyticsRange, + now: Date = new Date(), + ): Promise { + const issuer = await this.dataSource.getRepository(User).findOneBy({ stellarAddress: wallet }); + if (!issuer) { + throw new ServiceError("ISSUER_NOT_FOUND", "No issuer found for that wallet", 404); + } + return this.getAnalytics(issuer.id, range, now); + } + + async getAnalytics( + issuerId: string, + range: AnalyticsRange, + now: Date = new Date(), + ): Promise { + if (!issuerId) { + throw new ServiceError("MISSING_ISSUER", "issuer wallet/id is required"); + } + + const key = cacheKey(issuerId, range); + const cached = await this.cache.get(key); + if (cached) return cached; + + const value = await this.computeAnalytics(issuerId, range, now); + await this.cache.set(key, value); + return value; + } + + /** Call after any settlement or invoice state change for this issuer, so + * the next read recomputes rather than serving a stale cached figure. */ + invalidate(_issuerId: string): void { + this.cache.clear(); + } + + private async computeAnalytics( + issuerId: string, + range: AnalyticsRange, + now: Date, + ): Promise { + const invoiceRepo = this.dataSource.getRepository(Invoice); + const investmentRepo = this.dataSource.getRepository(Investment); + + const start = rangeStart(range, now); + const granularity = granularityFor(range); + + const invoices = start + ? await invoiceRepo + .createQueryBuilder("invoice") + .where("invoice.seller_id = :issuerId", { issuerId }) + .andWhere("invoice.created_at >= :start", { start }) + .getMany() + : await invoiceRepo.find({ where: { sellerId: issuerId } }); + + if (invoices.length === 0) { + return { + issuerId, + range, + granularity, + totalCapitalRaised: "0.0000", + avgDaysToFullFunding: null, + onTimeRepaymentRate: null, + reputationScore: 0, + investorCountTrend: [], + generatedAt: now.toISOString(), + }; + } + + const invoiceIds = invoices.map((invoice) => invoice.id); + const invoiceById = new Map(invoices.map((invoice) => [invoice.id, invoice])); + + const investments = await investmentRepo + .createQueryBuilder("investment") + .where("investment.invoice_id IN (:...invoiceIds)", { invoiceIds }) + .getMany(); + + // ---- total capital raised ---- + let totalCapitalRaised = new Decimal(0); + for (const investment of investments) { + if (CAPITAL_RAISED_STATUSES.includes(investment.status)) { + totalCapitalRaised = totalCapitalRaised.plus(new Decimal(investment.investmentAmount)); + } + } + + // ---- avg days to full funding ---- + const fundingDurations: number[] = []; + for (const invoice of invoices) { + if (invoice.status !== InvoiceStatus.FUNDED && invoice.status !== InvoiceStatus.SETTLED) { + continue; + } + const backing = investments.filter( + (investment) => + investment.invoiceId === invoice.id && + CAPITAL_RAISED_STATUSES.includes(investment.status), + ); + if (backing.length === 0) continue; + + const fundedAt = invoice.updatedAt; + fundingDurations.push(daysBetween(invoice.createdAt, fundedAt)); + } + const avgDaysToFullFunding = + fundingDurations.length > 0 + ? Number( + (fundingDurations.reduce((sum, d) => sum + d, 0) / fundingDurations.length).toFixed(2), + ) + : null; + + // ---- on-time repayment rate ---- + const settledInvoices = invoices.filter((invoice) => invoice.status === InvoiceStatus.SETTLED); + let onTimeRepaymentRate: number | null = null; + if (settledInvoices.length > 0) { + const onTime = settledInvoices.filter((invoice) => { + const dueDate = new Date(invoice.dueDate); + return invoice.updatedAt.getTime() <= dueDate.getTime() + 24 * 60 * 60 * 1000 - 1; + }).length; + onTimeRepaymentRate = Number(((onTime / settledInvoices.length) * 100).toFixed(2)); + } + + const reputationScore = onTimeRepaymentRate ?? 0; + + // ---- investor count trend ---- + const investorsByPeriod = new Map>(); + for (const investment of investments) { + if (!invoiceById.has(investment.invoiceId)) continue; // defensive; invoiceIds is exact + const key = periodKey(investment.createdAt, granularity); + const set = investorsByPeriod.get(key) ?? new Set(); + set.add(investment.investorId); + investorsByPeriod.set(key, set); + } + const investorCountTrend = Array.from(investorsByPeriod.entries()) + .map(([period, investorIds]) => ({ period, investorCount: investorIds.size })) + .sort((a, b) => (a.period < b.period ? -1 : a.period > b.period ? 1 : 0)); + + return { + issuerId, + range, + granularity, + totalCapitalRaised: totalCapitalRaised.toFixed(4), + avgDaysToFullFunding, + onTimeRepaymentRate, + reputationScore, + investorCountTrend, + generatedAt: now.toISOString(), + }; + } +} + +export function createIssuerAnalyticsService(dataSource: DataSource): IssuerAnalyticsService { + return new IssuerAnalyticsService(dataSource); +} diff --git a/src/services/issuer-reputation.service.ts b/src/services/issuer-reputation.service.ts index a1d5595e..5695d9ef 100644 --- a/src/services/issuer-reputation.service.ts +++ b/src/services/issuer-reputation.service.ts @@ -1,7 +1,6 @@ import { DataSource, EntityManager } from "typeorm"; import { IssuerReputation, IssuerReputationHistory, ReputationTier } from "../models/IssuerReputation.model"; import { Invoice } from "../models/Invoice.model"; -import { ServiceError } from "../utils/service-error"; import { logger } from "../observability/logger"; import Decimal from "decimal.js"; @@ -39,7 +38,7 @@ const TIER_THRESHOLDS = { POOR: 0, }; -const TIER_LABELS: Record = { +const _TIER_LABELS: Record = { [TIER_THRESHOLDS.EXCELLENT]: "excellent", [TIER_THRESHOLDS.GOOD]: "good", [TIER_THRESHOLDS.FAIR]: "fair", @@ -145,8 +144,6 @@ export class IssuerReputationService { let totalDaysEarly = 0; let lastSettledAt: Date | null = null; - const now = new Date(); - for (const invoice of invoices) { const dueDate = new Date(invoice.dueDate); const settledDate = invoice.updatedAt; // Using updatedAt as approximation of settlement date diff --git a/src/services/secondary-market-price.service.ts b/src/services/secondary-market-price.service.ts index e65718b1..a728b735 100644 --- a/src/services/secondary-market-price.service.ts +++ b/src/services/secondary-market-price.service.ts @@ -48,7 +48,7 @@ export class SecondaryMarketPriceService { const history = this.aggregatePrices(prices); const currentListings = await this.listingRepo.find({ - where: { invoiceId, soldAt: null as any }, + where: { invoiceId, soldAt: null as unknown as Date }, }); let currentAvg = new Decimal(0); diff --git a/src/services/stellar/account-balance.service.ts b/src/services/stellar/account-balance.service.ts new file mode 100644 index 00000000..a41385bf --- /dev/null +++ b/src/services/stellar/account-balance.service.ts @@ -0,0 +1,27 @@ +import { Decimal } from "decimal.js"; + +export interface AccountBalanceChecker { + getBalance(stellarAddress: string): Promise; +} + +/** + * Fetches the XLM balance for a Stellar account via the Horizon HTTP API. + * Used by the auto-invest rule engine to gate investments on minimum wallet balance. + */ +export class HorizonAccountBalanceService implements AccountBalanceChecker { + constructor(private readonly horizonUrl: string) {} + + async getBalance(stellarAddress: string): Promise { + const url = `${this.horizonUrl.replace(/\/$/, "")}/accounts/${encodeURIComponent(stellarAddress)}`; + const response = await fetch(url); + if (response.status === 404) { + return new Decimal(0); + } + if (!response.ok) { + throw new Error(`Horizon account lookup failed: ${response.status}`); + } + const data = (await response.json()) as { balances: Array<{ asset_type: string; balance: string }> }; + const native = data.balances.find((b) => b.asset_type === "native"); + return native ? new Decimal(native.balance) : new Decimal(0); + } +} diff --git a/src/types/enums.ts b/src/types/enums.ts index 97282f36..3ab958ea 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -97,3 +97,17 @@ export enum ListingStatus { CANCELLED = "cancelled", EXPIRED = "expired", } + +/** Risk tier for auto-invest rule criteria matching (issue #596). */ +export enum RiskTier { + LOW = "LOW", + MEDIUM = "MEDIUM", + HIGH = "HIGH", +} + +/** Execution outcome for an auto-invest rule run against an invoice (issue #596). */ +export enum AutoInvestExecutionStatus { + EXECUTED = "EXECUTED", + SKIPPED_INSUFFICIENT_BALANCE = "SKIPPED_INSUFFICIENT_BALANCE", + FAILED = "FAILED", +} diff --git a/src/types/network.ts b/src/types/network.ts new file mode 100644 index 00000000..6bdffb3e --- /dev/null +++ b/src/types/network.ts @@ -0,0 +1,19 @@ +import type { Request } from "express"; + +/** Networks the `X-Stellar-Network` header may select (issue #588). */ +export type StellarNetworkName = "mainnet" | "testnet"; + +export const STELLAR_NETWORK_NAMES: readonly StellarNetworkName[] = ["mainnet", "testnet"]; + +export interface StellarNetworkEndpoints { + network: StellarNetworkName; + horizonUrl: string; + networkPassphrase: string; + /** Soroban contract id deployed on this network, if one is configured. */ + contractId: string | null; +} + +/** Populated by `createNetworkContextMiddleware` for every downstream handler/service call in the request. */ +export interface NetworkAwareRequest extends Request { + stellarNetwork?: StellarNetworkEndpoints; +}