Automatische Mails im neuen Design (HTML mit Textfassung)
Alle 22 automatischen Mails gehen jetzt als HTML nach den Vorlagen aus
server/mail-templates raus, dazu eine aus dem HTML erzeugte Textfassung
(multipart/alternative). Werte werden HTML-sicher eingesetzt, optionale
Blöcke (WENN/ENDE) je nach Fall ein- oder ausgeblendet.
- Neu: services/mailTemplates.js (Vorlagen lesen, Platzhalter, Blöcke,
Textfassung); mailer.js nimmt { template, values, blocks }.
- K11 "Passwort geändert" war nicht im Design-Export: aus den Bausteinen
von K02 zusammengesetzt.
- T09 (Widerruf, intern): Fälle "kein Abo gefunden" und "Stripe-Kündigung
fehlgeschlagen" ergänzt; bisher stand dort immer "auf Kündigung gesetzt".
- Einheitlich: Datum TT.MM.JJJJ, Beträge deutsch (T04 "50,00 €"),
"WEBklar" statt "Webklar", Anrede mit Kundennamen, Team-Absender
"WEBklar Kundenportal", Betreff T05 "Kündigung (Stripe)".
- Logo für die Mails: public/webklar-logo.png (192 px, aus dem Design-SVG).
Tests: 52 neue (jede Vorlage: Kopf, Platzhalter, Blöcke, Rendern,
Escaping, Textfassung), npm test 97/97 auf Node 20.19.5; alle 40 Auslöser
einmal durch den Server geschickt (Appwrite-Mock, Stripe-Double,
SMTP-Falle mit STARTTLS).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3daffffa3
commit
18c3cdf3e5
11 files changed
+657
-316
No files matched your search
@@ -56,6 +56,15 @@ Database: woms-database
|
|||||||
|
|
||||||
`customerId` wird **im Ticketsystem** am `websiteProjects`-Eintrag gesetzt, nicht im Webhook.
|
`customerId` wird **im Ticketsystem** am `websiteProjects`-Eintrag gesetzt, nicht im Webhook.
|
||||||
|
|
||||||
|
## E-Mails
|
||||||
|
|
||||||
|
Alle automatischen Mails sind HTML-Vorlagen mit Textfassung in `server/mail-templates/`
|
||||||
|
(K01–K11 an Kunden, T01–T11 ans Team; Design aus Claude Design). Jede Vorlage nennt im
|
||||||
|
Kopfkommentar Betreff, Platzhalter (`{{name}}`) und optionale Blöcke
|
||||||
|
(`<!-- WENN x --> … <!-- ENDE x -->`). Eine neue Fassung aus dem Design kann die Datei direkt
|
||||||
|
ersetzen, solange Platzhalter und Blöcke gleich heißen; `npm test` prüft das. Das Logo in den
|
||||||
|
Mails liegt unter `public/webklar-logo.png`.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<!--
|
||||||
|
K11 · Passwort geändert (Kundenportal)
|
||||||
|
Betreff: Ihr Passwort für das WEBklar Kundenportal wurde geändert
|
||||||
|
Absender: „WEBklar“ <Portal-Postfach>
|
||||||
|
Empfänger: Kunde (E-Mail des Portal-Zugangs) · BCC intern
|
||||||
|
Platzhalter: {{name}}, {{email}}, {{geaendert_am}}
|
||||||
|
Nicht im Design-Export enthalten: aus den Bausteinen von K02 zusammengesetzt.
|
||||||
|
-->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<meta name="supported-color-schemes" content="light dark">
|
||||||
|
<title>Ihr Passwort für das WEBklar Kundenportal wurde geändert</title>
|
||||||
|
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||||
|
<style>
|
||||||
|
a{color:#111111;}
|
||||||
|
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||||
|
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||||
|
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Ihr Passwort wurde am {{geaendert_am}} Uhr geändert. ͏ ͏ ͏</span>
|
||||||
|
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||||
|
<tr><td align="center" style="padding:32px 12px;">
|
||||||
|
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||||
|
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||||
|
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||||
|
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Sicherheitshinweis</td>
|
||||||
|
</tr></table></td></tr>
|
||||||
|
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Passwort geändert</td></tr></table></td></tr>
|
||||||
|
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihr Passwort wurde geändert.</td></tr>
|
||||||
|
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>das Passwort für Ihren Zugang zum WEBklar Kundenportal ({{email}}) wurde am {{geaendert_am}} Uhr geändert.</td></tr>
|
||||||
|
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Auf allen anderen Geräten wurden Sie dabei abgemeldet.</td></tr>
|
||||||
|
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Waren Sie das? Dann ist nichts weiter zu tun.</td></tr>
|
||||||
|
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf6e7;border:1px solid #f0dcb0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Sie waren das nicht?</div><div class="mut" style="color:#4a4843;">Dann melden Sie sich bitte umgehend bei uns: <a class="mut" href="mailto:support@webklar.com" style="color:#4a4843;">support@webklar.com</a> oder 0176 23726355.</div></td></tr></table></td></tr>
|
||||||
|
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a> · <a class="mut" href="https://project.webklar.com/widerrufen.html" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a> · <a class="mut" href="https://project.webklar.com/kuendigen.html" style="color:#4a4843;text-decoration:underline;">Vertrag kündigen</a></td></tr>
|
||||||
|
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
|
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
|
||||||
Empfänger: kenso@webklar.com, andrej@webklar.com
|
Empfänger: kenso@webklar.com, andrej@webklar.com
|
||||||
Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}
|
Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}
|
||||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer
|
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen
|
||||||
-->
|
-->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
@@ -38,7 +38,19 @@ a{color:#111111;}
|
|||||||
<!-- WENN kundennummer -->
|
<!-- WENN kundennummer -->
|
||||||
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kundennummer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kundennummer}}</td></tr>
|
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kundennummer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kundennummer}}</td></tr>
|
||||||
<!-- ENDE kundennummer -->
|
<!-- ENDE kundennummer -->
|
||||||
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stripe_abo_id}} <span class="mut" style="color:#6b6964;">(auf Kündigung gesetzt)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Abo-Beginn</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{abo_beginn}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Widerrufsfrist bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{fristende}}, 24 Uhr</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Eingang</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;"><strong>{{rechtzeitig}}</strong></td></tr></table></td></tr>
|
<!-- WENN abo_gefunden -->
|
||||||
|
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stripe_abo_id}}<!-- WENN auf_kuendigung_gesetzt --> <span class="mut" style="color:#6b6964;">(auf Kündigung gesetzt)</span><!-- ENDE auf_kuendigung_gesetzt --></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Abo-Beginn</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{abo_beginn}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Widerrufsfrist bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{fristende}}, 24 Uhr</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Eingang</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;"><strong>{{rechtzeitig}}</strong></td></tr>
|
||||||
|
<!-- ENDE abo_gefunden -->
|
||||||
|
</table></td></tr>
|
||||||
|
|
||||||
|
<!-- WENN kein_abo_gefunden -->
|
||||||
|
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">KEIN aktives Abo zu dieser E-Mail gefunden. Bitte von Hand zuordnen und die Widerrufsfrist prüfen.</div></td></tr></table></td></tr>
|
||||||
|
<!-- ENDE kein_abo_gefunden -->
|
||||||
|
|
||||||
|
<!-- WENN kuendigung_fehlgeschlagen -->
|
||||||
|
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Abo {{stripe_abo_id}} konnte NICHT automatisch auf Kündigung gesetzt werden. Bitte in Stripe von Hand kündigen.</div></td></tr></table></td></tr>
|
||||||
|
<!-- ENDE kuendigung_fehlgeschlagen -->
|
||||||
|
|
||||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Abo in Stripe sofort beenden, Wertersatz berechnen (anteilige Tage) und den Rest innerhalb von 14 Tagen erstatten (Stripe → Rückerstattung).</div></td></tr></table></td></tr>
|
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Abo in Stripe sofort beenden, Wertersatz berechnen (anteilige Tage) und den Rest innerhalb von 14 Tagen erstatten (Stripe → Rückerstattung).</div></td></tr></table></td></tr>
|
||||||
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;"> </td></tr>
|
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;"> </td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+143
-151
@@ -27,9 +27,13 @@ const router = Router()
|
|||||||
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
|
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
|
||||||
|
|
||||||
const fmtDate = (iso) =>
|
const fmtDate = (iso) =>
|
||||||
iso ? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '–'
|
iso
|
||||||
|
? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
: '–'
|
||||||
const fmtDateTime = (d) =>
|
const fmtDateTime = (d) =>
|
||||||
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
|
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
const fmtEuro = (cents) =>
|
||||||
|
(Number(cents || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
|
||||||
|
|
||||||
function addMonthsIso(iso, months) {
|
function addMonthsIso(iso, months) {
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
@@ -182,88 +186,64 @@ router.post('/portal', requireSession, async (req, res) => {
|
|||||||
// Stripe-Webhook
|
// Stripe-Webhook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function sendContractConfirmation(subDoc) {
|
/** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */
|
||||||
|
async function customerNameOf(sub, fallback = '') {
|
||||||
|
const customer = sub?.customerId
|
||||||
|
? await getDocument(config.collections.customers, sub.customerId).catch(() => null)
|
||||||
|
: null
|
||||||
|
return customer?.name || fallback || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendContractConfirmation(subDoc, fallbackName = '') {
|
||||||
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
|
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
|
||||||
const pdfPath = path.join(publicDir, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
|
const pdfPath = path.join(publicDir, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
|
||||||
const pdf = await readFile(pdfPath).catch(() => null)
|
const pdf = await readFile(pdfPath).catch(() => null)
|
||||||
const lateStart = subDoc.startChoice === 'nach_frist'
|
const lateStart = subDoc.startChoice === 'nach_frist'
|
||||||
const consentLine = lateStart
|
await sendCustomerMail(subDoc.email, {
|
||||||
? `Sie haben gewählt, dass wir erst nach Ablauf der Widerrufsfrist beginnen. Leistungsbeginn und erste Abbuchung: ${fmtDate(subDoc.trialEnd)}. Bis dahin erbringen wir keine Leistungen und buchen nichts ab.`
|
template: 'K01',
|
||||||
: 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen, und wurden darüber informiert, dass Sie bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahlen.'
|
values: {
|
||||||
await sendCustomerMail(
|
name: await customerNameOf(subDoc, fallbackName),
|
||||||
subDoc.email,
|
angebot_text: subDoc.offerText || '',
|
||||||
'Ihr WEBklar Wartungs-Abo – Vertragsbestätigung',
|
vertragsschluss: fmtDate(subDoc.startedAt),
|
||||||
[
|
leistungsbeginn: fmtDate(lateStart ? subDoc.trialEnd : subDoc.startedAt),
|
||||||
'Guten Tag,',
|
// Abos nach Bedingungen ohne Mindestlaufzeit (vor v3) entstehen nicht mehr; sie hätten hier "–"
|
||||||
'',
|
mindestlaufzeit_bis: fmtDate(minTermEndOf(subDoc)),
|
||||||
'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:',
|
bedingungen_version: subDoc.termsVersion || config.abo.termsVersion,
|
||||||
'',
|
zustimmung_zeitpunkt: fmtDateTime(subDoc.termsAcceptedAt || Date.now()),
|
||||||
' Vertrag: WEBklar Wartungs-Abo',
|
},
|
||||||
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
|
blocks: {
|
||||||
...(subDoc.offerText ? [` Angebot: ${subDoc.offerText} (wie vereinbart)`] : []),
|
angebot: Boolean(subDoc.offerText),
|
||||||
` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
|
sofortbeginn: !lateStart,
|
||||||
` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
|
beginn_nach_widerrufsfrist: lateStart,
|
||||||
...(minTermEndOf(subDoc)
|
},
|
||||||
? [
|
attachments: pdf
|
||||||
` Laufzeit: Mindestlaufzeit 12 Monate bis ${fmtDate(minTermEndOf(subDoc))}; Kündigung zu diesem Termin bis einen Monat vorher,`,
|
|
||||||
' danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar',
|
|
||||||
]
|
|
||||||
: [' Laufzeit: unbestimmt, jederzeit zum Ende des Abrechnungsmonats kündbar']),
|
|
||||||
' Enthalten: Betrieb und Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach,',
|
|
||||||
' 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)',
|
|
||||||
'',
|
|
||||||
`Ihre Zustimmung zu den Vertragsbedingungen (Version ${subDoc.termsVersion || config.abo.termsVersion}) haben wir am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} erhalten.`,
|
|
||||||
consentLine,
|
|
||||||
'',
|
|
||||||
'Im Anhang finden Sie die vollständigen Vertragsunterlagen als PDF: Vertragsbedingungen mit Leistungsbeschreibung,',
|
|
||||||
'Widerrufsbelehrung mit Muster-Widerrufsformular und Auftragsverarbeitungsvertrag.',
|
|
||||||
'',
|
|
||||||
`Ihr Stundenkontingent sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
|
|
||||||
`Vertrag widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
|
|
||||||
`Verträge hier kündigen: ${config.publicBaseUrl}/kuendigen.html`,
|
|
||||||
'',
|
|
||||||
'Viele Grüße',
|
|
||||||
'Ihr WEBklar-Team',
|
|
||||||
'',
|
|
||||||
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com',
|
|
||||||
'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein',
|
|
||||||
],
|
|
||||||
pdf
|
|
||||||
? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
|
? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
|
||||||
: []
|
: [],
|
||||||
)
|
})
|
||||||
await updateDocument(config.collections.subscriptions, subDoc.$id, {
|
await updateDocument(config.collections.subscriptions, subDoc.$id, {
|
||||||
confirmationSentAt: new Date().toISOString(),
|
confirmationSentAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kuendigung ueber "Abo verwalten" (Stripe-Kundenportal): Bestaetigung in Textform + Hinweis an uns. */
|
/** Kuendigung direkt bei Stripe (Dashboard/Kundenportal): Bestaetigung in Textform + Hinweis an uns. */
|
||||||
async function sendPortalCancellationConfirmation(sub, createdUnix) {
|
async function sendPortalCancellationConfirmation(sub, createdUnix) {
|
||||||
const receivedAt = new Date((createdUnix || Date.now() / 1000) * 1000)
|
const receivedAt = new Date((createdUnix || Date.now() / 1000) * 1000)
|
||||||
const endsAt = sub.cancelAt || sub.currentPeriodEnd
|
const endsAt = sub.cancelAt || sub.currentPeriodEnd
|
||||||
await sendCustomerMail(sub.email, 'Bestätigung Ihrer Kündigung – WEBklar Wartungs-Abo', [
|
await sendCustomerMail(sub.email, {
|
||||||
'Guten Tag,',
|
template: 'K02',
|
||||||
'',
|
values: {
|
||||||
`wir bestätigen Ihre Kündigung des WEBklar Wartungs-Abos, eingegangen am ${fmtDateTime(receivedAt)} über „Abo verwalten“ im Kundenportal.`,
|
name: await customerNameOf(sub),
|
||||||
'',
|
eingang_zeitpunkt: fmtDateTime(receivedAt),
|
||||||
`Ihr Wartungs-Abo endet zum ${fmtDate(endsAt)}. Bis dahin bleiben alle Leistungen verfügbar,`,
|
vertragsende: fmtDate(endsAt),
|
||||||
'danach erfolgen keine weiteren Abbuchungen. Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende.',
|
},
|
||||||
'Ihre Daten, den Domain-AuthCode und Ihr Postfach stellen wir Ihnen auf Wunsch gemäß § 9 der Vertragsbedingungen bereit.',
|
})
|
||||||
'',
|
|
||||||
'Falls Sie das nicht veranlasst haben, melden Sie sich bitte umgehend bei uns.',
|
|
||||||
'',
|
|
||||||
'Viele Grüße',
|
|
||||||
'Ihr WEBklar-Team',
|
|
||||||
'',
|
|
||||||
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com',
|
|
||||||
])
|
|
||||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||||
cancelRequestedAt: receivedAt.toISOString(),
|
cancelRequestedAt: receivedAt.toISOString(),
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
await sendAdminNotice(`Kündigung (Kundenportal): ${sub.email}`, [
|
await sendAdminNotice({
|
||||||
`Abo ${sub.stripeSubscriptionId} endet zum ${fmtDate(endsAt)}. Bestätigung an den Kunden ist raus.`,
|
template: 'T05',
|
||||||
'Hosting/Domain/Postfach gemäß Ziff. 9 der Abo-Bedingungen abwickeln (Export, AuthCode).',
|
values: { email: sub.email, stripe_abo_id: sub.stripeSubscriptionId, vertragsende: fmtDate(endsAt) },
|
||||||
])
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStripeEvent(event) {
|
async function handleStripeEvent(event) {
|
||||||
@@ -273,20 +253,24 @@ async function handleStripeEvent(event) {
|
|||||||
if (obj.mode !== 'subscription' || !obj.subscription) return
|
if (obj.mode !== 'subscription' || !obj.subscription) return
|
||||||
const sub = await syncSubscription(obj.subscription)
|
const sub = await syncSubscription(obj.subscription)
|
||||||
const offerProblem = await activateOfferFromCheckout(obj).then(
|
const offerProblem = await activateOfferFromCheckout(obj).then(
|
||||||
() => '',
|
() => false,
|
||||||
(e) => {
|
(e) => {
|
||||||
console.error('[abo] Angebot konnte nicht aktiviert werden:', e.message)
|
console.error('[abo] Angebot konnte nicht aktiviert werden:', e.message)
|
||||||
return `ACHTUNG: Angebot konnte nicht automatisch angewendet werden (${e.message}) -> Rabatt in Stripe am Abo prüfen!`
|
return true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await sendAdminNotice(`Neues Wartungs-Abo: ${sub?.email || obj.customer_details?.email}`, [
|
await sendAdminNotice({
|
||||||
`Kunde: ${obj.customer_details?.name || ''} <${obj.customer_details?.email || ''}>`,
|
template: 'T02',
|
||||||
`Appwrite-Kunde: ${sub?.customerId || obj.client_reference_id}`,
|
values: {
|
||||||
`Stripe-Abo: ${obj.subscription}`,
|
email: sub?.email || obj.customer_details?.email || '',
|
||||||
...(obj.metadata?.offerText ? [`Angebot: ${obj.metadata.offerText}`] : []),
|
kunde_name: obj.customer_details?.name || obj.customer_details?.email || '',
|
||||||
...(offerProblem ? [offerProblem] : []),
|
appwrite_id: sub?.customerId || obj.client_reference_id || '',
|
||||||
])
|
stripe_abo_id: obj.subscription,
|
||||||
await sendContractConfirmation(sub).catch((e) =>
|
angebot_text: obj.metadata?.offerText || '',
|
||||||
|
},
|
||||||
|
blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem },
|
||||||
|
})
|
||||||
|
await sendContractConfirmation(sub, obj.customer_details?.name).catch((e) =>
|
||||||
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
|
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -301,10 +285,14 @@ async function handleStripeEvent(event) {
|
|||||||
case 'invoice.payment_failed': {
|
case 'invoice.payment_failed': {
|
||||||
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
|
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
|
||||||
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
|
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
|
||||||
await sendAdminNotice(`Zahlung fehlgeschlagen: ${sub?.email || obj.customer_email || ''}`, [
|
await sendAdminNotice({
|
||||||
`Rechnung ${obj.number || obj.id}, Betrag ${(obj.amount_due / 100).toFixed(2)} €.`,
|
template: 'T04',
|
||||||
'Stripe versucht die Zahlung automatisch erneut und mahnt per E-Mail.',
|
values: {
|
||||||
])
|
email: sub?.email || obj.customer_email || '',
|
||||||
|
rechnung_nr: obj.number || obj.id,
|
||||||
|
betrag: fmtEuro(obj.amount_due),
|
||||||
|
},
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'customer.subscription.created':
|
case 'customer.subscription.created':
|
||||||
@@ -327,18 +315,16 @@ async function handleStripeEvent(event) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (reactivated && sub) {
|
if (reactivated && sub) {
|
||||||
await sendAdminNotice(`Kündigung zurückgenommen: ${sub.email}`, [
|
await sendAdminNotice({ template: 'T06', values: { email: sub.email, stripe_abo_id: obj.id } })
|
||||||
`Abo ${obj.id} läuft wieder normal weiter (Kündigung aufgehoben).`,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'customer.subscription.deleted': {
|
case 'customer.subscription.deleted': {
|
||||||
const sub = await syncSubscription(obj.id)
|
const sub = await syncSubscription(obj.id)
|
||||||
await sendAdminNotice(`Abo beendet: ${sub?.email || ''}`, [
|
await sendAdminNotice({
|
||||||
`Stripe-Abo ${obj.id} ist beendet (Status ${obj.status}). Restguthaben verfällt.`,
|
template: 'T07',
|
||||||
'Hosting/Domain/Postfach gemäß Ziff. 9 der Abo-Bedingungen abwickeln (Export, AuthCode).',
|
values: { email: sub?.email || '', stripe_abo_id: obj.id, status: obj.status },
|
||||||
])
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -426,44 +412,45 @@ router.post('/public/cancel', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kindLabel = { ordentlich: 'ordentliche Kündigung', ausserordentlich: 'außerordentliche Kündigung', geschaeftsaufgabe: 'Sonderkündigung wegen Geschäftsaufgabe' }[d.kind]
|
const kindLabel = { ordentlich: 'ordentliche Kündigung', ausserordentlich: 'außerordentliche Kündigung', geschaeftsaufgabe: 'Sonderkündigung wegen Geschäftsaufgabe' }[d.kind]
|
||||||
const effectiveText = effectiveAt
|
const common = {
|
||||||
? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)}.${d.kind === 'geschaeftsaufgabe' ? ' Bitte senden Sie uns innerhalb von zwei Wochen einen Nachweis der Geschäftsaufgabe (z. B. Gewerbeabmeldung) an support@webklar.com.' : ''}`
|
name: d.name,
|
||||||
: d.kind === 'ausserordentlich'
|
email: d.email,
|
||||||
? 'Ihre außerordentliche Kündigung prüfen wir umgehend und bestätigen Ihnen das Vertragsende gesondert.'
|
kundennummer: d.customerNumber,
|
||||||
: 'Wir ordnen Ihre Kündigung Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
|
wunschtermin: d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
|
||||||
|
grund: d.reason,
|
||||||
|
vertragsende: fmtDate(effectiveAt),
|
||||||
|
}
|
||||||
|
|
||||||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [
|
await sendCustomerMail(d.email, {
|
||||||
`Guten Tag ${d.name},`,
|
template: 'K03',
|
||||||
'',
|
values: { ...common, kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) },
|
||||||
`wir haben Ihre ${kindLabel} am ${fmtDateTime(receivedAt)} erhalten.`,
|
blocks: {
|
||||||
'',
|
kundennummer: Boolean(d.customerNumber),
|
||||||
` Name: ${d.name}`,
|
grund: Boolean(d.reason),
|
||||||
` E-Mail: ${d.email}`,
|
automatisch_gekuendigt: Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
|
||||||
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
|
sonderkuendigung: Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
|
||||||
' Vertrag: Webklar Wartungs-Abo',
|
ausserordentlich: !effectiveAt && d.kind === 'ausserordentlich',
|
||||||
` Gewünschter Zeitpunkt: ${d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt'}`,
|
kein_abo_gefunden: !effectiveAt && d.kind !== 'ausserordentlich',
|
||||||
d.reason ? ` Grund: ${d.reason}` : null,
|
},
|
||||||
'',
|
}).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
|
||||||
effectiveText,
|
|
||||||
'Ein bestehendes Stundenguthaben verfällt mit Vertragsende. Ihre Daten, den Domain-AuthCode und',
|
|
||||||
'Ihr Postfach stellen wir Ihnen auf Wunsch gemäß Ziff. 9 der Abo-Bedingungen zur Verfügung.',
|
|
||||||
'',
|
|
||||||
'Viele Grüße',
|
|
||||||
'Ihr Webklar-Team',
|
|
||||||
].filter((l) => l !== null)).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
|
|
||||||
|
|
||||||
await sendAdminNotice(`Kündigung eingegangen: ${d.email}`, [
|
await sendAdminNotice({
|
||||||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
template: 'T08',
|
||||||
`Art: ${kindLabel}, gewünschter Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
|
values: {
|
||||||
d.reason ? `Grund: ${d.reason}` : '',
|
...common,
|
||||||
effectiveAt
|
kuendigungsart: kindLabel[0].toUpperCase() + kindLabel.slice(1),
|
||||||
? `Automatisch in Stripe zum ${fmtDate(effectiveAt)} gekündigt (${sub.stripeSubscriptionId}).`
|
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||||
: sub
|
},
|
||||||
? `Abo ${sub.stripeSubscriptionId} gefunden, aber NICHT automatisch gekündigt (außerordentlich) -> prüfen und Vertragsende bestätigen!`
|
blocks: {
|
||||||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
|
kundennummer: Boolean(d.customerNumber),
|
||||||
d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '',
|
grund: Boolean(d.reason),
|
||||||
d.kind === 'geschaeftsaufgabe' ? 'Sonderkündigung Geschäftsaufgabe -> Nachweis (z. B. Gewerbeabmeldung) anfordern/prüfen; ohne Nachweis Kündigungstermin in Stripe auf Ende der Mindestlaufzeit setzen.' : '',
|
automatisch_gekuendigt: Boolean(effectiveAt),
|
||||||
])
|
sonderkuendigung: d.kind === 'geschaeftsaufgabe',
|
||||||
|
// Abo gefunden, aber nicht gekuendigt: ausserordentlich oder Stripe-Fehler
|
||||||
|
ausserordentlich: !effectiveAt && Boolean(sub),
|
||||||
|
kein_abo_gefunden: !sub,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||||
})
|
})
|
||||||
@@ -475,11 +462,13 @@ router.post('/public/withdraw', async (req, res) => {
|
|||||||
const receivedAt = new Date()
|
const receivedAt = new Date()
|
||||||
|
|
||||||
let sub = null
|
let sub = null
|
||||||
|
let canceled = false
|
||||||
try {
|
try {
|
||||||
sub = await findActiveSubscriptionByEmail(d.email)
|
sub = await findActiveSubscriptionByEmail(d.email)
|
||||||
if (sub) {
|
if (sub) {
|
||||||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
||||||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')
|
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')
|
||||||
|
canceled = true
|
||||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||||
withdrawalRequestedAt: receivedAt.toISOString(),
|
withdrawalRequestedAt: receivedAt.toISOString(),
|
||||||
})
|
})
|
||||||
@@ -488,36 +477,39 @@ router.post('/public/withdraw', async (req, res) => {
|
|||||||
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihres Widerrufs – Webklar Wartungs-Abo', [
|
await sendCustomerMail(d.email, {
|
||||||
`Guten Tag ${d.name},`,
|
template: 'K04',
|
||||||
'',
|
values: { name: d.name, eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber },
|
||||||
`wir haben Ihren Widerruf des Vertrags „Webklar Wartungs-Abo" am ${fmtDateTime(receivedAt)} erhalten.`,
|
blocks: { kundennummer: Boolean(d.customerNumber) },
|
||||||
'',
|
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
||||||
` Name: ${d.name}`,
|
|
||||||
` E-Mail: ${d.email}`,
|
|
||||||
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
|
|
||||||
'',
|
|
||||||
'Es erfolgen keine weiteren Abbuchungen. Bereits gezahlte Beträge erstatten wir innerhalb von 14 Tagen',
|
|
||||||
'über das ursprüngliche Zahlungsmittel, abzüglich eines anteiligen Wertersatzes für die bis zum Widerruf',
|
|
||||||
'erbrachten Leistungen (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.',
|
|
||||||
'',
|
|
||||||
'Viele Grüße',
|
|
||||||
'Ihr Webklar-Team',
|
|
||||||
].filter((l) => l !== null)).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
|
||||||
|
|
||||||
// Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zählen muss
|
// Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zählen muss
|
||||||
const deadline = sub?.startedAt ? withdrawalDeadline(sub.startedAt) : null
|
const deadline = sub?.startedAt ? withdrawalDeadline(sub.startedAt) : null
|
||||||
await sendAdminNotice(`WIDERRUF eingegangen: ${d.email}`, [
|
await sendAdminNotice({
|
||||||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
template: 'T09',
|
||||||
sub
|
values: {
|
||||||
? `Stripe-Abo ${sub.stripeSubscriptionId} (Beginn ${fmtDate(sub.startedAt)}) auf Kündigung gesetzt.`
|
email: d.email,
|
||||||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> manuell zuordnen!',
|
name: d.name,
|
||||||
deadline
|
kundennummer: d.customerNumber,
|
||||||
? `Widerrufsfrist bis ${fmtDate(deadline.endsAt)}, 24 Uhr – Eingang ${receivedAt <= deadline.endsAt ? 'RECHTZEITIG' : 'NACH Fristende, vor einer Ablehnung bitte prüfen'}.`
|
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||||
: 'Widerrufsfrist: 14 Tage ab dem Tag nach Vertragsschluss bis 24 Uhr; fällt der letzte Tag auf Sa/So/Feiertag, am nächsten Werktag.',
|
abo_beginn: fmtDate(sub?.startedAt),
|
||||||
'TODO: Abo in Stripe sofort beenden, Wertersatz berechnen (anteilige Tage) und Rest innerhalb 14 Tagen',
|
fristende: fmtDate(deadline?.endsAt),
|
||||||
'erstatten (Stripe -> Rückerstattung).',
|
rechtzeitig: !sub
|
||||||
])
|
? 'ungeprüft (kein Abo gefunden)'
|
||||||
|
: !deadline
|
||||||
|
? 'ungeprüft (Abo-Beginn fehlt)'
|
||||||
|
: receivedAt <= deadline.endsAt
|
||||||
|
? 'RECHTZEITIG'
|
||||||
|
: 'NACH Fristende, vor einer Ablehnung bitte prüfen',
|
||||||
|
},
|
||||||
|
blocks: {
|
||||||
|
kundennummer: Boolean(d.customerNumber),
|
||||||
|
abo_gefunden: Boolean(sub),
|
||||||
|
auf_kuendigung_gesetzt: canceled,
|
||||||
|
kein_abo_gefunden: !sub,
|
||||||
|
kuendigung_fehlgeschlagen: Boolean(sub) && !canceled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { forgetStoredPortalPassword } from './customerAdmin.js'
|
|||||||
import { sendCustomerMail } from './mailer.js'
|
import { sendCustomerMail } from './mailer.js'
|
||||||
import { rememberPasswordVersion } from './passwordVersion.js'
|
import { rememberPasswordVersion } from './passwordVersion.js'
|
||||||
|
|
||||||
const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
|
|
||||||
|
|
||||||
const fmtDateTime = (d) =>
|
const fmtDateTime = (d) =>
|
||||||
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
|
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
|
||||||
@@ -77,20 +75,10 @@ function updateError(err) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendPasswordChangedMail(email, name, changedAt) {
|
function sendPasswordChangedMail(email, name, changedAt) {
|
||||||
return sendCustomerMail(email, 'Ihr Passwort für den WEBklar-Kundenbereich wurde geändert', [
|
return sendCustomerMail(email, {
|
||||||
`Guten Tag ${name || ''}`.trim() + ',',
|
template: 'K11',
|
||||||
'',
|
values: { name: name || '', email, geaendert_am: fmtDateTime(changedAt) },
|
||||||
`das Passwort für Ihren Zugang zum WEBklar-Kundenbereich (${email}) wurde am ${fmtDateTime(changedAt)} Uhr geändert.`,
|
})
|
||||||
'Auf allen anderen Geräten wurden Sie dabei abgemeldet.',
|
|
||||||
'',
|
|
||||||
'Waren Sie das? Dann ist nichts weiter zu tun.',
|
|
||||||
'Falls nicht, melden Sie sich bitte umgehend bei uns: support@webklar.com',
|
|
||||||
'',
|
|
||||||
'Viele Grüße',
|
|
||||||
'Ihr WEBklar-Team',
|
|
||||||
'',
|
|
||||||
FOOTER,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML-Mails aus den Vorlagen in server/mail-templates (Claude-Design-Projekt
|
||||||
|
* "Webklar Bestätigungsmail gestalten"; K = an Kunden, T = intern ans Team).
|
||||||
|
*
|
||||||
|
* Jede Vorlage beschreibt sich im Kopfkommentar selbst:
|
||||||
|
* Betreff: Text mit {{platzhaltern}}
|
||||||
|
* Platzhalter: {{name}}, {{email}}, ...
|
||||||
|
* Optionale Blöcke (...): angebot, grund -> <!-- WENN angebot --> ... <!-- ENDE angebot -->
|
||||||
|
* renderMail() setzt die Werte HTML-sicher ein, entfernt nicht zutreffende Blöcke samt Inhalt und
|
||||||
|
* liefert Betreff, HTML und eine Textfassung für Mailprogramme ohne HTML. Eine neue Fassung aus dem
|
||||||
|
* Design kann eine Datei 1:1 ersetzen, solange Platzhalter und Blöcke gleich heißen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'mail-templates')
|
||||||
|
|
||||||
|
// In den Vorlagen fest eingetragene Portal-Adresse; beim Rendern gilt config.publicBaseUrl
|
||||||
|
const DESIGN_BASE_URL = 'https://project.webklar.com'
|
||||||
|
|
||||||
|
const cache = new Map()
|
||||||
|
|
||||||
|
/** IDs aller Vorlagen (K01, K02, ..., T11) */
|
||||||
|
export function templateIds() {
|
||||||
|
return readdirSync(TEMPLATE_DIR)
|
||||||
|
.filter((f) => /^[KT]\d{2} .*\.html$/.test(f))
|
||||||
|
.map((f) => f.slice(0, 3))
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameList = (line) =>
|
||||||
|
String(line || '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().replace(/^\{\{|\}\}$/g, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
/** Vorlage lesen und Kopfkommentar auswerten (einmal je Prozess). */
|
||||||
|
export function loadTemplate(id) {
|
||||||
|
if (cache.has(id)) return cache.get(id)
|
||||||
|
const file = readdirSync(TEMPLATE_DIR).find((f) => f.startsWith(`${id} `) && f.endsWith('.html'))
|
||||||
|
if (!file) throw new Error(`Mail-Vorlage ${id} fehlt`)
|
||||||
|
const source = readFileSync(path.join(TEMPLATE_DIR, file), 'utf8')
|
||||||
|
const header = source.match(/<!--[ \t]*\n\s*([KT]\d{2}) ·[\s\S]*?-->\n?/)
|
||||||
|
if (header?.[1] !== id) throw new Error(`Mail-Vorlage ${file}: Kopfkommentar fehlt`)
|
||||||
|
const field = (label) => header[0].match(new RegExp(`^\\s*${label}[^:\\n]*:[ \\t]*(.*)$`, 'm'))?.[1]?.trim() || ''
|
||||||
|
const template = {
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
subject: field('Betreff'),
|
||||||
|
placeholders: nameList(field('Platzhalter')),
|
||||||
|
blocks: nameList(field('Optionale Blöcke')),
|
||||||
|
body: source.replace(header[0], ''),
|
||||||
|
}
|
||||||
|
if (!template.subject) throw new Error(`Mail-Vorlage ${file}: Betreff fehlt`)
|
||||||
|
cache.set(id, template)
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
export const escapeHtml = (value) =>
|
||||||
|
String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
/** <!-- WENN x --> ... <!-- ENDE x -->: Inhalt behalten (ohne Markierungen) oder ganz entfernen */
|
||||||
|
function applyBlocks(html, blocks) {
|
||||||
|
const block = /<!-- WENN (\w+) -->\n?([\s\S]*?)<!-- ENDE \1 -->\n?/g
|
||||||
|
// Von außen nach innen, bis keine Markierung mehr übrig ist (Blöcke dürfen verschachtelt sein)
|
||||||
|
for (let i = 0; i < 10 && html.includes('<!-- WENN '); i += 1) {
|
||||||
|
html = html.replace(block, (_, name, inner) => (blocks[name] ? inner : ''))
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail aus einer Vorlage erzeugen.
|
||||||
|
* @param {string} id z. B. 'K01'
|
||||||
|
* @param {Record<string, unknown>} values Platzhalterwerte (Klartext; Zeilenumbrüche werden zu <br>)
|
||||||
|
* @param {Record<string, boolean>} blocks optionale Blöcke, die erscheinen sollen
|
||||||
|
* @returns {{ id: string, subject: string, html: string, text: string, problems: string[] }}
|
||||||
|
* problems: unbekannte oder fehlende Platzhalter/Blöcke (die Mail geht trotzdem raus)
|
||||||
|
*/
|
||||||
|
export function renderMail(id, values = {}, blocks = {}) {
|
||||||
|
const template = loadTemplate(id)
|
||||||
|
const problems = []
|
||||||
|
for (const key of Object.keys(values)) {
|
||||||
|
if (!template.placeholders.includes(key)) problems.push(`unbekannter Platzhalter {{${key}}}`)
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(blocks)) {
|
||||||
|
if (!template.blocks.includes(key)) problems.push(`unbekannter Block "${key}"`)
|
||||||
|
}
|
||||||
|
const valueOf = (key) => {
|
||||||
|
if (!(key in values)) {
|
||||||
|
problems.push(`kein Wert für {{${key}}}`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return values[key] === null || values[key] === undefined ? '' : String(values[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = applyBlocks(template.body, blocks)
|
||||||
|
// übrige Hinweis-Kommentare raus, bedingte Kommentare für Outlook (<!--[if mso]>) bleiben
|
||||||
|
.replace(/<!--(?!\[if)[\s\S]*?-->\n?/g, '')
|
||||||
|
.replaceAll(DESIGN_BASE_URL, config.publicBaseUrl)
|
||||||
|
html = html
|
||||||
|
.replace(/\{\{(\w+)\}\}/g, (_, key) => escapeHtml(valueOf(key)).replace(/\r?\n/g, '<br>'))
|
||||||
|
// Anrede ohne bekannten Namen
|
||||||
|
.replace(/Guten Tag ,/g, 'Guten Tag,')
|
||||||
|
|
||||||
|
const subject = template.subject
|
||||||
|
.replace(/\{\{(\w+)\}\}/g, (_, key) => valueOf(key))
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return { id, subject, html, text: htmlToText(html), problems: [...new Set(problems)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Textfassung (multipart/alternative): aus dem gerenderten HTML abgeleitet,
|
||||||
|
// damit beide Fassungen immer denselben Inhalt haben
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NAMED_ENTITIES = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", rarr: '→' }
|
||||||
|
|
||||||
|
function decodeEntities(text) {
|
||||||
|
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, code) => {
|
||||||
|
if (code[0] !== '#') return NAMED_ENTITIES[code.toLowerCase()] ?? entity
|
||||||
|
const point = code[1].toLowerCase() === 'x' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)
|
||||||
|
return point === 847 ? '' : String.fromCodePoint(point)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkText({ href, label, button }) {
|
||||||
|
const url = decodeEntities(href)
|
||||||
|
const text = label.replace(/\s*→\s*$/, '').trim()
|
||||||
|
if (/^(mailto|tel):/i.test(url) || !url) return text
|
||||||
|
if (!text || text === url) return url
|
||||||
|
return button ? `\n${text}: ${url}\n` : `${text} (${url})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML der Vorlagen (Tabellen-Layout) in lesbaren Text umwandeln. */
|
||||||
|
export function htmlToText(html) {
|
||||||
|
const body = (html.match(/<body[^>]*>([\s\S]*)<\/body>/i)?.[1] ?? html)
|
||||||
|
.replace(/<span style="display:none[^"]*"[^>]*>[\s\S]*?<\/span>/i, '') // Vorschautext im Posteingang
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
|
||||||
|
let out = ''
|
||||||
|
let link = null
|
||||||
|
const cells = [] // Klassen der offenen <td>
|
||||||
|
for (const token of body.split(/(<[^>]+>)/)) {
|
||||||
|
if (!token.startsWith('<')) {
|
||||||
|
if (link) link.label += decodeEntities(token)
|
||||||
|
else out += decodeEntities(token)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const [, closing, name = '', attrs = ''] = token.match(/^<(\/?)(\w+)([^>]*)>$/) || []
|
||||||
|
const tag = name.toLowerCase()
|
||||||
|
if (tag === 'br') out += '\n'
|
||||||
|
else if (tag === 'a' && !closing) {
|
||||||
|
link = { href: attrs.match(/href="([^"]*)"/)?.[1] || '', label: '', button: /display:\s*block/.test(attrs) }
|
||||||
|
} else if (tag === 'a' && link) {
|
||||||
|
out += linkText(link)
|
||||||
|
link = null
|
||||||
|
} else if (tag === 'td' && !closing) {
|
||||||
|
cells.push(attrs.match(/class="([^"]*)"/)?.[1] || '')
|
||||||
|
} else if (tag === 'td') {
|
||||||
|
const cls = cells.pop() || ''
|
||||||
|
// Bezeichnung | Wert -> "Bezeichnung: Wert"; Abschnitte des Layouts mit Leerzeile trennen
|
||||||
|
out += /\blbl\b/.test(cls) ? ': ' : /\bpx\b/.test(cls) ? '\n\n' : '\n'
|
||||||
|
} else if ((tag === 'div' || tag === 'table') && closing) out += '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
out
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/[ \t ]+/g, ' ').trim())
|
||||||
|
// Fußzeile "Kundenportal (…) · Vertrag widerrufen (…) · …": je Link eine Zeile
|
||||||
|
.flatMap((line) => ((line.match(/\(https?:/g) || []).length > 1 ? line.split(/ · /) : [line]))
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim() + '\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
+42
-21
@@ -1,5 +1,10 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
|
import { renderMail } from './mailTemplates.js'
|
||||||
|
|
||||||
|
// Absendernamen wie im Design: Kundenmails "WEBklar", Hinweise ans Team "WEBklar Kundenportal"
|
||||||
|
const CUSTOMER_SENDER = 'WEBklar'
|
||||||
|
const TEAM_SENDER = 'WEBklar Kundenportal'
|
||||||
|
|
||||||
let transporter = null
|
let transporter = null
|
||||||
|
|
||||||
@@ -18,6 +23,13 @@ function getTransporter() {
|
|||||||
return transporter
|
return transporter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vorlage rendern (server/mail-templates); Unstimmigkeiten landen im Log, die Mail geht trotzdem raus. */
|
||||||
|
function render({ template, values = {}, blocks = {} }) {
|
||||||
|
const mail = renderMail(template, values, blocks)
|
||||||
|
for (const problem of mail.problems) console.warn(`[mailer] Vorlage ${template}: ${problem}`)
|
||||||
|
return mail
|
||||||
|
}
|
||||||
|
|
||||||
// Throttle: pro Kunde maximal eine Benachrichtigung je Zeitfenster,
|
// Throttle: pro Kunde maximal eine Benachrichtigung je Zeitfenster,
|
||||||
// damit ein aktiver Chat nicht pro Nachricht eine Mail ausloest.
|
// damit ein aktiver Chat nicht pro Nachricht eine Mail ausloest.
|
||||||
const lastNotifyByCustomer = new Map() // customerId -> timestamp
|
const lastNotifyByCustomer = new Map() // customerId -> timestamp
|
||||||
@@ -37,18 +49,16 @@ export async function sendChatNotification(customer, text) {
|
|||||||
if (now - last < throttleMs) return { sent: false, reason: 'throttled' }
|
if (now - last < throttleMs) return { sent: false, reason: 'throttled' }
|
||||||
lastNotifyByCustomer.set(customer.$id, now)
|
lastNotifyByCustomer.set(customer.$id, now)
|
||||||
|
|
||||||
const name = customer.name || customer.email || 'Kunde'
|
const mail = render({
|
||||||
|
template: 'T01',
|
||||||
|
values: { kunde_name: customer.name || customer.email || 'Kunde', nachricht: text },
|
||||||
|
})
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
from: `"WEBklar Kundenbereich" <${config.smtp.from}>`,
|
from: `"${TEAM_SENDER}" <${config.smtp.from}>`,
|
||||||
to: config.chatNotify.emails.join(', '),
|
to: config.chatNotify.emails.join(', '),
|
||||||
subject: `Neue Portalnachricht von ${name}`,
|
subject: mail.subject,
|
||||||
text: [
|
text: mail.text,
|
||||||
`${name} hat im Kundenportal geschrieben:`,
|
html: mail.html,
|
||||||
'',
|
|
||||||
text,
|
|
||||||
'',
|
|
||||||
'Antworten: https://project.webklar.com/admin.html (Tab Chat)',
|
|
||||||
].join('\n'),
|
|
||||||
})
|
})
|
||||||
return { sent: true }
|
return { sent: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -61,16 +71,22 @@ export async function sendChatNotification(customer, text) {
|
|||||||
// Wartungs-Abo: Vertrags-/Kuendigungs-/Widerrufsbestaetigungen
|
// Wartungs-Abo: Vertrags-/Kuendigungs-/Widerrufsbestaetigungen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Mail an die Webklar-Admins (Abo-Ereignisse). Fehler werden nur geloggt. */
|
/**
|
||||||
export async function sendAdminNotice(subject, lines) {
|
* Hinweis an das WEBklar-Team (Abo-Ereignisse) aus einer T-Vorlage; der Betreff steht in der Vorlage.
|
||||||
|
* Fehler werden nur geloggt.
|
||||||
|
* @param {{ template: string, values?: object, blocks?: object }} message
|
||||||
|
*/
|
||||||
|
export async function sendAdminNotice(message) {
|
||||||
try {
|
try {
|
||||||
const mailer = getTransporter()
|
const mailer = getTransporter()
|
||||||
if (!mailer || !config.chatNotify.emails.length) return { sent: false, reason: 'nicht konfiguriert' }
|
if (!mailer || !config.chatNotify.emails.length) return { sent: false, reason: 'nicht konfiguriert' }
|
||||||
|
const mail = render(message)
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
from: `"WEBklar Kundenbereich" <${config.smtp.from}>`,
|
from: `"${TEAM_SENDER}" <${config.smtp.from}>`,
|
||||||
to: config.chatNotify.emails.join(', '),
|
to: config.chatNotify.emails.join(', '),
|
||||||
subject: `[Abo] ${subject}`,
|
subject: mail.subject,
|
||||||
text: lines.join('\n'),
|
text: mail.text,
|
||||||
|
html: mail.html,
|
||||||
})
|
})
|
||||||
return { sent: true }
|
return { sent: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -80,20 +96,25 @@ export async function sendAdminNotice(subject, lines) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mail an Kunden. `attachments` fuer die Vertragsbestaetigung auf dauerhaftem
|
* Mail an Kunden aus einer K-Vorlage (HTML mit Textfassung), Kopie per BCC ans Team.
|
||||||
* Datentraeger (§ 312f BGB): Bedingungen als HTML-Anhang.
|
* `attachments` fuer die Vertragsbestaetigung auf dauerhaftem Datentraeger (§ 312f BGB):
|
||||||
|
* Vertragsunterlagen als PDF.
|
||||||
* Ergebnis: nodemailer-Info (messageId, Antwort des Mailservers, abgelehnte Empfaenger).
|
* Ergebnis: nodemailer-Info (messageId, Antwort des Mailservers, abgelehnte Empfaenger).
|
||||||
|
* @param {string} to
|
||||||
|
* @param {{ template: string, values?: object, blocks?: object, attachments?: object[] }} message
|
||||||
*/
|
*/
|
||||||
export async function sendCustomerMail(to, subject, lines, attachments = []) {
|
export async function sendCustomerMail(to, { attachments = [], ...message }) {
|
||||||
const mailer = getTransporter()
|
const mailer = getTransporter()
|
||||||
if (!mailer) throw new Error('SMTP nicht konfiguriert')
|
if (!mailer) throw new Error('SMTP nicht konfiguriert')
|
||||||
|
const mail = render(message)
|
||||||
return mailer.sendMail({
|
return mailer.sendMail({
|
||||||
from: `"WEBklar" <${config.smtp.from}>`,
|
from: `"${CUSTOMER_SENDER}" <${config.smtp.from}>`,
|
||||||
replyTo: config.smtp.replyTo || undefined,
|
replyTo: config.smtp.replyTo || undefined,
|
||||||
to,
|
to,
|
||||||
bcc: config.chatNotify.emails.join(', ') || undefined,
|
bcc: config.chatNotify.emails.join(', ') || undefined,
|
||||||
subject,
|
subject: mail.subject,
|
||||||
text: lines.join('\n'),
|
text: mail.text,
|
||||||
|
html: mail.html,
|
||||||
attachments,
|
attachments,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+24
-37
@@ -23,7 +23,8 @@ const COLLECTION = () => config.collections.aboOffers
|
|||||||
|
|
||||||
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
|
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
|
||||||
|
|
||||||
const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
|
// Anzeige im Team-Hinweis (wie im Admin-Bereich)
|
||||||
|
const OFFER_STATUS_LABEL = { offen: 'offen', aktiv: 'aktiv (schon eingelöst)', zurueckgezogen: 'zurückgezogen' }
|
||||||
|
|
||||||
function httpError(status, message) {
|
function httpError(status, message) {
|
||||||
const err = new Error(message)
|
const err = new Error(message)
|
||||||
@@ -256,40 +257,21 @@ export function validateOfferInput(body) {
|
|||||||
*/
|
*/
|
||||||
async function mailOffer(doc, customer, nextChargeAt = null) {
|
async function mailOffer(doc, customer, nextChargeAt = null) {
|
||||||
const running = doc.scope === 'laufend'
|
const running = doc.scope === 'laufend'
|
||||||
const lines = [
|
const values = {
|
||||||
`Guten Tag ${customer.name || ''}`.trim() + ',',
|
name: customer.name || '',
|
||||||
'',
|
angebot_text: offerText(doc),
|
||||||
running
|
ersparnis: euro(savingsOf(doc)),
|
||||||
? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${nextChargeAt ? ` am ${fmtDate(nextChargeAt)}` : ''}:`
|
hinweis: doc.note || '',
|
||||||
: 'wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt:',
|
// K06 (laufendes Abo): ab wann der Rabatt gilt; K05: bis wann das Angebot angenommen werden kann
|
||||||
'',
|
...(running ? { naechste_abbuchung: fmtDate(nextChargeAt) } : { gueltig_bis: fmtDate(doc.validUntil) }),
|
||||||
` Angebot: ${offerText(doc)}`,
|
}
|
||||||
` Ihre Ersparnis: ${euro(savingsOf(doc))}`,
|
|
||||||
...(running ? [] : [` Gültig bis: ${fmtDate(doc.validUntil)}`]),
|
|
||||||
...(doc.note ? [` Hinweis: ${doc.note}`] : []),
|
|
||||||
'',
|
|
||||||
...(running
|
|
||||||
? ['Der Rabatt wird bei den Abbuchungen automatisch berücksichtigt und erscheint auf Ihren Rechnungen.']
|
|
||||||
: [
|
|
||||||
`Sie können das Abo zu diesem Preis bis zum ${fmtDate(doc.validUntil)} im Kundenportal abschließen:`,
|
|
||||||
`${config.publicBaseUrl}/dashboard.html#abo`,
|
|
||||||
'',
|
|
||||||
'Es gelten unsere Vertragsbedingungen (Mindestlaufzeit 12 Monate, danach monatlich kündbar; gemäß § 19 UStG',
|
|
||||||
`ohne Umsatzsteuer): ${config.publicBaseUrl}/abo-bedingungen.html`,
|
|
||||||
]),
|
|
||||||
'',
|
|
||||||
'Viele Grüße',
|
|
||||||
'Ihr WEBklar-Team',
|
|
||||||
'',
|
|
||||||
FOOTER,
|
|
||||||
]
|
|
||||||
const ref = shortRef(doc.$id)
|
const ref = shortRef(doc.$id)
|
||||||
try {
|
try {
|
||||||
const info = await sendCustomerMail(
|
const info = await sendCustomerMail(customer.email, {
|
||||||
customer.email,
|
template: running ? 'K06' : 'K05',
|
||||||
running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo',
|
values,
|
||||||
lines
|
blocks: { hinweis: Boolean(doc.note) },
|
||||||
)
|
})
|
||||||
// Lehnt der Mailserver nur den Kunden ab, geht die BCC-Kopie trotzdem raus und sendMail meldet Erfolg
|
// Lehnt der Mailserver nur den Kunden ab, geht die BCC-Kopie trotzdem raus und sendMail meldet Erfolg
|
||||||
const to = String(customer.email).toLowerCase()
|
const to = String(customer.email).toLowerCase()
|
||||||
if ((info?.rejected || []).some((r) => String(r?.address || r).toLowerCase() === to)) {
|
if ((info?.rejected || []).some((r) => String(r?.address || r).toLowerCase() === to)) {
|
||||||
@@ -435,10 +417,15 @@ export async function activateOfferFromCheckout(session) {
|
|||||||
const doc = await getDocument(COLLECTION(), offerId).catch(() => null)
|
const doc = await getDocument(COLLECTION(), offerId).catch(() => null)
|
||||||
if (!doc || doc.status === OFFER_STATUS.active) return doc
|
if (!doc || doc.status === OFFER_STATUS.active) return doc
|
||||||
if (doc.status !== OFFER_STATUS.open) {
|
if (doc.status !== OFFER_STATUS.open) {
|
||||||
await sendAdminNotice(`Angebot ${shortRef(offerId)} war beim Abschluss nicht mehr offen`, [
|
await sendAdminNotice({
|
||||||
`Abo ${subscriptionId} wurde mit dem Angebot „${offerText(doc)}“ abgeschlossen, das Angebot ist aber „${doc.status}“.`,
|
template: 'T03',
|
||||||
'Bitte in Stripe prüfen, ob der Rabatt am Abo hängt, und ggf. korrigieren.',
|
values: {
|
||||||
])
|
angebot_nr: shortRef(offerId),
|
||||||
|
stripe_abo_id: subscriptionId,
|
||||||
|
angebot_text: offerText(doc),
|
||||||
|
angebot_status: OFFER_STATUS_LABEL[doc.status] || doc.status,
|
||||||
|
},
|
||||||
|
})
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (session.metadata?.offerDeferred === '1') {
|
if (session.metadata?.offerDeferred === '1') {
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export const REQUIRED_CHECKS = [
|
|||||||
{ key: 'replaced', label: 'Die Leistung wird durch das Wartungs-Abo ersetzt (Umzug zu WEBklar)' },
|
{ key: 'replaced', label: 'Die Leistung wird durch das Wartungs-Abo ersetzt (Umzug zu WEBklar)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
function httpError(status, message) {
|
function httpError(status, message) {
|
||||||
@@ -430,44 +429,35 @@ export async function createTakeoverRequest({ customer, input, uploads, consentI
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = customer.name || customer.companyName || input.email
|
const name = customer.name || customer.companyName || input.email
|
||||||
const summary = [
|
const summary = {
|
||||||
` Antrag: ${shortRef(doc.$id)}`,
|
antrag_nr: shortRef(doc.$id),
|
||||||
` Leistung: ${servicesLabel(doc.services)}`,
|
leistung: servicesLabel(doc.services),
|
||||||
` Bisher bei: ${doc.provider}`,
|
bisheriger_anbieter: doc.provider,
|
||||||
doc.domain ? ` Domain: ${doc.domain}` : null,
|
domain: doc.domain || '–',
|
||||||
` Rechnung: ${fmtEuro(doc.invoiceAmountCents)} für ${monthsLabel(doc.billingMonths)}`,
|
rechnung_betrag: fmtEuro(doc.invoiceAmountCents),
|
||||||
` Bezahlt bis: ${fmtDate(doc.paidUntil)}`,
|
rechnung_zeitraum: monthsLabel(doc.billingMonths),
|
||||||
` Nachweise: ${files.map((f) => f.name).join(', ')}`,
|
bezahlt_bis: fmtDate(doc.paidUntil),
|
||||||
].filter((l) => l !== null)
|
nachweise: files.map((f) => f.name).join(', '),
|
||||||
|
}
|
||||||
|
|
||||||
await sendCustomerMail(input.email, 'Eingangsbestätigung: Ihr Antrag auf Übernahmegutschrift – WEBklar', [
|
await sendCustomerMail(input.email, {
|
||||||
`Guten Tag ${customer.name || ''}`.trim() + ',',
|
template: 'K07',
|
||||||
'',
|
values: { name: customer.name || '', eingang_zeitpunkt: fmtDateTime(now), ...summary },
|
||||||
`wir haben Ihren Antrag auf eine Übernahmegutschrift am ${fmtDateTime(now)} erhalten.`,
|
}).catch((e) => console.error('[takeover] Eingangsbestaetigung fehlgeschlagen:', e.message))
|
||||||
'',
|
|
||||||
...summary,
|
|
||||||
'',
|
|
||||||
'Wir prüfen Ihre Angaben und den Nachweis und antworten Ihnen per E-Mail an diese Adresse.',
|
|
||||||
`Den Stand Ihres Antrags sehen Sie auch im Kundenportal: ${config.publicBaseUrl}/dashboard.html#gutschrift`,
|
|
||||||
'',
|
|
||||||
'Viele Grüße',
|
|
||||||
'Ihr WEBklar-Team',
|
|
||||||
'',
|
|
||||||
FOOTER,
|
|
||||||
]).catch((e) => console.error('[takeover] Eingangsbestaetigung fehlgeschlagen:', e.message))
|
|
||||||
|
|
||||||
// Nachweise bewusst nicht als Anhang: sie bleiben auf dem Server (Datenschutzerklaerung Abschnitt 7)
|
// Nachweise bewusst nicht als Anhang: sie bleiben auf dem Server (Datenschutzerklaerung Abschnitt 7)
|
||||||
await sendAdminNotice(`Übernahmegutschrift beantragt: ${name}`, [
|
await sendAdminNotice({
|
||||||
`${name}${customer.code ? ` (Kd.-Nr. ${customer.code})` : ''} hat eine Übernahmegutschrift beantragt.`,
|
template: 'T10',
|
||||||
`Antwort an: ${input.email}`,
|
values: {
|
||||||
'',
|
kunde_name: name,
|
||||||
...summary,
|
kundennummer: customer.code || '–',
|
||||||
'',
|
email: input.email,
|
||||||
'Grund / Nachricht des Kunden:',
|
...summary,
|
||||||
input.message,
|
nachricht: input.message,
|
||||||
'',
|
admin_link: `${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
|
||||||
`Nachweise ansehen, prüfen und entscheiden: ${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
|
},
|
||||||
])
|
blocks: { nachricht: Boolean(input.message) },
|
||||||
|
})
|
||||||
|
|
||||||
return doc
|
return doc
|
||||||
}
|
}
|
||||||
@@ -486,15 +476,17 @@ export async function answerQuestion({ customer, doc, message, uploads }) {
|
|||||||
history: withHistory(doc, { type: 'antwort', by: 'customer', text, files: added.map((f) => f.id) }),
|
history: withHistory(doc, { type: 'antwort', by: 'customer', text, files: added.map((f) => f.id) }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const name = customer.name || customer.companyName || doc.email
|
await sendAdminNotice({
|
||||||
await sendAdminNotice(`Übernahmegutschrift: Antwort von ${name}`, [
|
template: 'T11',
|
||||||
`${name} hat auf die Rückfrage zum Antrag ${shortRef(doc.$id)} geantwortet:`,
|
values: {
|
||||||
'',
|
kunde_name: customer.name || customer.companyName || doc.email,
|
||||||
text || '(ohne Text)',
|
antrag_nr: shortRef(doc.$id),
|
||||||
...(added.length ? ['', `Neue Nachweise: ${added.map((f) => f.name).join(', ')}`] : []),
|
antwort: text || '(ohne Text)',
|
||||||
'',
|
neue_nachweise: added.map((f) => f.name).join(', '),
|
||||||
`Prüfen und entscheiden: ${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
|
admin_link: `${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
|
||||||
])
|
},
|
||||||
|
blocks: { neue_nachweise: added.length > 0 },
|
||||||
|
})
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,57 +796,28 @@ export async function applyTakeoverCredit(doc) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function customerGreeting(doc, customer) {
|
|
||||||
return `Guten Tag ${customer?.name || ''}`.trim() + ','
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mailDecision(doc, customer, action) {
|
async function mailDecision(doc, customer, action) {
|
||||||
const ref = shortRef(doc.$id)
|
const common = { name: customer?.name || '', antrag_datum: fmtDate(doc.$createdAt), antrag_nr: shortRef(doc.$id) }
|
||||||
const submitted = fmtDate(doc.$createdAt)
|
|
||||||
const note = String(doc.decisionNote || '').trim()
|
const note = String(doc.decisionNote || '').trim()
|
||||||
const footer = ['', 'Viele Grüße', 'Ihr WEBklar-Team', '', FOOTER]
|
|
||||||
if (action === 'approve') {
|
if (action === 'approve') {
|
||||||
return sendCustomerMail(doc.email, 'Ihre Übernahmegutschrift ist genehmigt – WEBklar', [
|
return sendCustomerMail(doc.email, {
|
||||||
customerGreeting(doc, customer),
|
template: 'K09',
|
||||||
'',
|
values: {
|
||||||
`wir haben Ihren Antrag auf Übernahmegutschrift vom ${submitted} geprüft und genehmigt.`,
|
...common,
|
||||||
'',
|
gutschrift_betrag: fmtEuro(doc.amountCents),
|
||||||
` Antrag: ${ref}`,
|
leistung: servicesLabel(doc.services),
|
||||||
` Leistung: ${servicesLabel(doc.services)} (bisher bei ${doc.provider})`,
|
bisheriger_anbieter: doc.provider,
|
||||||
` Gutschrift: ${fmtEuro(doc.amountCents)}`,
|
berechnung: doc.calculation || '–',
|
||||||
` Berechnung: ${doc.calculation}`,
|
anmerkung: note,
|
||||||
...(note ? ['', note] : []),
|
},
|
||||||
'',
|
blocks: { anmerkung: Boolean(note) },
|
||||||
'Die Gutschrift verrechnen wir automatisch mit den nächsten Monatsgebühren Ihres Wartungs-Abos.',
|
})
|
||||||
'Auf Ihren Rechnungen erscheint sie als angerechnetes Guthaben. Eine Auszahlung ist nicht möglich;',
|
|
||||||
'ein bei Vertragsende noch nicht verrechneter Rest entfällt.',
|
|
||||||
...footer,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
if (action === 'reject') {
|
if (action === 'reject') {
|
||||||
return sendCustomerMail(doc.email, 'Ihr Antrag auf Übernahmegutschrift – WEBklar', [
|
return sendCustomerMail(doc.email, { template: 'K10', values: { ...common, begruendung: note } })
|
||||||
customerGreeting(doc, customer),
|
|
||||||
'',
|
|
||||||
`wir haben Ihren Antrag auf Übernahmegutschrift vom ${submitted} (${ref}) geprüft und können ihn leider nicht genehmigen.`,
|
|
||||||
'',
|
|
||||||
note,
|
|
||||||
'',
|
|
||||||
'Bei Fragen antworten Sie einfach auf diese E-Mail.',
|
|
||||||
...footer,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
const question = [...historyOf(doc)].reverse().find((h) => h.type === 'rueckfrage')?.text || ''
|
const question = [...historyOf(doc)].reverse().find((h) => h.type === 'rueckfrage')?.text || ''
|
||||||
return sendCustomerMail(doc.email, 'Rückfrage zu Ihrem Antrag auf Übernahmegutschrift – WEBklar', [
|
return sendCustomerMail(doc.email, { template: 'K08', values: { ...common, rueckfrage_text: question } })
|
||||||
customerGreeting(doc, customer),
|
|
||||||
'',
|
|
||||||
`zu Ihrem Antrag auf Übernahmegutschrift vom ${submitted} (${ref}) haben wir eine Rückfrage:`,
|
|
||||||
'',
|
|
||||||
question,
|
|
||||||
'',
|
|
||||||
`Bitte antworten Sie im Kundenportal unter ${config.publicBaseUrl}/dashboard.html#gutschrift – dort können Sie`,
|
|
||||||
'auch weitere Nachweise hochladen. Alternativ antworten Sie einfach auf diese E-Mail.',
|
|
||||||
...footer,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js'
|
||||||
|
|
||||||
|
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||||||
|
const EXPECTED = [
|
||||||
|
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
|
||||||
|
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
|
||||||
|
]
|
||||||
|
|
||||||
|
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
|
||||||
|
const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on]))
|
||||||
|
|
||||||
|
test('alle 22 Vorlagen sind da', () => {
|
||||||
|
assert.deepEqual(templateIds(), EXPECTED)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const id of EXPECTED) {
|
||||||
|
test(`${id}: Kopfkommentar passt zur Vorlage`, () => {
|
||||||
|
const tpl = loadTemplate(id)
|
||||||
|
const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
|
||||||
|
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
|
||||||
|
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
|
||||||
|
assert.equal(id.startsWith('T'), tpl.subject.startsWith('[Abo]') || id === 'T01', 'Team-Hinweise ab T02 mit [Abo]')
|
||||||
|
|
||||||
|
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
|
||||||
|
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
|
||||||
|
|
||||||
|
const opened = [...tpl.body.matchAll(/<!-- WENN (\w+) -->/g)].map((m) => m[1])
|
||||||
|
const closed = [...tpl.body.matchAll(/<!-- ENDE (\w+) -->/g)].map((m) => m[1])
|
||||||
|
assert.deepEqual([...new Set(opened)].sort(), [...tpl.blocks].sort(), 'deklarierte = verwendete Blöcke')
|
||||||
|
assert.deepEqual(opened.slice().sort(), closed.slice().sort(), 'jeder Block wird geschlossen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${id}: rendert ohne Reste, mit und ohne optionale Blöcke`, () => {
|
||||||
|
const tpl = loadTemplate(id)
|
||||||
|
for (const on of [true, false]) {
|
||||||
|
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, on))
|
||||||
|
assert.deepEqual(mail.problems, [])
|
||||||
|
assert.doesNotMatch(mail.html, /\{\{|WENN|ENDE|Platzhalter:/)
|
||||||
|
assert.match(mail.html, /<!--\[if mso\]>/, 'bedingter Outlook-Kommentar bleibt')
|
||||||
|
assert.equal((mail.html.match(/<!--/g) || []).length, 1, 'nur der Outlook-Kommentar bleibt')
|
||||||
|
assert.match(mail.html, /src="https:\/\/project\.webklar\.com\/webklar-logo\.png"/)
|
||||||
|
assert.doesNotMatch(mail.subject, /\{\{|\n/)
|
||||||
|
assert.doesNotMatch(mail.text, /<[a-z/!]|&[a-z#0-9]+;|\{\{/i, 'Textfassung ohne Tags und Entities')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Werte werden HTML-sicher eingesetzt, Zeilenumbrüche werden <br>', () => {
|
||||||
|
const mail = renderMail('T01', { kunde_name: 'Max <b>&"\'', nachricht: 'Zeile 1\nZeile <2>' })
|
||||||
|
assert.match(mail.html, /Max <b>&"' hat im Kundenportal geschrieben/)
|
||||||
|
assert.match(mail.html, /Zeile 1<br>Zeile <2>/)
|
||||||
|
assert.doesNotMatch(mail.html, /<b>&/)
|
||||||
|
assert.equal(mail.subject, 'Neue Portalnachricht von Max <b>&"\'')
|
||||||
|
assert.match(mail.text, /Zeile 1\nZeile <2>/)
|
||||||
|
assert.equal(escapeHtml('a&b'), 'a&b')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('optionale Blöcke: nur eingeschaltete erscheinen', () => {
|
||||||
|
const values = { name: 'Max', angebot_text: 'Erste 2 Monate je 25,00 €', vertragsschluss: '01.10.2026', leistungsbeginn: '15.10.2026', mindestlaufzeit_bis: '15.10.2027', bedingungen_version: 'v3', zustimmung_zeitpunkt: '01.10.2026, 10:00' }
|
||||||
|
const early = renderMail('K01', values, { angebot: false, sofortbeginn: true, beginn_nach_widerrufsfrist: false })
|
||||||
|
assert.doesNotMatch(early.html, /Ihr Angebot:/)
|
||||||
|
assert.match(early.html, /Sie haben ausdrücklich verlangt/)
|
||||||
|
assert.doesNotMatch(early.html, /erst nach Ablauf der Widerrufsfrist/)
|
||||||
|
const late = renderMail('K01', values, { angebot: true, sofortbeginn: false, beginn_nach_widerrufsfrist: true })
|
||||||
|
assert.match(late.html, /Ihr Angebot:<\/strong> Erste 2 Monate je 25,00 € \(wie vereinbart\)/)
|
||||||
|
assert.doesNotMatch(late.html, /Sie haben ausdrücklich verlangt/)
|
||||||
|
assert.match(late.html, /Leistungsbeginn und erste Abbuchung: 15\.10\.2026\./)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('verschachtelte Blöcke (T09) werden aufgelöst', () => {
|
||||||
|
const values = { email: 'a@example.com', name: 'A', kundennummer: '', stripe_abo_id: 'sub_1', abo_beginn: '01.10.2026', fristende: '15.10.2026', rechtzeitig: 'RECHTZEITIG' }
|
||||||
|
const ok = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: true })
|
||||||
|
assert.match(ok.html, /sub_1 <span[^>]*>\(auf Kündigung gesetzt\)<\/span>/)
|
||||||
|
assert.doesNotMatch(ok.html, /KEIN aktives Abo|NICHT automatisch/)
|
||||||
|
const failed = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: false, kuendigung_fehlgeschlagen: true })
|
||||||
|
assert.doesNotMatch(failed.html, /auf Kündigung gesetzt\)/)
|
||||||
|
assert.match(failed.html, /Abo sub_1 konnte NICHT automatisch auf Kündigung gesetzt werden/)
|
||||||
|
const none = renderMail('T09', values, { kein_abo_gefunden: true })
|
||||||
|
assert.doesNotMatch(none.html, /Stripe-Abo|Abo-Beginn|Widerrufsfrist bis/)
|
||||||
|
assert.match(none.html, /KEIN aktives Abo zu dieser E-Mail gefunden/)
|
||||||
|
assert.doesNotMatch(none.html, /WENN|ENDE/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Anrede ohne Namen, fehlende und unbekannte Werte werden gemeldet', () => {
|
||||||
|
const mail = renderMail('K10', { name: '', antrag_datum: '01.10.2026', antrag_nr: 'AB12CD34', begruendung: 'x', extra: 1 }, { gibtsnicht: true })
|
||||||
|
assert.match(mail.html, /Guten Tag,<br>/)
|
||||||
|
assert.match(mail.text, /^Guten Tag,$/m)
|
||||||
|
assert.deepEqual(mail.problems.sort(), ['unbekannter Block "gibtsnicht"', 'unbekannter Platzhalter {{extra}}'])
|
||||||
|
const missing = renderMail('K10', { name: 'A', antrag_datum: '01.10.2026', antrag_nr: 'X' })
|
||||||
|
assert.deepEqual(missing.problems, ['kein Wert für {{begruendung}}'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Textfassung K01: alle Vertragsangaben lesbar', () => {
|
||||||
|
const mail = renderMail(
|
||||||
|
'K01',
|
||||||
|
{ name: 'Erika Musterfrau', angebot_text: '', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-09-25-v3', zustimmung_zeitpunkt: '01.10.2026, 10:07' },
|
||||||
|
{ sofortbeginn: true }
|
||||||
|
)
|
||||||
|
const t = mail.text
|
||||||
|
for (const line of [
|
||||||
|
'Guten Tag Erika Musterfrau,',
|
||||||
|
'50,00 € / Monat',
|
||||||
|
'gemäß § 19 UStG ohne Umsatzsteuer',
|
||||||
|
'Vertragsschluss: 01.10.2026',
|
||||||
|
'Leistungsbeginn: 01.10.2026',
|
||||||
|
'Laufzeit: Mindestlaufzeit 12 Monate bis 01.10.2027. Kündigung zu diesem Termin bis einen Monat vorher. Danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar.',
|
||||||
|
'• 1 Domain',
|
||||||
|
'Zum Kundenportal: https://project.webklar.com/dashboard.html',
|
||||||
|
'📎 Widerrufsbelehrung mit Muster-Widerrufsformular',
|
||||||
|
'Ihre Zustimmung zu den Vertragsbedingungen (Version 2026-09-25-v3) haben wir am 01.10.2026, 10:07 Uhr erhalten.',
|
||||||
|
'Vertrag widerrufen (https://project.webklar.com/widerrufen.html)',
|
||||||
|
'Vertrag kündigen (https://project.webklar.com/kuendigen.html)',
|
||||||
|
'Telefon 0176 23726355 · support@webklar.com',
|
||||||
|
'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein',
|
||||||
|
]) {
|
||||||
|
assert.ok(t.split('\n').includes(line), `Zeile fehlt: ${line}\n---\n${t}`)
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(t, /Ihr Wartungs-Abo ist abgeschlossen\. Alle Vertragsunterlagen/, 'Vorschautext nicht im Text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('htmlToText: Links, Buttons und Leerzeilen', () => {
|
||||||
|
const t = htmlToText('<body><table><tr><td class="px">A <a href="mailto:x@y.de">x@y.de</a> B</td></tr><tr><td class="px"><a href="https://e.de/p" style="display:block">Los →</a></td></tr></table></body>')
|
||||||
|
assert.equal(t, 'A x@y.de B\n\nLos: https://e.de/p\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Vorlagen-Ordner enthält nur Vorlagen', () => {
|
||||||
|
assert.deepEqual(readdirSync(DIR).filter((f) => !/^[KT]\d{2} .*\.html$/.test(f)), [])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user