- server/config.js: amountCents 2000, lookup_key webklar_hosting_monatlich_2000 - ensurePrice haengt einen neuen Preis an das vorhandene Stripe-Produkt (metadata.webklar), statt ein zweites "WEBklar Hosting-Abo" anzulegen - abo-bedingungen.html (Uebersicht, Preise, Leistungsbeschreibung) + PDF, Leitfaden + PDF - Tests auf 20 € umgestellt (399/399) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
10 KiB
JavaScript
216 lines
10 KiB
JavaScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { existsSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
const { config } = await import('../server/config.js')
|
|
const {
|
|
getProduct,
|
|
productKeyForLookupKey,
|
|
describeItems,
|
|
priceIdsOfInvoice,
|
|
creditMinutesForInvoice,
|
|
termsDocFor,
|
|
productView,
|
|
contractAttachmentFor,
|
|
regularStorageLine,
|
|
} = 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, 2000)
|
|
assert.equal(getProduct('hosting').lookupKey, 'webklar_hosting_monatlich_2000')
|
|
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_2000'), '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_2000', unit_amount: 2000 } },
|
|
]
|
|
assert.deepEqual(describeItems(items), {
|
|
productKey: 'hosting',
|
|
aboItemId: 'si_a',
|
|
aboPriceId: 'price_h',
|
|
lookupKey: 'webklar_hosting_monatlich_2000',
|
|
priceAmount: 2000,
|
|
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', 2000), 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
|
|
// unbekannter Preis neben einem bekannten Abo-Preis: keine Stunde
|
|
assert.equal(creditMinutesForInvoice(inv(line('price_h', 2000), line('price_x', 800)), keys), 0)
|
|
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('Altbestand: Rechnung ohne bekannten Abo-Preis zählt als Wartung (wie im Abo-Spiegel)', () => {
|
|
const inv = (...lines) => ({ lines: { data: lines } })
|
|
assert.equal(creditMinutesForInvoice(inv(line('price_x', 5000)), keys), 60)
|
|
assert.equal(creditMinutesForInvoice(inv(line('price_x', 0)), keys), 0) // Probezeit bleibt ohne Stunde
|
|
assert.equal(creditMinutesForInvoice(inv(line('price_x', 2300, true)), keys), 0) // anteilig bleibt ohne Stunde
|
|
})
|
|
|
|
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'])
|
|
})
|
|
|
|
// Fix round 1 / B9: feste Form statt Einzelfelder
|
|
test('Vertragsunterlagen je Version', () => {
|
|
// F08 (Task 12): endParagraph = Paragraph zum Vertragsende der Fassung (v4: § 9 ist der Wechsel, § 11 das Vertragsende);
|
|
// Fix 1 (3): version = die Fassung, die K01 nennt - ohne Angabe die archivierte Fassung 3, passend zum PDF
|
|
assert.deepEqual(termsDocFor('2026-10-05-v4'), { version: '2026-10-05-v4', url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11' })
|
|
assert.deepEqual(termsDocFor('2026-09-25-v3'), {
|
|
version: '2026-09-25-v3',
|
|
url: '/vertragsunterlagen/v3/abo-bedingungen.html',
|
|
pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf',
|
|
endParagraph: '§ 9',
|
|
})
|
|
for (const none of [null, undefined, '']) {
|
|
assert.deepEqual(termsDocFor(none), {
|
|
version: '2026-09-25-v3',
|
|
url: '/vertragsunterlagen/v3/abo-bedingungen.html',
|
|
pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf',
|
|
endParagraph: '§ 9',
|
|
}, String(none))
|
|
}
|
|
assert.equal(termsDocFor('2026-09-25-v2').version, '2026-09-25-v2', 'aeltere Fassung bleibt benannt')
|
|
})
|
|
|
|
test('Produktansicht enthält Leistungen ohne 1-Postfach-Regel bei Wartung', () => {
|
|
const w = productView('wartung').features.flat().join(' ')
|
|
assert.ok(w.includes('beliebig viele Postfächer') && !w.includes('1 E-Mail-Postfach'))
|
|
assert.ok(productView('hosting').features.flat().join(' ').includes('1 E-Mail-Postfach'))
|
|
})
|
|
|
|
// Fix round 1, Important 3: Hosting darf keine Website-Pflege suggerieren (row 1 war von Wartung
|
|
// kopiert) und muss "in 15-Minuten-Schritten" nennen.
|
|
test('Produktansicht Hosting: Betriebssystem/Server-Software statt Website-Pflege', () => {
|
|
const h = productView('hosting').features.flat().join(' ')
|
|
assert.ok(h.includes('Betriebssystem und Server-Software'))
|
|
assert.ok(!h.includes('Updates, Sicherheit'))
|
|
assert.ok(h.includes('15-Minuten-Schritten'))
|
|
})
|
|
|
|
// Fix round 1, B9: volle Form statt Einzelfelder, Kopie statt Referenz auf den Katalog (B10)
|
|
test('productView: vollständige Form, Kopie der features', () => {
|
|
const v = productView('hosting')
|
|
assert.deepEqual(Object.keys(v).sort(), ['amountCents', 'features', 'key', 'minTermMonths', 'minutesPerMonth', 'name'].sort())
|
|
assert.equal(v.key, 'hosting')
|
|
assert.equal(v.name, 'WEBklar Hosting-Abo')
|
|
assert.equal(v.amountCents, 2000)
|
|
assert.equal(v.minTermMonths, 12)
|
|
assert.equal(v.minutesPerMonth, 0)
|
|
assert.ok(Array.isArray(v.features) && v.features.length > 0)
|
|
v.features.push(['x', 'y'])
|
|
assert.notEqual(productView('hosting').features.length, v.features.length)
|
|
})
|
|
|
|
// F07: die Wartungs-Beschreibung darf die alte 1-Postfach-Regel nicht mehr enthalten (Version 4:
|
|
// beliebig viele E-Mail-Postfächer, zusammen 5 GB). Der Stripe-Produkttext selbst wird erst beim
|
|
// Livegang ueber scripts/stripe-products.mjs gesetzt. B9: auch das § 19-Satzende pruefen.
|
|
test('Wartung-Beschreibung ohne 1-Postfach-Regel (F07)', () => {
|
|
const d = getProduct('wartung').description
|
|
assert.ok(!d.includes('1 E-Mail-Postfach'))
|
|
assert.ok(d.includes('beliebig viele E-Mail-Postfächer'))
|
|
assert.ok(d.endsWith('Gemäß § 19 UStG ohne Umsatzsteuer.'))
|
|
})
|
|
|
|
// Fix round 1, item 11: Domain-Zeile nennt die Endungen wie spec 3.1 / v4 § 3 Abs. 3
|
|
test('Produktansicht: Domain-Zeile nennt die Endungen', () => {
|
|
for (const key of ['wartung', 'hosting']) {
|
|
const flat = productView(key).features.flat().join(' ')
|
|
assert.ok(flat.includes('.de') && flat.includes('.com') && flat.includes('.eu') && flat.includes('.net') && flat.includes('.org'))
|
|
}
|
|
})
|
|
|
|
// F22 / Task 12: Vertragsunterlagen als Mail-Anhang in der Fassung, der der Kunde zugestimmt hat
|
|
test('contractAttachmentFor: Datei je Fassung mit absolutem Pfad unter public/', () => {
|
|
const v3 = contractAttachmentFor('2026-09-25-v3')
|
|
const v4 = contractAttachmentFor('2026-10-05-v4')
|
|
assert.equal(v3.filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
|
|
assert.equal(v4.filename, 'WEBklar-Abo-Vertragsunterlagen.pdf')
|
|
assert.ok(path.isAbsolute(v4.path) && v4.path.endsWith(path.join('public', v4.filename)))
|
|
assert.ok(existsSync(v4.path), v4.path)
|
|
assert.ok(existsSync(v3.path), v3.path)
|
|
assert.deepEqual(contractAttachmentFor(null), v3)
|
|
assert.deepEqual(Object.keys(v4).sort(), ['filename', 'path'])
|
|
})
|
|
|
|
// Task 8: die regulaere Zusatzspeicher-Zeile einer bezahlten Rechnung (Stueckzahl des Monats), nie eine anteilige
|
|
test('regularStorageLine: reguläre Zusatzspeicher-Zeile, keine anteilige, kein anderes Produkt', () => {
|
|
const inv = (...lines) => ({ lines: { data: lines } })
|
|
const storage = { ...line('price_s', 300), quantity: 3 }
|
|
assert.equal(regularStorageLine(inv(line('price_s', 90, true), line('price_w', 5000), storage), keys), storage)
|
|
assert.equal(regularStorageLine(inv(line('price_s', 90, true)), keys), null)
|
|
assert.equal(regularStorageLine(inv(line('price_w', 5000), line('price_h', 2000)), keys), null)
|
|
assert.equal(regularStorageLine(inv({ amount: 5 }), keys), null)
|
|
assert.equal(regularStorageLine({}, keys), null)
|
|
})
|
|
|
|
// Task 8 (Fix 1, Testluecke 9): eine Rechnung nur mit Zusatzspeicher bringt keine Stunde, auch nicht als
|
|
// "Altbestand" (der Speicherpreis ist bekannt, nur kein Abo-Produkt)
|
|
test('Nur Zusatzspeicher auf der Rechnung: keine Stunde', () => {
|
|
const inv = (...lines) => ({ lines: { data: lines } })
|
|
assert.equal(creditMinutesForInvoice(inv({ ...line('price_s', 300), quantity: 3 }), keys), 0)
|
|
assert.equal(creditMinutesForInvoice(inv(line('price_s', 100), line('price_s', 33, true)), keys), 0)
|
|
})
|