Stripe: Testrabatt-Namen, Produktbild, Hosting-Leistungen, Tests für Bestandskunden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-28 10:02:03 +00:00
1 parent c4607e9c43
commit 7df0b72019
7 files changed
+297 -52

No files matched your search

+18 -9
View File
@@ -31,22 +31,24 @@ const products = {
name: 'WEBklar Wartungs-Abo',
lookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
amountCents: 5000,
// Version 4 (Rechtsrunde 28.09.2026): beliebig viele Postfächer statt 1 E-Mail-Postfach (F07)
// Version 4 (Rechtsrunde 28.09.2026): beliebig viele E-Mail-Postfächer statt 1 E-Mail-Postfach (F07;
// Fix round 1, item 11: ausgeschrieben "E-Mail-Postfächer" statt bloß "Postfächer")
description:
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, beliebig viele Postfächer (zusammen 5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, beliebig viele E-Mail-Postfächer (zusammen 5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR WARTUNG',
priceNickname: 'Wartungs-Abo monatlich',
minTermMonths: 12,
minutesPerMonth: 60,
// Testrabatt: id passend zur amount_off-Regel (amountCents - 50), siehe global-constraints.md
testCouponId: 'WEBKLAR-TEST-050',
// Leistungen fuers Kundenportal (Vergleich, Bestellseite): [Titel, Detail], Spec 3.1 / Fassung 4
features: [
['Betrieb & Pflege Ihrer Website bzw. Web-Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'],
['1 Domain inklusive', '.de, .com, .eu, .net oder .org, Registrierung und DNS-Verwaltung'],
['beliebig viele Postfächer', 'zusammen 5 GB Speicher'],
['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'],
],
},
// Noch nicht bestellbar: Die Beschreibung geht erst mit den Vertragsbedingungen Version 4 an Stripe
hosting: {
key: 'hosting',
name: 'WEBklar Hosting-Abo',
@@ -58,11 +60,15 @@ const products = {
priceNickname: 'Hosting-Abo monatlich',
minTermMonths: 12,
minutesPerMonth: 0,
testCouponId: 'WEBKLAR-TEST-050-HOSTING',
// Fix round 1, Important 3: row 1 war wörtlich von Wartung kopiert und suggerierte Website-Pflege
// (die es im Hosting-Abo nicht gibt); row 4 nennt jetzt die Abrechnung in 15-Minuten-Schritten.
features: [
['Bereitstellung Ihrer statischen Website', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'],
['Bereitstellung Ihrer statischen Website', 'Server in Deutschland, SSL, Updates von Betriebssystem und Server-Software, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', '.de, .com, .eu, .net oder .org, Registrierung und DNS-Verwaltung'],
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'],
['Änderungen nur nach Freigabe', '100 € pro Stunde'],
['Keine Pflege der Website', 'Pflege und Updates der Website nur im Wartungs-Abo'],
['Änderungen nur nach Freigabe', '100 € pro Stunde in 15-Minuten-Schritten (gemäß § 19 UStG ohne Umsatzsteuer)'],
],
},
}
@@ -126,8 +132,9 @@ export const config = {
priceLookupKey: products.wartung.lookupKey,
priceAmountCents: products.wartung.amountCents,
productName: products.wartung.name,
productDescription: products.wartung.description,
statementDescriptor: products.wartung.statementDescriptor,
// Generisches Logo, allen drei Stripe-Produkten mitgegeben (ensurePrice); ein Katalogeintrag
// kann das mit einem eigenen "image"-Feld ueberschreiben
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
invoiceFooter:
@@ -154,7 +161,9 @@ export const config = {
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
testCouponId: 'WEBKLAR-TEST-050',
// Fix round 1, Minor 8: einzige Quelle ist der Katalog (products.wartung.testCouponId); Hosting
// hat sein eigenes unter products.hosting.testCouponId
testCouponId: products.wartung.testCouponId,
},
// Individuelle Abo-Angebote (z. B. erste 2 Monate je 25 €): je Angebot ein einmal einloesbarer Stripe-Coupon
+3 -1
View File
@@ -43,7 +43,9 @@ export function productView(key) {
amountCents: product.amountCents,
minTermMonths: product.minTermMonths,
minutesPerMonth: product.minutesPerMonth,
features: product.features,
// Kopie, kein Verweis auf den Katalog: aufrufender Code darf das zurueckgegebene Array
// veraendern, ohne den geteilten Katalogeintrag fuer alle anderen zu verfaelschen (B10)
features: product.features.map((row) => [...row]),
}
}
+43 -16
View File
@@ -81,6 +81,9 @@ export async function ensurePrice(key) {
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({
@@ -100,6 +103,11 @@ export async function ensurePrice(key) {
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
@@ -107,16 +115,25 @@ export async function ensurePortalConfiguration() {
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
// 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) {
await stripe.billingPortal.configurations.update(own.id, {
business_profile: {
headline: PORTAL_HEADLINE,
terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`,
},
})
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({
@@ -299,7 +316,13 @@ export function minTermMonthsFor(termsVersion) {
*/
export function termStartOf(sub) {
const termStartAt = sub.metadata?.termStartAt
if (termStartAt) return new Date(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)
}
@@ -405,12 +428,17 @@ async function getOrCreateStripeCustomer(customer) {
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' }
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1)
/** Rabatt fuer Testkonten holen oder (einmalig) anlegen. productKey: 'wartung' | 'hosting'. */
/**
* 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 id = TEST_COUPON_IDS[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
@@ -419,14 +447,13 @@ export async function ensureTestCoupon(productKey) {
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}`,
name: `Testzugang ${capitalize(productKey)} (0,50 €)`,
amount_off: catalog.amountCents - 50,
currency: 'eur',
duration: 'forever',
metadata: { webklar: `${productKey}-test` },
metadata: { webklar: `${PRODUCT_METADATA[productKey]}-test` },
})
return created.id
}