Abo-Katalog: Wartung, Hosting und Zusatzspeicher als Produkte (noch ohne Verwendung)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-27 15:29:16 +00:00
1 parent afec0b82fd
commit 877fde7a6f
3 files changed
+226 -8

No files matched your search

+53 -8
View File
@@ -23,6 +23,48 @@ function resolveDatabaseId() {
return WOMS_DATABASE_ID
}
// Abo-Produkte (Spec 2026-09-27 "Verträge, Hosting-Abo und E-Mail-Zusatzspeicher"). Preise in Cent,
// Endpreise gemäß § 19 UStG. Texte, die Kunden sehen, stimmen mit den Vertragsbedingungen überein.
const products = {
wartung: {
key: 'wartung',
name: 'WEBklar Wartungs-Abo',
lookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
amountCents: 5000,
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, 1 E-Mail-Postfach (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,
},
// Noch nicht bestellbar: Die Beschreibung geht erst mit den Vertragsbedingungen Version 4 an Stripe
hosting: {
key: 'hosting',
name: 'WEBklar Hosting-Abo',
lookupKey: 'webklar_hosting_monatlich_1500',
amountCents: 1500,
description:
'Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland (SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit, Updates von Betriebssystem und Server-Software), 1 Domain inklusive Verwaltung und 1 E-Mail-Postfach (5 GB). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR HOSTING',
priceNickname: 'Hosting-Abo monatlich',
minTermMonths: 12,
minutesPerMonth: 0,
},
}
// E-Mail-Zusatzspeicher: zweite Position (Stückzahl) im selben Stripe-Abo
const mailStorage = {
key: 'mailStorage',
name: 'WEBklar E-Mail-Zusatzspeicher 5 GB',
lookupKey: 'webklar_mailspeicher_5gb_monatlich_100',
amountCents: 100,
gbPerUnit: 5,
includedGb: 5,
maxUnits: 10,
priceNickname: 'E-Mail-Zusatzspeicher 5 GB monatlich',
}
export const config = {
port: Number(process.env.PORT) || 3000,
sessionSecret: process.env.SESSION_SECRET || '',
@@ -56,16 +98,19 @@ export const config = {
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
products,
mailStorage,
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
// Die Werte kommen aus dem Produktkatalog (products.wartung).
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
priceLookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
priceAmountCents: 5000,
productName: 'WEBklar Wartungs-Abo',
productDescription:
'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, 1 E-Mail-Postfach (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',
priceLookupKey: products.wartung.lookupKey,
priceAmountCents: products.wartung.amountCents,
productName: products.wartung.name,
productDescription: products.wartung.description,
statementDescriptor: products.wartung.statementDescriptor,
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
invoiceFooter:
@@ -77,8 +122,8 @@ export const config = {
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
termsVersion: '2026-09-25-v3',
// Mindestlaufzeit ab Leistungsbeginn; Kuendigung zum Ende bis 1 Monat vorher, danach monatlich
minTermMonths: 12,
minutesPerMonth: 60,
minTermMonths: products.wartung.minTermMonths,
minutesPerMonth: products.wartung.minutesPerMonth,
creditValidityMonths: 12,
usageRoundingMinutes: 15,
// Widerrufsfrist und "Beginn nach Ablauf der Widerrufsfrist": server/services/deadlines.js
+78
View File
@@ -0,0 +1,78 @@
import { config } from '../config.js'
/**
* Abo-Produkte (Hosting-Abo, Wartungs-Abo) und E-Mail-Zusatzspeicher.
*
* Reine Funktionen ohne Netzwerk: Sie ordnen Stripe-Preise (lookup_key) den Produkten zu und
* werten Abo-Positionen und Rechnungszeilen aus. Stripe-Objekte haben die Form der API-Version
* des SDK (2025-10-29.clover).
*/
export const PRODUCT_KEYS = Object.freeze(['wartung', 'hosting'])
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
/** Produkt aus dem Katalog; unbekannter Schlüssel -> 400. */
export function getProduct(key) {
const product = PRODUCT_KEYS.includes(key) ? config.products[key] : null
if (!product) throw httpError(400, `Unbekanntes Abo: ${key}`)
return product
}
/** lookup_key eines Stripe-Preises -> 'wartung' | 'hosting' | 'mailStorage' | null */
export function productKeyForLookupKey(lookupKey) {
if (!lookupKey) return null
const product = PRODUCT_KEYS.find((key) => config.products[key].lookupKey === lookupKey)
if (product) return product
return config.mailStorage.lookupKey === lookupKey ? 'mailStorage' : null
}
/**
* Positionen eines Stripe-Abos (subscription.items.data) auswerten. Der Abo-Posten ist die
* erste Position mit einem Hosting- oder Wartungs-Preis, unabhängig von der Reihenfolge.
*/
export function describeItems(items) {
const list = Array.isArray(items) ? items : []
const abo = list.find((item) => PRODUCT_KEYS.includes(productKeyForLookupKey(item?.price?.lookup_key)))
const storage = list.find((item) => productKeyForLookupKey(item?.price?.lookup_key) === 'mailStorage')
return {
productKey: abo ? productKeyForLookupKey(abo.price.lookup_key) : null,
aboItemId: abo?.id || null,
aboPriceId: abo?.price?.id || null,
lookupKey: abo?.price?.lookup_key || null,
priceAmount: abo?.price?.unit_amount ?? null,
storageItemId: storage?.id || null,
storageUnits: storage ? Number(storage.quantity) || 0 : 0,
}
}
const priceIdOfLine = (line) => line?.pricing?.price_details?.price || null
/** Preis-IDs aller Rechnungszeilen, jede nur einmal. */
export function priceIdsOfInvoice(invoice) {
const ids = (invoice?.lines?.data || []).map(priceIdOfLine).filter(Boolean)
return [...new Set(ids)]
}
/**
* Arbeitszeit, die eine bezahlte Rechnung gutschreibt: die Minuten des Produkts der ersten
* regulären (nicht anteiligen) Abo-Zeile über 0 €, deren Produkt Arbeitszeit enthält. Anteilige
* Verrechnungen, Probezeiten (0 €), Hosting, Zusatzspeicher und unbekannte Preise zählen nicht.
* priceKeys: Map Preis-ID -> 'wartung' | 'hosting' | 'mailStorage' | null
*/
export function creditMinutesForInvoice(invoice, priceKeys) {
for (const line of invoice?.lines?.data || []) {
const key = priceKeys.get(priceIdOfLine(line))
if (!PRODUCT_KEYS.includes(key)) continue
const minutes = config.products[key].minutesPerMonth
const regular =
line.parent?.type === 'subscription_item_details' &&
line.parent.subscription_item_details?.proration === false
if (minutes > 0 && regular && line.amount > 0) return minutes
}
return 0
}