Wartungs-Abo (50 EUR/Monat) ueber Stripe mit Stundenkonto im Portal
- Bestellung auf der Portal-Startseite: Zusammenfassung, AGB- und Vorab-Leistungs-Zustimmung, Button 'Zahlungspflichtig bestellen', danach Stripe Checkout (Abo, Kleinunternehmer-Fusszeile) - Stripe-Webhook /api/abo/webhook spiegelt Abos nach Appwrite (subscriptions) und schreibt je bezahlter Rechnung 60 min gut (hourCredits, idempotent ueber Invoice-ID, 12 Monate gueltig) - Stundenkonto: FIFO-Verbrauch aus Worksheets mit countsToPlan, 15-Minuten-Takt, Mehraufwand separat ausgewiesen - Stripe-Kundenportal fuer Zahlungsart/Rechnungen/Kuendigung - Oeffentlicher Kuendigungsbutton (§ 312k) und Widerrufsbutton (§ 356a) mit Eingangsbestaetigung per Mail - Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung, als Anhang der Vertragsbestaetigung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c3f56ba290
commit
65429aa3ac
17 files changed
+1677
-2
No files matched your search
@@ -0,0 +1,356 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { getLedger } from '../services/hourLedger.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
cancelAtPeriodEnd,
|
||||
constructWebhookEvent,
|
||||
createAboCheckout,
|
||||
createBillingPortalUrl,
|
||||
getCurrentSubscription,
|
||||
grantCreditForInvoice,
|
||||
isStripeConfigured,
|
||||
syncSubscription,
|
||||
} from '../services/stripeBilling.js'
|
||||
|
||||
const router = Router()
|
||||
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
|
||||
|
||||
const fmtDate = (iso) =>
|
||||
iso ? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '–'
|
||||
const fmtDateTime = (d) =>
|
||||
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
function publicSubscription(sub) {
|
||||
if (!sub) return null
|
||||
return {
|
||||
status: sub.status,
|
||||
active: ACTIVE_STATUSES.has(sub.status),
|
||||
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
|
||||
startedAt: sub.startedAt,
|
||||
currentPeriodEnd: sub.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
|
||||
endedAt: sub.endedAt,
|
||||
priceAmount: sub.priceAmount,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kunde (eingeloggt)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Abo-Status + Stundenkonto fuer die Startseite. */
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const sub = await getCurrentSubscription(customerId)
|
||||
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
const ledger = sub ? await getLedger(customerId, { active }) : null
|
||||
return res.json({
|
||||
available: isStripeConfigured(),
|
||||
termsVersion: config.abo.termsVersion,
|
||||
subscription: publicSubscription(sub),
|
||||
ledger,
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Bestellung: Zustimmungen pruefen, dann Stripe Checkout (nur Zahlungsart). */
|
||||
router.post('/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 { acceptTerms, earlyStart, termsVersion } = req.body || {}
|
||||
if (acceptTerms !== true || earlyStart !== true) {
|
||||
return res.status(400).json({ error: 'Bitte bestätigen Sie beide Punkte, um zu bestellen.' })
|
||||
}
|
||||
if (termsVersion !== config.abo.termsVersion) {
|
||||
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||||
}
|
||||
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
|
||||
const now = new Date().toISOString()
|
||||
const url = await createAboCheckout(customer, { termsAcceptedAt: now, earlyStartConsentAt: now })
|
||||
return res.json({ url })
|
||||
} catch (err) {
|
||||
console.error('[abo] Checkout fehlgeschlagen:', err.message)
|
||||
return res.status(err.status || 500).json({ error: err.message || 'Bestellung fehlgeschlagen' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Stripe-Kundenportal: Zahlungsart, Rechnungen, Kuendigung. */
|
||||
router.post('/portal', requireSession, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
|
||||
}
|
||||
const url = await createBillingPortalUrl(getSessionCustomerId(req))
|
||||
return res.json({ url })
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.message || 'Portal nicht verfügbar' })
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stripe-Webhook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendContractConfirmation(subDoc) {
|
||||
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
|
||||
const terms = await readFile(path.join(publicDir, 'abo-bedingungen.html'), 'utf8').catch(() => null)
|
||||
await sendCustomerMail(
|
||||
subDoc.email,
|
||||
'Ihr Webklar Wartungs-Abo – Vertragsbestätigung',
|
||||
[
|
||||
'Guten Tag,',
|
||||
'',
|
||||
'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:',
|
||||
'',
|
||||
' Vertrag: Webklar Wartungs-Abo',
|
||||
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
|
||||
` Vertragsbeginn: ${fmtDate(subDoc.startedAt)}`,
|
||||
' Laufzeit: unbestimmt, monatlich zum Ende des bezahlten Monats kündbar',
|
||||
' Enthalten: Betrieb & Pflege Ihrer Website/Anwendung, 1 Domain, 1 E-Mail-Postfach,',
|
||||
' 1 Stunde Arbeitszeit pro Monat (je 12 Monate gültig)',
|
||||
'',
|
||||
`Sie haben am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} den Abo-Bedingungen (Stand ${subDoc.termsVersion || config.abo.termsVersion}) zugestimmt`,
|
||||
'und ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen.',
|
||||
'Bei einem Widerruf schulden Sie Wertersatz für die bis dahin erbrachten Leistungen (§ 357a Abs. 2 BGB).',
|
||||
'',
|
||||
'Die vollständigen Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung finden Sie im Anhang',
|
||||
`sowie unter ${config.publicBaseUrl}/abo-bedingungen.html`,
|
||||
'',
|
||||
`Ihr Stundenkonto sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
|
||||
`Kündigen: ${config.publicBaseUrl}/kuendigen.html · Widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr Webklar-Team',
|
||||
'',
|
||||
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · kenso.gri@gmail.com',
|
||||
],
|
||||
terms
|
||||
? [{ filename: `Webklar-Abo-Bedingungen-${config.abo.termsVersion}.html`, content: terms, contentType: 'text/html' }]
|
||||
: []
|
||||
)
|
||||
await updateDocument(config.collections.subscriptions, subDoc.$id, {
|
||||
confirmationSentAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleStripeEvent(event) {
|
||||
const obj = event.data.object
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
if (obj.mode !== 'subscription' || !obj.subscription) return
|
||||
const sub = await syncSubscription(obj.subscription)
|
||||
await sendAdminNotice(`Neues Wartungs-Abo: ${sub?.email || obj.customer_details?.email}`, [
|
||||
`Kunde: ${obj.customer_details?.name || ''} <${obj.customer_details?.email || ''}>`,
|
||||
`Appwrite-Kunde: ${sub?.customerId || obj.client_reference_id}`,
|
||||
`Stripe-Abo: ${obj.subscription}`,
|
||||
])
|
||||
await sendContractConfirmation(sub).catch((e) =>
|
||||
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
|
||||
)
|
||||
return
|
||||
}
|
||||
case 'invoice.paid': {
|
||||
const result = await grantCreditForInvoice(obj)
|
||||
if (result.granted) {
|
||||
console.log(`[abo] 60 min gutgeschrieben: Kunde ${result.customerId}, Rechnung ${obj.id}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'invoice.payment_failed': {
|
||||
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
|
||||
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
|
||||
await sendAdminNotice(`Zahlung fehlgeschlagen: ${sub?.email || obj.customer_email || ''}`, [
|
||||
`Rechnung ${obj.number || obj.id}, Betrag ${(obj.amount_due / 100).toFixed(2)} €.`,
|
||||
'Stripe versucht die Zahlung automatisch erneut und mahnt per E-Mail.',
|
||||
])
|
||||
return
|
||||
}
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
await syncSubscription(obj.id)
|
||||
return
|
||||
case 'customer.subscription.deleted': {
|
||||
const sub = await syncSubscription(obj.id)
|
||||
await sendAdminNotice(`Abo beendet: ${sub?.email || ''}`, [
|
||||
`Stripe-Abo ${obj.id} ist beendet (Status ${obj.status}). Restguthaben verfällt.`,
|
||||
'Hosting/Domain/Postfach gemäß Ziff. 9 der Abo-Bedingungen abwickeln (Export, AuthCode).',
|
||||
])
|
||||
return
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/webhook', async (req, res) => {
|
||||
let event
|
||||
try {
|
||||
event = constructWebhookEvent(req.rawBody, req.get('stripe-signature'))
|
||||
} catch (err) {
|
||||
console.warn('[abo] Webhook-Signatur ungueltig:', err.message)
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
}
|
||||
try {
|
||||
await handleStripeEvent(event)
|
||||
return res.json({ received: true })
|
||||
} catch (err) {
|
||||
// 500 -> Stripe wiederholt das Event (Handler sind idempotent)
|
||||
console.error(`[abo] Webhook ${event.type} fehlgeschlagen:`, err.message)
|
||||
return res.status(500).json({ error: 'processing failed' })
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oeffentlich: Kuendigungsbutton (§ 312k BGB) + Widerrufsbutton (§ 356a BGB)
|
||||
// Ohne Login erreichbar; Bestaetigung geht an die angegebene E-Mail-Adresse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const recentRequests = new Map() // ip -> [timestamps]
|
||||
function rateLimited(ip) {
|
||||
const now = Date.now()
|
||||
const list = (recentRequests.get(ip) || []).filter((t) => now - t < 60 * 60 * 1000)
|
||||
list.push(now)
|
||||
recentRequests.set(ip, list)
|
||||
return list.length > 10
|
||||
}
|
||||
|
||||
function validDeclaration(body) {
|
||||
const name = String(body?.name || '').trim().slice(0, 200)
|
||||
const email = String(body?.email || '').trim().slice(0, 255)
|
||||
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
|
||||
kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : 'ordentlich',
|
||||
reason: String(body?.reason || '').trim().slice(0, 2000),
|
||||
effective: String(body?.effective || '').trim().slice(0, 40),
|
||||
}
|
||||
}
|
||||
|
||||
async function findActiveSubscriptionByEmail(email) {
|
||||
const customer =
|
||||
(await getCustomerByEmail(email).catch(() => null)) ||
|
||||
(await getCustomerByEmail(email.toLowerCase()).catch(() => null))
|
||||
if (!customer) return null
|
||||
const sub = await getCurrentSubscription(customer.$id)
|
||||
return sub && ACTIVE_STATUSES.has(sub.status) ? sub : null
|
||||
}
|
||||
|
||||
router.post('/public/cancel', async (req, res) => {
|
||||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
const receivedAt = new Date()
|
||||
|
||||
let sub = null
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
cancelRequestedAt: receivedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
|
||||
}
|
||||
|
||||
const effectiveText = sub
|
||||
? `Ihr Wartungs-Abo endet zum ${fmtDate(sub.currentPeriodEnd)} (Ende des bezahlten Monats).`
|
||||
: 'Wir ordnen Ihre Kündigung manuell Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
|
||||
|
||||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [
|
||||
`Guten Tag ${d.name},`,
|
||||
'',
|
||||
`wir haben Ihre ${d.kind === 'ausserordentlich' ? 'außerordentliche' : 'ordentliche'} Kündigung am ${fmtDateTime(receivedAt)} erhalten.`,
|
||||
'',
|
||||
` Name: ${d.name}`,
|
||||
` E-Mail: ${d.email}`,
|
||||
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
|
||||
' Vertrag: Webklar Wartungs-Abo',
|
||||
` Gewünschter Zeitpunkt: ${d.effective || 'nächstmöglicher Zeitpunkt'}`,
|
||||
d.reason ? ` Grund: ${d.reason}` : null,
|
||||
'',
|
||||
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}`, [
|
||||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
||||
`Art: ${d.kind}, Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
|
||||
d.reason ? `Grund: ${d.reason}` : '',
|
||||
sub
|
||||
? `Automatisch in Stripe zum ${fmtDate(sub.currentPeriodEnd)} gekündigt (${sub.stripeSubscriptionId}).`
|
||||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
|
||||
d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '',
|
||||
])
|
||||
|
||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||
})
|
||||
|
||||
router.post('/public/withdraw', async (req, res) => {
|
||||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
const receivedAt = new Date()
|
||||
|
||||
let sub = null
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
||||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
withdrawalRequestedAt: receivedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
||||
}
|
||||
|
||||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihres Widerrufs – Webklar Wartungs-Abo', [
|
||||
`Guten Tag ${d.name},`,
|
||||
'',
|
||||
`wir haben Ihren Widerruf des Vertrags „Webklar Wartungs-Abo" am ${fmtDateTime(receivedAt)} erhalten.`,
|
||||
'',
|
||||
` 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))
|
||||
|
||||
await sendAdminNotice(`WIDERRUF eingegangen: ${d.email}`, [
|
||||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
||||
sub
|
||||
? `Stripe-Abo ${sub.stripeSubscriptionId} (Beginn ${fmtDate(sub.startedAt)}) auf Kündigung gesetzt.`
|
||||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> manuell zuordnen!',
|
||||
'TODO: Widerrufsfrist prüfen (14 Tage ab Vertragsschluss), Abo in Stripe sofort beenden,',
|
||||
'Wertersatz berechnen (anteilige Tage) und Rest innerhalb 14 Tagen erstatten (Stripe -> Rückerstattung).',
|
||||
])
|
||||
|
||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in new issue
Block a user