Funktionen: Buchung per Stripe, Webhook, Mails und Freischalt-Job
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
770cbe32b7
commit
0cc35bf1a5
12 files changed
+642
-8
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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. ͏ ͏ ͏</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;">✓ 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 →</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 →</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. ͏ ͏ ͏</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;">✓ 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 →</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}}. ͏ ͏ ͏</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;"> </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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user