diff --git a/server/package-lock.json b/server/package-lock.json index 4d20a13..d9d6147 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,15 +11,85 @@ "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cookie-parser": "^1.4.6", + "exceljs": "^4.4.0", "express": "^4.19.2", "express-rate-limit": "^7.4.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "pdfkit": "^0.19.1", "pg": "^8.12.0", "zod": "^3.23.8" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -39,18 +109,156 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", @@ -75,6 +283,67 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "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", @@ -87,6 +356,23 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -136,6 +422,56 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -175,6 +511,12 @@ "node": ">= 0.6" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -245,6 +587,51 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "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": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -273,6 +660,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -287,6 +680,15 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -311,6 +713,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -356,6 +767,40 @@ "node": ">= 0.6" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -417,6 +862,25 @@ "express": ">= 4.11" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -435,6 +899,23 @@ "node": ">= 0.8" } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -453,6 +934,34 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -499,6 +1008,27 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -511,6 +1041,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -576,6 +1112,43 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -597,6 +1170,12 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -625,6 +1204,18 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -646,6 +1237,82 @@ "safe-buffer": "^5.0.1" } }, + "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/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -658,12 +1325,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -682,12 +1368,30 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -757,6 +1461,18 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -812,6 +1528,15 @@ "node": ">= 0.6" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -854,6 +1579,21 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -863,12 +1603,35 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -958,6 +1721,14 @@ "split2": "^4.1.0" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1077,6 +1848,55 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1103,6 +1923,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1160,6 +1992,12 @@ "node": ">= 0.8.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1279,6 +2117,51 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1288,6 +2171,21 @@ "node": ">=0.6" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1307,6 +2205,32 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1316,6 +2240,24 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1331,6 +2273,16 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1340,6 +2292,18 @@ "node": ">= 0.8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -1349,6 +2313,55 @@ "node": ">=0.4" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/server/package.json b/server/package.json index 92ab359..e92d86d 100644 --- a/server/package.json +++ b/server/package.json @@ -11,11 +11,13 @@ "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cookie-parser": "^1.4.6", + "exceljs": "^4.4.0", "express": "^4.19.2", "express-rate-limit": "^7.4.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "pdfkit": "^0.19.1", "pg": "^8.12.0", "zod": "^3.23.8" } diff --git a/server/src/billingDocuments.js b/server/src/billingDocuments.js new file mode 100644 index 0000000..9fdea38 --- /dev/null +++ b/server/src/billingDocuments.js @@ -0,0 +1,226 @@ +import PDFDocument from 'pdfkit'; +import ExcelJS from 'exceljs'; + +const RED = '#C1272D'; +const INK = '#1B1B1B'; +const GRAY = '#6E675F'; +const LIGHT = '#F4F1EC'; + +const text = (value) => String(value ?? ''); +const money = (value) => `${Number(value || 0).toFixed(2).replace('.', ',')} EUR`; +const dateFr = (value) => { + if (!value) return ''; + const raw = value instanceof Date ? value.toISOString().slice(0, 10) : String(value).slice(0, 10); + const [year, month, day] = raw.split('-'); + return `${day}/${month}/${year}`; +}; +const typeLabel = (value) => value === 'sluc_partner' ? 'Partenaire SLUC' : 'Non partenaire SLUC'; +const paymentLabel = (value) => ({ carte: 'Carte bleue', virement: 'Virement', cheque: 'Chèque' }[value] || ''); + +function collectPdf(draw, options = {}) { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ size: 'A4', margin: 48, bufferPages: true, ...options }); + const chunks = []; + doc.on('data', (chunk) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + draw(doc); + const range = doc.bufferedPageRange(); + for (let page = range.start; page < range.start + range.count; page += 1) { + doc.switchToPage(page); + const bottomMargin = doc.page.margins.bottom; + doc.page.margins.bottom = 0; + doc.fontSize(8).fillColor('#8A8279').text( + `Page ${page - range.start + 1} / ${range.count}`, + 48, + doc.page.height - 24, + { width: doc.page.width - 96, align: 'right', lineBreak: false } + ); + doc.page.margins.bottom = bottomMargin; + } + doc.end(); + }); +} + +function drawMultiline(doc, value, x, y, width, options = {}) { + doc.text(text(value), x, y, { width, lineGap: 2, ...options }); +} + +export function buildInvoicePdf(invoice) { + const issuer = invoice.issuer_snapshot || {}; + return collectPdf((doc) => { + doc.fillColor(INK).font('Helvetica-Bold').fontSize(21).text(text(issuer.association_name || 'Association'), 48, 48, { width: 310 }); + doc.fillColor(RED).fontSize(28).text('FACTURE', 365, 45, { width: 180, align: 'right' }); + doc.moveTo(48, 84).lineTo(547, 84).lineWidth(2).strokeColor(RED).stroke(); + + doc.font('Helvetica-Bold').fontSize(10).fillColor(GRAY).text('ÉMETTEUR', 48, 108); + doc.font('Helvetica').fontSize(10).fillColor(INK); + drawMultiline(doc, issuer.association_name, 48, 126, 215); + drawMultiline(doc, issuer.association_address, 48, 143, 215); + if (issuer.association_email) drawMultiline(doc, issuer.association_email, 48, 178, 215); + if (issuer.association_phone) drawMultiline(doc, issuer.association_phone, 48, 194, 215); + if (issuer.association_siret) doc.text(`SIRET : ${issuer.association_siret}`, 48, 210, { width: 215 }); + + doc.font('Helvetica-Bold').fontSize(10).fillColor(GRAY).text('DESTINATAIRE', 310, 108); + doc.font('Helvetica-Bold').fontSize(11).fillColor(INK).text(text(invoice.member_name), 310, 126, { width: 237 }); + doc.font('Helvetica').fontSize(10); + drawMultiline(doc, invoice.member_address, 310, 145, 237); + drawMultiline(doc, invoice.member_email, 310, 185, 237); + + doc.roundedRect(48, 240, 499, 58, 4).fill(LIGHT); + const meta = [ + ['N° de facture', invoice.invoice_number], + ['Date d’émission', dateFr(invoice.issued_at)], + ['Date d’échéance', dateFr(invoice.due_date)], + ]; + meta.forEach(([label, value], index) => { + const x = 62 + index * 164; + doc.font('Helvetica').fontSize(8).fillColor(GRAY).text(label.toUpperCase(), x, 253, { width: 145 }); + doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(text(value), x, 270, { width: 145 }); + }); + + const tableY = 332; + doc.rect(48, tableY, 499, 30).fill(INK); + doc.font('Helvetica-Bold').fontSize(9).fillColor('#FFFFFF'); + doc.text('DÉSIGNATION', 60, tableY + 10, { width: 275 }); + doc.text('HT', 350, tableY + 10, { width: 80, align: 'right' }); + doc.text('TVA', 445, tableY + 10, { width: 88, align: 'right' }); + doc.rect(48, tableY + 30, 499, 58).fill('#FAF8F5'); + doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(`Adhésion ${invoice.season}`, 60, tableY + 45, { width: 270 }); + doc.font('Helvetica').fontSize(9).fillColor(GRAY).text(typeLabel(invoice.billing_type), 60, tableY + 62, { width: 270 }); + doc.font('Helvetica').fontSize(10).fillColor(INK).text(money(invoice.amount_ht), 350, tableY + 50, { width: 80, align: 'right' }); + doc.text(`${Number(invoice.vat_rate).toFixed(2).replace('.', ',')} %`, 445, tableY + 50, { width: 88, align: 'right' }); + + const totalsY = 430; + const totals = [ + ['Total HT', invoice.amount_ht], + ['TVA', invoice.vat_amount], + ['Total TTC', invoice.amount_ttc], + ]; + totals.forEach(([label, value], index) => { + const y = totalsY + index * 27; + if (index === 2) doc.roundedRect(337, y - 7, 210, 28, 3).fill(RED); + doc.font('Helvetica-Bold').fontSize(index === 2 ? 11 : 10).fillColor(index === 2 ? '#FFFFFF' : INK) + .text(label, 350, y, { width: 90 }) + .text(money(value), 440, y, { width: 94, align: 'right' }); + }); + + let y = 540; + if (invoice.status === 'payee') { + doc.roundedRect(48, y, 499, 46, 4).fill('#E8F3EC'); + doc.font('Helvetica-Bold').fontSize(11).fillColor('#22623E').text('FACTURE RÉGLÉE', 62, y + 10); + doc.font('Helvetica').fontSize(9).text(`${paymentLabel(invoice.payment_method)} - ${dateFr(invoice.paid_at)}`, 62, y + 27); + y += 65; + } else { + doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text('RÈGLEMENT', 48, y); + doc.font('Helvetica').fontSize(9.5).fillColor(GRAY).text(`À régler avant le ${dateFr(invoice.due_date)}.`, 48, y + 18); + if (issuer.iban) doc.text(`IBAN : ${issuer.iban}`, 48, y + 36, { width: 499 }); + y += issuer.iban ? 72 : 54; + } + + if (issuer.legal_mentions) { + if (y > 690) { + doc.addPage(); + y = 60; + } + doc.moveTo(48, y).lineTo(547, y).lineWidth(1).strokeColor('#D9D3CB').stroke(); + doc.font('Helvetica-Bold').fontSize(8).fillColor(GRAY).text('MENTIONS LÉGALES', 48, y + 14); + doc.font('Helvetica').fontSize(8).fillColor(GRAY).text(text(issuer.legal_mentions), 48, y + 29, { width: 499, lineGap: 2 }); + } + }, { info: { Title: `Facture ${invoice.invoice_number}`, Author: text(invoice.issuer_snapshot?.association_name) } }); +} + +export function buildPaidMembersPdf({ season, rows }) { + return collectPdf((doc) => { + const pageWidth = doc.page.width; + const left = 36; + const widths = [90, 150, 90, 70, 75, 70, 70, 75]; + const headers = ['Facture', 'Entreprise', 'Type', 'Date', 'Règlement', 'HT', 'TVA', 'TTC']; + const drawHeader = () => { + doc.font('Helvetica-Bold').fontSize(20).fillColor(INK).text(`Adhésions réglées - ${season}`, left, 36); + doc.font('Helvetica').fontSize(9).fillColor(GRAY).text(`${rows.length} membre${rows.length > 1 ? 's' : ''} à jour`, left, 64); + let x = left; + doc.rect(left, 88, pageWidth - left * 2, 26).fill(INK); + headers.forEach((header, index) => { + doc.font('Helvetica-Bold').fontSize(7.5).fillColor('#FFFFFF').text(header.toUpperCase(), x + 4, 97, { width: widths[index] - 8, align: index >= 5 ? 'right' : 'left' }); + x += widths[index]; + }); + return 114; + }; + let y = drawHeader(); + rows.forEach((row, rowIndex) => { + if (y > 535) { + doc.addPage(); + y = drawHeader(); + } + if (rowIndex % 2 === 0) doc.rect(left, y, pageWidth - left * 2, 29).fill('#FAF8F5'); + const values = [ + row.invoice_number, + row.member_name, + typeLabel(row.billing_type), + dateFr(row.paid_at), + paymentLabel(row.payment_method), + money(row.amount_ht), + money(row.vat_amount), + money(row.amount_ttc), + ]; + let x = left; + values.forEach((value, index) => { + doc.font('Helvetica').fontSize(7.5).fillColor(INK).text(text(value), x + 4, y + 10, { width: widths[index] - 8, align: index >= 5 ? 'right' : 'left', ellipsis: true }); + x += widths[index]; + }); + y += 29; + }); + const total = rows.reduce((sum, row) => sum + Number(row.amount_ttc), 0); + doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(`Total encaissé : ${money(total)}`, left, y + 18, { width: pageWidth - left * 2, align: 'right' }); + }, { size: 'A4', layout: 'landscape', margin: 36, info: { Title: `Adhésions réglées ${season}` } }); +} + +export async function buildPaidMembersWorkbook({ season, rows }) { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'SBC - Module facturation'; + workbook.created = new Date(); + const sheet = workbook.addWorksheet('Adhésions payées', { views: [{ state: 'frozen', ySplit: 5 }] }); + sheet.mergeCells('A1:H1'); + sheet.getCell('A1').value = `Adhésions réglées - Saison ${season}`; + sheet.getCell('A1').font = { bold: true, size: 18, color: { argb: 'FF1B1B1B' } }; + sheet.getCell('A2').value = 'Membres à jour'; + sheet.getCell('B2').value = rows.length; + sheet.getCell('D2').value = 'Total encaissé TTC'; + sheet.getCell('E2').value = { formula: rows.length ? `SUM(H6:H${5 + rows.length})` : '0' }; + sheet.getCell('E2').numFmt = '#,##0.00 [$EUR]'; + sheet.getRow(5).values = ['N° facture', 'Entreprise', 'Type', 'Date de règlement', 'Mode de règlement', 'Montant HT', 'TVA', 'Montant TTC']; + sheet.getRow(5).height = 24; + sheet.getRow(5).eachCell((cell) => { + cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1B1B1B' } }; + cell.alignment = { vertical: 'middle' }; + }); + rows.forEach((row) => { + sheet.addRow([ + row.invoice_number, + row.member_name, + typeLabel(row.billing_type), + row.paid_at ? new Date(`${String(row.paid_at).slice(0, 10)}T00:00:00Z`) : null, + paymentLabel(row.payment_method), + Number(row.amount_ht), + Number(row.vat_amount), + Number(row.amount_ttc), + ]); + }); + const lastDataRow = 5 + rows.length; + if (rows.length) { + sheet.autoFilter = { from: 'A5', to: `H${lastDataRow}` }; + for (let row = 6; row <= lastDataRow; row += 1) { + sheet.getCell(`D${row}`).numFmt = 'dd/mm/yyyy'; + ['F', 'G', 'H'].forEach((column) => { sheet.getCell(`${column}${row}`).numFmt = '#,##0.00 [$EUR]'; }); + if (row % 2 === 0) { + sheet.getRow(row).eachCell((cell) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFAF8F5' } }; }); + } + } + } + const widths = [20, 34, 24, 20, 22, 16, 16, 18]; + widths.forEach((width, index) => { sheet.getColumn(index + 1).width = width; }); + sheet.getColumn(2).alignment = { wrapText: true, vertical: 'top' }; + return Buffer.from(await workbook.xlsx.writeBuffer()); +} diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 8ba2c09..69bf9df 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -22,13 +22,15 @@ import { createStaffUser, resetStaffAccess } from '../userAccess.js'; import { AccessError } from '../errors.js'; import { buildInvitationEmail } from '../emailTemplate.js'; import { buildCustomEmail } from '../customEmailTemplate.js'; +import { billingRouter } from './billing.js'; export const adminRouter = Router(); // Every route below requires at least an authenticated staff session // (admin or moderator); individual routes further restrict to admin-only // where noted. -adminRouter.use(requireAuth(['admin', 'moderator'])); +adminRouter.use(requireAuth(['admin', 'moderator', 'treasurer'])); +adminRouter.use('/billing', billingRouter); const adminOnly = requireAuth('admin'); const MEMBER_SQL = ` @@ -680,7 +682,7 @@ adminRouter.get('/users', adminOnly, async (req, res, next) => { SELECT id, email, full_name, role, must_change_password, CASE WHEN must_change_password THEN temp_password ELSE NULL END AS temp_password, created_at - FROM users WHERE role IN ('admin', 'moderator') + FROM users WHERE role IN ('admin', 'moderator', 'treasurer') ORDER BY role, full_name, email`); res.json({ users: result.rows.map((u) => ({ ...u, is_self: u.id === req.user.sub })) }); } catch (err) { @@ -714,7 +716,7 @@ adminRouter.put('/users/:id/role', adminOnly, validate(idParam, 'params'), valid if (req.params.id === req.user.sub) { return res.status(400).json({ error: 'Vous ne pouvez pas modifier votre propre rôle.' }); } - const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]); + const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator', 'treasurer')`, [req.params.id]); if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' }); if (target.rows[0].role === 'admin' && req.data.role !== 'admin') { const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`); @@ -734,7 +736,7 @@ adminRouter.delete('/users/:id', adminOnly, validate(idParam, 'params'), async ( if (req.params.id === req.user.sub) { return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte.' }); } - const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]); + const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator', 'treasurer')`, [req.params.id]); if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' }); if (target.rows[0].role === 'admin') { const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`); @@ -742,7 +744,7 @@ adminRouter.delete('/users/:id', adminOnly, validate(idParam, 'params'), async ( return res.status(400).json({ error: 'Impossible de supprimer le dernier compte administrateur.' }); } } - await query(`DELETE FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]); + await query(`DELETE FROM users WHERE id = $1 AND role IN ('admin', 'moderator', 'treasurer')`, [req.params.id]); res.json({ ok: true }); } catch (err) { next(err); diff --git a/server/src/routes/billing.js b/server/src/routes/billing.js new file mode 100644 index 0000000..607af27 --- /dev/null +++ b/server/src/routes/billing.js @@ -0,0 +1,293 @@ +import { Router } from 'express'; +import { pool, query } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; +import { + billingSeasonParam, + billingSettingsSchema, + billingTypeSchema, + invoiceGenerationSchema, + paymentSchema, + idParam, +} from '../schemas.js'; +import { associationSettings } from '../siteSettings.js'; +import { buildInvoicePdf, buildPaidMembersPdf, buildPaidMembersWorkbook } from '../billingDocuments.js'; + +export const billingRouter = Router(); +billingRouter.use(requireAuth(['admin', 'treasurer'])); + +const DEFAULT_SETTINGS = { + sluc_partner_amount_ht: 0, + non_partner_amount_ht: 0, + vat_rate: 20, + payment_due_days: 30, + iban: '', + legal_mentions: '', +}; + +function currentSeason(date = new Date()) { + const start = date.getMonth() >= 8 ? date.getFullYear() : date.getFullYear() - 1; + return `${start}-${start + 1}`; +} + +function numberRow(row) { + if (!row) return row; + for (const key of ['sluc_partner_amount_ht', 'non_partner_amount_ht', 'vat_rate', 'amount_ht', 'vat_amount', 'amount_ttc']) { + if (row[key] != null) row[key] = Number(row[key]); + } + return row; +} + +async function loadSettings(season, client = { query }) { + const result = await client.query('SELECT * FROM billing_season_settings WHERE season = $1', [season]); + return result.rows[0] ? { ...numberRow(result.rows[0]), configured: true } : { season, ...DEFAULT_SETTINGS, configured: false }; +} + +async function loadPaidRows(season) { + const result = await query( + `SELECT invoice_number, member_name, billing_type, paid_at, payment_method, + amount_ht, vat_amount, amount_ttc + FROM membership_invoices + WHERE season = $1 AND status = 'payee' + ORDER BY paid_at, member_name`, + [season] + ); + return result.rows.map(numberRow); +} + +billingRouter.get('/seasons', async (_req, res, next) => { + try { + const result = await query(` + SELECT season FROM billing_season_settings + UNION SELECT season FROM membership_invoices + ORDER BY season DESC`); + const seasons = [...new Set([currentSeason(), ...result.rows.map((row) => row.season)])].sort().reverse(); + res.json({ seasons, current: currentSeason() }); + } catch (err) { + next(err); + } +}); + +billingRouter.get('/seasons/:season', validate(billingSeasonParam, 'params'), async (req, res, next) => { + try { + const { season } = req.params; + const [settings, members, stats] = await Promise.all([ + loadSettings(season), + query( + `SELECT m.id, m.nom, m.dirigeant, m.email, m.adresse, m.billing_type, m.valide, + i.id AS invoice_id, i.invoice_number, i.issued_at, i.due_date, + i.amount_ht, i.vat_rate, i.vat_amount, i.amount_ttc, + i.status, i.payment_method, i.paid_at + FROM members m + LEFT JOIN LATERAL ( + SELECT * FROM membership_invoices mi + WHERE mi.member_id = m.id AND mi.season = $1 AND mi.status <> 'annulee' + ORDER BY mi.id DESC LIMIT 1 + ) i ON true + ORDER BY m.nom`, + [season] + ), + query( + `SELECT + (SELECT COUNT(*)::int FROM members) AS total_members, + COUNT(*) FILTER (WHERE status <> 'annulee')::int AS invoiced_count, + COUNT(*) FILTER (WHERE status = 'payee')::int AS paid_count, + COUNT(*) FILTER (WHERE status = 'emise')::int AS unpaid_count, + COALESCE(SUM(amount_ttc) FILTER (WHERE status <> 'annulee'), 0)::float8 AS total_invoiced, + COALESCE(SUM(amount_ttc) FILTER (WHERE status = 'payee'), 0)::float8 AS total_paid, + COALESCE(SUM(amount_ttc) FILTER (WHERE status = 'emise'), 0)::float8 AS total_outstanding, + COALESCE(SUM(vat_amount) FILTER (WHERE status = 'payee'), 0)::float8 AS vat_collected + FROM membership_invoices WHERE season = $1`, + [season] + ), + ]); + res.json({ + season, + settings, + members: members.rows.map(numberRow), + stats: stats.rows[0], + }); + } catch (err) { + next(err); + } +}); + +billingRouter.put('/seasons/:season/settings', validate(billingSeasonParam, 'params'), validate(billingSettingsSchema), async (req, res, next) => { + try { + const d = req.data; + const iban = d.iban.replace(/\s/g, '').toUpperCase(); + await query( + `INSERT INTO billing_season_settings + (season, sluc_partner_amount_ht, non_partner_amount_ht, vat_rate, payment_due_days, iban, legal_mentions) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (season) DO UPDATE SET + sluc_partner_amount_ht=EXCLUDED.sluc_partner_amount_ht, + non_partner_amount_ht=EXCLUDED.non_partner_amount_ht, + vat_rate=EXCLUDED.vat_rate, + payment_due_days=EXCLUDED.payment_due_days, + iban=EXCLUDED.iban, + legal_mentions=EXCLUDED.legal_mentions, + updated_at=now()`, + [req.params.season, d.sluc_partner_amount_ht, d.non_partner_amount_ht, d.vat_rate, + d.payment_due_days, iban, d.legal_mentions] + ); + res.json({ settings: await loadSettings(req.params.season) }); + } catch (err) { + next(err); + } +}); + +billingRouter.put('/members/:id/type', validate(idParam, 'params'), validate(billingTypeSchema), async (req, res, next) => { + try { + const result = await query( + 'UPDATE members SET billing_type=$1, updated_at=now() WHERE id=$2 RETURNING id, billing_type', + [req.data.billing_type, req.params.id] + ); + if (!result.rowCount) return res.status(404).json({ error: 'Membre introuvable' }); + res.json({ member: result.rows[0] }); + } catch (err) { + next(err); + } +}); + +billingRouter.post('/seasons/:season/invoices', validate(billingSeasonParam, 'params'), validate(invoiceGenerationSchema), async (req, res, next) => { + let client; + try { + client = await pool.connect(); + await client.query('BEGIN'); + const settingsResult = await client.query('SELECT * FROM billing_season_settings WHERE season=$1 FOR UPDATE', [req.params.season]); + if (!settingsResult.rowCount) { + await client.query('ROLLBACK'); + return res.status(409).json({ error: 'Configurez les tarifs de cette saison avant de générer les factures.' }); + } + const settings = numberRow(settingsResult.rows[0]); + const memberIds = req.data.member_ids; + const members = await client.query( + `SELECT id, nom, adresse, email, billing_type FROM members + ${memberIds ? 'WHERE id = ANY($1::int[])' : ''} + ORDER BY nom`, + memberIds ? [memberIds] : [] + ); + const existing = await client.query( + `SELECT member_id FROM membership_invoices + WHERE season=$1 AND status <> 'annulee' AND member_id = ANY($2::int[])`, + [req.params.season, members.rows.map((member) => member.id)] + ); + const existingIds = new Set(existing.rows.map((row) => row.member_id)); + const content = await client.query(`SELECT key, value FROM site_content WHERE key LIKE 'association_%'`); + const issuer = associationSettings(Object.fromEntries(content.rows.map((row) => [row.key, row.value]))); + const issuerSnapshot = { + ...issuer, + iban: settings.iban, + legal_mentions: settings.legal_mentions, + }; + let created = 0; + for (const member of members.rows) { + if (existingIds.has(member.id)) continue; + const amountHt = member.billing_type === 'sluc_partner' + ? settings.sluc_partner_amount_ht + : settings.non_partner_amount_ht; + const vatAmount = Math.round(amountHt * settings.vat_rate) / 100; + const amountTtc = Math.round((amountHt + vatAmount) * 100) / 100; + const sequence = await client.query(`SELECT nextval('billing_invoice_number_seq') AS n`); + const invoiceNumber = `FAC-${req.params.season.slice(0, 4)}-${String(sequence.rows[0].n).padStart(5, '0')}`; + await client.query( + `INSERT INTO membership_invoices + (season, member_id, invoice_number, due_date, member_name, member_address, member_email, + billing_type, amount_ht, vat_rate, vat_amount, amount_ttc, issuer_snapshot) + VALUES ($1, $2, $3, CURRENT_DATE + $4::int, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)`, + [req.params.season, member.id, invoiceNumber, settings.payment_due_days, member.nom, + member.adresse || '', member.email || '', member.billing_type, amountHt, settings.vat_rate, + vatAmount, amountTtc, JSON.stringify(issuerSnapshot)] + ); + created += 1; + } + await client.query('COMMIT'); + res.status(201).json({ created }); + } catch (err) { + if (client) await client.query('ROLLBACK').catch(() => {}); + next(err); + } finally { + client?.release(); + } +}); + +billingRouter.put('/invoices/:id/payment', validate(idParam, 'params'), validate(paymentSchema), async (req, res, next) => { + try { + const result = await query( + `UPDATE membership_invoices SET status='payee', payment_method=$1, paid_at=$2, updated_at=now() + WHERE id=$3 AND status <> 'annulee' RETURNING id`, + [req.data.payment_method, req.data.paid_at, req.params.id] + ); + if (!result.rowCount) return res.status(404).json({ error: 'Facture introuvable ou annulée' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +billingRouter.delete('/invoices/:id/payment', validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query( + `UPDATE membership_invoices SET status='emise', payment_method=NULL, paid_at=NULL, updated_at=now() + WHERE id=$1 AND status='payee' RETURNING id`, + [req.params.id] + ); + if (!result.rowCount) return res.status(404).json({ error: 'Règlement introuvable' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +billingRouter.post('/invoices/:id/cancel', validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query( + `UPDATE membership_invoices SET status='annulee', payment_method=NULL, paid_at=NULL, updated_at=now() + WHERE id=$1 AND status='emise' RETURNING id`, + [req.params.id] + ); + if (!result.rowCount) return res.status(409).json({ error: 'Seule une facture non réglée peut être annulée.' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +billingRouter.get('/invoices/:id/pdf', validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query('SELECT * FROM membership_invoices WHERE id=$1', [req.params.id]); + if (!result.rowCount) return res.status(404).json({ error: 'Facture introuvable' }); + const invoice = numberRow(result.rows[0]); + const pdf = await buildInvoicePdf(invoice); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${invoice.invoice_number}.pdf"`); + res.send(pdf); + } catch (err) { + next(err); + } +}); + +billingRouter.get('/seasons/:season/paid.xlsx', validate(billingSeasonParam, 'params'), async (req, res, next) => { + try { + const rows = await loadPaidRows(req.params.season); + const workbook = await buildPaidMembersWorkbook({ season: req.params.season, rows }); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename="adhesions-payees-${req.params.season}.xlsx"`); + res.send(workbook); + } catch (err) { + next(err); + } +}); + +billingRouter.get('/seasons/:season/paid.pdf', validate(billingSeasonParam, 'params'), async (req, res, next) => { + try { + const rows = await loadPaidRows(req.params.season); + const pdf = await buildPaidMembersPdf({ season: req.params.season, rows }); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="adhesions-payees-${req.params.season}.pdf"`); + res.send(pdf); + } catch (err) { + next(err); + } +}); diff --git a/server/src/schemas.js b/server/src/schemas.js index 987e0a1..469f8d6 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -87,7 +87,7 @@ export const pastPhotoParam = z.object({ export const categorySchema = z.object({ name: trimmed(80, 1) }); -export const staffRoleEnum = z.enum(['admin', 'moderator']); +export const staffRoleEnum = z.enum(['admin', 'moderator', 'treasurer']); export const staffUserSchema = z.object({ fullName: trimmed(120, 1), @@ -97,6 +97,37 @@ export const staffUserSchema = z.object({ export const roleChangeSchema = z.object({ role: staffRoleEnum }); +export const billingSeasonParam = z.object({ + season: z.string().regex(/^\d{4}-\d{4}$/, 'format AAAA-AAAA'), +}); + +const iban = z.string().trim().max(42).refine( + (value) => !value || /^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(value.replace(/\s/g, '').toUpperCase()), + 'IBAN invalide' +); + +export const billingSettingsSchema = z.object({ + sluc_partner_amount_ht: z.coerce.number().min(0).max(1000000), + non_partner_amount_ht: z.coerce.number().min(0).max(1000000), + vat_rate: z.coerce.number().min(0).max(100), + payment_due_days: z.coerce.number().int().min(0).max(365), + iban, + legal_mentions: trimmed(3000).optional().default(''), +}); + +export const billingTypeSchema = z.object({ + billing_type: z.enum(['sluc_partner', 'non_partner']), +}); + +export const invoiceGenerationSchema = z.object({ + member_ids: z.array(z.coerce.number().int().positive()).min(1).max(10000).optional(), +}); + +export const paymentSchema = z.object({ + payment_method: z.enum(['carte', 'virement', 'cheque']), + paid_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'), +}); + export const contentSchema = z.object({ hero_quote_text: trimmed(300).optional(), hero_quote_author: trimmed(120).optional(), diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index e1072d8..877ca21 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS members ( tel TEXT CHECK (char_length(tel) <= 30), site TEXT CHECK (char_length(site) <= 200), adresse TEXT CHECK (char_length(adresse) <= 300), + billing_type TEXT NOT NULL DEFAULT 'non_partner' CHECK (billing_type IN ('sluc_partner', 'non_partner')), presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000), valide BOOLEAN NOT NULL DEFAULT false, logo_path TEXT, @@ -25,14 +26,17 @@ CREATE TABLE IF NOT EXISTS members ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE members ADD COLUMN IF NOT EXISTS adresse TEXT CHECK (char_length(adresse) <= 300); +ALTER TABLE members ADD COLUMN IF NOT EXISTS billing_type TEXT NOT NULL DEFAULT 'non_partner'; +ALTER TABLE members DROP CONSTRAINT IF EXISTS members_billing_type_check; +ALTER TABLE members ADD CONSTRAINT members_billing_type_check CHECK (billing_type IN ('sluc_partner', 'non_partner')); CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254), password_hash TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('member', 'admin', 'moderator')), + role TEXT NOT NULL CHECK (role IN ('member', 'admin', 'moderator', 'treasurer')), member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE, - -- Display name for admin/moderator accounts (member accounts show their + -- Display name for staff accounts (member accounts show their -- name via the linked members row instead, this stays NULL for them). full_name TEXT, -- Temporary password shown to the admin (create / reset access), kept @@ -48,7 +52,7 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS temp_password TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT false; ALTER TABLE users ADD COLUMN IF NOT EXISTS full_name TEXT; ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; -ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('member', 'admin', 'moderator')); +ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('member', 'admin', 'moderator', 'treasurer')); CREATE TABLE IF NOT EXISTS rencontres ( id SERIAL PRIMARY KEY, @@ -158,3 +162,46 @@ CREATE TABLE IF NOT EXISTS site_content ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' ); + +-- Seasonal membership billing settings and immutable invoice snapshots. +CREATE TABLE IF NOT EXISTS billing_season_settings ( + season TEXT PRIMARY KEY CHECK (season ~ '^[0-9]{4}-[0-9]{4}$'), + sluc_partner_amount_ht NUMERIC(12,2) NOT NULL DEFAULT 0 CHECK (sluc_partner_amount_ht >= 0), + non_partner_amount_ht NUMERIC(12,2) NOT NULL DEFAULT 0 CHECK (non_partner_amount_ht >= 0), + vat_rate NUMERIC(5,2) NOT NULL DEFAULT 20 CHECK (vat_rate BETWEEN 0 AND 100), + payment_due_days INTEGER NOT NULL DEFAULT 30 CHECK (payment_due_days BETWEEN 0 AND 365), + iban TEXT NOT NULL DEFAULT '' CHECK (char_length(iban) <= 42), + legal_mentions TEXT NOT NULL DEFAULT '' CHECK (char_length(legal_mentions) <= 3000), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE SEQUENCE IF NOT EXISTS billing_invoice_number_seq START WITH 1; + +CREATE TABLE IF NOT EXISTS membership_invoices ( + id BIGSERIAL PRIMARY KEY, + season TEXT NOT NULL REFERENCES billing_season_settings(season) ON DELETE RESTRICT, + member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE RESTRICT, + invoice_number TEXT NOT NULL UNIQUE, + issued_at DATE NOT NULL DEFAULT CURRENT_DATE, + due_date DATE NOT NULL, + member_name TEXT NOT NULL, + member_address TEXT NOT NULL DEFAULT '', + member_email TEXT NOT NULL DEFAULT '', + billing_type TEXT NOT NULL CHECK (billing_type IN ('sluc_partner', 'non_partner')), + amount_ht NUMERIC(12,2) NOT NULL CHECK (amount_ht >= 0), + vat_rate NUMERIC(5,2) NOT NULL CHECK (vat_rate BETWEEN 0 AND 100), + vat_amount NUMERIC(12,2) NOT NULL CHECK (vat_amount >= 0), + amount_ttc NUMERIC(12,2) NOT NULL CHECK (amount_ttc >= 0), + issuer_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'emise' CHECK (status IN ('emise', 'payee', 'annulee')), + payment_method TEXT CHECK (payment_method IN ('carte', 'virement', 'cheque')), + paid_at DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ((status = 'payee' AND payment_method IS NOT NULL AND paid_at IS NOT NULL) OR status <> 'payee') +); +CREATE INDEX IF NOT EXISTS idx_membership_invoices_season ON membership_invoices(season); +CREATE INDEX IF NOT EXISTS idx_membership_invoices_member ON membership_invoices(member_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_membership_invoices_active_member_season + ON membership_invoices(member_id, season) WHERE status <> 'annulee'; diff --git a/server/src/userAccess.js b/server/src/userAccess.js index 9a5dc75..07c9bf2 100644 --- a/server/src/userAccess.js +++ b/server/src/userAccess.js @@ -3,9 +3,9 @@ import { query } from './db.js'; import { generateTempPassword } from './passwords.js'; import { AccessError } from './errors.js'; -const STAFF_ROLES = ['admin', 'moderator']; +const STAFF_ROLES = ['admin', 'moderator', 'treasurer']; -// Creates an admin or moderator account with a temporary password — same +// Creates a staff account with a temporary password — same // readable-until-changed mechanic as member accounts (see memberAccess.js). export async function createStaffUser({ fullName, email, role }) { if (!STAFF_ROLES.includes(role)) throw new AccessError(400, 'Rôle invalide.'); @@ -24,14 +24,14 @@ export async function createStaffUser({ fullName, email, role }) { } } -// Regenerates the temporary password for an existing admin/moderator +// Regenerates the temporary password for an existing staff // account (e.g. they lost it). export async function resetStaffAccess(userId) { const tempPassword = generateTempPassword(); const hash = await bcrypt.hash(tempPassword, 12); const result = await query( `UPDATE users SET password_hash=$1, temp_password=$2, must_change_password=true - WHERE id=$3 AND role IN ('admin', 'moderator') RETURNING id`, + WHERE id=$3 AND role IN ('admin', 'moderator', 'treasurer') RETURNING id`, [hash, tempPassword, userId] ); if (result.rowCount === 0) throw new AccessError(404, 'Compte introuvable.'); diff --git a/web/src/App.jsx b/web/src/App.jsx index 248b484..2b2aed8 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -23,7 +23,7 @@ export default function App() { const isAdminBackend = location.pathname.startsWith('/admin') || (location.pathname === '/espace-membre' && - (user?.role === 'admin' || user?.role === 'moderator') && + (user?.role === 'admin' || user?.role === 'moderator' || user?.role === 'treasurer') && !user?.mustChangePassword); useEffect(() => { diff --git a/web/src/components/admin/AdminBilling.jsx b/web/src/components/admin/AdminBilling.jsx new file mode 100644 index 0000000..6e3168f --- /dev/null +++ b/web/src/components/admin/AdminBilling.jsx @@ -0,0 +1,375 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api } from '../../lib/api.js'; +import Modal from '../Modal.jsx'; + +const TYPE_LABEL = { + sluc_partner: 'Partenaire SLUC', + non_partner: 'Non partenaire SLUC', +}; + +const PAYMENT_LABEL = { + carte: 'Carte bleue', + virement: 'Virement', + cheque: 'Chèque', +}; + +const euro = (value) => new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency: 'EUR', +}).format(Number(value || 0)); + +const dateInput = (value) => value ? String(value).slice(0, 10) : ''; +const dateLabel = (value) => value + ? new Date(`${String(value).slice(0, 10)}T00:00:00`).toLocaleDateString('fr-FR') + : '—'; +const seasonLabel = (season) => season?.replace('-', '–') || ''; + +function SettingsPanel({ season, settings, onSaved, onClose }) { + const [form, setForm] = useState({ + sluc_partner_amount_ht: settings.sluc_partner_amount_ht ?? 0, + non_partner_amount_ht: settings.non_partner_amount_ht ?? 0, + vat_rate: settings.vat_rate ?? 20, + payment_due_days: settings.payment_due_days ?? 30, + iban: settings.iban || '', + legal_mentions: settings.legal_mentions || '', + }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const change = (event) => setForm({ ...form, [event.target.name]: event.target.value }); + + const submit = async (event) => { + event.preventDefault(); + setBusy(true); + setError(''); + try { + await api.put(`/api/admin/billing/seasons/${season}/settings`, form); + onSaved(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + return ( +
Les montants sont saisis hors taxes. La TVA et le TTC sont calculés à la génération.
+{error || 'Chargement de la facturation…'}
; + + const stats = data.stats; + const recovery = Number(stats.total_invoiced) > 0 + ? Math.round((Number(stats.total_paid) / Number(stats.total_invoiced)) * 100) + : 0; + const missingCount = data.members.filter((member) => !member.invoice_id).length; + + return ( +{error}
} + +| Entreprise | Type d'adhésion | Montant | Facture | Situation | Règlement | |
|---|---|---|---|---|---|---|
| {member.nom} {member.dirigeant} |
+ + + | +{member.invoice_id ? euro(member.amount_ttc) : <>{euro(expectedHt)}HT avant TVA>} | +
+ {member.invoice_id ? <>
+ {member.invoice_number}
+ {dateLabel(member.issued_at)}
+ > : Non générée}
+ |
+ + {!member.invoice_id && À facturer} + {member.status === 'emise' && À régler} + {member.status === 'payee' && À jour} + | +
+ {member.status === 'payee' ? <>
+ {PAYMENT_LABEL[member.payment_method]}
+ {dateLabel(member.paid_at)}
+ > : member.status === 'emise' ? Échéance {dateLabel(member.due_date)} : '—'}
+ |
+ + {!member.invoice_id && } + {member.invoice_id && PDF} + {member.invoice_id && } + {member.status === 'emise' && } + | +