Files
Webklar-Kundenbereich/server/services/stripeBilling.js
T

596 lines
25 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'
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
}
const product = await stripe.products.create({
name: catalog.name,
...(catalog.description ? { description: catalog.description } : {}),
...(catalog.statementDescriptor ? { statement_descriptor: catalog.statementDescriptor } : {}),
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
/** 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) {
cachedPortalConfigId = own.id
// Aeltere Konfiguration (frueher nur Wartung) traegt noch die alte Ueberschrift -> anpassen
if (own.business_profile?.headline !== PORTAL_HEADLINE) {
await stripe.billingPortal.configurations.update(own.id, {
business_profile: {
headline: PORTAL_HEADLINE,
terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`,
},
})
}
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
}
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) return new Date(termStartAt)
return new Date(((sub.trial_end || sub.start_date) || 0) * 1000)
}
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
}
/** Bezahlte Wartungs-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' }
// 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 fresh = await getStripe().invoices.retrieve(invoice.id)
const minutes = creditMinutesForInvoice(fresh, await resolvePriceKeys(priceIdsOfInvoice(fresh)))
if (!minutes) return { skipped: 'keine Wartungs-Leistung' }
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
const grantedAt = new Date((fresh.status_transitions?.paid_at || fresh.created) * 1000)
try {
await createDocument(
config.collections.hourCredits,
{
customerId: sub.customerId,
stripeSubscriptionId: subscriptionId,
stripeInvoiceId: invoice.id,
minutes,
grantedAt: grantedAt.toISOString(),
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
source: 'stripe_invoice',
note: fresh.number ? `Rechnung ${fresh.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
}
// Testrabatt-IDs je Produkt: amount_off = amountCents - 50 -> es werden nur 0,50 € abgebucht
const TEST_COUPON_IDS = { wartung: config.abo.testCouponId, hosting: 'WEBKLAR-TEST-050-HOSTING' }
/** Rabatt fuer Testkonten holen oder (einmalig) anlegen. productKey: 'wartung' | 'hosting'. */
export async function ensureTestCoupon(productKey) {
const id = TEST_COUPON_IDS[productKey]
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 catalog = getProduct(productKey)
const created = await stripe.coupons.create({
id,
name: `Testzugang WEBklar (0,50 €) – ${catalog.name}`,
amount_off: catalog.amountCents - 50,
currency: 'eur',
duration: 'forever',
metadata: { webklar: `${productKey}-test` },
})
return created.id
}
// Preis wie in den Bedingungen: "15,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)
}