Files
Webklar-Kundenbereich/server/services/stripeBilling.js
T
ClaudeandClaude Opus 5.5 65429aa3ac 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>
2026-09-25 12:04:45 +00:00

337 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}