Files
Webklar-Kundenbereich/test/stripeBilling.test.js
T

233 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
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 {
syncSubscription,
ensurePrice,
ensureTestCoupon,
ensurePortalConfiguration,
termStartOf,
getStripe,
PORTAL_HEADLINE,
resetPortalConfigCacheForTests,
} = await import('../server/services/stripeBilling.js')
const sub = (id, items, extra = {}) => ({
id,
object: 'subscription',
status: 'active',
customer: 'cus_1',
start_date: 1790000000,
current_period_end: 1792600000,
trial_end: null,
cancel_at_period_end: false,
cancel_at: null,
canceled_at: null,
ended_at: null,
metadata: { webklarCustomerId: 'c1', email: 'k@example.invalid', termsVersion: '2026-09-25-v3' },
items: { object: 'list', data: items },
...extra,
})
const item = (id, lookup_key, unit_amount, quantity = 1) => ({
id: `si_${id}`,
object: 'subscription_item',
quantity,
price: { id: `price_${id}`, object: 'price', lookup_key, unit_amount },
})
test('Setup-Skript legt productKey an', () => {
assert.ok(schemaKeys('subscriptions').includes('productKey'))
})
test('Spiegel: Hosting-Abo, Zusatzspeicher steht vorne', async () => {
st.subscriptions.set(
'sub_h',
sub('sub_h', [item('s', 'webklar_mailspeicher_5gb_monatlich_100', 100, 2), item('h', 'webklar_hosting_monatlich_1500', 1500)])
)
const doc = await syncSubscription('sub_h')
assert.equal(doc.productKey, 'hosting')
assert.equal(doc.planKey, 'webklar_hosting_monatlich_1500')
assert.equal(doc.priceAmount, 1500)
})
test('Spiegel: Wartungs-Abo wie bisher', async () => {
st.subscriptions.set('sub_w', sub('sub_w', [item('w', 'webklar_wartung_monatlich_5000', 5000)]))
const doc = await syncSubscription('sub_w')
assert.deepEqual([doc.productKey, doc.planKey, doc.priceAmount], ['wartung', 'webklar_wartung_monatlich_5000', 5000])
// Fix round 1, Important 4: Bestandskunde ohne Wechsel-Metadaten -> termStartAt vom Leistungsbeginn (start_date)
assert.equal(doc.termStartAt, new Date(1790000000 * 1000).toISOString())
})
// Fix round 1, Important 4: mit Probezeit (trial_end) und ohne Wechsel-Metadaten gewinnt trial_end
test('Spiegel: termStartAt ohne Wechsel-Metadaten faellt auf trial_end zurueck (Probezeit)', async () => {
st.subscriptions.set('sub_trial', sub('sub_trial', [item('wt', 'webklar_wartung_monatlich_5000', 5000)], { trial_end: 1791234567 }))
const doc = await syncSubscription('sub_trial')
assert.equal(doc.termStartAt, new Date(1791234567 * 1000).toISOString())
})
// Fix round 1, Minor 5: eine von Hand verstellte/ungueltige Metadaten-Angabe darf nicht crashen
// (toISOString auf Invalid Date wuerde werfen) - Warnung statt Absturz, Fallback wie ohne Metadaten
test('termStartOf: ungültiges Datum in den Metadaten -> Warnung, Fallback auf trial_end/start_date', () => {
const original = console.warn
const warnings = []
console.warn = (...args) => warnings.push(args.join(' '))
try {
const result = termStartOf({ id: 'sub_bad', metadata: { termStartAt: 'nicht-valide' }, trial_end: 1791234567, start_date: 1790000000 })
assert.equal(result.toISOString(), new Date(1791234567 * 1000).toISOString())
assert.ok(warnings.some((w) => w.includes('termStartAt')))
} finally {
console.warn = original
}
})
test('Spiegel: unbekannter Preis gilt als Wartungs-Abo, Betrag vom ersten Posten', async () => {
st.subscriptions.set('sub_x', sub('sub_x', [item('x', null, 4900)]))
const doc = await syncSubscription('sub_x')
assert.deepEqual([doc.productKey, doc.planKey, doc.priceAmount], ['wartung', 'webklar_wartung_monatlich_5000', 4900])
})
// Stripe-Nachbau braucht dazu: POST /v1/products, POST /v1/prices, GET /v1/coupons/:id, POST /v1/coupons,
// GET/POST /v1/billing_portal/configurations und POST /v1/billing_portal/configurations/:id (F09)
// Fix round 1, B9: before-Snapshot statt ueber die ganze Datei zaehlen; Preis-/Produktfelder und Bild geprueft
test('ensurePrice legt Hosting einmal an: Preis- und Produktfelder, Bild', async () => {
const before = st.calls.length
const id1 = await ensurePrice('hosting'); const id2 = await ensurePrice('hosting')
assert.equal(id1, id2)
const calls = st.calls.slice(before)
const createdPrices = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices')
assert.equal(createdPrices.length, 1)
assert.equal(createdPrices[0].body['lookup_key'], 'webklar_hosting_monatlich_1500')
assert.equal(createdPrices[0].body['unit_amount'], '1500')
assert.equal(createdPrices[0].body['tax_behavior'], 'inclusive')
assert.equal(createdPrices[0].body['recurring[interval]'], 'month')
const createdProducts = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products')
assert.equal(createdProducts.length, 1)
assert.equal(createdProducts[0].body['metadata[webklar]'], 'hosting-abo')
// Important 2: Produktbild wiederhergestellt (7799d7c:71 hatte es, ging beim Verallgemeinern verloren)
assert.equal(createdProducts[0].body['images[0]'], config.stripe.productImage)
})
// Important 4: ein bereits vorhandener aktiver Preis wird gefunden, nichts wird bei Stripe angelegt
test('ensurePrice: vorhandener aktiver Preis wird gefunden, nichts wird angelegt', async () => {
st.prices.set('price_seeded_wartung', {
id: 'price_seeded_wartung',
object: 'price',
active: true,
lookup_key: 'webklar_wartung_monatlich_5000',
unit_amount: 5000,
recurring: { interval: 'month' },
tax_behavior: 'inclusive',
})
const before = st.calls.length
const id = await ensurePrice('wartung')
assert.equal(id, 'price_seeded_wartung')
const writes = st.calls.slice(before).filter((c) => c.method === 'POST' && (c.path === '/v1/products' || c.path === '/v1/prices'))
assert.deepEqual(writes, [])
})
// Important 1 (Stripe-Nachbau-Haertung): Stripe begrenzt Coupon-Namen auf 40 Zeichen, IDs und aktive
// lookup_keys sind eindeutig - der Nachbau soll das wie die echte API mit 400 quittieren.
test('Stripe-Nachbau: Grenzen bei Preisen und Coupons (400)', async () => {
const stripe = getStripe()
st.prices.set('price_dup_lookup', {
id: 'price_dup_lookup', object: 'price', active: true, lookup_key: 'lookup_fuer_diesen_test', unit_amount: 100,
})
await assert.rejects(
stripe.prices.create({ product: 'prod_x', currency: 'eur', unit_amount: 100, lookup_key: 'lookup_fuer_diesen_test' }),
(err) => err.statusCode === 400
)
st.coupons.set('coupon_dup', { id: 'coupon_dup', object: 'coupon', name: 'x' })
await assert.rejects(
stripe.coupons.create({ id: 'coupon_dup', name: 'y', amount_off: 100, currency: 'eur', duration: 'once' }),
(err) => err.statusCode === 400
)
await assert.rejects(
stripe.coupons.create({ name: 'x'.repeat(41), amount_off: 100, currency: 'eur', duration: 'once' }),
(err) => err.statusCode === 400
)
})
// Important 1: Namen ≤ 40 Zeichen (Stripe-Limit; die alten Namen mit dem vollen Produktnamen waren 49/50)
test('Testrabatt Hosting: 14,50 € Nachlass, Name ≤ 40 Zeichen', async () => {
const before = st.calls.length
assert.equal(await ensureTestCoupon('hosting'), 'WEBKLAR-TEST-050-HOSTING')
const call = st.calls.slice(before).find((c) => c.path === '/v1/coupons' && c.method === 'POST')
assert.equal(call.body['amount_off'], '1450')
assert.equal(call.body['name'], 'Testzugang Hosting (0,50 €)')
assert.ok(call.body['name'].length <= 40)
})
test('Testrabatt Wartung: 49,50 € Nachlass, Name ≤ 40 Zeichen', async () => {
const before = st.calls.length
assert.equal(await ensureTestCoupon('wartung'), 'WEBKLAR-TEST-050')
const call = st.calls.slice(before).find((c) => c.path === '/v1/coupons' && c.method === 'POST')
assert.equal(call.body['amount_off'], '4950')
assert.equal(call.body['name'], 'Testzugang Wartung (0,50 €)')
assert.ok(call.body['name'].length <= 40)
})
// F09: eine bestehende eigene Konfiguration mit der alten Wartungs-Abo-Überschrift wird angepasst,
// nicht doppelt angelegt. Fix round 1, Minor 6: eigener Cache-Reset + eigene Map, damit dieser Test
// unabhaengig vom No-op-Test (gleiche Funktion, anderer Zweig) lauffaehig bleibt - ensurePortalConfiguration
// cached die Konfigurations-ID sonst ueber den ganzen Prozess hinweg.
test('Kundenportal: alte Überschrift → configurations.update', async () => {
resetPortalConfigCacheForTests()
st.billingPortalConfigurations.clear()
st.billingPortalConfigurations.set('bpc_old', {
id: 'bpc_old',
object: 'billing_portal.configuration',
active: true,
business_profile: { headline: 'Webklar – Wartungs-Abo verwalten' },
metadata: { webklar: 'wartungs-abo' },
})
const id = await ensurePortalConfiguration()
assert.equal(id, 'bpc_old')
const updateCall = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/billing_portal/configurations/bpc_old')
assert.ok(updateCall)
assert.equal(updateCall.body['business_profile[headline]'], 'WEBklar – Abo verwalten')
})
// Minor 6: Ueberschrift schon richtig -> kein ueberfluessiger update-Aufruf
test('Kundenportal: Überschrift schon richtig → kein Update-Aufruf', async () => {
resetPortalConfigCacheForTests()
st.billingPortalConfigurations.clear()
st.billingPortalConfigurations.set('bpc_ok', {
id: 'bpc_ok',
object: 'billing_portal.configuration',
active: true,
business_profile: { headline: PORTAL_HEADLINE },
metadata: { webklar: 'wartungs-abo' },
})
const before = st.calls.length
const id = await ensurePortalConfiguration()
assert.equal(id, 'bpc_ok')
const updateCalls = st.calls.slice(before).filter((c) => c.method === 'POST' && c.path.startsWith('/v1/billing_portal/configurations/'))
assert.deepEqual(updateCalls, [])
})
test('Spiegel: termStartAt aus Metadaten (Wechsel), sonst Leistungsbeginn; storageUnits', async () => {
st.subscriptions.set(
'sub_s',
sub('sub_s', [item('w2', 'webklar_wartung_monatlich_5000', 5000), item('s2', 'webklar_mailspeicher_5gb_monatlich_100', 100, 3)], {
metadata: {
webklarCustomerId: 'c1',
email: 'k@example.invalid',
termsVersion: '2026-09-25-v3',
termStartAt: '2026-10-05T10:00:00.000Z',
},
})
)
const doc = await syncSubscription('sub_s')
assert.equal(doc.termStartAt, '2026-10-05T10:00:00.000Z')
assert.equal(doc.storageUnits, 3)
})