From 16fbb03c13870ce59e646237ca2dea43a9e74693 Mon Sep 17 00:00:00 2001 From: knso Date: Wed, 30 Sep 2026 16:24:21 +0000 Subject: [PATCH] =?UTF-8?q?Stripe:=20Einmalpreis=20News-Einrichtung=2030?= =?UTF-8?q?=20=E2=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/stripe-products.mjs | 12 ++++++++---- server/config.js | 11 +++++++++++ server/services/stripeBilling.js | 10 ++++++---- test/stripeBilling.test.js | 18 ++++++++++++++++++ 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/scripts/stripe-products.mjs b/scripts/stripe-products.mjs index afe88ea..26925c9 100644 --- a/scripts/stripe-products.mjs +++ b/scripts/stripe-products.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher): legt fehlende + * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-Einrichtung als Einmalpreis): legt fehlende * Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext * (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten"). * Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt. @@ -17,7 +17,7 @@ import { config } from '../server/config.js' import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js' import { PRODUCT_KEYS } from '../server/services/products.js' -const KEYS = [...PRODUCT_KEYS, 'mailStorage'] // ['wartung', 'hosting', 'mailStorage'] +const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup'] const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen' export function parseArgs(argv) { @@ -28,7 +28,9 @@ export function parseArgs(argv) { } function catalogEntry(key) { - return key === 'mailStorage' ? config.mailStorage : config.products[key] + if (key === 'mailStorage') return config.mailStorage + if (key === 'newsSetup') return config.newsSetup + return config.products[key] } async function findPrice(lookupKey) { @@ -42,7 +44,9 @@ async function checkPrice(key) { if (!price) return { ok: false, note: `Preis ${catalog.lookupKey} fehlt bei Stripe` } const mismatches = [] if (price.unit_amount !== catalog.amountCents) mismatches.push(`Betrag ${price.unit_amount} statt ${catalog.amountCents}`) - if (price.recurring?.interval !== 'month') mismatches.push(`recurring.interval "${price.recurring?.interval}" statt "month"`) + if (catalog.oneTime) { + if (price.recurring) mismatches.push('Preis ist wiederkehrend statt einmalig') + } else if (price.recurring?.interval !== 'month') mismatches.push(`recurring.interval "${price.recurring?.interval}" statt "month"`) if (price.tax_behavior !== 'inclusive') mismatches.push(`tax_behavior "${price.tax_behavior}" statt "inclusive"`) // Important 2: Produkt ohne Bild faellt in --pruefen auf (Checkout zeigt sonst kein Bild) const productId = typeof price.product === 'string' ? price.product : price.product?.id diff --git a/server/config.js b/server/config.js index d4efdcb..1601fdb 100644 --- a/server/config.js +++ b/server/config.js @@ -88,6 +88,16 @@ const mailStorage = { hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 }, } +// News-Pinnwand: einmalige Einrichtung (Einmalpreis, kein Abo) +const newsSetup = { + key: 'newsSetup', + name: 'News-Pinnwand – Einrichtung', + amountCents: 3000, + lookupKey: 'webklar_news_einrichtung_3000', + oneTime: true, + priceNickname: 'News-Einrichtung 30 €', +} + export const config = { port: Number(process.env.PORT) || 3000, sessionSecret: process.env.SESSION_SECRET || '', @@ -133,6 +143,7 @@ export const config = { products, mailStorage, + newsSetup, // Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an. // Die Werte kommen aus dem Produktkatalog (products.wartung). diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index cdb63e5..cf7c071 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -51,17 +51,19 @@ export function isStripeConfigured() { } // Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice) -const PRODUCT_METADATA = { wartung: 'wartungs-abo', hosting: 'hosting-abo', mailStorage: 'mail-speicher' } +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) { - return key === 'mailStorage' ? config.mailStorage : getProduct(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'. Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe + * '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) { @@ -94,7 +96,7 @@ export async function ensurePrice(key) { product: product.id, currency: 'eur', unit_amount: catalog.amountCents, - recurring: { interval: 'month' }, + ...(catalog.oneTime ? {} : { recurring: { interval: 'month' } }), tax_behavior: 'inclusive', lookup_key: catalog.lookupKey, nickname: catalog.priceNickname, diff --git a/test/stripeBilling.test.js b/test/stripeBilling.test.js index d808f01..083fbfc 100644 --- a/test/stripeBilling.test.js +++ b/test/stripeBilling.test.js @@ -242,3 +242,21 @@ test('Spiegel: termStartAt aus Metadaten (Wechsel), sonst Leistungsbeginn; stora assert.equal(doc.termStartAt, '2026-10-05T10:00:00.000Z') assert.equal(doc.storageUnits, 3) }) + +test('news_einrichtung_einmalpreis', async () => { + const before = st.calls.length + const id = await ensurePrice('newsSetup') + const calls = st.calls.slice(before) + const created = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices') + assert.equal(created.length, 1) + assert.equal(created[0].body['unit_amount'], '3000') + assert.equal(created[0].body['lookup_key'], 'webklar_news_einrichtung_3000') + assert.ok(!Object.keys(created[0].body).some((k) => k.startsWith('recurring')), 'kein recurring') + assert.equal(st.prices.get(id).recurring, null) + const products = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products') + assert.equal(products[0].body['metadata[webklar]'], 'news-einrichtung') + // zweiter Aufruf: kein zweiter Preis (auch ohne Cache per lookup_key gefunden) + const again = st.calls.length + assert.equal(await ensurePrice('newsSetup'), id) + assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0) +})