From 877fde7a6f904bcef61cd353660c459bda9d303a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:29:16 +0000 Subject: [PATCH] Abo-Katalog: Wartung, Hosting und Zusatzspeicher als Produkte (noch ohne Verwendung) Co-Authored-By: Claude Opus 5.5 --- server/config.js | 61 ++++++++++++++++++++---- server/services/products.js | 78 ++++++++++++++++++++++++++++++ test/products.test.js | 95 +++++++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 server/services/products.js create mode 100644 test/products.test.js diff --git a/server/config.js b/server/config.js index 9763869..953b7f5 100644 --- a/server/config.js +++ b/server/config.js @@ -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 diff --git a/server/services/products.js b/server/services/products.js new file mode 100644 index 0000000..0a3e56f --- /dev/null +++ b/server/services/products.js @@ -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 +} diff --git a/test/products.test.js b/test/products.test.js new file mode 100644 index 0000000..e7b26ca --- /dev/null +++ b/test/products.test.js @@ -0,0 +1,95 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +const { config } = await import('../server/config.js') +const { getProduct, productKeyForLookupKey, describeItems, priceIdsOfInvoice, creditMinutesForInvoice } = + await import('../server/services/products.js') + +const line = (price, amount, proration = false) => ({ + amount, + pricing: { price_details: { price } }, + parent: { type: 'subscription_item_details', subscription_item_details: { proration } }, +}) +const keys = new Map([['price_w', 'wartung'], ['price_h', 'hosting'], ['price_s', 'mailStorage']]) + +test('Katalog: Preise, lookup_keys, Minuten, Mindestlaufzeit', () => { + assert.equal(getProduct('wartung').amountCents, 5000) + assert.equal(getProduct('hosting').amountCents, 1500) + assert.equal(getProduct('hosting').lookupKey, 'webklar_hosting_monatlich_1500') + assert.equal(getProduct('wartung').minutesPerMonth, 60) + assert.equal(getProduct('hosting').minutesPerMonth, 0) + assert.equal(getProduct('hosting').minTermMonths, 12) + assert.equal(config.mailStorage.lookupKey, 'webklar_mailspeicher_5gb_monatlich_100') + assert.equal(config.mailStorage.maxUnits, 10) + assert.throws(() => getProduct('gold'), (e) => e.status === 400) +}) + +test('Bisherige Werte bleiben gleich', () => { + assert.equal(config.stripe.priceLookupKey, 'webklar_wartung_monatlich_5000') + assert.equal(config.stripe.priceAmountCents, 5000) + assert.equal(config.stripe.productName, 'WEBklar Wartungs-Abo') + assert.equal(config.stripe.statementDescriptor, 'WEBKLAR WARTUNG') + assert.equal(config.abo.minutesPerMonth, 60) + assert.equal(config.abo.minTermMonths, 12) +}) + +test('lookup_key → Produkt', () => { + assert.equal(productKeyForLookupKey('webklar_wartung_monatlich_5000'), 'wartung') + assert.equal(productKeyForLookupKey('webklar_hosting_monatlich_1500'), 'hosting') + assert.equal(productKeyForLookupKey('webklar_mailspeicher_5gb_monatlich_100'), 'mailStorage') + assert.equal(productKeyForLookupKey('irgendwas'), null) + assert.equal(productKeyForLookupKey(undefined), null) +}) + +test('describeItems: Abo-Posten und Zusatzspeicher, Reihenfolge egal', () => { + const items = [ + { id: 'si_s', quantity: 2, price: { id: 'price_s', lookup_key: 'webklar_mailspeicher_5gb_monatlich_100', unit_amount: 100 } }, + { id: 'si_a', quantity: 1, price: { id: 'price_h', lookup_key: 'webklar_hosting_monatlich_1500', unit_amount: 1500 } }, + ] + assert.deepEqual(describeItems(items), { + productKey: 'hosting', + aboItemId: 'si_a', + aboPriceId: 'price_h', + lookupKey: 'webklar_hosting_monatlich_1500', + priceAmount: 1500, + storageItemId: 'si_s', + storageUnits: 2, + }) +}) + +test('describeItems: ohne bekannten Abo-Preis', () => { + const d = describeItems([{ id: 'si_x', quantity: 1, price: { id: 'price_x', lookup_key: null, unit_amount: 4900 } }]) + assert.deepEqual(d, { + productKey: null, + aboItemId: null, + aboPriceId: null, + lookupKey: null, + priceAmount: null, + storageItemId: null, + storageUnits: 0, + }) + assert.deepEqual(describeItems(undefined).storageUnits, 0) +}) + +test('Stunden nur für eine reguläre Wartungs-Zeile über 0 €', () => { + const inv = (...lines) => ({ lines: { data: lines } }) + assert.equal(creditMinutesForInvoice(inv(line('price_w', 5000)), keys), 60) + assert.equal(creditMinutesForInvoice(inv(line('price_h', 1500), line('price_s', 200)), keys), 0) + assert.equal(creditMinutesForInvoice(inv(line('price_w', 2300, true)), keys), 0) // anteilige Verrechnung + assert.equal(creditMinutesForInvoice(inv(line('price_w', 0)), keys), 0) // Probezeit + assert.equal(creditMinutesForInvoice(inv(line('price_x', 5000)), keys), 0) // unbekannter Preis + const invoiceItem = { + amount: 1200, + pricing: { price_details: { price: 'price_w' } }, + parent: { type: 'invoice_item_details', invoice_item_details: { proration: true } }, + } + assert.equal(creditMinutesForInvoice(inv(invoiceItem), keys), 0) + // Wechsel-Rechnung: Gutschrift Hosting (anteilig, negativ) und voller Wartungs-Monat + assert.equal(creditMinutesForInvoice(inv(line('price_h', -900, true), line('price_w', 5000)), keys), 60) + assert.equal(creditMinutesForInvoice({}, keys), 0) +}) + +test('priceIdsOfInvoice: eindeutige Preis-IDs, ohne Lücken', () => { + const inv = { lines: { data: [line('price_w', 5000), line('price_w', -100, true), { amount: 5 }] } } + assert.deepEqual(priceIdsOfInvoice(inv), ['price_w']) +})