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) }