diff --git a/server/mail-templates/K01 Vertragsbestaetigung.html b/server/mail-templates/K01 Vertragsbestaetigung.html index 9d3db65..ad4c79e 100644 --- a/server/mail-templates/K01 Vertragsbestaetigung.html +++ b/server/mail-templates/K01 Vertragsbestaetigung.html @@ -8,7 +8,8 @@ Absender: „WEBklar“ 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}} - 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, anhang, ohne_anhang + anhang = die Vertragsunterlagen hängen als PDF an (immer, außer bei Fassung 6 ohne PDF); ohne_anhang = Fassung 6 ohne PDF: statt der Anhangsaussage der Satz, dass die Unterlagen gesondert kommen (T02 meldet es dem Team, Block anhang_fehlt). 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 oder 5 (Spec 3.1), wartung_v3 für ein Wartungs-Abo nach Fassung 3 (termsDocFor nicht v4/v5: „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. @@ -25,7 +26,7 @@ a{color:#111111;} -Ihr {{produkt}} ist abgeschlossen. Alle Vertragsunterlagen finden Sie im Anhang. ͏ ͏ ͏ +Ihr {{produkt}} ist abgeschlossen. Alle Vertragsunterlagen finden Sie im Anhang. ͏ ͏ ͏ + + + diff --git a/server/mail-templates/T13 Speicher-Aufgabe.html b/server/mail-templates/T13 Speicher-Aufgabe.html index 7d56a2c..ef4d2c6 100644 --- a/server/mail-templates/T13 Speicher-Aufgabe.html +++ b/server/mail-templates/T13 Speicher-Aufgabe.html @@ -8,7 +8,8 @@ Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com Platzhalter: {{aktion}}, {{email}}, {{name}}, {{stripe_abo_id}}, {{postfaecher_hinweis}}, {{neue_groesse_gb}}, {{pakete}}, {{faellig}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): einrichten, periodenende, teilweise, nachweis_fehlt + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): einrichten, periodenende, teilweise, nachweis_fehlt, anhang_fehlt + anhang_fehlt = Fassung 6, die PDFs liegen nicht vor: K13 ging ohne Anhang raus, das Team sendet die Unterlagen von Hand nach (auch wenn es sonst keine Aufgabe gibt: dann ist die Aktion „Unterlagen nachsenden“). aktion ist „einrichten“ oder „reduzieren zum TT.MM.JJJJ“; einrichten (Plus über die bezahlte Menge, sofort), periodenende (Minus oder Plus bis zur bezahlten Menge, zum Periodenende), teilweise (Schritt 2 bei Stripe fehlgeschlagen), nachweis_fehlt. --> @@ -53,6 +54,9 @@ a{color:#111111;} + + +   diff --git a/server/routes/abo.js b/server/routes/abo.js index 58f61f7..2ce56d4 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -1,4 +1,3 @@ -import { readFile } from 'node:fs/promises' import { Router } from 'express' import { config } from '../config.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' @@ -29,8 +28,9 @@ import { neueAnsicht } from '../services/kundenansicht/schalter.js' import { FEHLER_BEDINGUNGEN, FEHLER_NACHWEIS, FEHLER_UNVOLLSTAENDIG, WECHSEL_GESPERRT, bestelltexteFuerClient, bestellZeile, nachweisTexte } from '../services/bestelltexte.js' import { kundenartWirksam } from '../services/kundenart.js' import { fassungDerAnfrage, istNeueFassung, speichereNachweisFuerRoute } from '../services/vertragsNachweis.js' +import { ladeVertragsAnhang } from '../services/vertragsAnhang.js' import { duFehlerAntworten } from '../services/duFehler.js' -import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' +import { PRODUCT_KEYS, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, cancelAtPeriodEnd, @@ -636,14 +636,12 @@ async function customerNameOf(sub, fallback = '') { * v4 Archiv-PDF, v5 aktuelles PDF). Versionsangabe und Anhang kommen aus derselben Quelle (termsDocFor.version): ohne gespeicherte * Fassung beides Fassung 3. Ohne lesbare Datei geht die Mail ohne Anhang raus. */ -async function sendContractConfirmation(subDoc, fallbackName = '') { +async function sendContractConfirmation(subDoc, fallbackName = '', unterlagen = null) { if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return const terms = termsDocFor(subDoc.termsVersion) - const attachment = contractAttachmentFor(terms.version) - const pdf = await readFile(attachment.path).catch((err) => { - console.error(`[abo] Vertragsunterlagen ${attachment.filename} nicht lesbar:`, err.message) - return null - }) + // Fassung 6 ohne PDF: keine Behauptung "im Anhang" (Block ohne_anhang), T02 sagt es dem Team (F5); ältere Fassungen wie bisher. + // Der Aufrufer (Webhook) hat die Unterlagen für T02 schon geladen und reicht sie durch. + const { attachments, fehlt: anhangFehlt } = unterlagen ?? (await ladeVertragsAnhang(terms.version, 'abo')) const lateStart = subDoc.startChoice === 'nach_frist' const productKey = productKeyOf(subDoc) await sendCustomerMail(subDoc.email, { @@ -668,8 +666,10 @@ async function sendContractConfirmation(subDoc, fallbackName = '') { // 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, + anhang: !anhangFehlt, + ohne_anhang: anhangFehlt, }, - attachments: pdf ? [{ filename: attachment.filename, content: pdf, contentType: 'application/pdf' }] : [], + attachments, }) await updateDocument(config.collections.subscriptions, subDoc.$id, { confirmationSentAt: new Date().toISOString(), @@ -768,6 +768,8 @@ async function handleStripeEvent(event) { return true } ) + // Vertragsunterlagen der zugestimmten Fassung einmal laden: T02 meldet fehlende PDFs der Fassung 6 (anhang_fehlt), K01 hängt sie an + const unterlagen = sub ? await ladeVertragsAnhang(termsDocFor(sub.termsVersion).version, 'abo') : null await sendAdminNotice({ template: 'T02', values: { @@ -778,9 +780,9 @@ async function handleStripeEvent(event) { stripe_abo_id: obj.subscription, angebot_text: obj.metadata?.offerText || '', }, - blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem, nachweis_fehlt: proofProblem }, + blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem, nachweis_fehlt: proofProblem, anhang_fehlt: unterlagen?.fehlt === true }, }) - await sendContractConfirmation(sub, obj.customer_details?.name).catch((e) => + await sendContractConfirmation(sub, obj.customer_details?.name, unterlagen).catch((e) => console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message) ) return diff --git a/server/services/mailStorage.js b/server/services/mailStorage.js index 14c09a8..6657cbf 100644 --- a/server/services/mailStorage.js +++ b/server/services/mailStorage.js @@ -1,9 +1,9 @@ -import { readFile } from 'node:fs/promises' import { config } from '../config.js' import { getDocument, updateDocument } from './appwriteAdmin.js' import { listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' -import { contractAttachmentFor, describeItems, priceIdsOfInvoice, regularStorageLine } from './products.js' +import { describeItems, priceIdsOfInvoice, regularStorageLine } from './products.js' +import { ladeVertragsAnhang } from './vertragsAnhang.js' import { ACTIVE_STATUSES, ensurePrice, @@ -315,16 +315,6 @@ async function supersedeStorageTasks(events, change) { for (const e of superseded) await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' }) } -/** Vertragsunterlagen (PDF) in der zugestimmten Fassung als Mail-Anhang; ohne Datei geht die Mail ohne Anhang raus. */ -async function contractPdfAttachment(termsVersion) { - const { filename, path } = contractAttachmentFor(termsVersion) - const content = await readFile(path).catch((err) => { - console.error(`[speicher] Vertragsunterlagen ${filename} nicht lesbar:`, err.message) - return null - }) - return content ? [{ filename, content, contentType: 'application/pdf' }] : [] -} - /** * K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team (Aufgabe nach Art, dazu * die Bloecke teilweise und nachweis_fehlt). Ueber das Kuendigungsformular bestaetigt K03, K13 entfaellt dort @@ -335,8 +325,13 @@ async function sendStorageMails({ doc, change, consent, viaCancellation, proofMi const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null) const email = customer?.email || doc.email || '' const dueLabel = fmtDate(change.dueAt) + let anhangFehlt = false if (!viaCancellation && email) { - const attachments = plus ? await contractPdfAttachment(consent.termsVersion) : [] + // Plus: Vertragsunterlagen der zugestimmten Fassung. Fehlt das PDF der Fassung 6, geht K13 ohne Anhang und ohne die Behauptung "beigefügt" + // raus (Block ohne_anhang), T13 sagt es dem Team (anhang_fehlt, F5); ältere Fassungen wie bisher + const unterlagen = plus ? await ladeVertragsAnhang(consent.termsVersion, 'speicher') : { attachments: [], fehlt: false } + anhangFehlt = unterlagen.fehlt + const attachments = unterlagen.attachments await sendCustomerMail(email, { template: 'K13', values: { @@ -348,16 +343,17 @@ async function sendStorageMails({ doc, change, consent, viaCancellation, proofMi }, // plus: Einrichtung mit anteiliger Berechnung; plus_bezahlt: bis zur schon bezahlten Menge (keine Kosten); // bestellung: Unterlagen und Widerruf bei beiden; minus: Verringerung zum Periodenende - blocks: { plus: plus && change.kind === 'einrichten', plus_bezahlt: plus && change.kind !== 'einrichten', bestellung: plus, minus: !plus }, + blocks: { plus: plus && change.kind === 'einrichten', plus_bezahlt: plus && change.kind !== 'einrichten', bestellung: plus, minus: !plus, anhang: !anhangFehlt, ohne_anhang: anhangFehlt }, attachments, }).catch((e) => console.error(`[speicher] K13 an ${email} fehlgeschlagen:`, e.message)) } - // Ohne Auftrag fuers Team (Plus genau zurueck auf die bezahlte Menge) kein T13 - es gibt nichts zu tun - if (!change.task && !proofMissing) return + // Ohne Auftrag fuers Team (Plus genau zurueck auf die bezahlte Menge) kein T13 - es gibt nichts zu tun; ausser die Unterlagen muessen nachgesendet werden + if (!change.task && !proofMissing && !anhangFehlt) return + const nurUnterlagen = !change.task && !proofMissing await sendAdminNotice({ template: 'T13', values: { - aktion: change.kind === 'einrichten' ? 'einrichten' : `reduzieren zum ${dueLabel}`, + aktion: nurUnterlagen ? 'Unterlagen nachsenden' : change.kind === 'einrichten' ? 'einrichten' : `reduzieren zum ${dueLabel}`, email, name: customer?.name || '', stripe_abo_id: doc.$id, @@ -368,10 +364,11 @@ async function sendStorageMails({ doc, change, consent, viaCancellation, proofMi faellig: dueLabel, }, blocks: { - einrichten: change.kind === 'einrichten', - periodenende: change.kind === 'periodenende', + einrichten: !nurUnterlagen && change.kind === 'einrichten', + periodenende: !nurUnterlagen && change.kind === 'periodenende', teilweise: change.partial, nachweis_fehlt: proofMissing, + anhang_fehlt: anhangFehlt, }, }) } diff --git a/server/services/planSwitch.js b/server/services/planSwitch.js index 9b0ca4b..b48d05b 100644 --- a/server/services/planSwitch.js +++ b/server/services/planSwitch.js @@ -1,9 +1,9 @@ -import { readFile } from 'node:fs/promises' import { config } from '../config.js' import { getDocument } from './appwriteAdmin.js' import { eventIdFor, getContractEvent, listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' -import { contractAttachmentFor, describeItems } from './products.js' +import { describeItems } from './products.js' +import { anhangFehlt, ladeVertragsAnhang } from './vertragsAnhang.js' import { getFeatureDoc } from './siteFeatures.js' import { addMonths, @@ -276,23 +276,17 @@ async function previewFor(ctx, prorationDate) { } } -/** Vertragsunterlagen (PDF) in der zugestimmten Fassung; ohne Datei geht K12 ohne Anhang raus (Fehler im Log). */ -async function contractPdfAttachment(termsVersion) { - const { filename, path } = contractAttachmentFor(termsVersion) - const content = await readFile(path).catch((err) => { - console.error(`[wechsel] Vertragsunterlagen ${filename} nicht lesbar:`, err.message) - return null - }) - return content ? [{ filename, content, contentType: 'application/pdf' }] : [] -} - -/** K12 an den Kunden: Vertragsbestaetigung mit PDF. Ein Mailfehler bricht den Wechsel nicht ab. */ +/** + * K12 an den Kunden: Vertragsbestaetigung mit PDF der zugestimmten Fassung. Ein Mailfehler bricht den Wechsel nicht ab. + * Fehlt das PDF der Fassung 6, geht K12 ohne Anhang und ohne die Behauptung "im Anhang" raus (Block ohne_anhang, F5); die Rueckgabe + * { anhangFehlt } sagt es dem Aufrufer fuer T12 (Block anhang_fehlt). Ohne Datei der alten Fassungen bleibt es beim bisherigen Verhalten. + */ async function sendSwitchConfirmation(contact, consent, facts, blocks) { if (!contact.email) { console.error('[wechsel] K12 nicht verschickt: keine E-Mail-Adresse zum Kunden') - return + return { anhangFehlt: false } } - const attachments = await contractPdfAttachment(consent.termsVersion) + const { attachments, fehlt } = await ladeVertragsAnhang(consent.termsVersion, 'wechsel') await sendCustomerMail(contact.email, { template: 'K12', values: { @@ -304,9 +298,10 @@ async function sendSwitchConfirmation(contact, consent, facts, blocks) { bedingungen_version: consent.termsVersion, zustimmung_zeitpunkt: fmtDateTime(consent.consentAt), }, - blocks, + blocks: { ...blocks, anhang: !fehlt, ohne_anhang: fehlt }, attachments, }).catch((err) => console.error(`[wechsel] K12 an ${contact.email} fehlgeschlagen:`, err.message)) + return { anhangFehlt: fehlt } } /** T12 ans Team (sendAdminNotice protokolliert Fehler selbst und wirft nie). */ @@ -468,10 +463,10 @@ async function performSwitch(customerId, current, consent, prorationDate, expect const facts = { switchedAt, amountDueCents, monthlyCents } const lastschrift = sepa && effective && !paid // 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) }) + const { anhangFehlt: ohneAnhang } = 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 }) + await sendSwitchNotice(contact, doc.$id, facts, { wirksam: effective, lastschrift, nachweis_fehlt: !event, anhang_fehlt: ohneAnhang }) } return { subscription, paymentUrl, event } } @@ -544,7 +539,8 @@ async function completeSwitch(eventId, contact, doneBy = 'stripe') { doneBy, details: { ...(wasEffective ? {} : { effectiveAt: now }), paidAt: now }, }) - if (!wasEffective) await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { wirksam: true }) + // Das T12 "wirksam" kommt hier erst nach K12: fehlen die PDFs der Fassung 6 noch, sagt es auch dieses T12 dem Team (F5) + if (!wasEffective) await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { wirksam: true, anhang_fehlt: await anhangFehlt(event.termsVersion) }) return { completed: true, eventId } }) } diff --git a/server/services/vertragsAnhang.js b/server/services/vertragsAnhang.js new file mode 100644 index 0000000..e47656f --- /dev/null +++ b/server/services/vertragsAnhang.js @@ -0,0 +1,34 @@ +/** + * Vertragsunterlagen (PDF) als Mail-Anhang der Bestaetigungen K01 (Abo), K12 (Wechsel) und K13 (Zusatzspeicher-Plus), mit der Auskunft, ob sie fehlen. + * + * Fassung 6 (config.abo.termsVersionNeu) liefert die PDFs erst nach der Freigabe (build-legal-pdfs.sh abo6); bis dahin fehlt die Datei. Dann darf die + * Kundenmail nicht behaupten, die Unterlagen haengten an (Block ohne_anhang statt anhang), und das Team bekommt in T02, T12 bzw. T13 den Block + * anhang_fehlt und sendet sie von Hand nach (Muster K38/T24). Alle aelteren Fassungen bleiben, wie sie waren: ohne Datei geht die Mail ohne Anhang + * raus, Vorlage und Text unveraendert (fehlt bleibt false). + */ + +import { access, readFile } from 'node:fs/promises' +import { contractAttachmentFor } from './products.js' +import { istNeueFassung } from './vertragsNachweis.js' + +/** + * @param {string} termsVersion zugestimmte Fassung (die Fassung der Zustimmung, nicht die des Abos) + * @param {string} tag Kennung fuer das Log, z. B. 'abo' + * @returns {Promise<{ attachments: Array<{ filename: string, content: Buffer, contentType: string }>, fehlt: boolean }>} + * fehlt: nur bei Fassung 6 und nicht lesbarer Datei + */ +export async function ladeVertragsAnhang(termsVersion, tag) { + const { filename, path } = contractAttachmentFor(termsVersion) + const content = await readFile(path).catch((err) => { + console.error(`[${tag}] Vertragsunterlagen ${filename} nicht lesbar:`, err.message) + return null + }) + return { attachments: content ? [{ filename, content, contentType: 'application/pdf' }] : [], fehlt: !content && istNeueFassung(termsVersion) } +} + +/** Fehlen die PDFs der Fassung 6 (ohne Log, ohne Lesen der Datei)? Für ein später gesendetes T12; ältere Fassungen: nie. */ +export async function anhangFehlt(termsVersion) { + if (!istNeueFassung(termsVersion)) return false + const { path } = contractAttachmentFor(termsVersion) + return access(path).then(() => false, () => true) +} diff --git a/test/knUnterlagenFehlen.test.js b/test/knUnterlagenFehlen.test.js new file mode 100644 index 0000000..fbc217d --- /dev/null +++ b/test/knUnterlagenFehlen.test.js @@ -0,0 +1,237 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +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' + +// Abschlussprüfung F5: Fassung 6 ohne PDF. Fehlt der Anhang, behaupten K01, K12 und K13 kein „im Anhang“, und das Team bekommt in T02, T12 bzw. T13 +// den Block anhang_fehlt (Muster K38/T24). Alte Fassungen bleiben unverändert (Hash-Pins auf die Wiedergabe der alten Vorlagen). +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 { changeStorage } = await import('../server/services/mailStorage.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 NEU = config.abo.termsVersionNeu +const MIT_PDF = '2099-01-01-v4' // eine Fassung, deren PDF im Repo liegt (Archiv v4), um „Fassung 6 mit PDF“ nachzustellen +const nowUnix = () => Math.floor(Date.now() / 1000) +const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) +const sha = (html) => createHash('sha256').update(html).digest('hex') +const HINWEIS = 'Unterlagen von Hand nachsenden' + +/** Fassung 6 ist die neue Fassung dieses Laufs: mit termsVersionNeu auf eine Fassung mit PDF zeigt die Neu-Fassung eine vorhandene Datei. */ +async function mitNeuFassung(fassung, fn) { + const vorher = config.abo.termsVersionNeu + config.abo.termsVersionNeu = fassung + try { + return await fn() + } finally { + config.abo.termsVersionNeu = vorher + } +} + +// ---------------------------------------------------------------- Vorlagen +test('Vorlagen: K01, K12 und K13 kennen anhang und ohne_anhang, T02, T12 und T13 den Block anhang_fehlt', () => { + for (const id of ['K01', 'K12', 'K13']) assert.deepEqual(['anhang', 'ohne_anhang'].filter((b) => !loadTemplate(id).blocks.includes(b)), [], id) + for (const id of ['T02', 'T12', 'T13']) assert.ok(loadTemplate(id).blocks.includes('anhang_fehlt'), id) + // ohne Anhang keine Behauptung „im Anhang“ / „beigefügt“, dafür der Satz, dass die Unterlagen gesondert kommen + const K13_BESTELLUNG = { plus: true, bestellung: true } + for (const [id, blocks] of [['K01', { wartung: true, sofortbeginn: true }], ['K12', {}], ['K13', K13_BESTELLUNG]]) { + const ohne = renderMail(id, sampleValues(loadTemplate(id)), { ...blocks, ohne_anhang: true }) + assert.doesNotMatch(ohne.text, /Anhang|beigefügt/i, `${id} ohne Anhang`) + assert.match(ohne.text, /senden wir Ihnen gesondert zu/, id) + assert.deepEqual(ohne.problems, [], id) + const mit = renderMail(id, sampleValues(loadTemplate(id)), { ...blocks, anhang: true }) + assert.match(mit.text, /im Anhang|beigefügten/, `${id} mit Anhang`) + assert.doesNotMatch(mit.text, /gesondert zu/, id) + } + for (const id of ['T02', 'T12', 'T13']) { + const mit = renderMail(id, sampleValues(loadTemplate(id)), { anhang_fehlt: true }) + assert.match(mit.text, new RegExp(HINWEIS), id) + assert.match(mit.text, /nicht mit der Bestätigung beim Kunden angekommen/, id) + assert.doesNotMatch(renderMail(id, sampleValues(loadTemplate(id)), {}).text, new RegExp(HINWEIS), id) + } +}) + +// Hash der Wiedergabe der Vorlagen vor der Änderung (Stand kn-p7 plus P8-Nachträge, ohne anhang-Blöcke): die alten Fassungen bleiben bytegleich +const ALT = [ + ['K01', {"hosting": true, "sofortbeginn": true}, 'ea5fed3076d4bf6c4f5aa7042d20d61c1d90d11716f99811e439df09c95abd56'], + ['K01', {"wartung": true, "beginn_nach_widerrufsfrist": true, "angebot": true}, '434d4945815af9e8df98039184e09058b992c32f1f7a21844b25923be2130aca'], + ['K01', {"wartung_v3": true, "sofortbeginn": true}, '4a5a3252ad7ea5c5eda35b42480f95611a4cb5648e9a847afc6720692240d729'], + ['K12', {"zahlung_offen": true, "zusatzspeicher": true, "sofortverlangen": true}, '8c18e9a2455e28ec5467fb68dcdfe9b66afe9cc676ef16cc84281a4b2d97caf2'], + ['K12', {"lastschrift": true}, '9731b26f4043f29537e289edf7b00d4d024522c6bafb9166d3a0b6289e592963'], + ['K12', {}, '76a6f5f1f350f80a938828ce2cd1d84305aca2251427e2ad0df7bb550dc18ff7'], + ['K13', {"plus": true, "bestellung": true}, 'b01d4f6a30f562ccee3739f8f5b77bb9a660a61165366cd3d294a627ccfdc8c8'], + ['K13', {"plus_bezahlt": true, "bestellung": true}, '7a41e74fe6bf45a33bb6e9dd35d29f6995b8995961e3b6905ed5ba45e19b0e51'], + ['K13', {"minus": true}, 'a82497427b0d7af96aadd380974ce5e73930ec2c9a7f5b6357f5f13f8190d109'], + ['T02', {"angebot": true, "angebot_fehler": true, "nachweis_fehlt": true}, '438a623fb53046b34930a30110fa35d3c08c6f27ff84af21466426496202a441'], + ['T02', {}, '386199538e7a59eb09b41511f4cab3924c36cacdbbdba281af45b2fa86158755'], + ['T12', {"wirksam": true, "nachweis_fehlt": true}, '3777c2edc646e53b5b63280132e0958e31dba5d084848244525882a9b2fb1d07'], + ['T12', {"lastschrift": true, "gescheitert": true}, '56e1c70e82d260b6c62788fbed5503d2e4e6c6fff1046281b7e76261ced5f1c9'], + ['T12', {}, '928f6ee5118da82fce612d77f6dab22be4c3b0aefc736e64668a9d24b69da98a'], + ['T13', {"einrichten": true, "nachweis_fehlt": true}, '2f5543fec1ecdc7726931967b164d42386a9d32f0f062e8d5770002958c33a72'], + ['T13', {"periodenende": true, "teilweise": true}, '44fa59476f1edb46f977500b1d8ebed7f07205ca2f6087609c2602e15f10eaeb'], + ['T13', {}, '793af7c1cb98df24d914d1a5bf3330ace1751a2794713045b78a9ac2a93f1a9f'], +] +test('Alte Fassungen: die Wiedergabe der Vorlagen bleibt bytegleich (K mit anhang, T ohne anhang_fehlt)', () => { + for (const [id, blocks, soll] of ALT) { + const mitAnhang = id.startsWith('K') ? { ...blocks, anhang: true } : blocks + assert.equal(sha(renderMail(id, sampleValues(loadTemplate(id)), mitAnhang).html), soll, `${id} ${JSON.stringify(blocks)}`) + } +}) + +// ---------------------------------------------------------------- K01 und T02 +const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp' +const checkoutCompleted = (subscriptionId, metadata = {}) => ({ + id: `evt_${subscriptionId}`, object: 'event', type: 'checkout.session.completed', created: nowUnix(), + 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 clearEvents(customerId) { + for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) +} +async function k01(termsVersion) { + st.subscriptions.set(SUB_NEW, stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'wartung', metadata: { termsVersion, startChoice: 'sofort', earlyStartConsentAt: new Date().toISOString() } })) + mails.sent.length = 0 + try { + assert.equal((await postWebhook(checkoutCompleted(SUB_NEW))).status, 200) + return { kunde: mails.sent.find((m) => m.subject.includes('Vertragsbestätigung')), team: mails.sent.find((m) => m.subject.startsWith('[Abo] Neues')) } + } finally { + await clearEvents('c3') + await deleteDocument(config.collections.subscriptions, SUB_NEW).catch(() => {}) + } +} + +test('K01: Fassung 6 ohne PDF behauptet keinen Anhang, T02 sagt es dem Team', async () => { + const { kunde, team } = await k01(NEU) + assert.equal(kunde.attachments.length, 0) + assert.doesNotMatch(kunde.text, /Anhang|beigefügt/i) + assert.match(kunde.text, /senden wir Ihnen gesondert zu/) + assert.match(team.text, new RegExp(HINWEIS)) +}) +test('K01: Fassung 6 mit PDF und alte Fassung wie bisher (Anhang, kein Hinweis)', async () => { + for (const [name, fassung, neu] of [['Fassung 6 mit PDF', MIT_PDF, MIT_PDF], ['Fassung 5', config.abo.termsVersion, NEU]]) { + const { kunde, team } = await mitNeuFassung(neu, () => k01(fassung)) + assert.equal(kunde.attachments.length, 1, name) + assert.match(kunde.text, /im Anhang/, name) + assert.doesNotMatch(kunde.text, /gesondert zu/, name) + assert.doesNotMatch(team.text, new RegExp(HINWEIS), name) + } +}) + +// ---------------------------------------------------------------- K12 und T12 +const CARD = { id: 'pm_card_visa', object: 'payment_method', type: 'card' } +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 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 +} +async function k12(termsVersion) { + await resetHosting() + st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } + await switchToWartung('c1', { ...consent, termsVersion }, nowUnix()) + return { kunde: mails.sent.find((m) => m.subject === 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung'), team: mails.sent.find((m) => m.subject.startsWith('[Abo] Wechsel ins Wartungs-Abo')) } +} +test('K12: Fassung 6 ohne PDF behauptet keinen Anhang, T12 sagt es dem Team; mit PDF und alte Fassung wie bisher', async () => { + const ohne = await k12(NEU) + assert.equal(ohne.kunde.attachments.length, 0) + assert.doesNotMatch(ohne.kunde.text, /Anhang|beigefügt/i) + assert.match(ohne.kunde.text, /senden wir Ihnen gesondert zu/) + assert.match(ohne.team.text, new RegExp(HINWEIS)) + for (const [name, fassung, neu] of [['Fassung 6 mit PDF', MIT_PDF, MIT_PDF], ['Fassung 5', config.abo.termsVersion, NEU]]) { + const r = await mitNeuFassung(neu, () => k12(fassung)) + assert.equal(r.kunde.attachments.length, 1, name) + assert.match(r.kunde.text, /im Anhang/, name) + assert.doesNotMatch(r.team.text, new RegExp(HINWEIS), name) + } +}) + +// ---------------------------------------------------------------- K13 und T13 +async function resetSubH({ units, paidUnits }) { + await clearEvents('c1') + mails.sent.length = 0 + aw.seed('subscriptions', { ...hostingDoc, storageUnits: units, storagePaidUnits: paidUnits, storagePaidPeriodEnd: hostingDoc.currentPeriodEnd }) + st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: units })) +} +async function k13({ units, paidUnits, ziel, termsVersion }) { + await resetSubH({ units, paidUnits }) + await changeStorage('c1', ziel, { ...consent, termsVersion }) + return { kunde: mails.sent.find((m) => m.subject === 'Ihr E-Mail-Zusatzspeicher – Bestätigung'), team: mails.sent.find((m) => m.subject.startsWith('[Speicher]')) } +} +test('K13: Fassung 6 ohne PDF behauptet keinen Anhang, T13 sagt es dem Team (auch ohne Aufgabe); mit PDF und alte Fassung wie bisher', async () => { + const ohne = await k13({ units: 0, paidUnits: 0, ziel: 2, termsVersion: NEU }) + assert.equal(ohne.kunde.attachments.length, 0) + assert.doesNotMatch(ohne.kunde.text, /Anhang|beigefügt/i) + assert.match(ohne.kunde.text, /senden wir Ihnen gesondert zu/) + assert.match(ohne.team.text, new RegExp(HINWEIS)) + // Plus nur bis zur schon bezahlten Menge: keine Aufgabe, aber die Unterlagen müssen nachgesendet werden + const bezahlt = await k13({ units: 1, paidUnits: 3, ziel: 3, termsVersion: NEU }) + assert.equal(bezahlt.kunde.attachments.length, 0) + assert.ok(bezahlt.team, 'T13 geht auch ohne Aufgabe raus, wenn der Anhang fehlt') + assert.match(bezahlt.team.text, new RegExp(HINWEIS)) + for (const [name, fassung, neu] of [['Fassung 6 mit PDF', MIT_PDF, MIT_PDF], ['Fassung 5', config.abo.termsVersion, NEU]]) { + const r = await mitNeuFassung(neu, () => k13({ units: 0, paidUnits: 0, ziel: 2, termsVersion: fassung })) + assert.equal(r.kunde.attachments.length, 1, name) + assert.match(r.kunde.text, /beigefügten Vertragsunterlagen/, name) + assert.doesNotMatch(r.team.text, new RegExp(HINWEIS), name) + } + // Verringerung: nie ein Anhang, nie ein Hinweis + const minus = await k13({ units: 3, paidUnits: 3, ziel: 1, termsVersion: NEU }) + assert.equal(minus.kunde.attachments.length, 0) + assert.doesNotMatch(minus.team.text, new RegExp(HINWEIS)) +}) + +// Karte mit Bestätigung bei der Bank: K12 geht beim Klick, T12 "wirksam" erst mit pending_update_applied; auch dieses T12 sagt, dass die PDFs fehlen +test('T12 beim späteren Wirksamwerden (Karte, pending_update_applied) trägt anhang_fehlt, solange die PDFs der Fassung 6 fehlen', async () => { + await resetHosting() + const nowU = nowUnix() + st.nextSubscriptionUpdate = { + pending_update: { expires_at: nowU + 23 * 3600, billing_cycle_anchor: nowU, subscription_items: [], trial_end: null, trial_from_plan: null }, + latest_invoice: switchInvoice({ status: 'open', hosted_invoice_url: 'https://pay.example/karte' }), + } + await switchToWartung('c1', { ...consent, termsVersion: NEU }, nowU) + assert.ok(mails.sent.find((m) => m.subject === 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung'), 'K12 ging beim Klick raus') + assert.equal(mails.sent.filter((m) => m.subject.startsWith('[Abo] Wechsel ins Wartungs-Abo')).length, 0, 'T12 wartet auf die Zahlung') + st.applyPendingUpdate('sub_h') + const res = await postWebhook({ id: 'evt_pua', object: 'event', type: 'customer.subscription.pending_update_applied', created: nowU, data: { object: { id: 'sub_h', object: 'subscription' } } }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const t12 = mails.sent.filter((m) => m.subject.startsWith('[Abo] Wechsel ins Wartungs-Abo')) + assert.equal(t12.length, 1) + assert.match(t12[0].text, new RegExp(HINWEIS)) +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 0433ffe..7a067c0 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -260,11 +260,11 @@ test('Werte werden HTML-sicher eingesetzt, Zeilenumbrüche werden
', () => { test('optionale Blöcke: nur eingeschaltete erscheinen', () => { const values = { name: 'Max', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', angebot_text: 'Erste 2 Monate je 25,00 €', vertragsschluss: '01.10.2026', leistungsbeginn: '15.10.2026', mindestlaufzeit_bis: '15.10.2027', bedingungen_version: 'v3', zustimmung_zeitpunkt: '01.10.2026, 10:00' } - const early = renderMail('K01', values, { angebot: false, sofortbeginn: true, beginn_nach_widerrufsfrist: false }) + const early = renderMail('K01', values, { angebot: false, sofortbeginn: true, beginn_nach_widerrufsfrist: false, anhang: true }) assert.doesNotMatch(early.html, /Ihr Angebot:/) assert.match(early.html, /Sie haben ausdrücklich verlangt/) assert.doesNotMatch(early.html, /erst nach Ablauf der Widerrufsfrist/) - const late = renderMail('K01', values, { angebot: true, sofortbeginn: false, beginn_nach_widerrufsfrist: true }) + const late = renderMail('K01', values, { angebot: true, sofortbeginn: false, beginn_nach_widerrufsfrist: true, anhang: true }) assert.match(late.html, /Ihr Angebot:<\/strong> Erste 2 Monate je 25,00 € \(wie vereinbart\)/) assert.doesNotMatch(late.html, /Sie haben ausdrücklich verlangt/) assert.match(late.html, /Leistungsbeginn und erste Abbuchung: 15\.10\.2026\./) @@ -298,7 +298,7 @@ test('Textfassung K01: alle Vertragsangaben lesbar', () => { const mail = renderMail( 'K01', { name: 'Erika Musterfrau', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', angebot_text: '', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-09-25-v3', zustimmung_zeitpunkt: '01.10.2026, 10:07' }, - { sofortbeginn: true, wartung: true } + { sofortbeginn: true, wartung: true, anhang: true } ) assert.deepEqual(mail.problems, []) const t = mail.text @@ -373,7 +373,7 @@ test('K12: Block zahlung_offen wörtlich, Block lastschrift mit dem Satz aus § assert.match(sepa.text, /Wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt/) assert.match(sepa.text, /Ihr Abbuchungstag bleibt der Wechseltag/) assert.doesNotMatch(sepa.text, /sobald die Zahlung bestätigt ist/) - const plain = renderMail('K12', values, {}) + const plain = renderMail('K12', values, { anhang: true }) // Vertragsunterlagen hängen an (F5: ohne PDF der Fassung 6 stattdessen ohne_anhang) 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']) { @@ -406,22 +406,22 @@ test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => { assert.deepEqual(loadTemplate('K13').placeholders, ['name', 'pakete', 'gesamt_gb', 'monatspreis_zusatz', 'wirksam_ab']) // Fix 2 (3): plus = Einrichtung mit anteiliger Berechnung, plus_bezahlt = Plus bis zur schon bezahlten Menge, // bestellung = Unterlagen und Widerruf (bei beiden), minus = Verringerung zum Periodenende - assert.deepEqual(loadTemplate('K13').blocks, ['plus', 'plus_bezahlt', 'bestellung', 'minus']) + assert.deepEqual(loadTemplate('K13').blocks, ['plus', 'plus_bezahlt', 'bestellung', 'minus', 'anhang', 'ohne_anhang']) assert.equal(loadTemplate('T13').subject, '[Speicher] {{aktion}}: {{email}}') for (const p of ['aktion', 'email', 'name', 'stripe_abo_id', 'postfaecher_hinweis', 'neue_groesse_gb', 'pakete', 'faellig']) { assert.ok(loadTemplate('T13').placeholders.includes(p), p) } - assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise']) + assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['anhang_fehlt', 'einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise']) const values = sampleValues(loadTemplate('K13')) - const plus = renderMail('K13', values, { plus: true, bestellung: true }) + const plus = renderMail('K13', values, { plus: true, bestellung: true, anhang: true }) assert.deepEqual(plus.problems, []) assert.match(plus.text, /gemäß § 19 UStG ohne Umsatzsteuer/) assert.match(plus.text, /wird eingerichtet/) assert.match(plus.text, /14 Tagen widerrufen/) assert.match(plus.text, /Vertragsunterlagen im Anhang/) assert.doesNotMatch(plus.text, /ohne anteilige Erstattung|keine zusätzlichen Kosten/) - const paid = renderMail('K13', values, { plus_bezahlt: true, bestellung: true }) + const paid = renderMail('K13', values, { plus_bezahlt: true, bestellung: true, anhang: true }) assert.ok(paid.text.includes('Die Pakete sind für den laufenden Monat schon bezahlt; es entstehen keine zusätzlichen Kosten. Ab dem nächsten Abrechnungsmonat gelten Wert-pakete Pakete.')) assert.match(paid.text, /14 Tagen widerrufen/) assert.match(paid.text, /Vertragsunterlagen im Anhang/)