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.
This commit is contained in:
Seazn 2026-03-15 14:16:46 +01:00
parent ccf2f0212e
commit c2bbb1df15
10 changed files with 973 additions and 106 deletions

View File

@ -38,7 +38,11 @@ function guestRestriction(req, res, next) {
const isAllowed = GUEST_ALLOWED_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix)); 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(); return next();
} }

206
package-lock.json generated
View File

@ -22,6 +22,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"mysql2": "^3.17.2", "mysql2": "^3.17.2",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"pidusage": "^4.0.1", "pidusage": "^4.0.1",
@ -995,13 +996,13 @@
} }
}, },
"node_modules/@aws-sdk/xml-builder": { "node_modules/@aws-sdk/xml-builder": {
"version": "3.972.5", "version": "3.972.11",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.5.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.11.tgz",
"integrity": "sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==", "integrity": "sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/types": "^4.12.0", "@smithy/types": "^4.13.1",
"fast-xml-parser": "5.3.6", "fast-xml-parser": "5.4.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@ -1598,9 +1599,9 @@
} }
}, },
"node_modules/@smithy/types": { "node_modules/@smithy/types": {
"version": "4.12.0", "version": "4.13.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
"integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.6.2" "tslib": "^2.6.2"
@ -1868,9 +1869,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.3.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -2065,11 +2066,10 @@
} }
}, },
"node_modules/bare-fs": { "node_modules/bare-fs": {
"version": "4.5.4", "version": "4.5.5",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz",
"integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"bare-events": "^2.5.4", "bare-events": "^2.5.4",
"bare-path": "^3.0.0", "bare-path": "^3.0.0",
@ -2090,11 +2090,10 @@
} }
}, },
"node_modules/bare-os": { "node_modules/bare-os": {
"version": "3.6.2", "version": "3.8.0",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz",
"integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"bare": ">=1.14.0" "bare": ">=1.14.0"
} }
@ -2104,17 +2103,15 @@
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"bare-os": "^3.0.1" "bare-os": "^3.0.1"
} }
}, },
"node_modules/bare-stream": { "node_modules/bare-stream": {
"version": "2.8.0", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz",
"integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"streamx": "^2.21.0", "streamx": "^2.21.0",
"teex": "^1.0.1" "teex": "^1.0.1"
@ -2137,7 +2134,6 @@
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",
"integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"bare-path": "^3.0.0" "bare-path": "^3.0.0"
} }
@ -2163,9 +2159,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/basic-ftp": { "node_modules/basic-ftp": {
"version": "5.1.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
"integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@ -2645,9 +2641,9 @@
} }
}, },
"node_modules/devtools-protocol": { "node_modules/devtools-protocol": {
"version": "0.0.1566079", "version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/dfa": { "node_modules/dfa": {
@ -2959,10 +2955,10 @@
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-xml-parser": { "node_modules/fast-xml-builder": {
"version": "5.3.7", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz",
"integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -2971,6 +2967,23 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "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" "strnum": "^2.1.2"
}, },
"bin": { "bin": {
@ -3694,9 +3707,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.2", "version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
@ -3709,33 +3722,12 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/mitt": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT" "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": { "node_modules/moment": {
"version": "2.30.1", "version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@ -3752,21 +3744,22 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "2.0.2", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^1.6.0", "busboy": "^1.6.0",
"concat-stream": "^2.0.0", "concat-stream": "^2.0.0",
"mkdirp": "^0.5.6", "type-is": "^1.6.18"
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
}, },
"engines": { "engines": {
"node": ">= 10.16.0" "node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/multer/node_modules/media-typer": { "node_modules/multer/node_modules/media-typer": {
@ -3870,6 +3863,15 @@
"node": "^18 || ^20 || >= 21" "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": { "node_modules/node-gyp-build": {
"version": "4.8.4", "version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@ -4066,6 +4068,21 @@
"node": ">= 0.8" "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": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -4195,9 +4212,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.3", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"end-of-stream": "^1.1.0", "end-of-stream": "^1.1.0",
@ -4205,18 +4222,18 @@
} }
}, },
"node_modules/puppeteer": { "node_modules/puppeteer": {
"version": "24.37.5", "version": "24.39.1",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.5.tgz", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.39.1.tgz",
"integrity": "sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ==", "integrity": "sha512-68Zc9QpcVvfxp2C+3UL88TyUogEAn5tSylXidbEuEXvhiqK1+v65zeBU5ubinAgEHMGr3dcSYqvYrGtdzsPI3w==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@puppeteer/browsers": "2.13.0", "@puppeteer/browsers": "2.13.0",
"chromium-bidi": "14.0.0", "chromium-bidi": "14.0.0",
"cosmiconfig": "^9.0.0", "cosmiconfig": "^9.0.0",
"devtools-protocol": "0.0.1566079", "devtools-protocol": "0.0.1581282",
"puppeteer-core": "24.37.5", "puppeteer-core": "24.39.1",
"typed-query-selector": "^2.12.0" "typed-query-selector": "^2.12.1"
}, },
"bin": { "bin": {
"puppeteer": "lib/cjs/puppeteer/node/cli.js" "puppeteer": "lib/cjs/puppeteer/node/cli.js"
@ -4226,16 +4243,16 @@
} }
}, },
"node_modules/puppeteer-core": { "node_modules/puppeteer-core": {
"version": "24.37.5", "version": "24.39.1",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.1.tgz",
"integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", "integrity": "sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@puppeteer/browsers": "2.13.0", "@puppeteer/browsers": "2.13.0",
"chromium-bidi": "14.0.0", "chromium-bidi": "14.0.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"devtools-protocol": "0.0.1566079", "devtools-protocol": "0.0.1581282",
"typed-query-selector": "^2.12.0", "typed-query-selector": "^2.12.1",
"webdriver-bidi-protocol": "0.4.1", "webdriver-bidi-protocol": "0.4.1",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
@ -4714,9 +4731,9 @@
} }
}, },
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
"integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"pump": "^3.0.0", "pump": "^3.0.0",
@ -4728,12 +4745,13 @@
} }
}, },
"node_modules/tar-stream": { "node_modules/tar-stream": {
"version": "3.1.7", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"b4a": "^1.6.4", "b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0", "fast-fifo": "^1.2.0",
"streamx": "^2.15.0" "streamx": "^2.15.0"
} }
@ -4743,7 +4761,6 @@
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"streamx": "^2.12.5" "streamx": "^2.12.5"
} }
@ -4831,9 +4848,9 @@
} }
}, },
"node_modules/typed-query-selector": { "node_modules/typed-query-selector": {
"version": "2.12.0", "version": "2.12.1",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz",
"integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/typedarray": { "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -19,9 +19,9 @@
}, },
"overrides": { "overrides": {
"@aws-sdk/xml-builder": { "@aws-sdk/xml-builder": {
"fast-xml-parser": "^5.3.4", "fast-xml-parser": "^5.3.4",
"ajv": "8.18.0" "ajv": "8.18.0"
} }
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.992.0", "@aws-sdk/client-s3": "^3.992.0",
@ -37,6 +37,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"mysql2": "^3.17.2", "mysql2": "^3.17.2",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"pidusage": "^4.0.1", "pidusage": "^4.0.1",

View File

@ -304,6 +304,15 @@ class AbonemmentRepository {
return rows.map((r) => new Abonemment(r)); 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) { async listActiveByProduct(productId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT * FROM coffee_abonements WHERE coffee_table_id = ? AND status = 'active'`, `SELECT * FROM coffee_abonements WHERE coffee_table_id = ? AND status = 'active'`,

View File

@ -3,10 +3,10 @@ const UnitOfWork = require('../database/UnitOfWork');
const argon2 = require('argon2'); const argon2 = require('argon2');
async function createAdminUser() { async function createAdminUser() {
return;
// const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; // 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 || 'alexander.ibrahim.ai@gmail.com';
// const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@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 || 'Chalanger75$%';
// const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; // const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025';
const firstName = process.env.ADMIN_FIRST_NAME || 'Admin'; const firstName = process.env.ADMIN_FIRST_NAME || 'Admin';

View File

@ -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 = <ID>;
*
* -- 2. To test "paid" flow: mark last invoice as paid:
* UPDATE invoices SET status = 'paid' WHERE source_type = 'subscription' AND source_id = <ABO_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 = <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);
})();

View File

@ -14,6 +14,7 @@ const createAdminUser = require('./scripts/createAdminUser');
const createCompanyUser = require('./scripts/createCompanyUser'); const createCompanyUser = require('./scripts/createCompanyUser');
const createPersonalUser = require('./scripts/createPersonalUser'); const createPersonalUser = require('./scripts/createPersonalUser');
const createGuestUser = require('./scripts/createGuestUser'); const createGuestUser = require('./scripts/createGuestUser');
const RenewalCronService = require('./services/abonemments/RenewalCronService');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@ -187,6 +188,10 @@ async function startServer() {
// Create guest user // Create guest user
await createGuestUser(); await createGuestUser();
// Start automatic subscription renewal cron job
const renewalCron = new RenewalCronService();
renewalCron.start();
// Start the server // Start the server
app.listen(PORT, () => { app.listen(PORT, () => {
const host = process.env.HOST || 'localhost'; const host = process.env.HOST || 'localhost';

View File

@ -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;

View File

@ -78,6 +78,29 @@ class EmailVerificationService {
await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork); await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork);
logger.info('EmailVerificationService.verifyCode:pending_check_complete', { userId }); 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 }); logger.info('EmailVerificationService.verifyCode:success', { userId });
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@ -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 = `<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr>
<td style="padding:24px 28px;background:#111827;color:#ffffff;">
${logoUrl ? `<img src="${this._escapeForHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Ihr Abonnement wurde verlängert' : 'Your subscription has been renewed'}</h1>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">${isDe ? 'Hallo' : 'Hi'} ${safeName},</p>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe
? 'Ihr Kaffee-Abonnement wurde automatisch verlängert. Nachfolgend finden Sie die Details:'
: 'Your coffee subscription has been automatically renewed. Here are the details:'}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin-bottom:18px;">
<tr>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Rechnungsnummer' : 'Invoice number'}</td>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${safeInvoiceNumber}</td>
</tr>
<tr>
<td style="padding:12px 14px;font-size:13px;color:#6b7280;">${isDe ? 'Gesamtbetrag' : 'Total amount'}</td>
<td style="padding:12px 14px;font-size:13px;text-align:right;font-weight:700;">${safeTotalGross}</td>
</tr>
<tr>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Nächste Verlängerung' : 'Next renewal'}</td>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${safeNextDate}</td>
</tr>
</table>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${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.'}</p>
<p style="margin:0 0 8px 0;font-size:13px;color:#6b7280;line-height:1.5;">${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.'}</p>
</td>
</tr>
<tr>
<td style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#6b7280;">${isDe ? 'Viele Grüße Ihr ProfitPlanet Team' : 'Best regards Your ProfitPlanet Team'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
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 = `<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr>
<td style="padding:24px 28px;background:#92400e;color:#ffffff;">
${logoUrl ? `<img src="${this._escapeForHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Zahlungserinnerung' : 'Payment Reminder'}</h1>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">${isDe ? 'Hallo' : 'Hi'} ${safeName},</p>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${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:'}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin-bottom:18px;">
<tr>
<td style="padding:12px 14px;background:#fef3c7;font-size:13px;color:#92400e;">${isDe ? 'Rechnungsnummer' : 'Invoice number'}</td>
<td style="padding:12px 14px;background:#fef3c7;font-size:13px;text-align:right;font-weight:700;color:#92400e;">${safeInvoice}</td>
</tr>
<tr>
<td style="padding:12px 14px;font-size:13px;color:#6b7280;">${isDe ? 'Offener Betrag' : 'Outstanding amount'}</td>
<td style="padding:12px 14px;font-size:13px;text-align:right;font-weight:700;">${safeTotal}</td>
</tr>
<tr>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Überfällig seit' : 'Overdue by'}</td>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${safeDays} ${isDe ? 'Tagen' : 'days'}</td>
</tr>
</table>
<p style="margin:0 0 8px 0;font-size:13px;color:#6b7280;line-height:1.5;">${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.'}</p>
</td>
</tr>
<tr>
<td style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#6b7280;">${isDe ? 'Viele Grüße Ihr ProfitPlanet Team' : 'Best regards Your ProfitPlanet Team'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
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 = `<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr>
<td style="padding:24px 28px;background:#dc2626;color:#ffffff;">
${logoUrl ? `<img src="${this._escapeForHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Abonnement pausiert' : 'Subscription Paused'}</h1>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">${isDe ? 'Hallo' : 'Hi'} ${safeName},</p>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe
? 'Ihr Kaffee-Abonnement wurde vorübergehend pausiert.'
: 'Your coffee subscription has been temporarily paused.'}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #fecaca;border-radius:8px;overflow:hidden;margin-bottom:18px;">
<tr>
<td style="padding:14px 16px;background:#fef2f2;font-size:14px;color:#991b1b;">
<strong>${isDe ? 'Grund:' : 'Reason:'}</strong> ${this._escapeForHtml(isDe ? reasonTextDe : reasonTextEn)}
</td>
</tr>
</table>
<p style="margin:0 0 8px 0;font-size:14px;line-height:1.6;">${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.')}</p>
</td>
</tr>
<tr>
<td style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#6b7280;">${isDe ? 'Viele Grüße Ihr ProfitPlanet Team' : 'Best regards Your ProfitPlanet Team'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
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) { _escapeForHtml(value) {
return String(value ?? '') return String(value ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')