697 lines
30 KiB
JavaScript
697 lines
30 KiB
JavaScript
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', newsSetup: 'news-einrichtung' }
|
||
|
||
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, der Zusatzspeicher aus config.mailStorage. */
|
||
function catalogFor(key) {
|
||
if (key === 'mailStorage') return config.mailStorage
|
||
if (key === 'newsSetup') return config.newsSetup
|
||
return getProduct(key)
|
||
}
|
||
|
||
const priceIdCache = new Map()
|
||
/**
|
||
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
|
||
* 'mailStorage' | 'newsSetup' (Einmalpreis). 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,
|
||
...(catalog.oneTime ? {} : { 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.<key>.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
|
||
}
|
||
|
||
/**
|
||
* Checkout fuer die einmalige Einrichtung einer Funktion (News-Pinnwand, 30 € brutto, kein Abo). Die Sitzung
|
||
* verfaellt nach 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die
|
||
* Rechnung erzeugt Stripe selbst. Nur die Zuordnung steht in den Metadaten, der Zustimmungswortlaut nicht.
|
||
* @returns {Promise<{ id: string, url: string }>}
|
||
*/
|
||
export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) {
|
||
const stripe = getStripe()
|
||
const [priceId, stripeCustomerId] = await Promise.all([ensurePrice('newsSetup'), getOrCreateStripeCustomer(customer)])
|
||
const params = {
|
||
mode: 'payment',
|
||
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' },
|
||
invoice_creation: {
|
||
enabled: true,
|
||
invoice_data: { description: config.newsSetup.name, footer: config.stripe.invoiceFooter },
|
||
},
|
||
metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) },
|
||
expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60,
|
||
success_url: `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`,
|
||
cancel_url: `${config.publicBaseUrl}/dashboard.html#news`,
|
||
}
|
||
let session
|
||
try {
|
||
session = await stripe.checkout.sessions.create(params)
|
||
} catch (err) {
|
||
// SEPA-Lastschrift noch nicht im Stripe-Konto aktiviert -> nur Karte anbieten (wie beim Abo-Checkout)
|
||
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 { id: session.id, url: 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)
|
||
}
|