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:
ClaudeandClaude Opus 5.5 committed 2026-09-25 12:04:45 +00:00
1 parent c3f56ba290
commit 65429aa3ac
17 files changed
+1677 -2

No files matched your search

+24
View File
@@ -47,6 +47,30 @@ export const config = {
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
portalMessages: process.env.APPWRITE_COLLECTION_PORTAL_MESSAGES || 'portalMessages',
subscriptions: process.env.APPWRITE_COLLECTION_SUBSCRIPTIONS || 'subscriptions',
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
},
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
priceLookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
priceAmountCents: 5000,
productName: 'Webklar Wartungs-Abo',
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
invoiceFooter:
process.env.STRIPE_INVOICE_FOOTER ||
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler',
},
abo: {
termsVersion: '2026-09-25',
minutesPerMonth: 60,
creditValidityMonths: 12,
usageRoundingMinutes: 15,
},
smtp: {
+2
View File
@@ -11,6 +11,7 @@ import featuresRoutes from './routes/features.js'
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 giteaWebhookRoutes from './routes/webhook/gitea.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
@@ -48,6 +49,7 @@ app.use('/api/projects', projectsRoutes)
app.use('/api/features', featuresRoutes)
app.use('/api', customerDataRoutes)
app.use('/api/chat', chatRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
+356
View File
@@ -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
+131
View File
@@ -0,0 +1,131 @@
import { config } from '../config.js'
import { listDocuments, Query } from './appwriteAdmin.js'
/**
* Abo-Stundenkonto eines Kunden.
*
* Gutschriften: je bezahlter Abo-Rechnung 60 min (Collection hourCredits), gueltig 12 Monate.
* Verbrauch: Worksheets mit countsToPlan=true auf Tickets des Kunden, je Eintrag
* auf 15 min aufgerundet.
* Verrechnung: FIFO - die aelteste noch gueltige Gutschrift wird zuerst verbraucht.
* Was kein Guthaben mehr findet, ist Mehraufwand (separat abzurechnen).
* Vertragsende: Restguthaben verfaellt (siehe Abo-Bedingungen Ziff. 4).
*/
function parseSheetDate(ws) {
const raw = ws.startDate || ws.endDate || ''
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw)
if (m) return new Date(`${m[3]}-${m[2]}-${m[1]}T12:00:00Z`)
const d = new Date(raw || ws.createdAt || ws.$createdAt)
return Number.isNaN(d.getTime()) ? new Date(ws.$createdAt) : d
}
function roundUsage(minutes) {
const step = config.abo.usageRoundingMinutes
return Math.ceil(Math.max(0, Number(minutes) || 0) / step) * step
}
export async function listCredits(customerId) {
return listDocuments(config.collections.hourCredits, [
Query.equal('customerId', customerId),
Query.orderAsc('grantedAt'),
Query.limit(1000),
])
}
export async function listPlanUsage(customerId) {
const workorders = await listDocuments(config.collections.workorders, [
Query.equal('customerId', customerId),
Query.limit(5000),
])
if (!workorders.length) return []
const byId = new Map(workorders.map((w) => [w.$id, w]))
const ids = [...byId.keys()]
const sheets = []
// Appwrite begrenzt die Werte-Liste pro equal-Query -> in Bloecken abfragen
for (let i = 0; i < ids.length; i += 100) {
const chunk = await listDocuments(config.collections.worksheets, [
Query.equal('workorderId', ids.slice(i, i + 100)),
Query.equal('countsToPlan', true),
Query.limit(5000),
])
sheets.push(...chunk)
}
return sheets
.filter((ws) => Number(ws.totalTime) > 0)
.map((ws) => {
const wo = byId.get(ws.workorderId) || {}
return {
id: ws.$id,
wsid: ws.wsid || '',
woid: ws.woid || wo.woid || '',
topic: wo.topic || wo.title || '',
date: parseSheetDate(ws).toISOString(),
rawMinutes: Number(ws.totalTime) || 0,
minutes: roundUsage(ws.totalTime),
details: String(ws.details || '').slice(0, 300),
employee: ws.employeeName || ws.employeeShort || '',
}
})
.sort((a, b) => a.date.localeCompare(b.date))
}
/** Reine Berechnung (testbar ohne DB). */
export function computeLedger(credits, usage, { now = new Date(), active = true } = {}) {
const pool = credits
.map((c) => ({
id: c.$id,
minutes: Number(c.minutes) || 0,
remaining: Number(c.minutes) || 0,
grantedAt: c.grantedAt,
expiresAt: c.expiresAt,
}))
.sort((a, b) => String(a.grantedAt).localeCompare(String(b.grantedAt)))
let overageMinutes = 0
const entries = usage.map((u) => {
let need = u.minutes
const at = new Date(u.date)
for (const c of pool) {
if (!need) break
// Nur Gutschriften, die zum Arbeitszeitpunkt noch gueltig waren
if (c.remaining <= 0 || new Date(c.expiresAt) <= at) continue
const take = Math.min(need, c.remaining)
c.remaining -= take
need -= take
}
overageMinutes += need
return { ...u, coveredMinutes: u.minutes - need, overageMinutes: need }
})
const valid = pool.filter((c) => c.remaining > 0 && new Date(c.expiresAt) > now)
const expiredMinutes = pool
.filter((c) => c.remaining > 0 && new Date(c.expiresAt) <= now)
.reduce((s, c) => s + c.remaining, 0)
const balanceMinutes = active ? valid.reduce((s, c) => s + c.remaining, 0) : 0
const nextExpiry = active && valid.length
? valid.reduce((a, c) => (new Date(c.expiresAt) < new Date(a.expiresAt) ? c : a))
: null
return {
balanceMinutes,
grantedMinutes: pool.reduce((s, c) => s + c.minutes, 0),
usedMinutes: entries.reduce((s, e) => s + e.coveredMinutes, 0),
overageMinutes,
expiredMinutes,
nextExpiry: nextExpiry ? { minutes: nextExpiry.remaining, at: nextExpiry.expiresAt } : null,
credits: pool.map((c) => ({
grantedAt: c.grantedAt,
expiresAt: c.expiresAt,
minutes: c.minutes,
remaining: active && new Date(c.expiresAt) > now ? c.remaining : 0,
expired: new Date(c.expiresAt) <= now,
})),
usage: entries.reverse(),
}
}
export async function getLedger(customerId, { active = true } = {}) {
const [credits, usage] = await Promise.all([listCredits(customerId), listPlanUsage(customerId)])
return computeLedger(credits, usage, { active })
}
+39
View File
@@ -54,3 +54,42 @@ export async function sendChatNotification(customer, text) {
return { sent: false, reason: err.message }
}
}
// ---------------------------------------------------------------------------
// Wartungs-Abo: Vertrags-/Kuendigungs-/Widerrufsbestaetigungen
// ---------------------------------------------------------------------------
/** Mail an die Webklar-Admins (Abo-Ereignisse). Fehler werden nur geloggt. */
export async function sendAdminNotice(subject, lines) {
try {
const mailer = getTransporter()
if (!mailer || !config.chatNotify.emails.length) return { sent: false, reason: 'nicht konfiguriert' }
await mailer.sendMail({
from: `"Webklar Kundenportal" <${config.smtp.from}>`,
to: config.chatNotify.emails.join(', '),
subject: `[Abo] ${subject}`,
text: lines.join('\n'),
})
return { sent: true }
} catch (err) {
console.error('[mailer] Admin-Hinweis fehlgeschlagen:', err.message)
return { sent: false, reason: err.message }
}
}
/**
* Mail an Kunden. `attachments` fuer die Vertragsbestaetigung auf dauerhaftem
* Datentraeger (§ 312f BGB): Bedingungen als HTML-Anhang.
*/
export async function sendCustomerMail(to, subject, lines, attachments = []) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
await mailer.sendMail({
from: `"Webklar" <${config.smtp.from}>`,
to,
bcc: config.chatNotify.emails.join(', ') || undefined,
subject,
text: lines.join('\n'),
attachments,
})
}
+336
View File
@@ -0,0 +1,336 @@
import Stripe from 'stripe'
import { config } from '../config.js'
import {
createDocument,
getDocument,
listDocuments,
Query,
updateDocument,
} from './appwriteAdmin.js'
/**
* Wartungs-Abo ueber Stripe Billing.
*
* Wahrheit ueber den Abo-Status ist immer Stripe: jedes Webhook-Event laedt das
* Subscription-Objekt frisch nach und spiegelt es in die Appwrite-Collection
* `subscriptions` (Dokument-ID = Stripe-Subscription-ID -> Upsert ohne Duplikate).
* Stundengutschriften haengen an bezahlten Rechnungen (Dokument-ID = Invoice-ID
* -> ein Webhook-Retry kann nie doppelt gutschreiben).
*/
// Status, in denen der Kunde das Abo "hat" (past_due = Zahlung fehlgeschlagen, Stripe versucht erneut)
export const ACTIVE_STATUSES = new Set(['active', 'trialing', 'past_due'])
let stripeClient = null
export function getStripe() {
if (!config.stripe.secretKey) {
const err = new Error('Zahlungen sind noch nicht eingerichtet (STRIPE_SECRET_KEY fehlt).')
err.status = 503
throw err
}
if (!stripeClient) {
stripeClient = new Stripe(config.stripe.secretKey, { maxNetworkRetries: 2 })
}
return stripeClient
}
export function isStripeConfigured() {
return Boolean(config.stripe.secretKey)
}
let cachedPriceId = null
/** Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. */
export async function ensureAboPrice() {
if (cachedPriceId) return cachedPriceId
const stripe = getStripe()
const { data } = await stripe.prices.list({
lookup_keys: [config.stripe.priceLookupKey],
active: true,
limit: 1,
})
if (data[0]) {
cachedPriceId = data[0].id
return cachedPriceId
}
const product = await stripe.products.create({
name: config.stripe.productName,
description:
'Betrieb & Pflege Ihrer Website/Web-Anwendung, 1 Domain, 1 E-Mail-Postfach, 1 Stunde Arbeitszeit pro Monat (12 Monate gültig).',
metadata: { webklar: 'wartungs-abo' },
})
const price = await stripe.prices.create({
product: product.id,
currency: 'eur',
unit_amount: config.stripe.priceAmountCents,
recurring: { interval: 'month' },
tax_behavior: 'inclusive',
lookup_key: config.stripe.priceLookupKey,
nickname: 'Wartungs-Abo monatlich 50 EUR',
})
cachedPriceId = price.id
return cachedPriceId
}
let cachedPortalConfigId = null
/** Kundenportal-Konfiguration (Zahlungsmittel, Rechnungen, Kuendigung zum Periodenende). */
async function ensurePortalConfiguration() {
if (cachedPortalConfigId) return cachedPortalConfigId
const stripe = getStripe()
const { data } = await stripe.billingPortal.configurations.list({ active: true, limit: 20 })
const own = data.find((c) => c.metadata?.webklar === 'wartungs-abo')
if (own) {
cachedPortalConfigId = own.id
return own.id
}
const created = await stripe.billingPortal.configurations.create({
business_profile: {
headline: 'Webklar – Wartungs-Abo verwalten',
terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`,
},
default_return_url: `${config.publicBaseUrl}/dashboard.html`,
features: {
invoice_history: { enabled: true },
payment_method_update: { enabled: true },
customer_update: { enabled: true, allowed_updates: ['address', 'name', 'email'] },
subscription_cancel: {
enabled: true,
mode: 'at_period_end',
cancellation_reason: {
enabled: true,
options: ['too_expensive', 'unused', 'switched_service', 'other'],
},
},
},
metadata: { webklar: 'wartungs-abo' },
})
cachedPortalConfigId = created.id
return created.id
}
// ---------------------------------------------------------------------------
// Appwrite-Spiegel
// ---------------------------------------------------------------------------
export async function listSubscriptionsForCustomer(customerId) {
if (!customerId) return []
return listDocuments(config.collections.subscriptions, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(20),
])
}
/** Aktuell laufendes Abo (oder das zuletzt beendete, falls keins laeuft). */
export async function getCurrentSubscription(customerId) {
const subs = await listSubscriptionsForCustomer(customerId)
return subs.find((s) => ACTIVE_STATUSES.has(s.status)) || subs[0] || null
}
function toIso(unixSeconds) {
return unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null
}
function periodEndOf(sub) {
// Seit API 2025-03 liegt current_period_end am Subscription-Item
return sub.current_period_end || sub.items?.data?.[0]?.current_period_end || null
}
async function upsertSubscriptionDoc(id, data) {
const existing = await getDocument(config.collections.subscriptions, id).catch(() => null)
if (existing) return updateDocument(config.collections.subscriptions, id, data)
try {
return await createDocument(config.collections.subscriptions, data, id)
} catch (err) {
// Paralleles Event hat das Dokument gerade angelegt
if (err.status === 409) return updateDocument(config.collections.subscriptions, id, data)
throw err
}
}
/** Stripe-Subscription frisch laden und nach Appwrite spiegeln. */
export async function syncSubscription(subscriptionId) {
const stripe = getStripe()
const sub = await stripe.subscriptions.retrieve(subscriptionId)
const meta = sub.metadata || {}
const customerId = meta.webklarCustomerId
if (!customerId) {
console.warn(`[stripe] Subscription ${sub.id} ohne webklarCustomerId - ignoriert`)
return null
}
const customer = typeof sub.customer === 'string' ? null : sub.customer
const doc = await upsertSubscriptionDoc(sub.id, {
customerId,
email: meta.email || customer?.email || '',
stripeCustomerId: typeof sub.customer === 'string' ? sub.customer : sub.customer?.id,
stripeSubscriptionId: sub.id,
status: sub.status,
planKey: config.stripe.priceLookupKey,
priceAmount: sub.items?.data?.[0]?.price?.unit_amount ?? config.stripe.priceAmountCents,
startedAt: toIso(sub.start_date),
currentPeriodEnd: toIso(periodEndOf(sub)),
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end || sub.cancel_at),
canceledAt: toIso(sub.canceled_at),
endedAt: toIso(sub.ended_at),
termsVersion: meta.termsVersion || null,
termsAcceptedAt: meta.termsAcceptedAt || null,
earlyStartConsentAt: meta.earlyStartConsentAt || null,
})
return doc
}
function addMonths(date, months) {
const d = new Date(date)
d.setUTCMonth(d.getUTCMonth() + months)
return d
}
function subscriptionIdOfInvoice(invoice) {
return (
invoice.subscription ||
invoice.parent?.subscription_details?.subscription ||
invoice.lines?.data?.[0]?.parent?.subscription_item_details?.subscription ||
null
)
}
/** Bezahlte Abo-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
export async function grantCreditForInvoice(invoice) {
const ref = subscriptionIdOfInvoice(invoice)
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
if (!(invoice.amount_paid > 0)) return { skipped: 'Betrag 0' }
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
const grantedAt = new Date((invoice.status_transitions?.paid_at || invoice.created) * 1000)
try {
await createDocument(
config.collections.hourCredits,
{
customerId: sub.customerId,
stripeSubscriptionId: subscriptionId,
stripeInvoiceId: invoice.id,
minutes: config.abo.minutesPerMonth,
grantedAt: grantedAt.toISOString(),
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
source: 'stripe_invoice',
note: invoice.number ? `Rechnung ${invoice.number}` : '',
},
invoice.id
)
return { granted: true, customerId: sub.customerId, subscription: sub }
} catch (err) {
if (err.status === 409) return { skipped: 'bereits gutgeschrieben' }
throw err
}
}
// ---------------------------------------------------------------------------
// Checkout / Kundenportal / Kuendigung
// ---------------------------------------------------------------------------
async function getOrCreateStripeCustomer(customer) {
const stripe = getStripe()
const subs = await listSubscriptionsForCustomer(customer.$id)
const known = subs.find((s) => s.stripeCustomerId)?.stripeCustomerId
if (known) return known
const found = await stripe.customers
.search({ query: `metadata['webklarCustomerId']:'${customer.$id}'`, limit: 1 })
.catch(() => ({ data: [] }))
if (found.data[0]) return found.data[0].id
const created = await stripe.customers.create({
email: customer.email || undefined,
name: customer.companyName || customer.name || undefined,
preferred_locales: ['de'],
address: customer.street
? {
line1: customer.street,
postal_code: customer.postalCode || undefined,
city: customer.city || undefined,
country: customer.country && customer.country.length === 2 ? customer.country : 'DE',
}
: undefined,
invoice_settings: { footer: config.stripe.invoiceFooter },
metadata: { webklarCustomerId: customer.$id, kundennummer: customer.code || '' },
})
return created.id
}
export async function createAboCheckout(customer, { termsAcceptedAt, earlyStartConsentAt }) {
const current = await getCurrentSubscription(customer.$id)
if (current && ACTIVE_STATUSES.has(current.status)) {
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
err.status = 409
throw err
}
const stripe = getStripe()
const [priceId, stripeCustomerId] = await Promise.all([
ensureAboPrice(),
getOrCreateStripeCustomer(customer),
])
const meta = {
webklarCustomerId: customer.$id,
email: customer.email || '',
termsVersion: config.abo.termsVersion,
termsAcceptedAt,
earlyStartConsentAt,
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: stripeCustomerId,
client_reference_id: customer.$id,
line_items: [{ price: priceId, quantity: 1 }],
locale: 'de',
billing_address_collection: 'required',
customer_update: { address: 'auto', name: 'auto' },
subscription_data: {
metadata: meta,
description: 'Webklar Wartungs-Abo',
},
metadata: meta,
custom_text: {
submit: {
message:
'Der Vertrag ist mit Ihrer Bestellung im Kundenportal bereits geschlossen. Hier hinterlegen Sie nur noch die Zahlungsart. 50,00 € monatlich, monatlich kündbar.',
},
},
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`,
})
return session.url
}
export async function createBillingPortalUrl(customerId) {
const sub = await getCurrentSubscription(customerId)
if (!sub?.stripeCustomerId) {
const err = new Error('Kein Abo vorhanden.')
err.status = 404
throw err
}
const configuration = await ensurePortalConfiguration()
const session = await getStripe().billingPortal.sessions.create({
customer: sub.stripeCustomerId,
configuration,
locale: 'de',
return_url: `${config.publicBaseUrl}/dashboard.html`,
})
return session.url
}
/** Ordentliche Kuendigung zum Ende des bezahlten Monats. */
export async function cancelAtPeriodEnd(subscriptionId) {
await getStripe().subscriptions.update(subscriptionId, { cancel_at_period_end: true })
return syncSubscription(subscriptionId)
}
export function constructWebhookEvent(rawBody, signature) {
if (!config.stripe.webhookSecret) {
const err = new Error('STRIPE_WEBHOOK_SECRET fehlt')
err.status = 503
throw err
}
return getStripe().webhooks.constructEvent(rawBody, signature, config.stripe.webhookSecret)
}