Stripe: Katalog Terminplaner-Einrichtung (149 €) und Checkout je Funktion aus dem Register
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c584d220c8
commit
98fa03aad7
6 files changed
+238
-13
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-Einrichtung als Einmalpreis): legt fehlende
|
* Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News- und Terminplaner-Einrichtung als Einmalpreise): legt fehlende
|
||||||
* Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext
|
* Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext
|
||||||
* (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten").
|
* (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten").
|
||||||
* Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt.
|
* 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 { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js'
|
||||||
import { PRODUCT_KEYS } from '../server/services/products.js'
|
import { PRODUCT_KEYS } from '../server/services/products.js'
|
||||||
|
|
||||||
const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup']
|
export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup']
|
||||||
const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen'
|
const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen'
|
||||||
|
|
||||||
export function parseArgs(argv) {
|
export function parseArgs(argv) {
|
||||||
@@ -27,9 +27,10 @@ export function parseArgs(argv) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function catalogEntry(key) {
|
export function catalogEntry(key) {
|
||||||
if (key === 'mailStorage') return config.mailStorage
|
if (key === 'mailStorage') return config.mailStorage
|
||||||
if (key === 'newsSetup') return config.newsSetup
|
if (key === 'newsSetup') return config.newsSetup
|
||||||
|
if (key === 'termineSetup') return config.termineSetup
|
||||||
return config.products[key]
|
return config.products[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ const newsSetup = {
|
|||||||
priceNickname: 'News-Einrichtung 30 €',
|
priceNickname: 'News-Einrichtung 30 €',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminplaner: einmalige Einrichtung (Einmalpreis, kein Abo); der Terminplaner selbst laeuft, solange das Wartungs-Abo laeuft
|
||||||
|
const termineSetup = {
|
||||||
|
key: 'termineSetup',
|
||||||
|
name: 'Terminplaner – Einrichtung',
|
||||||
|
amountCents: 14900,
|
||||||
|
lookupKey: 'webklar_termine_einrichtung_14900',
|
||||||
|
oneTime: true,
|
||||||
|
priceNickname: 'Terminplaner-Einrichtung 149 €',
|
||||||
|
}
|
||||||
|
|
||||||
// Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut
|
// Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut
|
||||||
const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '')
|
const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '')
|
||||||
|
|
||||||
@@ -313,6 +323,7 @@ export const config = {
|
|||||||
products,
|
products,
|
||||||
mailStorage,
|
mailStorage,
|
||||||
newsSetup,
|
newsSetup,
|
||||||
|
termineSetup,
|
||||||
ustHinweis,
|
ustHinweis,
|
||||||
// Register der kaufbaren Funktionen (siehe oben)
|
// Register der kaufbaren Funktionen (siehe oben)
|
||||||
features,
|
features,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
updateDocument,
|
updateDocument,
|
||||||
} from './appwriteAdmin.js'
|
} from './appwriteAdmin.js'
|
||||||
import { lateStartAt } from './deadlines.js'
|
import { lateStartAt } from './deadlines.js'
|
||||||
|
import { featureConfig } from './featureRegistry.js'
|
||||||
import { creditMinutesForInvoice, describeItems, getProduct, priceIdsOfInvoice, productKeyForLookupKey } from './products.js'
|
import { creditMinutesForInvoice, describeItems, getProduct, priceIdsOfInvoice, productKeyForLookupKey } from './products.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,19 +52,26 @@ export function isStripeConfigured() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice)
|
// Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice)
|
||||||
const PRODUCT_METADATA = { wartung: 'wartungs-abo', hosting: 'hosting-abo', mailStorage: 'mail-speicher', newsSetup: 'news-einrichtung' }
|
const PRODUCT_METADATA = {
|
||||||
|
wartung: 'wartungs-abo',
|
||||||
|
hosting: 'hosting-abo',
|
||||||
|
mailStorage: 'mail-speicher',
|
||||||
|
newsSetup: 'news-einrichtung',
|
||||||
|
termineSetup: 'termine-einrichtung',
|
||||||
|
}
|
||||||
|
|
||||||
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, der Zusatzspeicher aus config.mailStorage. */
|
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, Zusatzspeicher und Einrichtungen aus config. */
|
||||||
function catalogFor(key) {
|
function catalogFor(key) {
|
||||||
if (key === 'mailStorage') return config.mailStorage
|
if (key === 'mailStorage') return config.mailStorage
|
||||||
if (key === 'newsSetup') return config.newsSetup
|
if (key === 'newsSetup') return config.newsSetup
|
||||||
|
if (key === 'termineSetup') return config.termineSetup
|
||||||
return getProduct(key)
|
return getProduct(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceIdCache = new Map()
|
const priceIdCache = new Map()
|
||||||
/**
|
/**
|
||||||
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
|
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
|
||||||
* 'mailStorage' | 'newsSetup' (Einmalpreis). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
|
* 'mailStorage' | 'newsSetup' | 'termineSetup' (Einmalpreise). 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.
|
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
|
||||||
*/
|
*/
|
||||||
export async function ensurePrice(key) {
|
export async function ensurePrice(key) {
|
||||||
@@ -639,14 +647,26 @@ export async function createAboCheckout(customer, consent, productKey, offer = n
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checkout fuer die einmalige Einrichtung einer Funktion (News-Pinnwand, 30 € brutto, kein Abo). Die Sitzung
|
* Checkout fuer die einmalige Einrichtung einer Funktion aus dem Register config.features (News-Pinnwand 30 €,
|
||||||
* verfaellt nach 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die
|
* Terminplaner 149 €; Einmalpreis, kein Abo). Preis, Rechnungstext und Rueckkehradresse kommen aus dem Register-Eintrag
|
||||||
* Rechnung erzeugt Stripe selbst. Nur die Zuordnung steht in den Metadaten, der Zustimmungswortlaut nicht.
|
* zu `key`; ein unbekannter Schluessel ist ein Fehler 400, bevor Stripe angesprochen wird. Die Sitzung verfaellt nach
|
||||||
|
* 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die Rechnung erzeugt Stripe
|
||||||
|
* selbst und traegt featureKey und projectId in ihren Metadaten. Nur die Zuordnung steht in den Metadaten der Sitzung,
|
||||||
|
* der Zustimmungswortlaut nicht.
|
||||||
* @returns {Promise<{ id: string, url: string }>}
|
* @returns {Promise<{ id: string, url: string }>}
|
||||||
*/
|
*/
|
||||||
export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) {
|
export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) {
|
||||||
|
const feature = featureConfig(key)
|
||||||
|
if (!feature) {
|
||||||
|
const err = new Error('Unbekannte Funktion.')
|
||||||
|
err.status = 400
|
||||||
|
throw err
|
||||||
|
}
|
||||||
const stripe = getStripe()
|
const stripe = getStripe()
|
||||||
const [priceId, stripeCustomerId] = await Promise.all([ensurePrice('newsSetup'), getOrCreateStripeCustomer(customer)])
|
const [priceId, stripeCustomerId] = await Promise.all([ensurePrice(feature.product), getOrCreateStripeCustomer(customer)])
|
||||||
|
// Rueckkehr ins Dashboard: bei Funktionen mit returnProjekt (Terminplaner) mit dem Projekt, damit die Seite es oeffnet
|
||||||
|
const projekt = feature.returnProjekt ? encodeURIComponent(projectId) : ''
|
||||||
|
const returnUrl = `${config.publicBaseUrl}/dashboard.html#${feature.returnHash}`
|
||||||
const params = {
|
const params = {
|
||||||
mode: 'payment',
|
mode: 'payment',
|
||||||
customer: stripeCustomerId,
|
customer: stripeCustomerId,
|
||||||
@@ -658,12 +678,16 @@ export async function createFeatureCheckout(customer, { projectId, key = 'news',
|
|||||||
customer_update: { address: 'auto', name: 'auto' },
|
customer_update: { address: 'auto', name: 'auto' },
|
||||||
invoice_creation: {
|
invoice_creation: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
invoice_data: { description: config.newsSetup.name, footer: config.stripe.invoiceFooter },
|
invoice_data: {
|
||||||
|
description: catalogFor(feature.product).name,
|
||||||
|
footer: config.stripe.invoiceFooter,
|
||||||
|
metadata: { featureKey: key, projectId },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) },
|
metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) },
|
||||||
expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60,
|
expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60,
|
||||||
success_url: `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`,
|
success_url: `${returnUrl}?bestellt=1${projekt ? `&projekt=${projekt}` : ''}`,
|
||||||
cancel_url: `${config.publicBaseUrl}/dashboard.html#news`,
|
cancel_url: `${returnUrl}${projekt ? `?projekt=${projekt}` : ''}`,
|
||||||
}
|
}
|
||||||
let session
|
let session
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { test, after } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
|
import { startFakeStripe } from './support/fakeStripe.js'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
import { schemaKeys } from './support/schema.js'
|
||||||
|
|
||||||
|
// createFeatureCheckout (Stripe-Checkout je Funktion aus dem Register config.features), direkter Aufruf gegen den Stripe-Nachbau
|
||||||
|
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
|
||||||
|
const st = await startFakeStripe()
|
||||||
|
after(() => Promise.all([aw.close(), st.close()]))
|
||||||
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
|
const { config } = await import('../server/config.js')
|
||||||
|
const { createFeatureCheckout } = await import('../server/services/stripeBilling.js')
|
||||||
|
|
||||||
|
const customer = { $id: 'c1', name: 'Test Kunde', email: 'kunde@example.invalid', code: 'K-1' }
|
||||||
|
const CONSENT_AT = '2026-10-04T10:00:00.000Z'
|
||||||
|
const sessionCalls = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
|
||||||
|
const lastCall = () => sessionCalls().at(-1)
|
||||||
|
const priceOf = (call) => st.prices.get(call.body['line_items[0][price]'])
|
||||||
|
|
||||||
|
test('News: Preis 3000, URLs und Rechnungsdaten wie bisher', async () => {
|
||||||
|
const created = await createFeatureCheckout(customer, { projectId: 'p1', key: 'news', consentAt: CONSENT_AT })
|
||||||
|
assert.match(created.id, /^cs_test_/)
|
||||||
|
assert.equal(created.url, `https://checkout.stripe.test/c/pay/${created.id}`)
|
||||||
|
const call = lastCall()
|
||||||
|
assert.equal(priceOf(call).unit_amount, 3000)
|
||||||
|
assert.equal(call.body['mode'], 'payment')
|
||||||
|
assert.equal(call.body['client_reference_id'], 'c1')
|
||||||
|
assert.equal(call.body['success_url'], `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`)
|
||||||
|
assert.equal(call.body['cancel_url'], `${config.publicBaseUrl}/dashboard.html#news`)
|
||||||
|
assert.equal(call.body['invoice_creation[enabled]'], 'true')
|
||||||
|
assert.equal(call.body['invoice_creation[invoice_data][description]'], 'News-Pinnwand – Einrichtung')
|
||||||
|
assert.equal(call.body['invoice_creation[invoice_data][footer]'], config.stripe.invoiceFooter)
|
||||||
|
assert.equal(call.body['invoice_creation[invoice_data][metadata][featureKey]'], 'news')
|
||||||
|
assert.equal(call.body['invoice_creation[invoice_data][metadata][projectId]'], 'p1')
|
||||||
|
// Metadaten der Sitzung unveraendert
|
||||||
|
assert.equal(call.body['metadata[customerId]'], 'c1')
|
||||||
|
assert.equal(call.body['metadata[projectId]'], 'p1')
|
||||||
|
assert.equal(call.body['metadata[featureKey]'], 'news')
|
||||||
|
assert.equal(call.body['metadata[consentAt]'], CONSENT_AT)
|
||||||
|
// Ablauf in hoechstens 24 Stunden, Karte + SEPA
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
assert.ok(Number(call.body['expires_at']) > now)
|
||||||
|
assert.ok(Number(call.body['expires_at']) < now + 24 * 60 * 60)
|
||||||
|
assert.equal(call.body['payment_method_types[0]'], 'card')
|
||||||
|
assert.equal(call.body['payment_method_types[1]'], 'sepa_debit')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('News: ohne consentAt steht kein consentAt in den Metadaten', async () => {
|
||||||
|
await createFeatureCheckout(customer, { projectId: 'p1' })
|
||||||
|
const call = lastCall()
|
||||||
|
assert.equal(call.body['metadata[featureKey]'], 'news', 'Standardschluessel news')
|
||||||
|
assert.ok(!('metadata[consentAt]' in call.body))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('News: SEPA nicht verfuegbar -> zweiter Versuch nur mit Karte', async () => {
|
||||||
|
const before = sessionCalls().length
|
||||||
|
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type "sepa_debit" is invalid.' })
|
||||||
|
const created = await createFeatureCheckout(customer, { projectId: 'p1', key: 'news', consentAt: CONSENT_AT })
|
||||||
|
const calls = sessionCalls().slice(before)
|
||||||
|
assert.equal(calls.length, 2)
|
||||||
|
assert.equal(calls[0].body['payment_method_types[1]'], 'sepa_debit')
|
||||||
|
assert.equal(calls[1].body['payment_method_types[0]'], 'card')
|
||||||
|
assert.ok(!('payment_method_types[1]' in calls[1].body), 'nur Karte')
|
||||||
|
assert.ok(created.id && created.url)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Termine: Preis 14900, Rechnungsdaten mit featureKey und projectId, URLs mit Projekt', async () => {
|
||||||
|
const created = await createFeatureCheckout(customer, { projectId: 'p/1', key: 'termine', consentAt: CONSENT_AT })
|
||||||
|
assert.equal(created.url, `https://checkout.stripe.test/c/pay/${created.id}`)
|
||||||
|
const call = lastCall()
|
||||||
|
assert.equal(priceOf(call).unit_amount, 14900)
|
||||||
|
assert.equal(priceOf(call).lookup_key, 'webklar_termine_einrichtung_14900')
|
||||||
|
assert.equal(call.body['invoice_creation[invoice_data][description]'], 'Terminplaner – Einrichtung')
|
||||||
|
assert.equal(call.body['invoice_creation[invoice_data][footer]'], config.stripe.invoiceFooter)
|
||||||
|
assert.equal(call.body['invoice_creation[invoice_data][metadata][featureKey]'], 'termine')
|
||||||
|
assert.equal(call.body['invoice_creation[invoice_data][metadata][projectId]'], 'p/1')
|
||||||
|
assert.equal(call.body['success_url'], `${config.publicBaseUrl}/dashboard.html#termine?bestellt=1&projekt=p%2F1`)
|
||||||
|
assert.equal(call.body['cancel_url'], `${config.publicBaseUrl}/dashboard.html#termine?projekt=p%2F1`)
|
||||||
|
assert.equal(call.body['metadata[customerId]'], 'c1')
|
||||||
|
assert.equal(call.body['metadata[projectId]'], 'p/1')
|
||||||
|
assert.equal(call.body['metadata[featureKey]'], 'termine')
|
||||||
|
assert.equal(call.body['metadata[consentAt]'], CONSENT_AT)
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
assert.ok(Number(call.body['expires_at']) > now)
|
||||||
|
assert.ok(Number(call.body['expires_at']) < now + 24 * 60 * 60)
|
||||||
|
assert.equal(call.body['payment_method_types[1]'], 'sepa_debit')
|
||||||
|
const sessionObj = st.checkoutSessions.get(created.id)
|
||||||
|
assert.equal(sessionObj.metadata.featureKey, 'termine')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Termine: SEPA nicht verfuegbar -> zweiter Versuch nur mit Karte, gleiche URLs', async () => {
|
||||||
|
const before = sessionCalls().length
|
||||||
|
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type "sepa_debit" is invalid.' })
|
||||||
|
await createFeatureCheckout(customer, { projectId: 'p2', key: 'termine', consentAt: CONSENT_AT })
|
||||||
|
const calls = sessionCalls().slice(before)
|
||||||
|
assert.equal(calls.length, 2)
|
||||||
|
assert.ok(!('payment_method_types[1]' in calls[1].body), 'nur Karte')
|
||||||
|
assert.equal(calls[1].body['success_url'], `${config.publicBaseUrl}/dashboard.html#termine?bestellt=1&projekt=p2`)
|
||||||
|
assert.equal(calls[1].body['cancel_url'], `${config.publicBaseUrl}/dashboard.html#termine?projekt=p2`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unbekannter Schluessel: Fehler 400 vor jedem Stripe-Aufruf', async () => {
|
||||||
|
const before = st.calls.length
|
||||||
|
for (const key of ['analyse', '__proto__', 'toString', '']) {
|
||||||
|
await assert.rejects(
|
||||||
|
createFeatureCheckout(customer, { projectId: 'p1', key, consentAt: CONSENT_AT }),
|
||||||
|
(err) => err.status === 400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert.equal(st.calls.length, before, 'kein einziger Stripe-Aufruf')
|
||||||
|
})
|
||||||
@@ -260,3 +260,24 @@ test('news_einrichtung_einmalpreis', async () => {
|
|||||||
assert.equal(await ensurePrice('newsSetup'), id)
|
assert.equal(await ensurePrice('newsSetup'), id)
|
||||||
assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0)
|
assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('termine_einrichtung_einmalpreis', async () => {
|
||||||
|
const before = st.calls.length
|
||||||
|
const id = await ensurePrice('termineSetup')
|
||||||
|
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'], '14900')
|
||||||
|
assert.equal(created[0].body['lookup_key'], 'webklar_termine_einrichtung_14900')
|
||||||
|
assert.equal(created[0].body['nickname'], 'Terminplaner-Einrichtung 149 €')
|
||||||
|
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.length, 1)
|
||||||
|
assert.equal(products[0].body['name'], 'Terminplaner – Einrichtung')
|
||||||
|
assert.equal(products[0].body['metadata[webklar]'], 'termine-einrichtung')
|
||||||
|
// zweiter Aufruf: kein zweiter Preis (auch ohne Cache per lookup_key gefunden)
|
||||||
|
const again = st.calls.length
|
||||||
|
assert.equal(await ensurePrice('termineSetup'), id)
|
||||||
|
assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { test, after } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
|
import { startFakeStripe } from './support/fakeStripe.js'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
|
||||||
|
// scripts/stripe-products.mjs: der Import darf weder main() starten (kein Stripe-Aufruf, kein process.exit) noch etwas anlegen;
|
||||||
|
// "--anlegen" macht der Inhaber im Livegang (Block G, Schritt 4).
|
||||||
|
const aw = await startAppwriteMock()
|
||||||
|
const st = await startFakeStripe()
|
||||||
|
after(() => Promise.all([aw.close(), st.close()]))
|
||||||
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
|
const { config } = await import('../server/config.js')
|
||||||
|
|
||||||
|
const realExit = process.exit
|
||||||
|
let exitCalls = 0
|
||||||
|
process.exit = () => {
|
||||||
|
exitCalls += 1
|
||||||
|
}
|
||||||
|
let script
|
||||||
|
try {
|
||||||
|
script = await import('../scripts/stripe-products.mjs')
|
||||||
|
// main() liefe asynchron an: einen Moment warten, damit ein versehentlicher Start sichtbar wird
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
} finally {
|
||||||
|
process.exit = realExit
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Import startet main() nicht: kein Stripe-Aufruf, kein process.exit', () => {
|
||||||
|
assert.equal(exitCalls, 0)
|
||||||
|
assert.equal(st.calls.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('KEYS enthaelt den Terminplaner-Katalogschluessel', () => {
|
||||||
|
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => {
|
||||||
|
assert.equal(script.catalogEntry('termineSetup'), config.termineSetup)
|
||||||
|
assert.equal(script.catalogEntry('newsSetup'), config.newsSetup)
|
||||||
|
assert.equal(script.catalogEntry('mailStorage'), config.mailStorage)
|
||||||
|
assert.equal(script.catalogEntry('wartung'), config.products.wartung)
|
||||||
|
for (const key of script.KEYS) assert.ok(script.catalogEntry(key)?.lookupKey, `${key} hat einen lookupKey`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('config.termineSetup: Festwerte', () => {
|
||||||
|
assert.deepEqual(config.termineSetup, {
|
||||||
|
key: 'termineSetup',
|
||||||
|
name: 'Terminplaner – Einrichtung',
|
||||||
|
amountCents: 14900,
|
||||||
|
lookupKey: 'webklar_termine_einrichtung_14900',
|
||||||
|
oneTime: true,
|
||||||
|
priceNickname: 'Terminplaner-Einrichtung 149 €',
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user