Hosting-Abo: Preis 15 € -> 20 € pro Monat (Vertragsbedingungen v4, Katalog, Leitfaden)

- 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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-29 16:29:01 +00:00
1 parent 78b4d7b7fe
commit e4efdac226
18 files changed
+79 -59

No files matched your search

Binary file not shown.
+1 -1
View File
@@ -48,7 +48,7 @@
<div class="box"> <div class="box">
<strong>Unsere Abos auf einen Blick</strong> · beide mit Mindestlaufzeit 12 Monate, danach monatlich kündbar <strong>Unsere Abos auf einen Blick</strong> · beide mit Mindestlaufzeit 12 Monate, danach monatlich kündbar
<ul class="abos"> <ul class="abos">
<li><b>Hosting-Abo, 15,00 € pro Monat</b> (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer): nur statische Websites (ohne CMS, Login, <li><b>Hosting-Abo, 20,00 € pro Monat</b> (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer): nur statische Websites (ohne CMS, Login,
Datenbank oder Formulare); 1 Domain, 1 E-Mail-Postfach mit 5 GB; keine Pflege der Website und kein Stundenkontingent Datenbank oder Formulare); 1 Domain, 1 E-Mail-Postfach mit 5 GB; keine Pflege der Website und kein Stundenkontingent
(Änderungen nur nach Freigabe, gesondert berechnet).</li> (Änderungen nur nach Freigabe, gesondert berechnet).</li>
<li><b>Wartungs-Abo, 50,00 € pro Monat</b> (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer): alle Websites und Web-Anwendungen; Betrieb und Pflege, <li><b>Wartungs-Abo, 50,00 € pro Monat</b> (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer): alle Websites und Web-Anwendungen; Betrieb und Pflege,
Binary file not shown.
+3 -3
View File
@@ -65,7 +65,7 @@
<strong>Das Wichtigste auf einen Blick</strong> <strong>Das Wichtigste auf einen Blick</strong>
<table> <table>
<tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein (jeder einzeln vertretungsberechtigt) · Telefon 0176 23726355 · E-Mail support@webklar.com</td></tr> <tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein (jeder einzeln vertretungsberechtigt) · Telefon 0176 23726355 · E-Mail support@webklar.com</td></tr>
<tr><td>Hosting-Abo</td><td>15,00 € pro Monat · Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland · 1 Domain · 1 E-Mail-Postfach mit 5 GB · Änderungen an der Website nur nach Ihrer Freigabe, 100 € pro Stunde (Leistungsbeschreibung A)</td></tr> <tr><td>Hosting-Abo</td><td>20,00 € pro Monat · Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland · 1 Domain · 1 E-Mail-Postfach mit 5 GB · Änderungen an der Website nur nach Ihrer Freigabe, 100 € pro Stunde (Leistungsbeschreibung A)</td></tr>
<tr><td>Wartungs-Abo</td><td>50,00 € pro Monat · Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland · 1 Domain · beliebig viele E-Mail-Postfächer mit zusammen 5 GB · 1 Stunde Arbeitszeit pro Monat, nicht genutzte Zeit bleibt 12 Monate verfügbar; Mehraufwand nur nach Ihrer Freigabe, 100 € pro Stunde (Leistungsbeschreibung B)</td></tr> <tr><td>Wartungs-Abo</td><td>50,00 € pro Monat · Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland · 1 Domain · beliebig viele E-Mail-Postfächer mit zusammen 5 GB · 1 Stunde Arbeitszeit pro Monat, nicht genutzte Zeit bleibt 12 Monate verfügbar; Mehraufwand nur nach Ihrer Freigabe, 100 € pro Stunde (Leistungsbeschreibung B)</td></tr>
<tr><td>E-Mail-Zusatzspeicher</td><td>In beiden Abos: je Paket von 5 GB 1,00 € pro Monat, bis zu 10 Pakete. Im Kundenportal bestellbar und reduzierbar nach Leistungsbeginn, solange das Abo nicht gekündigt ist und keine Zahlung offen ist; ganz oder teilweise kündbar zum Ende des Abrechnungsmonats.</td></tr> <tr><td>E-Mail-Zusatzspeicher</td><td>In beiden Abos: je Paket von 5 GB 1,00 € pro Monat, bis zu 10 Pakete. Im Kundenportal bestellbar und reduzierbar nach Leistungsbeginn, solange das Abo nicht gekündigt ist und keine Zahlung offen ist; ganz oder teilweise kündbar zum Ende des Abrechnungsmonats.</td></tr>
<tr><td>Preise</td><td>Alle Preise sind Endpreise, zahlbar monatlich im Voraus. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</td></tr> <tr><td>Preise</td><td>Alle Preise sind Endpreise, zahlbar monatlich im Voraus. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</td></tr>
@@ -206,7 +206,7 @@
<h2 id="p7">§ 7 Preise und Zahlung</h2> <h2 id="p7">§ 7 Preise und Zahlung</h2>
<ol class="abs"> <ol class="abs">
<li>Die Monatsgebühr beträgt im Hosting-Abo 15,00 € und im Wartungs-Abo 50,00 €. E-Mail-Zusatzspeicher kostet je Paket von 5 GB 1,00 € pro Monat (§ 10). Alle Preise sind Endpreise. Als Kleinunternehmer im Sinne von § 19 UStG berechnen wir keine Umsatzsteuer.</li> <li>Die Monatsgebühr beträgt im Hosting-Abo 20,00 € und im Wartungs-Abo 50,00 €. E-Mail-Zusatzspeicher kostet je Paket von 5 GB 1,00 € pro Monat (§ 10). Alle Preise sind Endpreise. Als Kleinunternehmer im Sinne von § 19 UStG berechnen wir keine Umsatzsteuer.</li>
<li>Die Monatsgebühr ist monatlich im Voraus fällig, erstmals mit Beginn der Leistung (bei sofortigem Beginn mit Vertragsschluss, sonst nach Ablauf der Widerrufsfrist), danach jeweils am selben Kalendertag der Folgemonate (Abrechnungsmonat). Gebuchter Zusatzspeicher wird zusammen mit der Monatsgebühr abgebucht. Die Gebühren werden per Kreditkarte oder SEPA-Lastschrift über die Stripe Payments Europe, Limited (Irland) eingezogen. Rechnungen erhält der Kunde per E-Mail und im Kundenportal über „Abo verwalten &amp; Rechnungen“.</li> <li>Die Monatsgebühr ist monatlich im Voraus fällig, erstmals mit Beginn der Leistung (bei sofortigem Beginn mit Vertragsschluss, sonst nach Ablauf der Widerrufsfrist), danach jeweils am selben Kalendertag der Folgemonate (Abrechnungsmonat). Gebuchter Zusatzspeicher wird zusammen mit der Monatsgebühr abgebucht. Die Gebühren werden per Kreditkarte oder SEPA-Lastschrift über die Stripe Payments Europe, Limited (Irland) eingezogen. Rechnungen erhält der Kunde per E-Mail und im Kundenportal über „Abo verwalten &amp; Rechnungen“.</li>
<li>Bestellt der Kunde Zusatzspeicher, berechnen wir für den Rest des laufenden Abrechnungsmonats einen anteiligen Betrag. Er wird in der Bestellübersicht angezeigt und mit der nächsten Abbuchung eingezogen. Ab dem folgenden Abrechnungsmonat gilt der volle Monatspreis. Pakete, die für den laufenden Abrechnungsmonat schon bezahlt sind, berechnen wir nicht noch einmal. Verringert der Kunde den Zusatzspeicher, wirkt das zum Ende des laufenden Abrechnungsmonats; die Gebühr für den laufenden Monat wird nicht anteilig erstattet.</li> <li>Bestellt der Kunde Zusatzspeicher, berechnen wir für den Rest des laufenden Abrechnungsmonats einen anteiligen Betrag. Er wird in der Bestellübersicht angezeigt und mit der nächsten Abbuchung eingezogen. Ab dem folgenden Abrechnungsmonat gilt der volle Monatspreis. Pakete, die für den laufenden Abrechnungsmonat schon bezahlt sind, berechnen wir nicht noch einmal. Verringert der Kunde den Zusatzspeicher, wirkt das zum Ende des laufenden Abrechnungsmonats; die Gebühr für den laufenden Monat wird nicht anteilig erstattet.</li>
<li>Beim Wechsel vom Hosting- ins Wartungs-Abo (§ 9) wird die Monatsgebühr des Wartungs-Abos am Wechseltag fällig, zusammen mit gebuchtem Zusatzspeicher. Die im Voraus bezahlten Gebühren für die nicht genutzten Tage des laufenden Abrechnungsmonats schreiben wir auf dieser Rechnung gut. Die folgenden Abrechnungsmonate beginnen jeweils am Kalendertag des Wechsels. Den fälligen Betrag und den neuen Abbuchungstag zeigt die Bestellübersicht vor der Bestellung. Wird die Wechselrechnung nicht bezahlt, gilt § 9 Abs. 3.</li> <li>Beim Wechsel vom Hosting- ins Wartungs-Abo (§ 9) wird die Monatsgebühr des Wartungs-Abos am Wechseltag fällig, zusammen mit gebuchtem Zusatzspeicher. Die im Voraus bezahlten Gebühren für die nicht genutzten Tage des laufenden Abrechnungsmonats schreiben wir auf dieser Rechnung gut. Die folgenden Abrechnungsmonate beginnen jeweils am Kalendertag des Wechsels. Den fälligen Betrag und den neuen Abbuchungstag zeigt die Bestellübersicht vor der Bestellung. Wird die Wechselrechnung nicht bezahlt, gilt § 9 Abs. 3.</li>
@@ -283,7 +283,7 @@
<p class="anlage-kopf neue-seite" id="hosting">Leistungsbeschreibung A</p> <p class="anlage-kopf neue-seite" id="hosting">Leistungsbeschreibung A</p>
<h2>Hosting-Abo</h2> <h2>Hosting-Abo</h2>
<p class="hinweis">15,00 € pro Monat · Endpreis, gemäß § 19 UStG ohne Umsatzsteuer · Mindestlaufzeit 12 Monate · für statische Websites</p> <p class="hinweis">20,00 € pro Monat · Endpreis, gemäß § 19 UStG ohne Umsatzsteuer · Mindestlaufzeit 12 Monate · für statische Websites</p>
<h3>1. Umfang</h3> <h3>1. Umfang</h3>
<ul> <ul>
<li>Bereitstellung der Website auf unserem Server in Deutschland mit SSL-Zertifikat, Überwachung der Erreichbarkeit und Updates von Betriebssystem und Server-Software (§ 3 Abs. 2)</li> <li>Bereitstellung der Website auf unserem Server in Deutschland mit SSL-Zertifikat, Überwachung der Erreichbarkeit und Updates von Betriebssystem und Server-Software (§ 3 Abs. 2)</li>
+1 -1
View File
@@ -806,7 +806,7 @@ function aboTermText(sub) {
/* Seitenleiste: noch kein Abo (Auswahl steht in der Ansicht "Verträge") */ /* Seitenleiste: noch kein Abo (Auswahl steht in der Ansicht "Verträge") */
function renderAboSideOffer(card, data, { ended }) { function renderAboSideOffer(card, data, { ended }) {
const cheapest = Math.min(...(data.products || []).map((p) => p.amountCents).filter(Number.isFinite), 1500) const cheapest = Math.min(...(data.products || []).map((p) => p.amountCents).filter(Number.isFinite), 2000)
card.classList.add('kb-abo-offer') card.classList.add('kb-abo-offer')
card.innerHTML = ` card.innerHTML = `
<p class="kb-label">Ihr Vertrag</p> <p class="kb-label">Ihr Vertrag</p>
+2 -2
View File
@@ -52,8 +52,8 @@ const products = {
hosting: { hosting: {
key: 'hosting', key: 'hosting',
name: 'WEBklar Hosting-Abo', name: 'WEBklar Hosting-Abo',
lookupKey: 'webklar_hosting_monatlich_1500', lookupKey: 'webklar_hosting_monatlich_2000',
amountCents: 1500, amountCents: 2000,
description: description:
'Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland (SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit, Updates von Betriebssystem und Server-Software), 1 Domain inklusive Verwaltung und 1 E-Mail-Postfach (5 GB). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.', 'Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland (SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit, Updates von Betriebssystem und Server-Software), 1 Domain inklusive Verwaltung und 1 E-Mail-Postfach (5 GB). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR HOSTING', statementDescriptor: 'WEBKLAR HOSTING',
@@ -9,7 +9,7 @@
Empfänger: Kunde (E-Mail aus der Bestellung) · BCC intern · Anhang: Vertragsunterlagen der zugestimmten Fassung (PDF, contractAttachmentFor) Empfänger: Kunde (E-Mail aus der Bestellung) · BCC intern · Anhang: Vertragsunterlagen der zugestimmten Fassung (PDF, contractAttachmentFor)
Platzhalter: {{name}}, {{produkt}}, {{preis}}, {{angebot_text}}, {{vertragsschluss}}, {{leistungsbeginn}}, {{mindestlaufzeit_bis}}, {{bedingungen_version}}, {{zustimmung_zeitpunkt}} Platzhalter: {{name}}, {{produkt}}, {{preis}}, {{angebot_text}}, {{vertragsschluss}}, {{leistungsbeginn}}, {{mindestlaufzeit_bis}}, {{bedingungen_version}}, {{zustimmung_zeitpunkt}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, hosting, wartung, wartung_v3, sofortbeginn, beginn_nach_widerrufsfrist Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, hosting, wartung, wartung_v3, sofortbeginn, beginn_nach_widerrufsfrist
produkt = Katalogname ohne „WEBklar “ („Hosting-Abo“, „Wartungs-Abo“), preis z. B. „15,00 € / Monat“; genau einer der Blöcke hosting/wartung/wartung_v3 bringt die Liste „Enthalten“: produkt = Katalogname ohne „WEBklar “ („Hosting-Abo“, „Wartungs-Abo“), preis z. B. „20,00 € / Monat“; genau einer der Blöcke hosting/wartung/wartung_v3 bringt die Liste „Enthalten“:
hosting und wartung nach Fassung 4 (Spec 3.1), wartung_v3 für ein Wartungs-Abo nach Fassung 3 (termsDocFor nicht v4: „1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat“ wie in den Bedingungen v3 § 3). hosting und wartung nach Fassung 4 (Spec 3.1), wartung_v3 für ein Wartungs-Abo nach Fassung 3 (termsDocFor nicht v4: „1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat“ wie in den Bedingungen v3 § 3).
bedingungen_version und das PDF im Anhang kommen aus derselben Quelle (termsDocFor(subDoc.termsVersion).version): ohne gespeicherte Fassung beides Fassung 3. bedingungen_version und das PDF im Anhang kommen aus derselben Quelle (termsDocFor(subDoc.termsVersion).version): ohne gespeicherte Fassung beides Fassung 3.
--> -->
+6 -2
View File
@@ -77,7 +77,11 @@ export async function ensurePrice(key) {
priceIdCache.set(key, data[0].id) priceIdCache.set(key, data[0].id)
return data[0].id return data[0].id
} }
const product = await stripe.products.create({ // Neuer Preis fuer ein bestehendes Produkt (z. B. Hosting 15 -> 20 EUR, 29.09.2026): an das vorhandene
// Produkt haengen statt ein zweites gleichnamiges Produkt bei Stripe anzulegen
const products = await stripe.products.list({ active: true, limit: 100 })
const existing = products.data.find((p) => p.metadata?.webklar === PRODUCT_METADATA[key])
const product = existing || await stripe.products.create({
name: catalog.name, name: catalog.name,
...(catalog.description ? { description: catalog.description } : {}), ...(catalog.description ? { description: catalog.description } : {}),
...(catalog.statementDescriptor ? { statement_descriptor: catalog.statementDescriptor } : {}), ...(catalog.statementDescriptor ? { statement_descriptor: catalog.statementDescriptor } : {}),
@@ -484,7 +488,7 @@ export async function ensureTestCoupon(productKey) {
return created.id return created.id
} }
// Preis wie in den Bedingungen: "15,00 €" (normales Leerzeichen, kein geschuetztes wie bei toLocaleString) // Preis wie in den Bedingungen: "20,00 €" (normales Leerzeichen, kein geschuetztes wie bei toLocaleString)
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €` const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
/** /**
+6 -6
View File
@@ -81,7 +81,7 @@ async function clearEvents(customerId) {
test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async () => { test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async () => {
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 }) aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
const r = await get('/api/abo', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) }) const r = await get('/api/abo', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) })
assert.deepEqual(r.products.map((p) => [p.key, p.amountCents]), [['hosting', 1500], ['wartung', 5000]]) assert.deepEqual(r.products.map((p) => [p.key, p.amountCents]), [['hosting', 2000], ['wartung', 5000]])
assert.equal(r.subscription.productKey, 'hosting'); assert.equal(r.subscription.storage.units, 2) assert.equal(r.subscription.productKey, 'hosting'); assert.equal(r.subscription.storage.units, 2)
assert.equal(r.subscription.canSwitch, true); assert.equal(r.ledger, null) assert.equal(r.subscription.canSwitch, true); assert.equal(r.ledger, null)
assert.equal(r.subscription.termsUrl, '/abo-bedingungen.html') assert.equal(r.subscription.termsUrl, '/abo-bedingungen.html')
@@ -134,10 +134,10 @@ test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => {
assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.status, 200, JSON.stringify(res.body))
assert.match(res.body.url, /^https:\/\//) assert.match(res.body.url, /^https:\/\//)
assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo') assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo')
assert.match(message(call), /15,00 € monatlich/) assert.match(message(call), /20,00 € monatlich/)
assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting') assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting')
assert.equal(message(call), `${INTRO_SOFORT} 15,00 € monatlich, ${TAIL}`) assert.equal(message(call), `${INTRO_SOFORT} 20,00 € monatlich, ${TAIL}`)
assert.equal(call.body['line_items[0][price]'], priceIdFor('webklar_hosting_monatlich_1500')) assert.equal(call.body['line_items[0][price]'], priceIdFor('webklar_hosting_monatlich_2000'))
assert.equal(call.body['discounts[0][coupon]'], undefined) assert.equal(call.body['discounts[0][coupon]'], undefined)
assert.equal(call.body['subscription_data[trial_end]'], undefined) assert.equal(call.body['subscription_data[trial_end]'], undefined)
// Nachweis der Zustimmung nur am Abo (F41c): an der Checkout-Sitzung bleiben Zuordnung und Produkt // Nachweis der Zustimmung nur am Abo (F41c): an der Checkout-Sitzung bleiben Zuordnung und Produkt
@@ -156,7 +156,7 @@ test('Checkout Hosting nach der Widerrufsfrist: Probezeit bis zum Leistungsbegin
const { res, call } = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt })) const { res, call } = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt }))
assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(call.body['subscription_data[trial_end]'], String(unix(lateStartAt))) assert.equal(call.body['subscription_data[trial_end]'], String(unix(lateStartAt)))
assert.equal(message(call), `${INTRO_FRIST} Danach 15,00 € monatlich, ${TAIL}`) assert.equal(message(call), `${INTRO_FRIST} Danach 20,00 € monatlich, ${TAIL}`)
// Veralteter Leistungsbeginn (Datumswechsel seit dem Laden der Seite) -> 409, kein Checkout // Veralteter Leistungsbeginn (Datumswechsel seit dem Laden der Seite) -> 409, kein Checkout
const stale = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt: '2026-01-02T05:00:00.000Z' })) const stale = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt: '2026-01-02T05:00:00.000Z' }))
assert.equal(stale.res.status, 409) assert.equal(stale.res.status, 409)
@@ -421,7 +421,7 @@ test('Webhook checkout.session.completed: K01 je Produkt mit PDF der zugestimmte
assert.equal(k01.subject, 'Ihr WEBklar Hosting-Abo – Vertragsbestätigung') assert.equal(k01.subject, 'Ihr WEBklar Hosting-Abo – Vertragsbestätigung')
assert.deepEqual(k01.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']]) assert.deepEqual(k01.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']])
assert.ok(k01.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt') assert.ok(k01.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt')
for (const text of ['15,00 € / Monat', 'gemäß § 19 UStG ohne Umsatzsteuer', '1 E-Mail-Postfach', `(Version ${config.abo.termsVersion})`]) { for (const text of ['20,00 € / Monat', 'gemäß § 19 UStG ohne Umsatzsteuer', '1 E-Mail-Postfach', `(Version ${config.abo.termsVersion})`]) {
assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`) assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`)
} }
assert.ok(!k01.text.includes('Stunde Arbeitszeit') && !k01.text.includes('Stundenkontingent'), k01.text) assert.ok(!k01.text.includes('Stunde Arbeitszeit') && !k01.text.includes('Stundenkontingent'), k01.text)
+5 -5
View File
@@ -15,7 +15,7 @@ const { grantCreditForInvoice, loadInvoice, invoiceLines } = await import('../se
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', lookup_key, unit_amount, active: true }) const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', lookup_key, unit_amount, active: true })
st.prices.set('price_w', price('price_w', 'webklar_wartung_monatlich_5000', 5000)) st.prices.set('price_w', price('price_w', 'webklar_wartung_monatlich_5000', 5000))
st.prices.set('price_h', price('price_h', 'webklar_hosting_monatlich_1500', 1500)) st.prices.set('price_h', price('price_h', 'webklar_hosting_monatlich_2000', 2000))
const sub = (id, priceObj) => ({ const sub = (id, priceObj) => ({
id, id,
@@ -66,14 +66,14 @@ test('Wartungs-Rechnung: 60 Minuten, genau einmal', async () => {
}) })
test('Hosting-Rechnung: keine Stunde', async () => { test('Hosting-Rechnung: keine Stunde', async () => {
st.invoices.set('in_2', inv('in_2', 'sub_h', [regular('price_h', 1500)])) st.invoices.set('in_2', inv('in_2', 'sub_h', [regular('price_h', 2000)]))
assert.deepEqual(await grantCreditForInvoice(payload('in_2', 'sub_h', 1500)), { skipped: 'keine Wartungs-Leistung' }) assert.deepEqual(await grantCreditForInvoice(payload('in_2', 'sub_h', 2000)), { skipped: 'keine Wartungs-Leistung' })
assert.equal(aw.docs('hourCredits').has('in_2'), false) assert.equal(aw.docs('hourCredits').has('in_2'), false)
}) })
test('Frisch geladene Rechnung zählt, nicht die Webhook-Nutzlast', async () => { test('Frisch geladene Rechnung zählt, nicht die Webhook-Nutzlast', async () => {
// Nutzlast in alter Form (ohne pricing/parent) behauptet Wartung, frisch geladen ist es Hosting // Nutzlast in alter Form (ohne pricing/parent) behauptet Wartung, frisch geladen ist es Hosting
st.invoices.set('in_3', inv('in_3', 'sub_h', [regular('price_h', 1500)])) st.invoices.set('in_3', inv('in_3', 'sub_h', [regular('price_h', 2000)]))
const oldPayload = { const oldPayload = {
id: 'in_3', id: 'in_3',
subscription: 'sub_h', subscription: 'sub_h',
@@ -94,7 +94,7 @@ test('Preis-Zuordnung wird zwischengespeichert', async () => {
}) })
test('Unbekannte Preis-ID (404) neben Hosting: keine Stunde, kein Absturz', async () => { test('Unbekannte Preis-ID (404) neben Hosting: keine Stunde, kein Absturz', async () => {
st.invoices.set('in_5', inv('in_5', 'sub_h', [regular('price_h', 1500), regular('price_weg', 800)])) st.invoices.set('in_5', inv('in_5', 'sub_h', [regular('price_h', 2000), regular('price_weg', 800)]))
assert.deepEqual(await grantCreditForInvoice(payload('in_5', 'sub_h', 2300)), { skipped: 'keine Wartungs-Leistung' }) assert.deepEqual(await grantCreditForInvoice(payload('in_5', 'sub_h', 2300)), { skipped: 'keine Wartungs-Leistung' })
assert.equal(aw.docs('hourCredits').has('in_5'), false) assert.equal(aw.docs('hourCredits').has('in_5'), false)
}) })
+2 -2
View File
@@ -62,7 +62,7 @@ test('Widerrufsbelehrung und Muster-Widerrufsformular wortgleich mit Fassung 3',
test('Fassung 4: beide Abos, Postfächer, Zusatzspeicher, Wechsel', () => { test('Fassung 4: beide Abos, Postfächer, Zusatzspeicher, Wechsel', () => {
const v4html = read(V4), v4 = strip(v4html) const v4html = read(V4), v4 = strip(v4html)
for (const s of ['15,00 €', '50,00 €', 'beliebig viele Postfächer', 'zusammen 5 GB', '1,00 €', 'Zusatzspeicher', for (const s of ['20,00 €', '50,00 €', 'beliebig viele Postfächer', 'zusammen 5 GB', '1,00 €', 'Zusatzspeicher',
'12 Monate', 'statische', 'Hetzner', '§ 19 UStG', 'Leistungsbeschreibung A', 'Leistungsbeschreibung B', '12 Monate', 'statische', 'Hetzner', '§ 19 UStG', 'Leistungsbeschreibung A', 'Leistungsbeschreibung B',
'„Zahlungspflichtig bestellen“', '„Verträge hier kündigen“', '„Nur den E-Mail-Zusatzspeicher“', '„Speichern“', '„Zahlungspflichtig bestellen“', '„Verträge hier kündigen“', '„Nur den E-Mail-Zusatzspeicher“', '„Speichern“',
'Vertragsbedingungen für die WEBklar-Abos']) assert.ok(v4.includes(s), s) 'Vertragsbedingungen für die WEBklar-Abos']) assert.ok(v4.includes(s), s)
@@ -412,7 +412,7 @@ test('Übernahmegutschrift und Leitfaden: produktneutral', () => {
// Leitfaden: beide Preise mit § 19 UStG, Zusatzspeicher, Wechsel, Gutschrift für beide Abos, Bestellweg „Verträge“ // Leitfaden: beide Preise mit § 19 UStG, Zusatzspeicher, Wechsel, Gutschrift für beide Abos, Bestellweg „Verträge“
const lfText = strip(lf) const lfText = strip(lf)
for (const s of ['zusammen 5 GB', 'Zusatzspeicher', 'Wechsel', 'Übernahmegutschrift', 'beiden Abos', '„Verträge“']) assert.ok(lfText.includes(s), s) for (const s of ['zusammen 5 GB', 'Zusatzspeicher', 'Wechsel', 'Übernahmegutschrift', 'beiden Abos', '„Verträge“']) assert.ok(lfText.includes(s), s)
for (const preis of ['15,00 €', '50,00 €', '1,00 €']) { for (const preis of ['20,00 €', '50,00 €', '1,00 €']) {
const i = lfText.indexOf(preis) const i = lfText.indexOf(preis)
assert.ok(i >= 0, preis) assert.ok(i >= 0, preis)
assert.ok(lfText.slice(i, i + 80).includes('§ 19 UStG'), `${preis} ohne § 19 UStG`) assert.ok(lfText.slice(i, i + 80).includes('§ 19 UStG'), `${preis} ohne § 19 UStG`)
+6 -6
View File
@@ -173,7 +173,7 @@ test('Vorschau Plus: anteiliger Betrag aus der Stripe-Vorschau, Gesamtgröße, n
st.invoicePreview = (body) => st.invoicePreview = (body) =>
previewInvoice( previewInvoice(
line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) }), line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) }),
line('price_hosting', 1500), line('price_hosting', 2000),
line('price_mailStorage', 200, { quantity: 2 }) line('price_mailStorage', 200, { quantity: 2 })
) )
const before = st.calls.length const before = st.calls.length
@@ -208,7 +208,7 @@ test('Vorschau Plus mit Posten: Gutschrift und Belastung ab proration_date, ält
line('price_mailStorage', 40, { quantity: 1, proration: true, periodStart: at - 60 }), line('price_mailStorage', 40, { quantity: 1, proration: true, periodStart: at - 60 }),
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at - 1 }), line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at - 1 }),
line('price_mailStorage', 200, { quantity: 3, proration: true, periodStart: at + 1 }), line('price_mailStorage', 200, { quantity: 3, proration: true, periodStart: at + 1 }),
line('price_hosting', 1500), line('price_hosting', 2000),
line('price_mailStorage', 300, { quantity: 3 }) line('price_mailStorage', 300, { quantity: 3 })
) )
} }
@@ -257,7 +257,7 @@ test('Vorschau Plus in zwei Schritten, flexibler Modus (Standard): Betrag der St
return previewInvoice( return previewInvoice(
line('price_mailStorage', -200, { quantity: 3, proration: true, periodStart: at }), line('price_mailStorage', -200, { quantity: 3, proration: true, periodStart: at }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }), line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }),
line('price_hosting', 1500), line('price_hosting', 2000),
line('price_mailStorage', 500, { quantity: 5 }) line('price_mailStorage', 500, { quantity: 5 })
) )
} }
@@ -287,7 +287,7 @@ test('Vorschau Plus in zwei Schritten, klassischer Modus: anteilig nur für die
return previewInvoice( return previewInvoice(
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }), line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }), line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }),
line('price_hosting', 1500), line('price_hosting', 2000),
line('price_mailStorage', 500, { quantity: 5 }) line('price_mailStorage', 500, { quantity: 5 })
) )
} }
@@ -905,7 +905,7 @@ test('invoice.paid: anteilige Speicher-Zeile oder Rechnung ohne Speicher ändert
await resetSubH({ units: 2, paidUnits: 2 }) await resetSubH({ units: 2, paidUnits: 2 })
const r1 = await recordPaidStorage({ id: 'in_x', lines: { data: [line('price_mailStorage', 90, { quantity: 3, proration: true })] } }) const r1 = await recordPaidStorage({ id: 'in_x', lines: { data: [line('price_mailStorage', 90, { quantity: 3, proration: true })] } })
assert.ok(r1.skipped) assert.ok(r1.skipped)
const r2 = await recordPaidStorage({ id: 'in_y', lines: { data: [line('price_hosting', 1500)] } }) const r2 = await recordPaidStorage({ id: 'in_y', lines: { data: [line('price_hosting', 2000)] } })
assert.ok(r2.skipped) assert.ok(r2.skipped)
assert.equal(doc().storagePaidUnits, 2) assert.equal(doc().storagePaidUnits, 2)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
@@ -953,7 +953,7 @@ test('Webhook invoice.paid: Appwrite 4xx → 200 und übersprungen, 5xx → 500'
st.invoices.set('in_h2', { st.invoices.set('in_h2', {
id: 'in_h2', object: 'invoice', number: 'WK-h2', created: now, status_transitions: { paid_at: now }, id: 'in_h2', object: 'invoice', number: 'WK-h2', created: now, status_transitions: { paid_at: now },
parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } }, parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } },
lines: { object: 'list', data: [line('price_hosting', 1500, { periodEnd: PERIOD_END_UNIX + 30 * DAY }), line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] }, lines: { object: 'list', data: [line('price_hosting', 2000, { periodEnd: PERIOD_END_UNIX + 30 * DAY }), line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] },
}) })
const event = { id: 'evt_in_h2', object: 'event', type: 'invoice.paid', created: now, data: { object: { id: 'in_h2', object: 'invoice', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } } } } const event = { id: 'evt_in_h2', object: 'event', type: 'invoice.paid', created: now, data: { object: { id: 'in_h2', object: 'invoice', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } } } }
const errors = [] const errors = []
+3 -3
View File
@@ -325,12 +325,12 @@ test('Neue Vorlagen vorhanden', () => {
}) })
test('K01 Hosting: 1 Postfach, keine Arbeitsstunde; Wartung: beliebig viele Postfächer', () => { test('K01 Hosting: 1 Postfach, keine Arbeitsstunde; Wartung: beliebig viele Postfächer', () => {
const base = { name: 'K', produkt: 'Hosting-Abo', preis: '15,00 € / Monat', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', const base = { name: 'K', produkt: 'Hosting-Abo', preis: '20,00 € / Monat', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026',
mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-10-05-v4', zustimmung_zeitpunkt: '01.10.2026, 10:00', angebot_text: '' } mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-10-05-v4', zustimmung_zeitpunkt: '01.10.2026, 10:00', angebot_text: '' }
const h = renderMail('K01', base, { hosting: true, sofortbeginn: true }) const h = renderMail('K01', base, { hosting: true, sofortbeginn: true })
assert.equal(h.subject, 'Ihr WEBklar Hosting-Abo – Vertragsbestätigung') assert.equal(h.subject, 'Ihr WEBklar Hosting-Abo – Vertragsbestätigung')
assert.ok(h.text.includes('1 E-Mail-Postfach') && !h.text.includes('Stunde Arbeitszeit'), h.text) assert.ok(h.text.includes('1 E-Mail-Postfach') && !h.text.includes('Stunde Arbeitszeit'), h.text)
assert.ok(h.text.includes('15,00 € / Monat') && h.text.includes('gemäß § 19 UStG ohne Umsatzsteuer')) assert.ok(h.text.includes('20,00 € / Monat') && h.text.includes('gemäß § 19 UStG ohne Umsatzsteuer'))
assert.ok(h.text.includes('Ihr Hosting-Abo ist bestätigt.') && h.text.includes('Abschluss Ihres Hosting-Abos')) assert.ok(h.text.includes('Ihr Hosting-Abo ist bestätigt.') && h.text.includes('Abschluss Ihres Hosting-Abos'))
assert.doesNotMatch(h.text, /Stundenkontingent/) assert.doesNotMatch(h.text, /Stundenkontingent/)
assert.deepEqual(h.problems, []) assert.deepEqual(h.problems, [])
@@ -466,7 +466,7 @@ test('K01: Block wartung_v3 mit der Zeile der Fassung 3, wartung mit der Fassung
assert.match(v4.text, /• beliebig viele Postfächer/) assert.match(v4.text, /• beliebig viele Postfächer/)
assert.match(v4.text, /1 Stunde Arbeitszeit je bezahltem Monat/) assert.match(v4.text, /1 Stunde Arbeitszeit je bezahltem Monat/)
assert.doesNotMatch(v4.text, /1 E-Mail-Postfach|pro Monat/) assert.doesNotMatch(v4.text, /1 E-Mail-Postfach|pro Monat/)
const h = renderMail('K01', { ...base, produkt: 'Hosting-Abo', preis: '15,00 € / Monat' }, { hosting: true, sofortbeginn: true }) const h = renderMail('K01', { ...base, produkt: 'Hosting-Abo', preis: '20,00 € / Monat' }, { hosting: true, sofortbeginn: true })
assert.doesNotMatch(h.text, /Stundenkontingent|Stunde Arbeitszeit/) assert.doesNotMatch(h.text, /Stundenkontingent|Stunde Arbeitszeit/)
}) })
+5 -5
View File
@@ -42,9 +42,9 @@ const ADMIN = { name: 'Admin', email: 'admin@example.invalid' }
// Step 1 (Brief): validateOfferInput + getOpenOffer // Step 1 (Brief): validateOfferInput + getOpenOffer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
test('Angebot Hosting: Normalpreis 15 €, Preis darunter', () => { test('Angebot Hosting: Normalpreis 20 €, Preis darunter', () => {
assert.equal(validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1000 }).regularPriceCents, 1500) assert.equal(validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1000 }).regularPriceCents, 2000)
assert.throws(() => validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1500 }), (e) => e.status === 400) assert.throws(() => validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 2000 }), (e) => e.status === 400)
assert.throws(() => validateOfferInput({ months: 2, monthlyPriceCents: 1000 }), (e) => e.status === 400) assert.throws(() => validateOfferInput({ months: 2, monthlyPriceCents: 1000 }), (e) => e.status === 400)
}) })
@@ -69,7 +69,7 @@ test('createOffer: offene Angebote fuer Hosting und Wartung koennen nebeneinande
const hosting = await createOffer({ customerId: 'c3', productKey: 'hosting', months: 2, monthlyPriceCents: 1000, notifyCustomer: false }, ADMIN) const hosting = await createOffer({ customerId: 'c3', productKey: 'hosting', months: 2, monthlyPriceCents: 1000, notifyCustomer: false }, ADMIN)
try { try {
assert.equal(hosting.doc.productKey, 'hosting') assert.equal(hosting.doc.productKey, 'hosting')
assert.equal(hosting.doc.regularPriceCents, 1500) assert.equal(hosting.doc.regularPriceCents, 2000)
assert.equal(hosting.doc.status, 'offen') assert.equal(hosting.doc.status, 'offen')
await assert.rejects( await assert.rejects(
@@ -118,7 +118,7 @@ test('createOffer: nur bei passendem Produkt wird das Angebot sofort aufs laufen
test('offerContext: products mit Normalpreisen, subscription.productKey, offene Angebote je Produkt', async () => { test('offerContext: products mit Normalpreisen, subscription.productKey, offene Angebote je Produkt', async () => {
const withoutSub = await offerContext('c3') const withoutSub = await offerContext('c3')
assert.deepEqual(withoutSub.products, { hosting: { regularPriceCents: 1500 }, wartung: { regularPriceCents: 5000 } }) assert.deepEqual(withoutSub.products, { hosting: { regularPriceCents: 2000 }, wartung: { regularPriceCents: 5000 } })
assert.equal(withoutSub.subscription, null) assert.equal(withoutSub.subscription, null)
assert.deepEqual(withoutSub.openOffers, { hosting: null, wartung: null }) assert.deepEqual(withoutSub.openOffers, { hosting: null, wartung: null })
+10 -10
View File
@@ -25,8 +25,8 @@ const keys = new Map([['price_w', 'wartung'], ['price_h', 'hosting'], ['price_s'
test('Katalog: Preise, lookup_keys, Minuten, Mindestlaufzeit', () => { test('Katalog: Preise, lookup_keys, Minuten, Mindestlaufzeit', () => {
assert.equal(getProduct('wartung').amountCents, 5000) assert.equal(getProduct('wartung').amountCents, 5000)
assert.equal(getProduct('hosting').amountCents, 1500) assert.equal(getProduct('hosting').amountCents, 2000)
assert.equal(getProduct('hosting').lookupKey, 'webklar_hosting_monatlich_1500') assert.equal(getProduct('hosting').lookupKey, 'webklar_hosting_monatlich_2000')
assert.equal(getProduct('wartung').minutesPerMonth, 60) assert.equal(getProduct('wartung').minutesPerMonth, 60)
assert.equal(getProduct('hosting').minutesPerMonth, 0) assert.equal(getProduct('hosting').minutesPerMonth, 0)
assert.equal(getProduct('hosting').minTermMonths, 12) assert.equal(getProduct('hosting').minTermMonths, 12)
@@ -46,7 +46,7 @@ test('Bisherige Werte bleiben gleich', () => {
test('lookup_key → Produkt', () => { test('lookup_key → Produkt', () => {
assert.equal(productKeyForLookupKey('webklar_wartung_monatlich_5000'), 'wartung') assert.equal(productKeyForLookupKey('webklar_wartung_monatlich_5000'), 'wartung')
assert.equal(productKeyForLookupKey('webklar_hosting_monatlich_1500'), 'hosting') assert.equal(productKeyForLookupKey('webklar_hosting_monatlich_2000'), 'hosting')
assert.equal(productKeyForLookupKey('webklar_mailspeicher_5gb_monatlich_100'), 'mailStorage') assert.equal(productKeyForLookupKey('webklar_mailspeicher_5gb_monatlich_100'), 'mailStorage')
assert.equal(productKeyForLookupKey('irgendwas'), null) assert.equal(productKeyForLookupKey('irgendwas'), null)
assert.equal(productKeyForLookupKey(undefined), null) assert.equal(productKeyForLookupKey(undefined), null)
@@ -55,14 +55,14 @@ test('lookup_key → Produkt', () => {
test('describeItems: Abo-Posten und Zusatzspeicher, Reihenfolge egal', () => { test('describeItems: Abo-Posten und Zusatzspeicher, Reihenfolge egal', () => {
const items = [ const items = [
{ id: 'si_s', quantity: 2, price: { id: 'price_s', lookup_key: 'webklar_mailspeicher_5gb_monatlich_100', unit_amount: 100 } }, { 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_1500', unit_amount: 1500 } }, { id: 'si_a', quantity: 1, price: { id: 'price_h', lookup_key: 'webklar_hosting_monatlich_2000', unit_amount: 2000 } },
] ]
assert.deepEqual(describeItems(items), { assert.deepEqual(describeItems(items), {
productKey: 'hosting', productKey: 'hosting',
aboItemId: 'si_a', aboItemId: 'si_a',
aboPriceId: 'price_h', aboPriceId: 'price_h',
lookupKey: 'webklar_hosting_monatlich_1500', lookupKey: 'webklar_hosting_monatlich_2000',
priceAmount: 1500, priceAmount: 2000,
storageItemId: 'si_s', storageItemId: 'si_s',
storageUnits: 2, storageUnits: 2,
}) })
@@ -85,11 +85,11 @@ test('describeItems: ohne bekannten Abo-Preis', () => {
test('Stunden nur für eine reguläre Wartungs-Zeile über 0 €', () => { test('Stunden nur für eine reguläre Wartungs-Zeile über 0 €', () => {
const inv = (...lines) => ({ lines: { data: lines } }) const inv = (...lines) => ({ lines: { data: lines } })
assert.equal(creditMinutesForInvoice(inv(line('price_w', 5000)), keys), 60) assert.equal(creditMinutesForInvoice(inv(line('price_w', 5000)), keys), 60)
assert.equal(creditMinutesForInvoice(inv(line('price_h', 1500), line('price_s', 200)), keys), 0) 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', 2300, true)), keys), 0) // anteilige Verrechnung
assert.equal(creditMinutesForInvoice(inv(line('price_w', 0)), keys), 0) // Probezeit assert.equal(creditMinutesForInvoice(inv(line('price_w', 0)), keys), 0) // Probezeit
// unbekannter Preis neben einem bekannten Abo-Preis: keine Stunde // unbekannter Preis neben einem bekannten Abo-Preis: keine Stunde
assert.equal(creditMinutesForInvoice(inv(line('price_h', 1500), line('price_x', 800)), keys), 0) assert.equal(creditMinutesForInvoice(inv(line('price_h', 2000), line('price_x', 800)), keys), 0)
const invoiceItem = { const invoiceItem = {
amount: 1200, amount: 1200,
pricing: { price_details: { price: 'price_w' } }, pricing: { price_details: { price: 'price_w' } },
@@ -156,7 +156,7 @@ test('productView: vollständige Form, Kopie der features', () => {
assert.deepEqual(Object.keys(v).sort(), ['amountCents', 'features', 'key', 'minTermMonths', 'minutesPerMonth', 'name'].sort()) assert.deepEqual(Object.keys(v).sort(), ['amountCents', 'features', 'key', 'minTermMonths', 'minutesPerMonth', 'name'].sort())
assert.equal(v.key, 'hosting') assert.equal(v.key, 'hosting')
assert.equal(v.name, 'WEBklar Hosting-Abo') assert.equal(v.name, 'WEBklar Hosting-Abo')
assert.equal(v.amountCents, 1500) assert.equal(v.amountCents, 2000)
assert.equal(v.minTermMonths, 12) assert.equal(v.minTermMonths, 12)
assert.equal(v.minutesPerMonth, 0) assert.equal(v.minutesPerMonth, 0)
assert.ok(Array.isArray(v.features) && v.features.length > 0) assert.ok(Array.isArray(v.features) && v.features.length > 0)
@@ -201,7 +201,7 @@ test('regularStorageLine: reguläre Zusatzspeicher-Zeile, keine anteilige, kein
const storage = { ...line('price_s', 300), quantity: 3 } 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), line('price_w', 5000), storage), keys), storage)
assert.equal(regularStorageLine(inv(line('price_s', 90, true)), keys), null) assert.equal(regularStorageLine(inv(line('price_s', 90, true)), keys), null)
assert.equal(regularStorageLine(inv(line('price_w', 5000), line('price_h', 1500)), 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(inv({ amount: 5 }), keys), null)
assert.equal(regularStorageLine({}, keys), null) assert.equal(regularStorageLine({}, keys), null)
}) })
+19 -7
View File
@@ -51,12 +51,12 @@ test('Setup-Skript legt productKey an', () => {
test('Spiegel: Hosting-Abo, Zusatzspeicher steht vorne', async () => { test('Spiegel: Hosting-Abo, Zusatzspeicher steht vorne', async () => {
st.subscriptions.set( st.subscriptions.set(
'sub_h', 'sub_h',
sub('sub_h', [item('s', 'webklar_mailspeicher_5gb_monatlich_100', 100, 2), item('h', 'webklar_hosting_monatlich_1500', 1500)]) sub('sub_h', [item('s', 'webklar_mailspeicher_5gb_monatlich_100', 100, 2), item('h', 'webklar_hosting_monatlich_2000', 2000)])
) )
const doc = await syncSubscription('sub_h') const doc = await syncSubscription('sub_h')
assert.equal(doc.productKey, 'hosting') assert.equal(doc.productKey, 'hosting')
assert.equal(doc.planKey, 'webklar_hosting_monatlich_1500') assert.equal(doc.planKey, 'webklar_hosting_monatlich_2000')
assert.equal(doc.priceAmount, 1500) assert.equal(doc.priceAmount, 2000)
}) })
test('Spiegel: Wartungs-Abo wie bisher', async () => { test('Spiegel: Wartungs-Abo wie bisher', async () => {
@@ -105,8 +105,8 @@ test('ensurePrice legt Hosting einmal an: Preis- und Produktfelder, Bild', async
const calls = st.calls.slice(before) const calls = st.calls.slice(before)
const createdPrices = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices') const createdPrices = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices')
assert.equal(createdPrices.length, 1) assert.equal(createdPrices.length, 1)
assert.equal(createdPrices[0].body['lookup_key'], 'webklar_hosting_monatlich_1500') assert.equal(createdPrices[0].body['lookup_key'], 'webklar_hosting_monatlich_2000')
assert.equal(createdPrices[0].body['unit_amount'], '1500') assert.equal(createdPrices[0].body['unit_amount'], '2000')
assert.equal(createdPrices[0].body['tax_behavior'], 'inclusive') assert.equal(createdPrices[0].body['tax_behavior'], 'inclusive')
assert.equal(createdPrices[0].body['recurring[interval]'], 'month') assert.equal(createdPrices[0].body['recurring[interval]'], 'month')
const createdProducts = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products') const createdProducts = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products')
@@ -116,6 +116,18 @@ test('ensurePrice legt Hosting einmal an: Preis- und Produktfelder, Bild', async
assert.equal(createdProducts[0].body['images[0]'], config.stripe.productImage) assert.equal(createdProducts[0].body['images[0]'], config.stripe.productImage)
}) })
// Preisaenderung (Hosting 15 -> 20 EUR): neuer Preis haengt am vorhandenen Produkt, kein zweites Produkt
test('ensurePrice: neuer Preis fuer vorhandenes Produkt legt kein zweites Produkt an', async () => {
st.products.set('prod_alt_speicher', { id: 'prod_alt_speicher', object: 'product', active: true, name: 'WEBklar E-Mail-Zusatzspeicher 5 GB', images: [], metadata: { webklar: 'mail-speicher' } })
const before = st.calls.length
await ensurePrice('mailStorage')
const calls = st.calls.slice(before)
assert.equal(calls.filter((c) => c.method === 'POST' && c.path === '/v1/products').length, 0)
const created = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices')
assert.equal(created.length, 1)
assert.equal(created[0].body['product'], 'prod_alt_speicher')
})
// Important 4: ein bereits vorhandener aktiver Preis wird gefunden, nichts wird bei Stripe angelegt // 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 () => { test('ensurePrice: vorhandener aktiver Preis wird gefunden, nichts wird angelegt', async () => {
st.prices.set('price_seeded_wartung', { st.prices.set('price_seeded_wartung', {
@@ -157,11 +169,11 @@ test('Stripe-Nachbau: Grenzen bei Preisen und Coupons (400)', async () => {
}) })
// Important 1: Namen ≤ 40 Zeichen (Stripe-Limit; die alten Namen mit dem vollen Produktnamen waren 49/50) // 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 () => { test('Testrabatt Hosting: 19,50 € Nachlass, Name ≤ 40 Zeichen', async () => {
const before = st.calls.length const before = st.calls.length
assert.equal(await ensureTestCoupon('hosting'), 'WEBKLAR-TEST-050-HOSTING') assert.equal(await ensureTestCoupon('hosting'), 'WEBKLAR-TEST-050-HOSTING')
const call = st.calls.slice(before).find((c) => c.path === '/v1/coupons' && c.method === 'POST') 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['amount_off'], '1950')
assert.equal(call.body['name'], 'Testzugang Hosting (0,50 €)') assert.equal(call.body['name'], 'Testzugang Hosting (0,50 €)')
assert.ok(call.body['name'].length <= 40) assert.ok(call.body['name'].length <= 40)
}) })
+4
View File
@@ -235,6 +235,10 @@ export async function startFakeStripe() {
if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) { if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) {
return products.has(m[1]) ? [200, products.get(m[1])] : noSuch('product', m[1]) return products.has(m[1]) ? [200, products.get(m[1])] : noSuch('product', m[1])
} }
if (method === 'GET' && path === '/v1/products') {
const active = query.get('active')
return [200, listOf([...products.values()].filter((p) => active === null || String(p.active !== false) === active))]
}
if (method === 'POST' && path === '/v1/products') { if (method === 'POST' && path === '/v1/products') {
const data = unflatten(body) const data = unflatten(body)
const obj = { const obj = {
+5 -5
View File
@@ -5,8 +5,8 @@ import assert from 'node:assert/strict'
// Die Pruefung vergleicht den Monatsbetrag beim bisherigen Anbieter mit der Monatsgebuehr des Abos des Kunden. // Die Pruefung vergleicht den Monatsbetrag beim bisherigen Anbieter mit der Monatsgebuehr des Abos des Kunden.
const { runAutoChecks } = await import('../server/services/takeoverCredits.js') const { runAutoChecks } = await import('../server/services/takeoverCredits.js')
// 240 € fuer 12 Monate = 20,00 € pro Monat: mehr als das Hosting-Abo (15,00 €), weniger als das Wartungs-Abo (50,00 €) // 300 € fuer 12 Monate = 25,00 € pro Monat: mehr als das Hosting-Abo (20,00 €), weniger als das Wartungs-Abo (50,00 €)
const doc = { $id: 'req1', $createdAt: '2026-10-01T10:00:00.000Z', domain: '', invoiceAmountCents: 24000, billingMonths: 12, files: '[]' } const doc = { $id: 'req1', $createdAt: '2026-10-01T10:00:00.000Z', domain: '', invoiceAmountCents: 30000, billingMonths: 12, files: '[]' }
const sub = (productKey) => ({ const sub = (productKey) => ({
status: 'active', status: 'active',
startedAt: '2026-10-01T00:00:00.000Z', startedAt: '2026-10-01T00:00:00.000Z',
@@ -16,15 +16,15 @@ const sub = (productKey) => ({
const amountCheck = async (subscription) => const amountCheck = async (subscription) =>
(await runAutoChecks(doc, { allRequests: [], subscription, calc: null })).find((c) => c.key === 'amount') (await runAutoChecks(doc, { allRequests: [], subscription, calc: null })).find((c) => c.key === 'amount')
test('Plausibilität: 20 € pro Monat ist mehr als das Hosting-Abo (15,00 €)', async () => { test('Plausibilität: 25 € pro Monat ist mehr als das Hosting-Abo (20,00 €)', async () => {
const hosting = await amountCheck(sub('hosting')) const hosting = await amountCheck(sub('hosting'))
assert.ok(hosting, 'Hinweis „mehr als die Monatsgebühr“ fehlt beim Hosting-Abo') assert.ok(hosting, 'Hinweis „mehr als die Monatsgebühr“ fehlt beim Hosting-Abo')
assert.equal(hosting.status, 'info') assert.equal(hosting.status, 'info')
assert.ok(hosting.label.includes('15,00'), hosting.label)
assert.ok(hosting.label.includes('20,00'), hosting.label) assert.ok(hosting.label.includes('20,00'), hosting.label)
assert.ok(hosting.label.includes('25,00'), hosting.label)
}) })
test('Plausibilität: 20 € pro Monat ist nicht mehr als das Wartungs-Abo (50,00 €)', async () => { test('Plausibilität: 25 € pro Monat ist nicht mehr als das Wartungs-Abo (50,00 €)', async () => {
assert.equal(await amountCheck(sub('wartung')), undefined) assert.equal(await amountCheck(sub('wartung')), undefined)
}) })