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:
1 parent
afec0b82fd
commit
877fde7a6f
3 files changed
+226
-8
No files matched your search
+53
-8
@@ -23,6 +23,48 @@ function resolveDatabaseId() {
|
|||||||
return WOMS_DATABASE_ID
|
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 = {
|
export const config = {
|
||||||
port: Number(process.env.PORT) || 3000,
|
port: Number(process.env.PORT) || 3000,
|
||||||
sessionSecret: process.env.SESSION_SECRET || '',
|
sessionSecret: process.env.SESSION_SECRET || '',
|
||||||
@@ -56,16 +98,19 @@ export const config = {
|
|||||||
|
|
||||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
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.
|
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
|
||||||
|
// Die Werte kommen aus dem Produktkatalog (products.wartung).
|
||||||
stripe: {
|
stripe: {
|
||||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||||
priceLookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
|
priceLookupKey: products.wartung.lookupKey,
|
||||||
priceAmountCents: 5000,
|
priceAmountCents: products.wartung.amountCents,
|
||||||
productName: 'WEBklar Wartungs-Abo',
|
productName: products.wartung.name,
|
||||||
productDescription:
|
productDescription: products.wartung.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: products.wartung.statementDescriptor,
|
||||||
statementDescriptor: 'WEBKLAR WARTUNG',
|
|
||||||
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
|
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
|
||||||
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
|
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
|
||||||
invoiceFooter:
|
invoiceFooter:
|
||||||
@@ -77,8 +122,8 @@ export const config = {
|
|||||||
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
|
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
|
||||||
termsVersion: '2026-09-25-v3',
|
termsVersion: '2026-09-25-v3',
|
||||||
// Mindestlaufzeit ab Leistungsbeginn; Kuendigung zum Ende bis 1 Monat vorher, danach monatlich
|
// Mindestlaufzeit ab Leistungsbeginn; Kuendigung zum Ende bis 1 Monat vorher, danach monatlich
|
||||||
minTermMonths: 12,
|
minTermMonths: products.wartung.minTermMonths,
|
||||||
minutesPerMonth: 60,
|
minutesPerMonth: products.wartung.minutesPerMonth,
|
||||||
creditValidityMonths: 12,
|
creditValidityMonths: 12,
|
||||||
usageRoundingMinutes: 15,
|
usageRoundingMinutes: 15,
|
||||||
// Widerrufsfrist und "Beginn nach Ablauf der Widerrufsfrist": server/services/deadlines.js
|
// Widerrufsfrist und "Beginn nach Ablauf der Widerrufsfrist": server/services/deadlines.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
|
||||||
|
}
|
||||||
@@ -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'])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user