Kundenansicht neu P7: K01 und K12 behaupten für Betriebe kein Sofortbeginn-Verlangen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 20:05:06 +00:00
1 parent 4e0e42f633
commit 528ca0875e
5 files changed
+134 -8

No files matched your search

@@ -8,8 +8,9 @@
Absender: „WEBklar“ <Portal-Postfach>
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.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -47,7 +48,7 @@ a{color:#111111;}
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;color:#4a4843;">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.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="https://project.webklar.com/dashboard.html" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px" style="padding:32px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ihre Vertragsunterlagen im Anhang (PDF)</div><div class="mut" style="color:#4a4843;">&#128206;&nbsp; Vertragsbedingungen mit Leistungsbeschreibung<br>&#128206;&nbsp; Widerrufsbelehrung mit Muster-Widerrufsformular<br>&#128206;&nbsp; Auftragsverarbeitungsvertrag</div></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Ihre Zustimmung zu den Vertragsbedingungen (Version {{bedingungen_version}}) einschließlich des Auftragsverarbeitungsvertrags haben wir am {{zustimmung_zeitpunkt}} Uhr erhalten.<br><br>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.</td></tr>
<tr><td class="px mut" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Ihre Zustimmung zu den Vertragsbedingungen (Version {{bedingungen_version}}) einschließlich des Auftragsverarbeitungsvertrags haben wir am {{zustimmung_zeitpunkt}} Uhr erhalten.<!-- WENN sofortverlangen --><br><br>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.<!-- ENDE sofortverlangen --></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
+2 -1
View File
@@ -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' }] : [],
+2 -2
View File
@@ -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 })
+120
View File
@@ -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/)
})
+7 -3
View File
@@ -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\./)