- 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>
396 lines
15 KiB
JavaScript
396 lines
15 KiB
JavaScript
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)
|
||
}
|