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, FASSUNGEN, aktuelleAboFassung, isTermsV4OrNewer, } = 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-v5'), { version: '2026-10-05-v5', url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11' }) assert.deepEqual(termsDocFor('2026-09-30-v4'), { version: '2026-09-30-v4', url: '/vertragsunterlagen/v4/abo-bedingungen.html', pdfFile: 'vertragsunterlagen/v4/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('Fassungstabelle: Reihenfolge, Muster nur am Ende, v6 im Vorschaupfad', () => { assert.deepEqual(FASSUNGEN.map((f) => f.muster?.source ?? 'sonst'), ['-v6$', '-v5$', '-v4$', 'sonst']) assert.deepEqual(termsDocFor('2026-10-16-v6'), { version: '2026-10-16-v6', url: '/neu/rechtliches/abo-bedingungen.html', pdfFile: 'neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11', }) for (const v of ['2026-10-16-v60', '2026-10-16-v6x', 'v6-2026-10-16', '2026-10-16-v66']) { assert.equal(termsDocFor(v).url, '/vertragsunterlagen/v3/abo-bedingungen.html', v) } const a = contractAttachmentFor('2026-10-16-v6') assert.equal(a.filename, 'WEBklar-Abo-Vertragsunterlagen.pdf') assert.ok(path.isAbsolute(a.path) && a.path.endsWith(path.join('public', 'neu', 'rechtliches', 'WEBklar-Abo-Vertragsunterlagen.pdf'))) }) test('Fassung 5 bleibt bis P10 an den heutigen Adressen, das Archiv ist vorbereitet', () => { const v5 = FASSUNGEN.find((f) => f.muster?.source === '-v5$') assert.equal(termsDocFor('2026-09-30-v5').url, '/abo-bedingungen.html') assert.deepEqual(Object.keys(termsDocFor('2026-09-30-v5')).sort(), ['endParagraph', 'pdfFile', 'url', 'version']) assert.deepEqual(v5.archiv, { url: '/vertragsunterlagen/v5/abo-bedingungen.html', pdfFile: 'vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf' }) }) test('isTermsV4OrNewer: v4, v5, v6 ja; v3, fehlende und fremde Endungen nein', () => { for (const v of ['2026-09-30-v4', '2026-09-30-v5', '2026-10-16-v6']) assert.equal(isTermsV4OrNewer(v), true, v) for (const v of ['2026-09-25-v3', '2026-09-25', null, undefined, '', '2026-10-16-v60', 7]) assert.equal(isTermsV4OrNewer(v), false, String(v)) }) test('aktuelleAboFassung: v6 nur mit neuer Ansicht', () => { // appwriteUserId gehört zur gültigen Kunden-Sitzung (N1, schalter.js istKundenSitzung) const k = (email, extra = {}) => ({ session: { customerId: 'c1', appwriteUserId: 'u1', email, ...extra } }) const vorher = { ...config.kundenansichtNeu } try { Object.assign(config.kundenansichtNeu, { mode: 'off', pilotEmails: ['*'] }) assert.equal(aktuelleAboFassung(k('a@example.invalid')), '2026-09-30-v5', 'off schlägt die Liste') Object.assign(config.kundenansichtNeu, { mode: 'pilot', pilotEmails: [] }) assert.equal(aktuelleAboFassung(k('a@example.invalid')), '2026-09-30-v5', 'leere Liste lässt niemanden durch') config.kundenansichtNeu.pilotEmails = ['a@example.invalid'] assert.equal(aktuelleAboFassung(k(' A@Example.INVALID ')), '2026-10-16-v6', 'Großschreibung und Leerraum') assert.equal(aktuelleAboFassung(k('b@example.invalid')), '2026-09-30-v5') assert.equal(aktuelleAboFassung(k('b@example.invalid', { viewAs: true, viewId: 'v1' })), '2026-10-16-v6', 'Mitarbeiter-Kundenansicht') Object.assign(config.kundenansichtNeu, { mode: 'on', pilotEmails: [] }) assert.equal(aktuelleAboFassung(k('b@example.invalid')), '2026-10-16-v6') } finally { Object.assign(config.kundenansichtNeu, vorher) } assert.deepEqual([config.abo.termsVersion, config.abo.termsVersionNeu], ['2026-09-30-v5', '2026-10-16-v6']) }) 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-09-30-v4') const v5 = contractAttachmentFor('2026-10-05-v5') assert.equal(v3.filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf') assert.equal(v4.filename, 'WEBklar-Abo-Vertragsunterlagen.pdf') assert.equal(v5.filename, 'WEBklar-Abo-Vertragsunterlagen.pdf') assert.ok(path.isAbsolute(v5.path) && v5.path.endsWith(path.join('public', v5.filename))) assert.ok(v4.path.endsWith(path.join('public', 'vertragsunterlagen', 'v4', v4.filename))) assert.ok(existsSync(v4.path), v4.path) assert.ok(existsSync(v5.path), v5.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) })