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:
parent
ccf2f0212e
commit
c2bbb1df15
@ -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
206
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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'`,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
49
scripts/testRenewalCron.js
Normal file
49
scripts/testRenewalCron.js
Normal 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);
|
||||||
|
})();
|
||||||
@ -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';
|
||||||
|
|||||||
393
services/abonemments/RenewalCronService.js
Normal file
393
services/abonemments/RenewalCronService.js
Normal 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;
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user