From c2bbb1df15f91c84bf7016310bae12d2afd07b84 Mon Sep 17 00:00:00 2001 From: Seazn Date: Sun, 15 Mar 2026 14:16:46 +0100 Subject: [PATCH] feat: implement auto-renewal cron service for subscriptions - Added RenewalCronService to handle automatic subscription renewals and reactivations. - Introduced listPausedAutoRenew method in AbonemmentRepository to fetch paused subscriptions eligible for reactivation. - Created test script for renewal cron job to simulate subscription renewal scenarios. - Updated MailService to send renewal confirmation and payment reminder emails. - Enhanced EmailVerificationService to auto-grant 'can_subscribe' permission upon email verification. - Modified createAdminUser script to allow different admin email configurations. - Added node-cron dependency for scheduling tasks. --- middleware/guestRestriction.js | 6 +- package-lock.json | 206 ++++----- package.json | 7 +- .../abonemments/AbonemmentRepository.js | 9 + scripts/createAdminUser.js | 6 +- scripts/testRenewalCron.js | 49 +++ server.js | 5 + services/abonemments/RenewalCronService.js | 393 ++++++++++++++++++ services/email/EmailVerificationService.js | 23 + services/email/MailService.js | 375 +++++++++++++++++ 10 files changed, 973 insertions(+), 106 deletions(-) create mode 100644 scripts/testRenewalCron.js create mode 100644 services/abonemments/RenewalCronService.js diff --git a/middleware/guestRestriction.js b/middleware/guestRestriction.js index e4de08f..59e8631 100644 --- a/middleware/guestRestriction.js +++ b/middleware/guestRestriction.js @@ -38,7 +38,11 @@ function guestRestriction(req, res, next) { const isAllowed = GUEST_ALLOWED_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix)); - if (isAllowed) { + // Allow guests to fetch their own permissions + const userId = user.userId || user.id; + const isOwnPermissions = userId && normalizedPath === `/users/${userId}/permissions`; + + if (isAllowed || isOwnPermissions) { return next(); } diff --git a/package-lock.json b/package-lock.json index 4c47ba3..cd95b67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", "mysql2": "^3.17.2", + "node-cron": "^4.2.1", "nodemailer": "^8.0.1", "pdfkit": "^0.17.2", "pidusage": "^4.0.1", @@ -995,13 +996,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.5.tgz", - "integrity": "sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.11.tgz", + "integrity": "sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "fast-xml-parser": "5.3.6", + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" }, "engines": { @@ -1598,9 +1599,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", - "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1868,9 +1869,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", "optional": true, "dependencies": { @@ -2065,11 +2066,10 @@ } }, "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -2090,11 +2090,10 @@ } }, "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", + "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } @@ -2104,17 +2103,15 @@ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", - "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", + "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", "license": "Apache-2.0", - "optional": true, "dependencies": { "streamx": "^2.21.0", "teex": "^1.0.1" @@ -2137,7 +2134,6 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -2163,9 +2159,9 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", - "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2645,9 +2641,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1566079", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", - "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", + "version": "0.0.1581282", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", + "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", "license": "BSD-3-Clause" }, "node_modules/dfa": { @@ -2959,10 +2955,10 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, - "node_modules/fast-xml-parser": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", - "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "node_modules/fast-xml-builder": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", + "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", "funding": [ { "type": "github", @@ -2971,6 +2967,23 @@ ], "license": "MIT", "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz", + "integrity": "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.3", + "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { @@ -3694,9 +3707,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -3709,33 +3722,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -3752,21 +3744,22 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { @@ -3870,6 +3863,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -4066,6 +4068,21 @@ "node": ">= 0.8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4195,9 +4212,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -4205,18 +4222,18 @@ } }, "node_modules/puppeteer": { - "version": "24.37.5", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.5.tgz", - "integrity": "sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ==", + "version": "24.39.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.39.1.tgz", + "integrity": "sha512-68Zc9QpcVvfxp2C+3UL88TyUogEAn5tSylXidbEuEXvhiqK1+v65zeBU5ubinAgEHMGr3dcSYqvYrGtdzsPI3w==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1566079", - "puppeteer-core": "24.37.5", - "typed-query-selector": "^2.12.0" + "devtools-protocol": "0.0.1581282", + "puppeteer-core": "24.39.1", + "typed-query-selector": "^2.12.1" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" @@ -4226,16 +4243,16 @@ } }, "node_modules/puppeteer-core": { - "version": "24.37.5", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", - "integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", + "version": "24.39.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.1.tgz", + "integrity": "sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==", "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", - "devtools-protocol": "0.0.1566079", - "typed-query-selector": "^2.12.0", + "devtools-protocol": "0.0.1581282", + "typed-query-selector": "^2.12.1", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" }, @@ -4714,9 +4731,9 @@ } }, "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -4728,12 +4745,13 @@ } }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } @@ -4743,7 +4761,6 @@ "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "license": "MIT", - "optional": true, "dependencies": { "streamx": "^2.12.5" } @@ -4831,9 +4848,9 @@ } }, "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", + "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", "license": "MIT" }, "node_modules/typedarray": { @@ -5044,15 +5061,6 @@ } } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 8559098..2320352 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ }, "overrides": { "@aws-sdk/xml-builder": { - "fast-xml-parser": "^5.3.4", - "ajv": "8.18.0" - } + "fast-xml-parser": "^5.3.4", + "ajv": "8.18.0" + } }, "dependencies": { "@aws-sdk/client-s3": "^3.992.0", @@ -37,6 +37,7 @@ "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", "mysql2": "^3.17.2", + "node-cron": "^4.2.1", "nodemailer": "^8.0.1", "pdfkit": "^0.17.2", "pidusage": "^4.0.1", diff --git a/repositories/abonemments/AbonemmentRepository.js b/repositories/abonemments/AbonemmentRepository.js index e643a1d..596b3f8 100644 --- a/repositories/abonemments/AbonemmentRepository.js +++ b/repositories/abonemments/AbonemmentRepository.js @@ -304,6 +304,15 @@ class AbonemmentRepository { return rows.map((r) => new Abonemment(r)); } + async listPausedAutoRenew() { + const [rows] = await pool.query( + `SELECT * FROM coffee_abonements + WHERE status = 'paused' AND is_auto_renew = 1 + ORDER BY updated_at ASC`, + ); + return rows.map((r) => new Abonemment(r)); + } + async listActiveByProduct(productId) { const [rows] = await pool.query( `SELECT * FROM coffee_abonements WHERE coffee_table_id = ? AND status = 'active'`, diff --git a/scripts/createAdminUser.js b/scripts/createAdminUser.js index c4c6d88..a75482f 100644 --- a/scripts/createAdminUser.js +++ b/scripts/createAdminUser.js @@ -3,10 +3,10 @@ const UnitOfWork = require('../database/UnitOfWork'); const argon2 = require('argon2'); async function createAdminUser() { - return; + // const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; - const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; - // const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; + // const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; + const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%'; // const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; const firstName = process.env.ADMIN_FIRST_NAME || 'Admin'; diff --git a/scripts/testRenewalCron.js b/scripts/testRenewalCron.js new file mode 100644 index 0000000..8930ed1 --- /dev/null +++ b/scripts/testRenewalCron.js @@ -0,0 +1,49 @@ +/** + * Test script for the auto-renewal cron job. + * + * Usage: + * node scripts/testRenewalCron.js + * + * This will immediately run the full cron logic: + * Phase 1: Active subscriptions due for renewal + * - Renew if last invoice is 'paid' (or no invoice exists) + * - Pause if user_id is NULL (gift recipient not registered) + * - Skip + send reminder if last invoice is unpaid (7+ days: overdue, 30+ days: pause) + * Phase 2: Paused subscriptions with paid invoices + * - Reactivate for current cycle if paid before the 11th + * - Reactivate for next month if paid on/after the 11th + * + * Test setup (run in MySQL/phpMyAdmin): + * + * -- 1. Make a subscription due for billing: + * UPDATE coffee_abonements SET next_billing_at = NOW() - INTERVAL 1 DAY WHERE id = ; + * + * -- 2. To test "paid" flow: mark last invoice as paid: + * UPDATE invoices SET status = 'paid' WHERE source_type = 'subscription' AND source_id = ORDER BY id DESC LIMIT 1; + * + * -- 3. To test "unpaid" flow: leave invoice status as 'issued' + * + * -- 4. To test gift-pause: set user_id to NULL: + * UPDATE coffee_abonements SET user_id = NULL WHERE id = ; + */ +require('dotenv').config(); +const RenewalCronService = require('../services/abonemments/RenewalCronService'); + +(async () => { + console.log('=== Renewal Cron Test ==='); + console.log('Time:', new Date().toISOString()); + console.log('Day of month:', new Date().getDate(), '(reactivation before 11th?', new Date().getDate() <= 10, ')'); + console.log(''); + + const cron = new RenewalCronService(); + + try { + await cron.processDueRenewals(); + console.log(''); + console.log('=== Test complete ==='); + } catch (err) { + console.error('Test failed:', err); + } + + setTimeout(() => process.exit(0), 3000); +})(); diff --git a/server.js b/server.js index 2775007..5268713 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,7 @@ const createAdminUser = require('./scripts/createAdminUser'); const createCompanyUser = require('./scripts/createCompanyUser'); const createPersonalUser = require('./scripts/createPersonalUser'); const createGuestUser = require('./scripts/createGuestUser'); +const RenewalCronService = require('./services/abonemments/RenewalCronService'); const app = express(); const PORT = process.env.PORT || 3001; @@ -187,6 +188,10 @@ async function startServer() { // Create guest user await createGuestUser(); + // Start automatic subscription renewal cron job + const renewalCron = new RenewalCronService(); + renewalCron.start(); + // Start the server app.listen(PORT, () => { const host = process.env.HOST || 'localhost'; diff --git a/services/abonemments/RenewalCronService.js b/services/abonemments/RenewalCronService.js new file mode 100644 index 0000000..dea48fb --- /dev/null +++ b/services/abonemments/RenewalCronService.js @@ -0,0 +1,393 @@ +const cron = require('node-cron'); +const AbonemmentRepository = require('../../repositories/abonemments/AbonemmentRepository'); +const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository'); +const InvoiceService = require('../invoice/InvoiceService'); +const MailService = require('../email/MailService'); +const { logger } = require('../../middleware/logger'); + +class RenewalCronService { + constructor() { + this.repo = new AbonemmentRepository(); + this.invoiceRepo = new InvoiceRepository(); + this.invoiceService = new InvoiceService(); + } + + addInterval(date, interval, count) { + const d = new Date(date); + if (interval === 'day') d.setDate(d.getDate() + count); + if (interval === 'week') d.setDate(d.getDate() + 7 * count); + if (interval === 'month') d.setMonth(d.getMonth() + count); + if (interval === 'year') d.setFullYear(d.getFullYear() + count); + return d; + } + + /** + * Get the latest invoice for a subscription. + */ + async getLatestInvoice(abonementId) { + const invoices = await this.invoiceRepo.findByAbonement(abonementId); + return invoices.length ? invoices[0] : null; // already sorted DESC + } + + // ────────────────────────────────────────────── + // PHASE 1 — Process active subscriptions due for renewal + // ────────────────────────────────────────────── + + async processActiveRenewals(now) { + let dueAbonements; + try { + dueAbonements = await this.repo.listDueForBilling(now); + } catch (err) { + logger.error('RenewalCron:fetch_due_error', { message: err?.message }); + return; + } + + if (!dueAbonements.length) { + logger.info('RenewalCron:no_due_subscriptions'); + return; + } + + logger.info('RenewalCron:found_due', { count: dueAbonements.length }); + const results = { renewed: 0, skipped: 0, paused: 0, reminded: 0, errors: [] }; + + for (const abon of dueAbonements) { + try { + // Re-check status (race-condition protection) + const fresh = await this.repo.getAbonementById(abon.id); + if (!fresh || fresh.status !== 'active') { + results.skipped++; + continue; + } + + // Gift-abo where recipient hasn't registered yet → pause + if (!fresh.user_id) { + await this.pauseAbonement(fresh, 'no_registered_user'); + results.paused++; + continue; + } + + // Check last invoice payment status + const lastInvoice = await this.getLatestInvoice(fresh.id); + + if (!lastInvoice) { + // First cycle, no invoice yet → renew + await this.renewSingleAbonement(fresh); + results.renewed++; + continue; + } + + if (lastInvoice.status === 'paid') { + // Previous invoice paid → renew + await this.renewSingleAbonement(fresh); + results.renewed++; + continue; + } + + // Previous invoice NOT paid — do NOT renew, do NOT shift next_billing_at + // Check how overdue it is + const issuedAt = lastInvoice.issued_at ? new Date(lastInvoice.issued_at) : new Date(lastInvoice.created_at); + const daysSinceIssued = Math.floor((now - issuedAt) / (1000 * 60 * 60 * 24)); + + if (daysSinceIssued >= 30) { + // 30+ days unpaid → pause subscription + if (lastInvoice.status !== 'overdue') { + await this.invoiceRepo.updateStatus(lastInvoice.id, 'overdue'); + } + await this.pauseAbonement(fresh, 'unpaid_30_days'); + results.paused++; + } else if (daysSinceIssued >= 7) { + // 7+ days → mark overdue + send reminder + if (lastInvoice.status !== 'overdue') { + await this.invoiceRepo.updateStatus(lastInvoice.id, 'overdue'); + } + await this.sendPaymentReminder(fresh, lastInvoice, daysSinceIssued); + results.reminded++; + } else { + // < 7 days — just skip, wait longer + results.skipped++; + } + } catch (err) { + results.errors.push({ abonementId: abon.id, message: err?.message }); + logger.error('RenewalCron:renewal_failed', { + abonementId: abon.id, + message: err?.message, + stack: err?.stack, + }); + } + } + + logger.info('RenewalCron:active_phase_complete', results); + } + + // ────────────────────────────────────────────── + // PHASE 2 — Reactivate paused subscriptions that have been paid + // ────────────────────────────────────────────── + + async processPausedReactivations(now) { + let pausedAbonements; + try { + pausedAbonements = await this.repo.listPausedAutoRenew(); + } catch (err) { + logger.error('RenewalCron:fetch_paused_error', { message: err?.message }); + return; + } + + if (!pausedAbonements.length) { + logger.info('RenewalCron:no_paused_to_reactivate'); + return; + } + + logger.info('RenewalCron:found_paused', { count: pausedAbonements.length }); + const dayOfMonth = now.getDate(); + const results = { reactivated: 0, deferred: 0, skipped: 0 }; + + for (const abon of pausedAbonements) { + try { + // Gift-abo still without user → skip + if (!abon.user_id) { + results.skipped++; + continue; + } + + const lastInvoice = await this.getLatestInvoice(abon.id); + if (!lastInvoice || lastInvoice.status !== 'paid') { + results.skipped++; + continue; + } + + // Invoice is paid — check if before 11th of current month + if (dayOfMonth <= 10) { + // Before the 11th → reactivate for this cycle + const nextBilling = this.addInterval( + now, + abon.billing_interval || 'month', + abon.interval_count || 1, + ); + await this.reactivateAbonement(abon, nextBilling); + results.reactivated++; + } else { + // On or after the 11th → defer to next month + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const nextBilling = this.addInterval( + nextMonth, + abon.billing_interval || 'month', + abon.interval_count || 1, + ); + await this.reactivateAbonement(abon, nextBilling); + results.deferred++; + } + } catch (err) { + logger.error('RenewalCron:reactivation_failed', { + abonementId: abon.id, + message: err?.message, + }); + } + } + + logger.info('RenewalCron:paused_phase_complete', results); + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + async renewSingleAbonement(abon) { + const abonId = abon.id; + const oldNextBilling = new Date(abon.next_billing_at); + const newNextBilling = this.addInterval( + oldNextBilling, + abon.billing_interval || 'month', + abon.interval_count || 1, + ); + + logger.info('RenewalCron:renewing', { + abonementId: abonId, + oldNextBilling: oldNextBilling.toISOString(), + newNextBilling: newNextBilling.toISOString(), + }); + + // Update billing date + history + const renewed = await this.repo.transitionBilling(abonId, newNextBilling, { + event_type: 'renewed', + actor_user_id: null, + details: { + pack_group: abon.pack_group, + trigger: 'auto_renewal_cron', + previous_next_billing_at: oldNextBilling.toISOString(), + }, + }); + + // Issue new invoice + let invoice = null; + try { + const lang = abon.language || abon.lang || 'de'; + invoice = await this.invoiceService.issueForAbonement( + renewed, + oldNextBilling, + newNextBilling, + { actorUserId: null, lang }, + ); + + logger.info('RenewalCron:invoice_issued', { + abonementId: abonId, + invoiceId: invoice?.id, + invoiceNumber: invoice?.invoice_number, + }); + + await this.repo.appendHistory(abonId, 'invoice_issued', null, { + pack_group: renewed.pack_group, + invoiceId: invoice.id, + trigger: 'auto_renewal_cron', + }, new Date()); + } catch (invoiceErr) { + logger.error('RenewalCron:invoice_error', { + abonementId: abonId, + message: invoiceErr?.message, + }); + } + + // Send renewal confirmation email + try { + const recipientEmail = abon.email || invoice?.buyer_email; + if (recipientEmail) { + const lang = abon.language || abon.lang || 'de'; + const customerName = [abon.first_name, abon.last_name].filter(Boolean).join(' ') || recipientEmail; + + await MailService.sendRenewalEmail({ + email: recipientEmail, + customerName, + invoiceNumber: invoice?.invoice_number || '-', + totalGross: invoice?.total_gross + ? `${Number(invoice.total_gross).toFixed(2)} ${invoice.currency || abon.currency || 'EUR'}` + : '-', + nextBillingDate: newNextBilling.toISOString().slice(0, 10), + lang, + }); + } + } catch (mailErr) { + logger.error('RenewalCron:renewal_email_error', { + abonementId: abonId, + message: mailErr?.message, + }); + } + + return { abonementId: abonId, invoiceId: invoice?.id || null }; + } + + async pauseAbonement(abon, reason) { + logger.info('RenewalCron:pausing', { abonementId: abon.id, reason }); + + await this.repo.transitionStatus(abon.id, 'paused', { + event_type: 'paused', + actor_user_id: null, + details: { + pack_group: abon.pack_group, + trigger: 'auto_renewal_cron', + reason, + }, + }); + + // Notify the subscriber (or purchaser) + try { + const recipientEmail = abon.email; + if (recipientEmail) { + const lang = abon.language || abon.lang || 'de'; + const customerName = [abon.first_name, abon.last_name].filter(Boolean).join(' ') || recipientEmail; + await MailService.sendSubscriptionPausedEmail({ email: recipientEmail, customerName, reason, lang }); + } + } catch (mailErr) { + logger.error('RenewalCron:pause_email_error', { + abonementId: abon.id, + message: mailErr?.message, + }); + } + } + + async reactivateAbonement(abon, nextBillingAt) { + logger.info('RenewalCron:reactivating', { + abonementId: abon.id, + nextBillingAt: nextBillingAt.toISOString(), + }); + + // Set status back to active + update billing date + await this.repo.transitionStatus(abon.id, 'active', { + event_type: 'resumed', + actor_user_id: null, + details: { + pack_group: abon.pack_group, + trigger: 'auto_renewal_cron', + reason: 'payment_received', + next_billing_at: nextBillingAt.toISOString(), + }, + }); + + await this.repo.updateBilling(abon.id, nextBillingAt); + } + + async sendPaymentReminder(abon, invoice, daysSinceIssued) { + const recipientEmail = abon.email || invoice.buyer_email; + if (!recipientEmail) return; + + const lang = abon.language || abon.lang || 'de'; + const customerName = [abon.first_name, abon.last_name].filter(Boolean).join(' ') || recipientEmail; + + try { + await MailService.sendPaymentReminderEmail({ + email: recipientEmail, + customerName, + invoiceNumber: invoice.invoice_number || '-', + totalGross: invoice.total_gross + ? `${Number(invoice.total_gross).toFixed(2)} ${invoice.currency || abon.currency || 'EUR'}` + : '-', + daysOverdue: daysSinceIssued, + lang, + }); + + logger.info('RenewalCron:payment_reminder_sent', { + abonementId: abon.id, + invoiceId: invoice.id, + daysOverdue: daysSinceIssued, + }); + } catch (mailErr) { + logger.error('RenewalCron:payment_reminder_error', { + abonementId: abon.id, + message: mailErr?.message, + }); + } + } + + // ────────────────────────────────────────────── + // Main entry point + // ────────────────────────────────────────────── + + async processDueRenewals() { + const now = new Date(); + logger.info('RenewalCron:tick_start', { now: now.toISOString() }); + + await this.processActiveRenewals(now); + await this.processPausedReactivations(now); + + logger.info('RenewalCron:tick_end', { now: now.toISOString() }); + } + + /** + * Start the cron schedule. + * Runs every day at 02:00 AM. + */ + start() { + cron.schedule('0 2 * * *', async () => { + try { + await this.processDueRenewals(); + } catch (err) { + logger.error('RenewalCron:unhandled_error', { + message: err?.message, + stack: err?.stack, + }); + } + }); + + logger.info('RenewalCron:scheduled', { schedule: '0 2 * * * (daily at 02:00)' }); + } +} + +module.exports = RenewalCronService; diff --git a/services/email/EmailVerificationService.js b/services/email/EmailVerificationService.js index f5f6d20..ad94a56 100644 --- a/services/email/EmailVerificationService.js +++ b/services/email/EmailVerificationService.js @@ -78,6 +78,29 @@ class EmailVerificationService { await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork); logger.info('EmailVerificationService.verifyCode:pending_check_complete', { userId }); + // Auto-grant can_subscribe permission if user doesn't already have it + try { + const [permRows] = await unitOfWork.connection.query( + `SELECT id FROM permissions WHERE name = 'can_subscribe' AND is_active = TRUE LIMIT 1` + ); + if (permRows.length > 0) { + const permId = permRows[0].id; + const [existing] = await unitOfWork.connection.query( + `SELECT 1 FROM user_permissions WHERE user_id = ? AND permission_id = ? LIMIT 1`, + [userId, permId] + ); + if (existing.length === 0) { + await unitOfWork.connection.query( + `INSERT INTO user_permissions (user_id, permission_id) VALUES (?, ?)`, + [userId, permId] + ); + logger.info('EmailVerificationService.verifyCode:can_subscribe_granted', { userId }); + } + } + } catch (permError) { + logger.error('EmailVerificationService.verifyCode:permission_grant_error', { userId, error: permError.message }); + } + logger.info('EmailVerificationService.verifyCode:success', { userId }); return { success: true }; } catch (error) { diff --git a/services/email/MailService.js b/services/email/MailService.js index 6f23e8a..25f5df0 100644 --- a/services/email/MailService.js +++ b/services/email/MailService.js @@ -342,6 +342,381 @@ class MailService { } } + async sendRenewalEmail({ email, customerName, invoiceNumber, totalGross, nextBillingDate, lang = 'en' }) { + logger.info('MailService.sendRenewalEmail:start', { email, lang, invoiceNumber }); + const isDe = lang === 'de'; + + const subject = isDe + ? `ProfitPlanet: Ihr Abonnement wurde verlängert – Rechnung ${invoiceNumber}` + : `ProfitPlanet: Your subscription has been renewed – Invoice ${invoiceNumber}`; + + const safeName = this._escapeForHtml(customerName || ''); + const safeInvoiceNumber = this._escapeForHtml(invoiceNumber || ''); + const safeTotalGross = this._escapeForHtml(totalGross || '-'); + const safeNextDate = this._escapeForHtml(nextBillingDate || '-'); + + const text = isDe + ? [ + `Hallo ${customerName || ''},`, + '', + 'Ihr ProfitPlanet Kaffee-Abonnement wurde automatisch verlängert.', + '', + `Rechnungsnummer: ${invoiceNumber}`, + `Gesamtbetrag: ${totalGross}`, + `Nächste Verlängerung: ${nextBillingDate}`, + '', + 'Ihre Rechnung ist als PDF im Anhang der vorherigen E-Mail enthalten.', + 'Sie können Ihre Rechnungen auch jederzeit in Ihrem Dashboard einsehen.', + '', + 'Falls Sie Ihr Abonnement pausieren oder kündigen möchten, können Sie dies in Ihrem Profil tun.', + '', + 'Viele Grüße', + 'Ihr ProfitPlanet Team', + ].join('\n') + : [ + `Hi ${customerName || ''},`, + '', + 'Your ProfitPlanet coffee subscription has been automatically renewed.', + '', + `Invoice number: ${invoiceNumber}`, + `Total amount: ${totalGross}`, + `Next renewal: ${nextBillingDate}`, + '', + 'Your invoice is attached as a PDF in the previous email.', + 'You can also view all your invoices in your dashboard at any time.', + '', + 'If you would like to pause or cancel your subscription, you can do so in your profile.', + '', + 'Best regards,', + 'Your ProfitPlanet Team', + ].join('\n'); + + const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || ''; + + const html = ` + + + + + + + +
+ + + + + + + + + + +
+ ${logoUrl ? `ProfitPlanet` : ''} +

${isDe ? 'Ihr Abonnement wurde verlängert' : 'Your subscription has been renewed'}

+
+

${isDe ? 'Hallo' : 'Hi'} ${safeName},

+

${isDe + ? 'Ihr Kaffee-Abonnement wurde automatisch verlängert. Nachfolgend finden Sie die Details:' + : 'Your coffee subscription has been automatically renewed. Here are the details:'}

+ + + + + + + + + + + + + + +
${isDe ? 'Rechnungsnummer' : 'Invoice number'}${safeInvoiceNumber}
${isDe ? 'Gesamtbetrag' : 'Total amount'}${safeTotalGross}
${isDe ? 'Nächste Verlängerung' : 'Next renewal'}${safeNextDate}
+ +

${isDe + ? 'Ihre Rechnung mit PDF wurde Ihnen separat zugesendet. Sie können Ihre Rechnungen auch in Ihrem Dashboard einsehen.' + : 'Your invoice with PDF has been sent to you separately. You can also view your invoices in your dashboard.'}

+ +

${isDe + ? 'Falls Sie Ihr Abonnement pausieren oder kündigen möchten, können Sie dies jederzeit in Ihrem Profil tun.' + : 'If you would like to pause or cancel your subscription, you can do so anytime in your profile.'}

+
+

${isDe ? 'Viele Grüße – Ihr ProfitPlanet Team' : 'Best regards – Your ProfitPlanet Team'}

+
+
+ +`; + + try { + const payload = { + sender: this.sender, + to: [{ email }], + subject, + textContent: text, + htmlContent: html, + }; + + const data = await this.brevo.transactionalEmails.sendTransacEmail(payload); + logger.info('MailService.sendRenewalEmail:email_sent', { email, lang, invoiceNumber }); + return data; + } catch (error) { + const brevoError = this._extractBrevoErrorDetails(error); + logger.error('MailService.sendRenewalEmail:error', { + email, + lang, + invoiceNumber, + message: error?.message, + brevoStatus: brevoError.status, + brevoData: brevoError.data, + }); + throw error; + } + } + + async sendPaymentReminderEmail({ email, customerName, invoiceNumber, totalGross, daysOverdue, lang = 'en' }) { + logger.info('MailService.sendPaymentReminderEmail:start', { email, lang, invoiceNumber, daysOverdue }); + const isDe = lang === 'de'; + + const subject = isDe + ? `ProfitPlanet: Zahlungserinnerung – Rechnung ${invoiceNumber}` + : `ProfitPlanet: Payment reminder – Invoice ${invoiceNumber}`; + + const safeName = this._escapeForHtml(customerName || ''); + const safeInvoice = this._escapeForHtml(invoiceNumber || ''); + const safeTotal = this._escapeForHtml(totalGross || '-'); + const safeDays = this._escapeForHtml(String(daysOverdue || 0)); + + const text = isDe + ? [ + `Hallo ${customerName || ''},`, + '', + `wir möchten Sie daran erinnern, dass Ihre Rechnung ${invoiceNumber} noch offen ist.`, + '', + `Rechnungsnummer: ${invoiceNumber}`, + `Offener Betrag: ${totalGross}`, + `Überfällig seit: ${daysOverdue} Tagen`, + '', + 'Bitte begleichen Sie den offenen Betrag, damit Ihr Abonnement weiterhin aktiv bleibt.', + 'Falls die Zahlung bereits erfolgt ist, können Sie diese Erinnerung ignorieren.', + '', + 'Viele Grüße', + 'Ihr ProfitPlanet Team', + ].join('\n') + : [ + `Hi ${customerName || ''},`, + '', + `This is a reminder that your invoice ${invoiceNumber} is still unpaid.`, + '', + `Invoice number: ${invoiceNumber}`, + `Outstanding amount: ${totalGross}`, + `Overdue by: ${daysOverdue} days`, + '', + 'Please settle the outstanding amount to keep your subscription active.', + 'If payment has already been made, you can disregard this reminder.', + '', + 'Best regards,', + 'Your ProfitPlanet Team', + ].join('\n'); + + const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || ''; + + const html = ` + + + + + + + +
+ + + + + + + + + + +
+ ${logoUrl ? `ProfitPlanet` : ''} +

${isDe ? 'Zahlungserinnerung' : 'Payment Reminder'}

+
+

${isDe ? 'Hallo' : 'Hi'} ${safeName},

+

${isDe + ? 'wir möchten Sie daran erinnern, dass folgende Rechnung noch offen ist:' + : 'This is a friendly reminder that the following invoice is still unpaid:'}

+ + + + + + + + + + + + + +
${isDe ? 'Rechnungsnummer' : 'Invoice number'}${safeInvoice}
${isDe ? 'Offener Betrag' : 'Outstanding amount'}${safeTotal}
${isDe ? 'Überfällig seit' : 'Overdue by'}${safeDays} ${isDe ? 'Tagen' : 'days'}
+

${isDe + ? 'Bitte begleichen Sie den Betrag, damit Ihr Abonnement aktiv bleibt. Bei Nicht-Zahlung wird Ihr Abonnement automatisch pausiert.' + : 'Please settle the amount to keep your subscription active. Your subscription will be automatically paused if payment is not received.'}

+
+

${isDe ? 'Viele Grüße – Ihr ProfitPlanet Team' : 'Best regards – Your ProfitPlanet Team'}

+
+
+ +`; + + try { + const payload = { + sender: this.sender, + to: [{ email }], + subject, + textContent: text, + htmlContent: html, + }; + const data = await this.brevo.transactionalEmails.sendTransacEmail(payload); + logger.info('MailService.sendPaymentReminderEmail:email_sent', { email, lang, invoiceNumber }); + return data; + } catch (error) { + const brevoError = this._extractBrevoErrorDetails(error); + logger.error('MailService.sendPaymentReminderEmail:error', { + email, lang, invoiceNumber, + message: error?.message, + brevoStatus: brevoError.status, + brevoData: brevoError.data, + }); + throw error; + } + } + + async sendSubscriptionPausedEmail({ email, customerName, reason, lang = 'en' }) { + logger.info('MailService.sendSubscriptionPausedEmail:start', { email, lang, reason }); + const isDe = lang === 'de'; + + const subject = isDe + ? 'ProfitPlanet: Ihr Abonnement wurde pausiert' + : 'ProfitPlanet: Your subscription has been paused'; + + const safeName = this._escapeForHtml(customerName || ''); + + const reasonTextDe = reason === 'no_registered_user' + ? 'Der Empfänger hat sich noch nicht als Gastkunde registriert.' + : 'Wir konnten leider keinen Zahlungseingang für Ihre letzte Rechnung feststellen.'; + const reasonTextEn = reason === 'no_registered_user' + ? 'The recipient has not yet registered as a guest customer.' + : 'We were unable to confirm payment for your latest invoice.'; + + const text = isDe + ? [ + `Hallo ${customerName || ''},`, + '', + 'Ihr ProfitPlanet Kaffee-Abonnement wurde vorübergehend pausiert.', + '', + `Grund: ${reasonTextDe}`, + '', + reason === 'no_registered_user' + ? 'Sobald sich der Empfänger registriert hat, wird das Abonnement automatisch fortgesetzt.' + : 'Sobald die Zahlung eingegangen ist und vor dem 11. des Monats bestätigt wird, wird Ihr Abonnement im aktuellen Monat fortgesetzt. Andernfalls startet es im nächsten Monat.', + '', + 'Viele Grüße', + 'Ihr ProfitPlanet Team', + ].join('\n') + : [ + `Hi ${customerName || ''},`, + '', + 'Your ProfitPlanet coffee subscription has been temporarily paused.', + '', + `Reason: ${reasonTextEn}`, + '', + reason === 'no_registered_user' + ? 'Once the recipient registers, the subscription will automatically resume.' + : 'Once payment is confirmed before the 11th of the month, your subscription will continue in the current month. Otherwise, it will resume in the following month.', + '', + 'Best regards,', + 'Your ProfitPlanet Team', + ].join('\n'); + + const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || ''; + + const html = ` + + + + + + + +
+ + + + + + + + + + +
+ ${logoUrl ? `ProfitPlanet` : ''} +

${isDe ? 'Abonnement pausiert' : 'Subscription Paused'}

+
+

${isDe ? 'Hallo' : 'Hi'} ${safeName},

+

${isDe + ? 'Ihr Kaffee-Abonnement wurde vorübergehend pausiert.' + : 'Your coffee subscription has been temporarily paused.'}

+ + + + +
+ ${isDe ? 'Grund:' : 'Reason:'} ${this._escapeForHtml(isDe ? reasonTextDe : reasonTextEn)} +
+

${isDe + ? (reason === 'no_registered_user' + ? 'Sobald sich der Empfänger registriert hat, wird das Abonnement automatisch fortgesetzt.' + : 'Sobald die Zahlung eingegangen ist und vor dem 11. des Monats bestätigt wird, wird Ihr Abonnement im aktuellen Monat fortgesetzt. Andernfalls startet es im nächsten Monat.') + : (reason === 'no_registered_user' + ? 'Once the recipient registers, the subscription will automatically resume.' + : 'Once payment is confirmed before the 11th of the month, your subscription will continue in the current month. Otherwise, it will resume in the following month.')}

+
+

${isDe ? 'Viele Grüße – Ihr ProfitPlanet Team' : 'Best regards – Your ProfitPlanet Team'}

+
+
+ +`; + + try { + const payload = { + sender: this.sender, + to: [{ email }], + subject, + textContent: text, + htmlContent: html, + }; + const data = await this.brevo.transactionalEmails.sendTransacEmail(payload); + logger.info('MailService.sendSubscriptionPausedEmail:email_sent', { email, lang, reason }); + return data; + } catch (error) { + const brevoError = this._extractBrevoErrorDetails(error); + logger.error('MailService.sendSubscriptionPausedEmail:error', { + email, lang, reason, + message: error?.message, + brevoStatus: brevoError.status, + brevoData: brevoError.data, + }); + throw error; + } + } + _escapeForHtml(value) { return String(value ?? '') .replace(/&/g, '&')