Funktionen: Buchung per Stripe, Webhook, Mails und Freischalt-Job

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 16:33:48 +00:00
1 parent 770cbe32b7
commit 0cc35bf1a5
12 files changed
+642 -8

No files matched your search

+7
View File
@@ -144,6 +144,13 @@ export const config = {
products,
mailStorage,
newsSetup,
// Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung,
// der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB)
funktionen: {
setupEstimate: 'meist innerhalb von 1–2 Wochen',
consentText:
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).',
},
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
// Die Werte kommen aus dem Produktkatalog (products.wartung).
+10
View File
@@ -12,9 +12,11 @@ import customerDataRoutes from './routes/customerData.js'
import portalAdminRoutes from './routes/portalAdmin.js'
import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
import { runActivation } from './services/siteFeatures.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
import pushTimeRoutes from './routes/pushTime.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
@@ -60,6 +62,7 @@ app.use('/api', customerDataRoutes)
app.use('/api/chat', chatRoutes)
app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
@@ -130,6 +133,13 @@ const server = app.listen(config.port, '0.0.0.0', () => {
.catch((err) => console.warn('[chat] Aufräumen fehlgeschlagen:', err.message))
setTimeout(purgeChat, 90 * 1000).unref()
setInterval(purgeChat, 60 * 60 * 1000).unref()
// Funktionen (News-Pinnwand): geschlossene Einrichtungs-Tickets freischalten und den Kunden benachrichtigen (alle 5 Minuten)
const activateFeatures = () =>
runActivation()
.then((done) => done.length && console.log(`[funktionen] ${done.length} Funktion(en) freigeschaltet`))
.catch((err) => console.warn('[funktionen] Freischalten fehlgeschlagen:', err.message))
setInterval(activateFeatures, 5 * 60 * 1000).unref()
})
server.on('error', (err) => {
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K15 · Bestellung Funktion (News-Pinnwand)
Betreff: Deine Bestellung: News-Pinnwand
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{preis}}, {{zeit}}, {{rechnung_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung
rechnung = Link zur Stripe-Rechnung (nur wenn Stripe die Rechnung schon geliefert hat).
-->
<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>Deine Bestellung: News-Pinnwand</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;">Deine Bestellung der News-Pinnwand ist bei uns eingegangen.&#8199;&#847;&#8199;&#847;&#8199;&#847;</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;">Bestätigung</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:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Bestellung eingegangen</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;">Deine Bestellung: News-Pinnwand</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;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><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;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>News-Pinnwand</strong> – Einrichtung</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;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</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;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">(brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</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:#f7f6f3;border:1px solid #e8e6e0;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;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;">Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.</div></td></tr></table></td></tr>
<!-- WENN rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{rechnung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Rechnung ansehen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="https://project.webklar.com/dashboard.html#news" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></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>Dein 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></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>
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K16 · Funktion bereit (News-Pinnwand)
Betreff: Deine News-Pinnwand ist bereit
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
-->
<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>Deine News-Pinnwand ist bereit</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;">Deine News-Pinnwand ist freigeschaltet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</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;">Bereit</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:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; News-Pinnwand</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;">Deine News-Pinnwand ist bereit</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;">Hallo {{kunde_name}},<br>die News-Pinnwand ist auf Deiner Website eingebaut und freigeschaltet. Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><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;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>News-Pinnwand</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Beitrag schreiben &rarr;</a></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>Dein 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></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>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T16 · Funktion bestellt (News-Pinnwand)
Betreff: Neue Funktion bestellt: News-Pinnwand
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{woid}}, {{sitekey}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen
zahlung_fehlgeschlagen = die Zahlung (SEPA) ist nicht eingegangen; es wurde nichts bestellt, woid und sitekey sind dann leer.
-->
<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>Neue Funktion bestellt: News-Pinnwand</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;">{{kunde_name}}: News-Pinnwand, Projekt {{projekt}}.&#8199;&#847;&#8199;&#847;&#8199;&#847;</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;">Intern · Kundenportal</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;">Funktionen</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;">Neue Funktion bestellt: News-Pinnwand</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><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;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kunde_name}}</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;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</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;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</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;">Ticket</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{woid}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Site-Key</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{sitekey}}</td></tr></table></td></tr>
<!-- WENN zahlung_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;">Zahlung fehlgeschlagen</div><div class="mut" style="color:#4a4843;">Die Zahlung ist nicht eingegangen (zum Beispiel SEPA-Lastschrift abgelehnt). Es wurde nichts eingerichtet und kein Ticket angelegt; die Bestellung steht im Portal wieder auf „nicht bestellt“.</div></td></tr></table></td></tr>
<!-- ENDE zahlung_fehlgeschlagen -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+6
View File
@@ -18,6 +18,7 @@ import {
reconcilePendingSwitch,
switchToWartung,
} from '../services/planSwitch.js'
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent } from '../services/siteFeatures.js'
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
import {
ACTIVE_STATUSES,
@@ -518,6 +519,11 @@ async function recordAbschluss(sub, session) {
async function handleStripeEvent(event) {
const obj = event.data.object
// Einmalkauf einer Funktion (News-Pinnwand): eigener Weg, der Abo-Zweig unten bleibt unberuehrt
if (isFeatureCheckoutEvent(event)) {
await handleFeatureCheckoutEvent(event)
return
}
switch (event.type) {
case 'checkout.session.completed': {
if (obj.mode !== 'subscription' || !obj.subscription) return
+63
View File
@@ -0,0 +1,63 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { createFeatureCheckout } from '../services/stripeBilling.js'
import { assertOwnProject, getFeatureDoc, listCustomerFeatures, markOrdered, runActivation } from '../services/siteFeatures.js'
const router = Router()
const DAY_MS = 24 * 60 * 60 * 1000
function fail(res, err) {
const status = err.status >= 400 && err.status < 600 ? err.status : 500
return res.status(status).json({ error: err.message || 'Interner Serverfehler' })
}
// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
router.get('/', requireSession, async (req, res) => {
try {
const customerId = getSessionCustomerId(req)
await runActivation({ customerId })
res.json({ projects: await listCustomerFeatures(customerId), setupEstimate: config.funktionen.setupEstimate })
} catch (err) {
fail(res, err)
}
})
router.post('/news/checkout', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
}
const { projectId, consent } = req.body || {}
if (consent !== true) {
return res.status(400).json({ error: 'Bitte stimme der Erklärung zu, um zu bestellen.' })
}
if (typeof projectId !== 'string' || !projectId) {
return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
}
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId)
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv oder eine noch gueltige offene Bestellung -> 409
const existing = await getFeatureDoc(customerId, projectId, 'news')
const freshOrder = existing?.state === 'ordered' && Date.now() - new Date(existing.orderedAt || 0).getTime() < DAY_MS
if (existing?.state === 'setup' || existing?.state === 'active' || freshOrder) {
return res.status(409).json({ error: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.' })
}
const customer = await getDocument(config.collections.customers, customerId)
const now = new Date().toISOString()
const session = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now })
await markOrdered({
customerId,
projectId,
key: 'news',
sessionId: session.id,
consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') },
})
res.json({ url: session.url })
} catch (err) {
fail(res, err)
}
})
export default router
+146 -6
View File
@@ -2,6 +2,8 @@ import { createHash, randomBytes } from 'node:crypto'
import { config } from '../config.js'
import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe } from './stripeBilling.js'
import { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = ['news']
@@ -200,13 +202,20 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
})
}
/** Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. */
/**
* Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck.
* Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte
* Bestellung ('setup') oder eine aktive Funktion anfasst.
*/
export async function markCheckoutFailed(sessionId) {
const doc = await findBySession(sessionId)
if (!doc || doc.state !== 'ordered') return doc
return updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('off'),
purchaseStatus: 'failed',
if (!sessionId) return null
return withLocks([`feature-paid:${sessionId}`], async () => {
const doc = await findBySession(sessionId)
if (!doc || doc.state !== 'ordered') return doc
return updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('off'),
purchaseStatus: 'failed',
})
})
}
@@ -274,3 +283,134 @@ export async function findActiveBySiteKey(siteKey) {
])
return doc || null
}
// ---------------------------------------------------------------------------
// Stripe-Webhook, Mails und Freischalt-Job
// ---------------------------------------------------------------------------
const eur = (cents) => `${(cents / 100).toFixed(2).replace('.', ',')} €`
const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#news`
/** Ereignisse, die handleFeatureCheckoutEvent versteht (Funktions-Checkout: mode 'payment' mit featureKey). */
export const FEATURE_EVENT_TYPES = [
'checkout.session.completed',
'checkout.session.async_payment_succeeded',
'checkout.session.async_payment_failed',
'checkout.session.expired',
]
export function isFeatureCheckoutEvent(event) {
const obj = event?.data?.object
return obj?.mode === 'payment' && Boolean(obj.metadata?.featureKey) && FEATURE_EVENT_TYPES.includes(event.type)
}
async function projectName(projectId) {
try {
return (await getDocument(config.collections.websiteProjects, projectId)).projectName || ''
} catch (err) {
if (err.status === 404) return ''
throw err
}
}
async function customerOf(customerId) {
try {
return await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) return null
throw err
}
}
/** Link zur Stripe-Rechnung; fehlt sie noch oder ist sie nicht lesbar, geht die Mail ohne Link raus. */
async function invoiceOf(invoiceRef) {
const id = typeof invoiceRef === 'string' ? invoiceRef : invoiceRef?.id
if (!id) return { invoiceId: '', invoiceUrl: '' }
try {
const invoice = await getStripe().invoices.retrieve(id)
return { invoiceId: id, invoiceUrl: invoice.hosted_invoice_url || '' }
} catch (err) {
console.warn(`[funktionen] Rechnung ${id} nicht lesbar:`, err.message)
return { invoiceId: id, invoiceUrl: '' }
}
}
async function ticketWoidOf(doc) {
return doc.ticketId ? (await ticketWoid(doc.ticketId)) || '' : ''
}
async function teamNotice(doc, customer, { failed = false } = {}) {
await sendAdminNotice({
template: 'T16',
values: {
kunde_name: customer?.name || customer?.email || doc.customerId,
email: customer?.email || '',
projekt: await projectName(doc.projectId),
woid: failed ? '' : await ticketWoidOf(doc),
sitekey: failed ? '' : doc.siteKey || '',
},
blocks: { zahlung_fehlgeschlagen: failed },
})
}
async function onFeaturePaid(session) {
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
const { doc, created } = await markPaid({ sessionId: session.id, invoiceId, invoiceUrl })
if (!created) return
const customer = await customerOf(doc.customerId)
if (customer?.email) {
await sendCustomerMail(customer.email, {
template: 'K15',
values: {
kunde_name: customer.name || '',
projekt: await projectName(doc.projectId),
preis: eur(config.newsSetup.amountCents),
zeit: config.funktionen.setupEstimate,
rechnung_url: invoiceUrl,
},
blocks: { rechnung: Boolean(invoiceUrl) },
}).catch((err) => console.error('[funktionen] K15 fehlgeschlagen:', err.message))
}
await teamNotice(doc, customer)
}
/** Stripe-Ereignis eines Funktions-Checkouts (isFeatureCheckoutEvent); idempotent, Fehler -> Stripe wiederholt. */
export async function handleFeatureCheckoutEvent(event) {
const session = event.data.object
switch (event.type) {
case 'checkout.session.completed':
// SEPA: die Sitzung ist abgeschlossen, das Geld noch nicht da -> erst async_payment_succeeded
if (session.payment_status !== 'paid') return
await onFeaturePaid(session)
return
case 'checkout.session.async_payment_succeeded':
await onFeaturePaid(session)
return
case 'checkout.session.async_payment_failed': {
const doc = await markCheckoutFailed(session.id)
if (doc && doc.state === 'off') await teamNotice(doc, await customerOf(doc.customerId), { failed: true })
return
}
case 'checkout.session.expired':
await markCheckoutFailed(session.id)
return
}
}
/** Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden. */
export async function runActivation({ now = new Date(), customerId = null } = {}) {
const activated = await activateClosedTickets({ now, customerId })
for (const doc of activated) {
try {
const customer = await customerOf(doc.customerId)
if (!customer?.email) continue
await sendCustomerMail(customer.email, {
template: 'K16',
values: { kunde_name: customer.name || '', projekt: await projectName(doc.projectId), portal_url: portalUrl() },
})
} catch (err) {
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
}
}
return activated
}
+42
View File
@@ -591,6 +591,48 @@ export async function createAboCheckout(customer, consent, productKey, offer = n
return session.url
}
/**
* Checkout fuer die einmalige Einrichtung einer Funktion (News-Pinnwand, 30 € brutto, kein Abo). Die Sitzung
* verfaellt nach 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die
* Rechnung erzeugt Stripe selbst. Nur die Zuordnung steht in den Metadaten, der Zustimmungswortlaut nicht.
* @returns {Promise<{ id: string, url: string }>}
*/
export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) {
const stripe = getStripe()
const [priceId, stripeCustomerId] = await Promise.all([ensurePrice('newsSetup'), getOrCreateStripeCustomer(customer)])
const params = {
mode: 'payment',
customer: stripeCustomerId,
client_reference_id: customer.$id,
line_items: [{ price: priceId, quantity: 1 }],
payment_method_types: ['card', 'sepa_debit'],
locale: 'de',
billing_address_collection: 'required',
customer_update: { address: 'auto', name: 'auto' },
invoice_creation: {
enabled: true,
invoice_data: { description: config.newsSetup.name, footer: config.stripe.invoiceFooter },
},
metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) },
expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60,
success_url: `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`,
cancel_url: `${config.publicBaseUrl}/dashboard.html#news`,
}
let session
try {
session = await stripe.checkout.sessions.create(params)
} catch (err) {
// SEPA-Lastschrift noch nicht im Stripe-Konto aktiviert -> nur Karte anbieten (wie beim Abo-Checkout)
if (/sepa_debit|payment_method_types/i.test(err.message || '')) {
console.warn('[stripe] SEPA-Lastschrift nicht verfuegbar, Checkout nur mit Karte:', err.message)
session = await stripe.checkout.sessions.create({ ...params, payment_method_types: ['card'] })
} else {
throw err
}
}
return { id: session.id, url: session.url }
}
export async function createBillingPortalUrl(customerId) {
const sub = await getCurrentSubscription(customerId)
if (!sub?.stripeCustomerId) {
+180
View File
@@ -0,0 +1,180 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js')
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const sf = await import('../server/services/siteFeatures.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes })
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Kiosk' })
aw.seed('websiteProjects', { $id: 'p4', customerId: 'c1', projectName: 'Bistro' })
aw.seed('websiteProjects', { $id: 'p5', customerId: 'c2', projectName: 'Fremd' })
const cookie = customerCookie('c1')
const checkout = (projectId, consent = true) => post('/api/funktionen/news/checkout', { projectId, consent }, { cookie })
const sessions = () => [...st.checkoutSessions.values()]
const workorders = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('News-Pinnwand einbauen'))
const subjects = () => mails.sent.map((m) => m.subject)
const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
const paidSession = (id, projectId, extra = {}) => ({
id,
object: 'checkout.session',
mode: 'payment',
payment_status: 'paid',
metadata: { customerId: 'c1', projectId, featureKey: 'news' },
...extra,
})
test('liste_zustaende', async () => {
const body = await get('/api/funktionen', { cookie })
assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
assert.equal(body.projects.length, 4)
for (const p of body.projects) assert.equal(p.features.news.state, 'off')
})
test('checkout_ohne_zustimmung_400', async () => {
assert.equal((await checkout('p1', false)).status, 400)
assert.equal((await post('/api/funktionen/news/checkout', { projectId: 'p1' }, { cookie })).status, 400)
assert.equal(sessions().length, 0)
})
test('checkout_fremdes_projekt_403', async () => {
assert.equal((await checkout('p5')).status, 403)
assert.equal(sessions().length, 0)
})
test('checkout_legt_session_an', async () => {
const res = await checkout('p1')
assert.equal(res.status, 200)
assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\//)
const [session] = sessions()
assert.equal(session.mode, 'payment')
assert.equal(session.metadata.featureKey, 'news')
assert.equal(session.metadata.projectId, 'p1')
assert.equal(session.metadata.customerId, 'c1')
const call = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
assert.equal(call.body['invoice_creation[enabled]'], 'true')
assert.equal(call.body.success_url, `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`)
assert.equal(call.body.cancel_url, `${config.publicBaseUrl}/dashboard.html#news`)
assert.ok(Number(call.body.expires_at) > Date.now() / 1000)
const price = [...st.prices.values()].find((p) => p.id === call.body['line_items[0][price]'])
assert.equal(price.unit_amount, 3000)
const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
assert.equal(doc.state, 'ordered')
assert.equal(doc.stripeSessionId, session.id)
assert.equal(doc.consentText, config.funktionen.consentText)
assert.ok(doc.consentAt)
assert.ok(doc.consentUserAgent)
// zweite Bestellung waehrend die erste offen ist: 409, keine neue Sitzung
assert.equal((await checkout('p1')).status, 409)
assert.equal(sessions().length, 1)
})
test('abgelaufene_bestellung_darf_neu_gestartet_werden', async () => {
const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), orderedAt: new Date(Date.now() - 25 * 3600 * 1000).toISOString() })
assert.equal((await checkout('p1')).status, 200)
assert.equal(sessions().length, 2)
})
test('checkout_bei_setup_409', async () => {
const sid = 'cs_setup'
await sf.markOrdered({ customerId: 'c1', projectId: 'p2', key: 'news', sessionId: sid, consent: {} })
await sf.markPaid({ sessionId: sid })
const before = sessions().length
assert.equal((await checkout('p2')).status, 409)
assert.equal(sessions().length, before)
})
test('sepa_wartet_auf_zahlung', async () => {
const res = await checkout('p3')
assert.equal(res.status, 200)
const id = sessions().at(-1).id
const mailsBefore = mails.sent.length
const wosBefore = workorders().length
const unpaid = paidSession(id, 'p3', { payment_status: 'unpaid' })
assert.equal((await postWebhook(event('checkout.session.completed', unpaid))).status, 200)
assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'ordered')
assert.equal(workorders().length, wosBefore)
assert.equal(mails.sent.length, mailsBefore)
assert.equal((await postWebhook(event('checkout.session.async_payment_succeeded', paidSession(id, 'p3')))).status, 200)
assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'setup')
assert.equal(workorders().length, wosBefore + 1)
assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand'])
const k15 = mails.sent.at(-2).subject.startsWith('Deine') ? mails.sent.at(-2) : mails.sent.at(-1)
assert.equal(k15.to[0].address, 'k@example.invalid')
assert.match(k15.text, /30,00 €/)
})
test('webhook_idempotent', async () => {
await checkout('p4')
const id = sessions().at(-1).id
const mailsBefore = mails.sent.length
const wosBefore = workorders().length
for (let i = 0; i < 2; i += 1) {
assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'p4')))).status, 200)
}
assert.equal(workorders().length, wosBefore + 1)
assert.equal(mails.sent.length - mailsBefore, 2)
assert.equal(subjects().slice(mailsBefore).filter((s) => s.startsWith('Deine Bestellung')).length, 1)
})
test('abgelaufen_zurueck_auf_off', async () => {
aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Werkstatt' })
await checkout('p6')
const id = sessions().at(-1).id
const expired = { id, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p6', featureKey: 'news' } }
assert.equal((await postWebhook(event('checkout.session.expired', expired))).status, 200)
assert.equal((await sf.getFeatureDoc('c1', 'p6', 'news')).state, 'off')
assert.equal((await checkout('p6')).status, 200)
})
test('zahlung_fehlgeschlagen_mailt_team', async () => {
aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Garage' })
await checkout('p7')
const id = sessions().at(-1).id
const mailsBefore = mails.sent.length
await postWebhook(event('checkout.session.async_payment_failed', paidSession(id, 'p7', { payment_status: 'unpaid' })))
assert.equal((await sf.getFeatureDoc('c1', 'p7', 'news')).state, 'off')
assert.equal(mails.sent.length - mailsBefore, 1)
assert.match(mails.sent.at(-1).text, /Zahlung fehlgeschlagen/)
})
test('aktivierung_mailt', async () => {
const doc = await sf.getFeatureDoc('c1', 'p3', 'news')
assert.equal(doc.state, 'setup')
aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' })
const mailsBefore = mails.sent.length
const done = await sf.runActivation()
assert.equal(done.length, 1)
assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'active')
assert.equal(mails.sent.length - mailsBefore, 1)
assert.equal(mails.sent.at(-1).subject, 'Deine News-Pinnwand ist bereit')
assert.equal(mails.sent.at(-1).to[0].address, 'k@example.invalid')
assert.deepEqual(await sf.runActivation(), [])
assert.equal(mails.sent.length - mailsBefore, 1)
})
+5 -2
View File
@@ -6,17 +6,20 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand)
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
'K13',
'K14',
'K15',
'K16',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12',
'T13',
'T14',
'T15',
'T16',
]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -33,7 +36,7 @@ for (const id of EXPECTED) {
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit]')
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01' || id === 'T16', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit] (T16 Funktion bestellt ohne Kennung)')
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')
+31
View File
@@ -131,3 +131,34 @@ test('parallele_webhooks_ein_ticket', async () => {
assert.equal(tickets().length, before + 1)
assert.ok((await sf.getFeatureDoc('c1', 'p4', 'news')).ticketId)
})
test('abgelaufen_fasst_setup_und_active_nicht_an', async () => {
// cs_1 ist bezahlt ('setup' bzw. nach dem Schliessen des Tickets 'active'): ein spaetes expired aendert nichts
const before = await sf.getFeatureDoc('c1', 'p1', 'news')
assert.notEqual(before.state, 'ordered')
const after = await sf.markCheckoutFailed('cs_1')
assert.equal(after.state, before.state)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, before.state)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).purchaseStatus, 'paid')
})
test('bezahlt_und_abgelaufen_gleichzeitig_bleiben_stimmig', async () => {
const pid = 'p_race'
aw.seed('websiteProjects', { $id: pid, customerId: 'c1', projectName: 'Rennen' })
await sf.markOrdered({ customerId: 'c1', projectId: pid, key: 'news', sessionId: 'cs_race', consent })
const ticketsBefore = tickets().length
await Promise.all([
sf.markPaid({ sessionId: 'cs_race', invoiceId: 'in_r', invoiceUrl: '' }),
sf.markCheckoutFailed('cs_race'),
])
const doc = await sf.getFeatureDoc('c1', pid, 'news')
// entweder bezahlt (mit Ticket) oder zurueckgesetzt (ohne): nie 'setup' ohne Ticket oder 'off' mit Ticket
if (doc.state === 'setup') {
assert.ok(doc.ticketId)
assert.equal(tickets().length, ticketsBefore + 1)
} else {
assert.equal(doc.state, 'off')
assert.equal(doc.ticketId || '', '')
assert.equal(tickets().length, ticketsBefore)
}
})