import Stripe from 'stripe' import { config } from '../config.js' import { createDocument, getDocument, listDocuments, Query, updateDocument, } from './appwriteAdmin.js' import { lateStartAt } from './deadlines.js' import { creditMinutesForInvoice, describeItems, getProduct, priceIdsOfInvoice, productKeyForLookupKey } from './products.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) { const options = { maxNetworkRetries: 2 } // Tests: Stripe-Nachbau auf 127.0.0.1 (config.stripe.apiBase ist in Produktion leer). Nur lokale // Adressen, damit ein verirrter Wert den Schlüssel nie an einen fremden Server schickt. if (config.stripe.apiBase) { const base = new URL(config.stripe.apiBase) if (!['127.0.0.1', 'localhost'].includes(base.hostname)) { throw new Error(`STRIPE_API_BASE darf nur auf 127.0.0.1 oder localhost zeigen, nicht auf ${base.hostname}.`) } Object.assign(options, { host: base.hostname, port: Number(base.port), protocol: base.protocol.replace(':', '') }) } stripeClient = new Stripe(config.stripe.secretKey, options) } return stripeClient } export function isStripeConfigured() { return Boolean(config.stripe.secretKey) } // Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice) const PRODUCT_METADATA = { wartung: 'wartungs-abo', hosting: 'hosting-abo', mailStorage: 'mail-speicher' } /** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, der Zusatzspeicher aus config.mailStorage. */ function catalogFor(key) { return key === 'mailStorage' ? config.mailStorage : getProduct(key) } const priceIdCache = new Map() /** * Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' | * 'mailStorage'. Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe * nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog. */ export async function ensurePrice(key) { if (priceIdCache.has(key)) return priceIdCache.get(key) const stripe = getStripe() const catalog = catalogFor(key) const { data } = await stripe.prices.list({ lookup_keys: [catalog.lookupKey], active: true, limit: 1, }) if (data[0]) { priceIdCache.set(key, data[0].id) return data[0].id } // Neuer Preis fuer ein bestehendes Produkt (z. B. Hosting 15 -> 20 EUR, 29.09.2026): an das vorhandene // Produkt haengen statt ein zweites gleichnamiges Produkt bei Stripe anzulegen const products = await stripe.products.list({ active: true, limit: 100 }) const existing = products.data.find((p) => p.metadata?.webklar === PRODUCT_METADATA[key]) const product = existing || await stripe.products.create({ name: catalog.name, ...(catalog.description ? { description: catalog.description } : {}), ...(catalog.statementDescriptor ? { statement_descriptor: catalog.statementDescriptor } : {}), // Produktbild: eigenes Katalogbild, sonst das generische Logo (Fix round 1, Important 2 - war // beim Verallgemeinern von ensureAboPrice zu ensurePrice verloren gegangen) images: [catalog.image || config.stripe.productImage], metadata: { webklar: PRODUCT_METADATA[key] }, }) const price = await stripe.prices.create({ product: product.id, currency: 'eur', unit_amount: catalog.amountCents, recurring: { interval: 'month' }, tax_behavior: 'inclusive', lookup_key: catalog.lookupKey, nickname: catalog.priceNickname, }) priceIdCache.set(key, price.id) return price.id } // Beide Abos zusammen im Kundenportal, nicht mehr nur die Wartung (Hosting-Abo dazu) export const PORTAL_HEADLINE = 'WEBklar – Abo verwalten' let cachedPortalConfigId = null /** Nur fuer Tests: Cache zuruecksetzen, damit beide Zweige (Update noetig / schon richtig) einzeln pruefbar sind. */ export function resetPortalConfigCacheForTests() { cachedPortalConfigId = null } /** Kundenportal-Konfiguration (Zahlungsmittel, Rechnungen, Kuendigung zum Periodenende). */ export 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) { // Aeltere Konfiguration (frueher nur Wartung) traegt noch die alte Ueberschrift -> anpassen. // Fix round 1, Minor 6: Cache erst nach Erfolg setzen, damit ein Fehlschlag beim naechsten // Aufruf (z. B. Klick auf "Abo verwalten") erneut versucht wird statt fuer immer stehen zu // bleiben; ein Fehlschlag bricht den Aufruf selbst nicht ab (die Portal-Sitzung soll trotzdem // zustande kommen), nur eine Warnung im Log. if (own.business_profile?.headline !== PORTAL_HEADLINE) { try { await stripe.billingPortal.configurations.update(own.id, { business_profile: { headline: PORTAL_HEADLINE, terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`, }, }) } catch (err) { console.warn(`[stripe] Kundenportal-Konfiguration ${own.id}: Überschrift konnte nicht aktualisiert werden - ${err.message}`) return own.id } } cachedPortalConfigId = own.id return own.id } const created = await stripe.billingPortal.configurations.create({ business_profile: { headline: PORTAL_HEADLINE, 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'] }, // Kuendigung nur ueber die eigene Kuendigungsseite: Stripe kennt die Mindestlaufzeit nicht subscription_cancel: { enabled: false }, }, 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 writeSubscriptionDoc(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 } } async function upsertSubscriptionDoc(id, data) { let fields = data for (;;) { try { return await writeSubscriptionDoc(id, fields) } catch (err) { // Neues Feld noch nicht in Appwrite (Setup-Skript nicht gelaufen): ohne das Feld spiegeln, // damit Webhooks und der öffentliche Kündigungs-/Widerrufsknopf weiter funktionieren const missing = err.status === 400 && /Unknown attribute: "(\w+)"/.exec(err.message || '')?.[1] if (!missing || !(missing in fields)) throw err console.error( `[stripe] Appwrite kennt das Feld "${missing}" nicht (scripts/setup-abo-collections.mjs ausführen) – Abo ${id} ohne dieses Feld gespiegelt` ) const { [missing]: _dropped, ...rest } = fields fields = rest } } } /** 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 // Abo-Posten (Hosting/Wartung) unabhängig von der Reihenfolge; ohne bekannten Preis gilt das Abo // als Wartungs-Abo (Altbestand bzw. von Hand angelegt) const items = describeItems(sub.items?.data) if (!items.productKey) { console.warn(`[stripe] Abo ${sub.id}: kein bekannter Abo-Preis – als Wartungs-Abo gespiegelt`) } 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, productKey: items.productKey || 'wartung', // Plan 2: Wechseltag (Beginn der neuen Mindestlaufzeit) und bezahlter Speicherstand termStartAt: termStartOf(sub).toISOString(), storageUnits: items.storageUnits, planKey: items.lookupKey || config.products.wartung.lookupKey, priceAmount: items.priceAmount ?? sub.items?.data?.[0]?.price?.unit_amount ?? config.products.wartung.amountCents, startedAt: toIso(sub.start_date), currentPeriodEnd: toIso(periodEndOf(sub)), cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end || sub.cancel_at), cancelAt: sub.cancel_at ? toIso(sub.cancel_at) : sub.cancel_at_period_end ? toIso(periodEndOf(sub)) : null, 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, offerId: meta.offerId || null, offerText: meta.offerText || null, }) return doc } export function addMonths(date, months) { // Monatsende abfangen (31.01. + 1 Monat = 28./29.02., wie bei Stripe) const d = new Date(date) const day = d.getUTCDate() d.setUTCDate(1) d.setUTCMonth(d.getUTCMonth() + months) const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate() d.setUTCDate(Math.min(day, last)) return d } /** * Beendigungszeitpunkt nach § 8 der Bedingungen (Version 3): * - Leistungsbeginn = Ende der Probezeit (Beginn nach Widerrufsfrist) bzw. Abo-Start * - Mindestlaufzeit 12 Abrechnungsmonate; Kuendigung bis 1 Monat vor deren Ende wirkt zum Ende, * spaeter eingehende Kuendigungen zum Ende des darauffolgenden Abrechnungsmonats * - nach der Mindestlaufzeit: zum Ende des laufenden Abrechnungsmonats * - Geschaeftsaufgabe: 1 Monat Frist zum Ende eines Abrechnungsmonats * - Wunschtermin: fruehestens zum berechneten Zeitpunkt, sonst zum Ende des Abrechnungsmonats, * in dem der Wunschtermin liegt */ export function computeCancellationDate({ serviceStart, receivedAt = new Date(), requestedDate = null, kind = 'ordentlich', minTermMonths = config.abo.minTermMonths }) { const start = new Date(serviceStart) const boundary = (k) => addMonths(start, k) const firstBoundaryFrom = (minK, notBefore) => { let k = Math.max(1, minK) while (boundary(k) < notBefore) k += 1 return k } const minTermK = minTermMonths let k if (kind === 'geschaeftsaufgabe') { k = firstBoundaryFrom(1, addMonths(receivedAt, 1)) } else { const deadline = addMonths(boundary(minTermK), -1) k = receivedAt <= deadline ? minTermK : firstBoundaryFrom(minTermK + 1, receivedAt) } if (requestedDate && requestedDate > boundary(k)) k = firstBoundaryFrom(k, requestedDate) return { effectiveAt: boundary(k), minTermEnd: boundary(minTermK), serviceStart: start } } /** Es gilt die Mindestlaufzeit der Bedingungen, denen der Kunde zugestimmt hat (erst ab Version 3). */ export function minTermMonthsFor(termsVersion) { return ['2026-09-25', '2026-09-25-v2'].includes(termsVersion) ? 0 : config.abo.minTermMonths } /** * Beginn der aktuellen Mindestlaufzeit: der Wechseltag aus den Metadaten (nach einem Wechsel ins * Wartungs-Abo), sonst der Leistungsbeginn (Ende der Probezeit bzw. Abo-Start). Ersetzt * serviceStartOf in cancelSubscriptionAt. */ export function termStartOf(sub) { const termStartAt = sub.metadata?.termStartAt if (termStartAt) { const date = new Date(termStartAt) if (!Number.isNaN(date.getTime())) return date // Von Hand verstellte/beschaedigte Metadaten duerfen syncSubscription (toISOString) und // cancelSubscriptionAt nicht mit einer Exception abschiessen - Warnung, Fallback wie ohne Wechsel console.warn(`[stripe] Abo ${sub.id || '?'}: ungültiges termStartAt in den Metadaten ("${termStartAt}") - falle auf Leistungsbeginn zurück`) } return new Date(((sub.trial_end || sub.start_date) || 0) * 1000) } /** Stripe-Abo-ID (oder -Objekt) einer Rechnung: Webhook-Nutzlast (alt/neu) oder frisch geladene Rechnung. */ export function subscriptionIdOfInvoice(invoice) { return ( invoice.subscription || invoice.parent?.subscription_details?.subscription || invoice.lines?.data?.[0]?.parent?.subscription_item_details?.subscription || null ) } // Preis-ID -> Produkt ('wartung' | 'hosting' | 'mailStorage' | null); Preise ändern ihren lookup_key nicht const priceKeyCache = new Map() /** Produkte zu Preis-IDs; unbekannte IDs einmal bei Stripe nachschlagen (gelöscht/404 -> null). */ export async function resolvePriceKeys(priceIds) { const keys = new Map() for (const id of priceIds) { if (!priceKeyCache.has(id)) { const price = await getStripe() .prices.retrieve(id) .catch((err) => { if (err?.statusCode === 404) return null throw err }) priceKeyCache.set(id, productKeyForLookupKey(price?.lookup_key)) } keys.set(id, priceKeyCache.get(id)) } return keys } /** * Alle Zeilen einer Rechnung. invoices.retrieve bettet hoechstens 10 Zeilen ein (lines.has_more); anteilige * Zeilen stehen vor den Abo-Zeilen, mehrere Speicheraenderungen in einem Monat schieben also die Wartungs- * oder Speicher-Zeile hinaus. Bei has_more kommen alle Seiten ueber invoices.listLineItems (F18). Eine * Vorschau-Rechnung hat keine ID und laesst sich nicht nachladen: dort zaehlen die eingebetteten Zeilen. */ export async function invoiceLines(invoice) { const embedded = invoice?.lines?.data || [] if (!invoice?.lines?.has_more || !invoice.id) return embedded return getStripe().invoices.listLineItems(invoice.id, { limit: 100 }).autoPagingToArray({ limit: 1000 }) } /** * Rechnung frisch laden (in der API-Version des SDK, nicht der des Webhook-Endpunkts), mit allen Zeilen. * Der Webhook invoice.paid laedt sie genau einmal und reicht sie an Stundengutschrift und Speicher-Buchung weiter. */ export async function loadInvoice(invoiceId) { const fresh = await getStripe().invoices.retrieve(invoiceId) const data = await invoiceLines(fresh) return { ...fresh, lines: { ...(fresh.lines || {}), data, has_more: false } } } /** * Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). invoice ist die * Webhook-Nutzlast (wird frisch geladen) oder, mit fresh: true, eine schon per loadInvoice geladene Rechnung. */ export async function grantCreditForInvoice(invoice, { fresh = false } = {}) { const ref = subscriptionIdOfInvoice(invoice) const subscriptionId = typeof ref === 'string' ? ref : ref?.id if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' } // Frisch laden: Die Webhook-Nutzlast hat die API-Version des Webhook-Endpunkts, nicht die des SDK. // Es zählt die reguläre Wartungs-Zeile vor Rabatt und Guthaben: Auch ein Monat, den ein Angebot // oder eine Übernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Probezeit (0 €), // anteilige Verrechnungen, Hosting und Zusatzspeicher bringen keine Stunde. const loaded = fresh ? invoice : await loadInvoice(invoice.id) const minutes = creditMinutesForInvoice(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded))) if (!minutes) return { skipped: 'keine Wartungs-Leistung' } const sub = await syncSubscription(subscriptionId) if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' } const grantedAt = new Date((loaded.status_transitions?.paid_at || loaded.created) * 1000) try { await createDocument( config.collections.hourCredits, { customerId: sub.customerId, stripeSubscriptionId: subscriptionId, stripeInvoiceId: loaded.id, minutes, grantedAt: grantedAt.toISOString(), expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(), source: 'stripe_invoice', note: loaded.number ? `Rechnung ${loaded.number}` : '', }, loaded.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 } const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) /** * Rabatt fuer Testkonten holen oder (einmalig) anlegen. productKey: 'wartung' | 'hosting'; die ID * kommt aus dem Katalog (products..testCouponId), wie amountCents und der lookup_key. * Fix round 1, Important 1: Stripe begrenzt den Coupon-Namen auf 40 Zeichen - der volle Produktname * ("Testzugang WEBklar (0,50 €) – WEBklar Hosting-Abo" = 49) hätte in echtem Stripe 400 zurückgegeben. */ export async function ensureTestCoupon(productKey) { const catalog = getProduct(productKey) // 'mailStorage' oder unbekannt -> 400 (kein Testrabatt dafuer) const id = catalog.testCouponId if (!id) { const err = new Error(`Kein Testrabatt fuer ${productKey}`) err.status = 400 throw err } const stripe = getStripe() const existing = await stripe.coupons.retrieve(id).catch(() => null) if (existing) return existing.id const created = await stripe.coupons.create({ id, name: `Testzugang ${capitalize(productKey)} (0,50 €)`, amount_off: catalog.amountCents - 50, currency: 'eur', duration: 'forever', metadata: { webklar: `${PRODUCT_METADATA[productKey]}-test` }, }) return created.id } // Preis wie in den Bedingungen: "20,00 €" (normales Leerzeichen, kein geschuetztes wie bei toLocaleString) const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €` /** * Hinweis ueber dem Bezahlknopf im Checkout (custom_text): Vertragsschluss, Leistungsbeginn, Preis * und Mindestlaufzeit des bestellten Produkts; bei einem Angebot der vereinbarte Preis. */ function checkoutMessage(product, lateStart, offer) { const intro = `Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.` + (lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : '') const price = offer ? `Preis wie vereinbart: ${offer.text}.` : `${lateStart ? 'Danach ' : ''}${fmtEuro(product.amountCents)} monatlich,` return `${intro} ${price} Mindestlaufzeit ${product.minTermMonths} Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.` } /** * Stripe Checkout fuer ein Abo (productKey 'wartung' | 'hosting'). Der Kunde hat seine Bestellung * vorher im Portal abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart. * Position, Beschreibung, Hinweistext und Testrabatt richten sich nach dem Produkt. * startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist' (Probezeit ohne Leistung * und ohne Abbuchung bis consent.serviceStartAt, dem Tag nach dem Ende der Widerrufsfrist, deadlines.js). * offer: individuelles Angebot { id, stripeCouponId, text } -> Coupon im Checkout; bei Testkonten * bleibt der Testrabatt im Checkout und das Angebot wird nach dem Abschluss angehaengt (offerDeferred). */ export async function createAboCheckout(customer, consent, productKey, offer = null) { const product = getProduct(productKey) // unbekanntes Produkt -> 400 // Hoechstens ein laufendes Abo je Kunde (Spec 7); der Wechsel zwischen den Abos laeuft ueber planSwitch const current = await getCurrentSubscription(customer.$id) if (current && ACTIVE_STATUSES.has(current.status)) { const err = new Error('Sie haben bereits ein laufendes Abo abgeschlossen.') err.status = 409 throw err } const stripe = getStripe() const [priceId, stripeCustomerId] = await Promise.all([ ensurePrice(productKey), getOrCreateStripeCustomer(customer), ]) const lateStart = consent.startChoice === 'nach_frist' const serviceStartAt = lateStart ? consent.serviceStartAt || lateStartAt() : null const isTestAccount = config.abo.testEmails.includes(String(customer.email || '').toLowerCase()) const testCoupon = isTestAccount ? await ensureTestCoupon(productKey) : null const offerMeta = offer ? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) } : {} // Stripe-Metadaten am Abo (max. 500 Zeichen je Wert): Zuordnung, Produkt und Nachweis der Zustimmung const meta = { webklarCustomerId: customer.$id, email: customer.email || '', productKey, 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), ...offerMeta, } // Checkout erlaubt nur einen Rabatt: Testkonto -> Testrabatt, sonst der Angebots-Coupon const coupon = testCoupon || offer?.stripeCouponId || null 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: product.name, ...(serviceStartAt ? { trial_end: Math.floor(serviceStartAt.getTime() / 1000) } : {}), }, // An der Checkout-Sitzung nur, was der Webhook braucht (Zuordnung, Produkt, Angebot). Keine // Zustimmungsdaten: abgeschlossene Sitzungen lassen sich spaeter nicht mehr bereinigen // (Loeschfristen, Datenschutzerklaerung Abschnitt 7) - die stehen nur am Abo. metadata: { webklarCustomerId: customer.$id, productKey, ...offerMeta }, ...(coupon ? { discounts: [{ coupon }] } : {}), custom_text: { submit: { message: checkoutMessage(product, lateStart, offer) } }, 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, source = 'system') { // cancelSource: der Webhook erkennt daran, dass die Bestaetigung schon verschickt wird await getStripe().subscriptions.update(subscriptionId, { cancel_at_period_end: true, metadata: { cancelSource: source }, }) return syncSubscription(subscriptionId) } /** Kuendigung nach § 8 (Mindestlaufzeit, Wunschtermin, Geschaeftsaufgabe). */ export async function cancelSubscriptionAt(subscriptionId, requestedDate = null, source = 'kuendigungsbutton', kind = 'ordentlich', receivedAt = new Date()) { const stripe = getStripe() const sub = await stripe.subscriptions.retrieve(subscriptionId) const { effectiveAt, minTermEnd } = computeCancellationDate({ serviceStart: termStartOf(sub), receivedAt, requestedDate, kind, minTermMonths: minTermMonthsFor(sub.metadata?.termsVersion), }) const periodEnd = periodEndOf(sub) const effectiveUnix = Math.floor(effectiveAt.getTime() / 1000) if (periodEnd && Math.abs(effectiveUnix - periodEnd) < 3600) { await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true, metadata: { cancelSource: source } }) } else { await stripe.subscriptions.update(subscriptionId, { cancel_at: effectiveUnix, proration_behavior: 'none', metadata: { cancelSource: source }, }) } const doc = await syncSubscription(subscriptionId) return { subscription: doc, effectiveAt: effectiveAt.toISOString(), minTermEnd: minTermEnd.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) }