Files
Webklar-Kundenbereich/server/services/stripeBilling.js
T
ClaudeandClaude Opus 5.5 4ab154bfa2 Wartungs-Abo v2: Rechtsrunde umgesetzt
- Bestellseite: Pflichtangaben direkt ueber dem Button, Zahlungsarten,
  Wahl des Leistungsbeginns (sofort / nach Widerrufsfrist via 15 Tage
  Stripe-Probezeit), keine Vorauswahl
- Zustimmungsnachweis: Zeitpunkt, Version, Wahl, IP, Browser in Stripe-
  Metadaten und Appwrite (neue Attribute, Setup-Skript ergaenzt)
- Checkout: Karte + SEPA (Fallback nur Karte), korrigierter Hinweistext
- Vertragsbestaetigung mit PDF der Vertragsunterlagen statt HTML
- Kuendigen/Widerrufen: Formular direkt (keine Zwischenseite), Wunsch-
  termin, Druck/PDF der Bestaetigung, Vorbefuellung bei Login
- Widerrufsbutton im Abo waehrend der Frist, 'beginnt am'-Status
- Pilotbetrieb per ABO_PILOT_EMAILS
- Vertragsbedingungen v2, Datenschutzerklaerung, Kalkulator ohne MwSt
  und mit 100/80 EUR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:10:21 +00:00

396 lines
15 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: config.stripe.productDescription,
statement_descriptor: config.stripe.statementDescriptor,
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',
})
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,
startChoice: meta.startChoice || null,
trialEnd: toIso(sub.trial_end),
consentIp: meta.consentIp || null,
consentUserAgent: meta.consentUserAgent || null,
consentTextVersion: meta.consentTextVersion || 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
}
/**
* Stripe Checkout fuer das Abo. Der Kunde hat seine Bestellung vorher im Portal
* abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
* startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist'
* (15 Tage Probezeit ohne Leistung und ohne Abbuchung, Start nach der Widerrufsfrist).
*/
export async function createAboCheckout(customer, consent) {
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 lateStart = consent.startChoice === 'nach_frist'
// Stripe-Metadaten: max. 500 Zeichen je Wert
const meta = {
webklarCustomerId: customer.$id,
email: customer.email || '',
termsVersion: config.abo.termsVersion,
consentTextVersion: config.abo.termsVersion,
termsAcceptedAt: consent.termsAcceptedAt,
startChoice: consent.startChoice,
earlyStartConsentAt: consent.earlyStartConsentAt || '',
consentIp: String(consent.consentIp || '').slice(0, 64),
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
}
const params = {
mode: 'subscription',
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' },
subscription_data: {
metadata: meta,
description: config.stripe.productName,
...(lateStart ? { trial_period_days: config.abo.lateStartTrialDays } : {}),
},
metadata: meta,
custom_text: {
submit: {
message: lateStart
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, monatlich kündbar.'
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, monatlich kündbar.',
},
},
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`,
}
let session
try {
session = await stripe.checkout.sessions.create(params)
} catch (err) {
// SEPA-Lastschrift noch nicht im Stripe-Konto aktiviert -> nur Karte anbieten
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 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)
}
/**
* Kuendigung zu einem Wunschtermin: wirksam zum Ende des Abrechnungsmonats, in dem
* der Wunschtermin liegt (ohne Wunschtermin: Ende des laufenden Monats).
* Liefert das Abo-Dokument und den Beendigungszeitpunkt (ISO).
*/
export async function cancelSubscriptionAt(subscriptionId, requestedDate = null) {
const stripe = getStripe()
const sub = await stripe.subscriptions.retrieve(subscriptionId)
const periodEnd = periodEndOf(sub)
const requested = requestedDate ? Math.floor(requestedDate.getTime() / 1000) : null
if (!requested || !periodEnd || requested <= periodEnd) {
await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true })
const doc = await syncSubscription(subscriptionId)
return { subscription: doc, effectiveAt: toIso(periodEnd) }
}
let boundary = new Date(periodEnd * 1000)
for (let i = 0; i < 36 && boundary.getTime() / 1000 < requested; i += 1) {
boundary = addMonths(new Date(periodEnd * 1000), i + 1)
}
await stripe.subscriptions.update(subscriptionId, {
cancel_at: Math.floor(boundary.getTime() / 1000),
proration_behavior: 'none',
})
const doc = await syncSubscription(subscriptionId)
return { subscription: doc, effectiveAt: boundary.toISOString() }
}
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)
}