From 528ca0875e57444e4c52fab052793a98054f1b75 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:05:06 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20P7:=20K01=20und=20K12=20b?= =?UTF-8?q?ehaupten=20f=C3=BCr=20Betriebe=20kein=20Sofortbeginn-Verlangen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../K12 Wechsel Wartungs-Abo.html | 5 +- server/routes/abo.js | 3 +- server/services/planSwitch.js | 4 +- test/knBestaetigungsmails.test.js | 120 ++++++++++++++++++ test/mailTemplates.test.js | 10 +- 5 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 test/knBestaetigungsmails.test.js diff --git a/server/mail-templates/K12 Wechsel Wartungs-Abo.html b/server/mail-templates/K12 Wechsel Wartungs-Abo.html index 5beba73..c476c5b 100644 --- a/server/mail-templates/K12 Wechsel Wartungs-Abo.html +++ b/server/mail-templates/K12 Wechsel Wartungs-Abo.html @@ -8,8 +8,9 @@ Absender: „WEBklar“ Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern · Anhang: Vertragsunterlagen der Fassung 4 (PDF) Platzhalter: {{name}}, {{wechseltag}}, {{betrag_heute}}, {{monatspreis}}, {{mindestlaufzeit_bis}}, {{bedingungen_version}}, {{zustimmung_zeitpunkt}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_offen, lastschrift, zusatzspeicher + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_offen, lastschrift, zusatzspeicher, sofortverlangen zahlung_offen bei Karte mit zurückgehaltener Zahlung, lastschrift bei SEPA (Bedingungen § 9 Abs. 3); zusatzspeicher, wenn der Kunde Zusatzspeicher gebucht hat – monatspreis ist dann die Monatsgebühr mit Zusatzspeicher (Spec 3.5). + sofortverlangen (Absatz „Sie haben ausdrücklich verlangt …“ samt Wertersatz): nur, wenn der Kunde den Sofortbeginn ausdrücklich verlangt hat (Zeitstempel earlyStartConsentAt); ein Betrieb in Fassung 6 hat nichts verlangt, der Absatz entfällt. --> @@ -47,7 +48,7 @@ a{color:#111111;} Ihre Postfächer und gebuchter Zusatzspeicher bleiben unverändert. Weitere Postfächer richten wir ein, sobald die Wechselrechnung bezahlt ist; dann steht auch Ihre erste Stunde Arbeitszeit bereit.
Zum Kundenportal →
-Ihre Zustimmung zu den Vertragsbedingungen (Version {{bedingungen_version}}) einschließlich des Auftragsverarbeitungsvertrags haben wir am {{zustimmung_zeitpunkt}} Uhr erhalten.

Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen, und wurden darüber informiert, dass Sie bei einem Widerruf des Wechsels Wertersatz für die bis dahin erbrachte Leistung zahlen: den anteiligen Mehrpreis des Wartungs-Abos gegenüber dem Hosting-Abo für die Tage bis zum Widerruf. Widerrufen Sie den Wechsel, läuft Ihr Hosting-Abo zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit; den gezahlten Mehrbetrag erstatten wir abzüglich dieses Wertersatzes. +Ihre Zustimmung zu den Vertragsbedingungen (Version {{bedingungen_version}}) einschließlich des Auftragsverarbeitungsvertrags haben wir am {{zustimmung_zeitpunkt}} Uhr erhalten.

Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen, und wurden darüber informiert, dass Sie bei einem Widerruf des Wechsels Wertersatz für die bis dahin erbrachte Leistung zahlen: den anteiligen Mehrpreis des Wartungs-Abos gegenüber dem Hosting-Abo für die Tage bis zum Widerruf. Widerrufen Sie den Wechsel, läuft Ihr Hosting-Abo zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit; den gezahlten Mehrbetrag erstatten wir abzüglich dieses Wertersatzes. Viele Grüße
Ihr WEBklar-Team diff --git a/server/routes/abo.js b/server/routes/abo.js index 43df9ee..1162663 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -636,7 +636,8 @@ async function sendContractConfirmation(subDoc, fallbackName = '') { hosting: productKey === 'hosting', wartung: productKey === 'wartung' && isTermsV4OrNewer(terms.version), wartung_v3: productKey === 'wartung' && !isTermsV4OrNewer(terms.version), - sofortbeginn: !lateStart, + // Fassung 6: ein Betrieb (Sofortbeginn ohne Zeitstempel) hat nichts verlangt; die Mail behauptet es nicht + sofortbeginn: !lateStart && !(istNeueFassung(subDoc.termsVersion) && !subDoc.earlyStartConsentAt), beginn_nach_widerrufsfrist: lateStart, }, attachments: pdf ? [{ filename: attachment.filename, content: pdf, contentType: 'application/pdf' }] : [], diff --git a/server/services/planSwitch.js b/server/services/planSwitch.js index 9857a9a..9b0ca4b 100644 --- a/server/services/planSwitch.js +++ b/server/services/planSwitch.js @@ -467,8 +467,8 @@ async function performSwitch(customerId, current, consent, prorationDate, expect const contact = { name: customer?.name || '', email: customer?.email || doc.email || '' } const facts = { switchedAt, amountDueCents, monthlyCents } const lastschrift = sepa && effective && !paid - // K12 sofort; zahlung_offen nur bei zurueckgehaltener Aenderung (Karte), lastschrift bei SEPA (F37) - await sendSwitchConfirmation(contact, consent, facts, { zahlung_offen: pending, lastschrift, zusatzspeicher: ctx.storageUnits > 0 }) + // K12 sofort; zahlung_offen nur bei zurueckgehaltener Aenderung (Karte), lastschrift bei SEPA (F37); sofortverlangen nur mit Zeitstempel (kein Betrieb in Fassung 6) + await sendSwitchConfirmation(contact, consent, facts, { zahlung_offen: pending, lastschrift, zusatzspeicher: ctx.storageUnits > 0, sofortverlangen: Boolean(consent.earlyStartConsentAt) }) // T12 erst, wenn der Wechsel wirksam ist - ohne Nachweis sofort, damit das Team ihn von Hand verfolgt if (effective || !event) { await sendSwitchNotice(contact, doc.$id, facts, { wirksam: effective, lastschrift, nachweis_fehlt: !event }) diff --git a/test/knBestaetigungsmails.test.js b/test/knBestaetigungsmails.test.js new file mode 100644 index 0000000..49212e3 --- /dev/null +++ b/test/knBestaetigungsmails.test.js @@ -0,0 +1,120 @@ +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' +import { mailSink } from './support/mailSink.js' + +// K01 und K12 behaupten für Betriebe (Fassung 6 ohne Sofortbeginn-Zeitstempel) kein „ausdrücklich verlangt“ +const aw = await startAppwriteMock({ + schema: { + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + hourCredits: schemaKeys('hourCredits'), + featureConsents: schemaKeys('featureConsents'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'K@example.invalid, k3@example.invalid' }) +const { config } = await import('../server/config.js') +const { startTestApp, postWebhook } = await import('./support/testApp.js') +const { hostingDoc, consent, seedFixtures, stripeSubscription } = await import('./support/fixtures.js') +const { default: aboRoutes } = await import('../server/routes/abo.js') +const { switchToWartung } = await import('../server/services/planSwitch.js') +const { listContractEvents } = await import('../server/services/contractEvents.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js') + +const app = await startTestApp({ '/api/abo': aboRoutes }) +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) +seedFixtures(aw) + +const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } }) +st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents)) +st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents)) +st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents)) + +const nowUnix = () => Math.floor(Date.now() / 1000) +const K12_SUBJECT = 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung' +const CARD = { id: 'pm_card_visa', object: 'payment_method', type: 'card' } +const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) +const switchInvoice = (overrides = {}) => ({ id: 'in_wechsel', object: 'invoice', status: 'paid', amount_due: 4100, billing_reason: 'subscription_update', hosted_invoice_url: 'https://invoice.stripe.test/i/in_wechsel', ...overrides }) + +async function clearEvents(customerId) { + for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) +} +async function resetHosting() { + await clearEvents('c1') + aw.seed('subscriptions', { ...hostingDoc }) + st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', default_payment_method: CARD })) + st.nextSubscriptionUpdate = null + mails.sent.length = 0 +} + +test('Vorlage K12: der Absatz „ausdrücklich verlangt“ hängt am Block sofortverlangen', () => { + const tpl = loadTemplate('K12') + const werte = sampleValues(tpl) + assert.ok(tpl.blocks.includes('sofortverlangen')) + assert.match(renderMail('K12', werte, { sofortverlangen: true }).text, /Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen/) + assert.doesNotMatch(renderMail('K12', werte, {}).text, /ausdrücklich verlangt|Wertersatz/) + assert.match(renderMail('K12', werte, {}).text, /haben wir am Wert-zustimmung_zeitpunkt Uhr erhalten\./, 'die Zustimmungszeile bleibt') + assert.deepEqual(renderMail('K12', werte, { sofortverlangen: true }).problems, []) + assert.deepEqual(renderMail('K12', werte, {}).problems, []) +}) + +test('K12 im Betrieb-Wechsel ohne den Satz, im Privat-Wechsel und in der alten Ansicht mit dem Satz', async () => { + const text = async (consentExtra) => { + await resetHosting() + st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } + const { earlyStartConsentAt: _x, ...ohne } = consent + await switchToWartung('c1', { ...ohne, ...consentExtra }, nowUnix()) + return mails.sent.find((m) => m.subject === K12_SUBJECT).text + } + assert.doesNotMatch(await text({ kundenart: 'unternehmen' }), /ausdrücklich verlangt|Wertersatz/) + assert.match(await text({ kundenart: 'privat', earlyStartConsentAt: consent.consentAt }), /Sie haben ausdrücklich verlangt/) + assert.match(await text({ earlyStartConsentAt: consent.consentAt }), /Sie haben ausdrücklich verlangt/) +}) + +// Abo-IDs sind 28 Zeichen wie bei Stripe (sub_ + 24) +const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp' +const checkoutCompleted = (subscriptionId, metadata = {}) => ({ + id: `evt_${subscriptionId}`, + object: 'event', + type: 'checkout.session.completed', + created: Math.floor(Date.now() / 1000), + data: { + object: { + id: `cs_${subscriptionId}`, object: 'checkout.session', mode: 'subscription', subscription: subscriptionId, customer: 'cus_c3', + customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' }, + metadata: { webklarCustomerId: 'c3', productKey: 'wartung', ...metadata }, + }, + }, +}) +async function cleanupWebhook(subscriptionId) { + await clearEvents('c3') + await deleteDocument(config.collections.subscriptions, subscriptionId).catch(() => {}) +} + +test('K01: Fassung 6 ohne Zeitstempel (Betrieb) ohne „ausdrücklich verlangt“, mit Zeitstempel und in Fassung 5 mit dem Satz', async () => { + const k01 = async (metadata) => { + st.subscriptions.set(SUB_NEW, stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'wartung', metadata })) + mails.sent.length = 0 + try { + assert.equal((await postWebhook(checkoutCompleted(SUB_NEW))).status, 200) + return mails.sent.find((m) => m.subject.includes('Vertragsbestätigung')).text + } finally { + await cleanupWebhook(SUB_NEW) + } + } + assert.doesNotMatch(await k01({ termsVersion: config.abo.termsVersionNeu, startChoice: 'sofort', earlyStartConsentAt: '' }), /ausdrücklich verlangt/) + assert.match(await k01({ termsVersion: config.abo.termsVersionNeu, startChoice: 'sofort', earlyStartConsentAt: new Date().toISOString() }), /ausdrücklich verlangt/) + assert.match(await k01({ termsVersion: config.abo.termsVersion, startChoice: 'sofort', earlyStartConsentAt: '' }), /ausdrücklich verlangt/) +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 2948f56..0433ffe 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -376,9 +376,10 @@ test('K12: Block zahlung_offen wörtlich, Block lastschrift mit dem Satz aus § const plain = renderMail('K12', values, {}) assert.doesNotMatch(plain.text, /sobald die Zahlung bestätigt ist|Wird die Lastschrift nicht eingelöst/) // Vertragsbestaetigung: Preis mit § 19 UStG, Mindestlaufzeit, Unterlagen, Zustimmung zum Sofortbeginn - for (const text of ['gemäß § 19 UStG ohne Umsatzsteuer', 'Mindestlaufzeit 12 Monate bis Wert-mindestlaufzeit_bis.', 'Widerrufsbelehrung mit Muster-Widerrufsformular', 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen']) { + for (const text of ['gemäß § 19 UStG ohne Umsatzsteuer', 'Mindestlaufzeit 12 Monate bis Wert-mindestlaufzeit_bis.', 'Widerrufsbelehrung mit Muster-Widerrufsformular']) { assert.ok(plain.text.includes(text), text) } + // der Absatz „ausdrücklich verlangt“ hängt am Block sofortverlangen (knBestaetigungsmails.test.js prüft ihn mit und ohne) }) test('K14: Hosting-Abo läuft wie vor dem Wechsel weiter; SEPA: Abbuchungstag bleibt', () => { @@ -653,8 +654,11 @@ test('K12: Monatspreis mit gebuchtem Zusatzspeicher (Block), Widerruf des Wechse assert.match(mit.text, /einschließlich Ihres gebuchten E-Mail-Zusatzspeichers/) const ohne = renderMail('K12', values, {}) assert.doesNotMatch(ohne.text, /einschließlich Ihres gebuchten/) - assert.match(ohne.text, /Widerrufen Sie den Wechsel, läuft Ihr Hosting-Abo zu den bisherigen Bedingungen weiter/) - assert.match(ohne.text, /Mehrbetrag/) + // der Absatz zum Widerruf des Wechsels samt Wertersatz hängt am Block sofortverlangen (Betrieb in Fassung 6: ohne) + const verlangt = renderMail('K12', values, { sofortverlangen: true }) + assert.match(verlangt.text, /Widerrufen Sie den Wechsel, läuft Ihr Hosting-Abo zu den bisherigen Bedingungen weiter/) + assert.match(verlangt.text, /Mehrbetrag/) + assert.doesNotMatch(ohne.text, /Widerrufen Sie den Wechsel|Mehrbetrag/) // K13: Widerruf des Zusatzspeichers ohne Wertersatz const k13 = renderMail('K13', sampleValues(loadTemplate('K13')), { plus: true, bestellung: true }) assert.match(k13.text, /erstatten alles, was Sie dafür gezahlt haben\. Wertersatz verlangen wir nicht\./)