diff --git a/server/mail-templates/K13 Zusatzspeicher.html b/server/mail-templates/K13 Zusatzspeicher.html index ec10023..e199f2c 100644 --- a/server/mail-templates/K13 Zusatzspeicher.html +++ b/server/mail-templates/K13 Zusatzspeicher.html @@ -8,8 +8,8 @@ Absender: „WEBklar“ Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern · Anhang bei einer Bestellung (Plus): Vertragsunterlagen der zugestimmten Fassung (PDF) Platzhalter: {{name}}, {{pakete}}, {{gesamt_gb}}, {{monatspreis_zusatz}}, {{wirksam_ab}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): plus, minus - Stand Aufgabe 8 (Fix 1): plus = Bestellung (sofort, anteilig, mit Widerrufshinweis und PDF), minus = Verringerung zum Ende des Abrechnungsmonats (Spec 3.4); Endfassung der Texte in Aufgabe 12. + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): plus, plus_bezahlt, bestellung, minus + Stand Aufgabe 8 (Fix 2): plus = Bestellung über die schon bezahlte Menge (Einrichtung, anteilige Berechnung), plus_bezahlt = Bestellung bis zur schon bezahlten Menge (keine Kosten, nichts einzurichten), bestellung = Unterlagen und Widerrufshinweis (bei beiden Bestellungen), minus = Verringerung zum Ende des Abrechnungsmonats (Spec 3.4); Endfassung der Texte in Aufgabe 12. --> @@ -35,9 +35,9 @@ a{color:#111111;} - + - + @@ -46,14 +46,17 @@ a{color:#111111;} + + + - + - +
✓  E-Mail-Zusatzspeicher
Ihr E-Mail-Zusatzspeicher
Guten Tag {{name}},
vielen Dank für Ihre Bestellung. Hiermit bestätigen wir Ihren E-Mail-Zusatzspeicher.
Guten Tag {{name}},
hiermit bestätigen wir die Änderung Ihres E-Mail-Zusatzspeichers.
Zum Kundenportal →
Sie können diese Bestellung innerhalb von 14 Tagen widerrufen; die Widerrufsbelehrung finden Sie in den beigefügten Vertragsunterlagen. Widerrufen Sie, nehmen wir die Pakete zurück und erstatten alles, was Sie dafür gezahlt haben.
Viele Grüße
Ihr WEBklar-Team
diff --git a/server/services/mailStorage.js b/server/services/mailStorage.js index 60cb102..14c09a8 100644 --- a/server/services/mailStorage.js +++ b/server/services/mailStorage.js @@ -207,10 +207,23 @@ export async function previewStorageChange(customerId, newUnits) { } } -function assertPlanAllowed(plan, consent) { +/** + * Richtung und Zustimmung pruefen: unveraendert 400, Plus ohne Zustimmung 400. reconciled: der Plan wurde nach + * dem Abgleich mit Stripe neu berechnet (Spiegel und Stripe wichen ab) - dann sagt die Meldung, dass die + * Seite neu geladen werden muss; ueber das Kuendigungsformular ist dann nur ein Minus erlaubt (409). + */ +function assertPlanAllowed(plan, consent, { reconciled = false, viaCancellation = false } = {}) { if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.') + if (reconciled && viaCancellation && plan.direction !== 'minus') { + throw httpError(409, 'Über die Kündigung lässt sich der Zusatzspeicher nur verringern.') + } if (plan.direction === 'plus' && !(consent?.termsVersion && consent?.consentAt)) { - throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.') + throw httpError( + 400, + reconciled + ? 'Ihr Speicherstand hat sich geändert. Bitte Seite neu laden und der Bestellung zustimmen.' + : 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.' + ) } } @@ -245,7 +258,7 @@ function assertChangeAllowed(doc, events, direction, viaCancellation) { */ async function recordStorageEvent({ doc, change, consent, viaCancellation }) { try { - return await recordContractEvent({ + const event = await recordContractEvent({ customerId: doc.customerId, subscriptionId: doc.$id, type: 'speicher', @@ -270,9 +283,15 @@ async function recordStorageEvent({ doc, change, consent, viaCancellation }) { consentUserAgent: consent.consentUserAgent || null, } : null, - status: 'offen', + status: change.task ? 'offen' : 'erledigt', dueAt: change.dueAt, }) + if (change.task) return event + // Kein Auftrag fuers Team: als erledigt markiert, damit die Aufgabenliste (Task 13) es nicht zeigt + return await updateContractEvent(event.$id, { doneAt: new Date().toISOString(), doneBy: 'keine Aufgabe' }).catch((e) => { + console.error(`[speicher] Ereignis ${event.$id} nicht als erledigt markiert:`, e.message) + return event + }) } catch (err) { console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message) return null @@ -327,10 +346,14 @@ async function sendStorageMails({ doc, change, consent, viaCancellation, proofMi monatspreis_zusatz: fmtEuro(change.monthlyCents), wirksam_ab: fmtDate(change.effectiveAt), }, - blocks: { plus, minus: !plus }, + // 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 }, 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 await sendAdminNotice({ template: 'T13', values: { @@ -371,12 +394,13 @@ export async function changeStorage(customerId, newUnits, consent, options = {}) // Vorpruefung mit dem Spiegel: Grenzen, Richtung, Zustimmung, Status - ohne Stripe-Aufruf const plan = planStorageChange({ currentUnits: unitsOf(doc), paidUnits: paidUnitsOf(doc), newUnits, maxUnits: config.mailStorage.maxUnits }) assertPlanAllowed(plan, consent) - const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id) - assertChangeAllowed(doc, events, plan.direction, viaCancellation) - // Sperre pruefen und setzen ohne await dazwischen + // Sperre pruefen und setzen ohne await dazwischen; die offenen Ereignisse erst unter der Sperre lesen, damit + // das Ersetzen aelterer Aufgaben nie auf einer veralteten Liste arbeitet if (activeChanges.has(doc.$id)) throw httpError(409, 'Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.') activeChanges.add(doc.$id) try { + const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id) + assertChangeAllowed(doc, events, plan.direction, viaCancellation) return await performChange({ doc, events, newUnits, consent, viaCancellation }) } finally { activeChanges.delete(doc.$id) @@ -393,14 +417,20 @@ async function performChange({ doc, events, newUnits, consent, viaCancellation } const currentUnits = unitsOf(fresh) const paidUnits = paidUnitsOf(fresh) const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits }) - if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.') + // Weicht der frische Stand vom Spiegel ab, kann aus einem Minus ein Plus werden: Zustimmung und + // Kuendigungsweg erneut pruefen, bevor irgendetwas geschrieben wird (Fix 2) + assertPlanAllowed(plan, consent, { reconciled: true, viaCancellation }) const plus = plan.direction === 'plus' const now = new Date().toISOString() const kind = plus && newUnits > paidUnits ? 'einrichten' : 'periodenende' + // Ein Plus genau zurueck auf die bezahlte Menge: konsoleH steht schon darauf, zum Periodenende ist nichts zu + // tun - kein offener Auftrag fuers Team, das Ereignis bleibt als Nachweis (erledigt) + const task = !(plus && newUnits === paidUnits) const reductionAt = reductionEffectiveAt(fresh) const change = { direction: plan.direction, kind, + task, fromUnits: currentUnits, toUnits: newUnits, // Fuer den Kunden: ein Plus gilt sofort, ein Minus zum Periodenende; fuers Team: die Einrichtung ist @@ -413,7 +443,7 @@ async function performChange({ doc, events, newUnits, consent, viaCancellation } partial: false, } - // 1. Ereignis zuerst: Nachweis und Aufgabe, Status offen + // 1. Ereignis zuerst: Nachweis und Aufgabe, Status offen (ohne Aufgabe: erledigt) const event = await recordStorageEvent({ doc: fresh, change, consent, viaCancellation }) // 2. Stripe, Schritt fuer Schritt (planStorageChange) diff --git a/test/mailStorage.test.js b/test/mailStorage.test.js index 7746c4b..725e4aa 100644 --- a/test/mailStorage.test.js +++ b/test/mailStorage.test.js @@ -413,22 +413,81 @@ test('Plus in zwei Schritten: erst die bezahlte Menge ohne Verrechnung, dann ant test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, Aufgabe zum Periodenende, kein Einrichten', async () => { await resetSubH({ units: 1, paidUnits: 3 }) const before = st.calls.length - const result = await changeStorage('c1', 3, consent) + const result = await changeStorage('c1', 2, consent) const upd = subscriptionUpdates(before) assert.equal(upd.length, 1) - assert.equal(upd[0].body['items[0][quantity]'], '3') + assert.equal(upd[0].body['items[0][quantity]'], '2') assert.equal(upd[0].body['proration_behavior'], 'none') - assert.equal(doc().storageUnits, 3) + assert.equal(doc().storageUnits, 2) assert.equal(doc().storagePaidUnits, 3) const [ev] = await listContractEvents('c1') + assert.equal(ev.status, 'offen') assert.equal(ev.details.kind, 'periodenende') - assert.equal(ev.details.toUnits, 3) + assert.equal(ev.details.toUnits, 2) assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd) assert.equal(ev.termsVersion, consent.termsVersion, 'Bestellung mit Zustimmung') assert.equal(result.change.kind, 'periodenende') assert.ok(Math.abs(new Date(result.change.effectiveAt).getTime() - Date.now()) < 5000, 'fuer den Kunden sofort wirksam') assert.equal(t13().subject, `[Speicher] reduzieren zum ${PERIOD_END_LABEL}: k@example.invalid`) - assert.ok(k13().attachments.length === 1, 'K13 mit PDF (Bestellung)') + // Fix 2 (3): K13 als Bestellung (PDF, Widerruf), aber ohne Einrichtung/anteilige Berechnung - schon bezahlt + const customerMail = k13() + assert.equal(customerMail.attachments.length, 1, 'K13 mit PDF (Bestellung)') + for (const text of ['schon bezahlt', 'keine zusätzlichen Kosten', '2 Pakete', '14 Tagen widerrufen', 'Vertragsunterlagen im Anhang']) { + assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`) + } + assert.doesNotMatch(customerMail.text, /wird eingerichtet|anteilig|ohne anteilige Erstattung/) +}) + +// Fix 2 (5): ein Plus genau zurueck auf die bezahlte Menge - konsoleH steht schon darauf, zum Periodenende ist +// nichts zu tun: die offene Verringerung wird geschlossen, keine neue Aufgabe, das Ereignis bleibt als Nachweis +test('Plus genau zurück auf die bezahlte Menge: Verringerung aufgehoben, keine neue Aufgabe, Nachweis bleibt', async () => { + await resetSubH({ units: 3, paidUnits: 3 }) + await changeStorage('c1', 1, null) + const [minusEvent] = await listContractEvents('c1') + mails.sent.length = 0 + const before = st.calls.length + const result = await changeStorage('c1', 3, consent) + assert.equal(subscriptionUpdates(before)[0].body['proration_behavior'], 'none') + assert.equal(doc().storageUnits, 3) + assert.deepEqual(await openTasks(), []) + const events = await listContractEvents('c1') + const superseded = events.find((e) => e.$id === minusEvent.$id) + assert.equal(superseded.status, 'erledigt') + assert.equal(superseded.doneBy, 'ersetzt') + const plusEvent = events.find((e) => e.$id === result.event.$id) + assert.equal(plusEvent.status, 'erledigt') + assert.equal(plusEvent.doneBy, 'keine Aufgabe') + assert.ok(plusEvent.doneAt) + assert.equal(plusEvent.details.kind, 'periodenende') + assert.equal(plusEvent.details.toUnits, 3) + assert.equal(plusEvent.termsVersion, consent.termsVersion, 'Nachweis der Bestellung') + assert.equal(result.change.task, false) + // K13 bestaetigt die Bestellung (nichts berechnet), T13 entfaellt: nichts zu tun + assert.equal(mails.sent.length, 1) + assert.ok(k13().text.includes('keine zusätzlichen Kosten')) + assert.equal(t13(), null) +}) + +// Fix 2 (1): weichen Spiegel und Stripe ab, wird der Plan nach dem Abgleich erneut geprueft - ein Plus ohne +// Zustimmung oder ueber das Kuendigungsformular darf nicht durchrutschen +test('Spiegel und Stripe weichen ab: Plan nach dem Abgleich erneut geprüft (400/409), nichts angelegt', async () => { + await resetSubH({ units: 3 }) + // Spiegel sagt 3 Pakete, Stripe nur 1: aus dem vermeintlichen Minus auf 2 wird ein Plus + st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: 1 })) + const before = st.calls.length + await assert.rejects(changeStorage('c1', 2, null), (e) => e.status === 400 && /zustimmen/.test(e.message)) + assert.deepEqual(await listContractEvents('c1'), [], 'kein Ereignis') + assert.deepEqual(subscriptionUpdates(before), [], 'keine Stripe-Aenderung') + assert.deepEqual(mails.sent, []) + assert.equal(storageItem().quantity, 1) + await assert.rejects(changeStorage('c1', 2, null, { viaCancellation: true }), (e) => e.status === 409) + assert.deepEqual(await listContractEvents('c1'), []) + assert.deepEqual(subscriptionUpdates(before), []) + // Mit Zustimmung geht das Plus, mit dem frischen Stand aus Stripe + const result = await changeStorage('c1', 2, consent) + assert.equal(result.change.fromUnits, 1) + assert.equal(result.change.kind, 'einrichten') + assert.equal(doc().storageUnits, 2) }) test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => { @@ -704,6 +763,35 @@ test('Spiegel nicht aktualisierbar nach der Stripe-Änderung: Aufruf gelingt, Er } }) +// Fix 2 (4): aeltere offene Aufgaben werden nur ersetzt, wenn die neue Aufgabe existiert und Stripe die Aenderung +// uebernommen hat - sonst bliebe das Team ohne Aufgabe +test('Ältere offene Aufgabe bleibt, wenn das Ereignis nicht speicherbar ist oder Stripe Schritt 1 ablehnt', async () => { + await resetSubH({ units: 0 }) + await changeStorage('c1', 2, consent) + const [first] = await listContractEvents('c1') + const original = console.error + console.error = () => {} + try { + aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 }) + await changeStorage('c1', 3, consent) + let events = await listContractEvents('c1') + assert.equal(events.length, 1) + assert.equal(events[0].$id, first.$id) + assert.equal(events[0].status, 'offen', 'aeltere Aufgabe bleibt offen') + assert.equal(storageItem().quantity, 3) + + st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau: abgelehnt' }) + await assert.rejects(changeStorage('c1', 4, consent), (e) => e.status === 502) + events = await listContractEvents('c1') + assert.equal(events.length, 2) + assert.equal(events.find((e) => e.$id === first.$id).status, 'offen', 'aeltere Aufgabe bleibt offen') + assert.equal(events.find((e) => e.$id !== first.$id).status, 'gescheitert') + assert.equal((await openTasks()).length, 1) + } finally { + console.error = original + } +}) + test('Ereignis nicht speicherbar: Stripe-Änderung läuft, T13 mit Block nachweis_fehlt', async () => { await resetSubH({ units: 0 }) const errors = [] diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 42731bf..82f027b 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -209,7 +209,9 @@ test('Wechsel-Mails nennen keine feste Frist von 23 Stunden', () => { test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => { assert.equal(loadTemplate('K13').subject, 'Ihr E-Mail-Zusatzspeicher – Bestätigung') assert.deepEqual(loadTemplate('K13').placeholders, ['name', 'pakete', 'gesamt_gb', 'monatspreis_zusatz', 'wirksam_ab']) - assert.deepEqual(loadTemplate('K13').blocks, ['plus', 'minus']) + // 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.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) @@ -217,17 +219,22 @@ test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => { assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise']) const values = sampleValues(loadTemplate('K13')) - const plus = renderMail('K13', values, { plus: true }) + const plus = renderMail('K13', values, { plus: true, bestellung: 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/) + assert.doesNotMatch(plus.text, /ohne anteilige Erstattung|keine zusätzlichen Kosten/) + const paid = renderMail('K13', values, { plus_bezahlt: true, bestellung: 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/) + assert.doesNotMatch(paid.text, /wird eingerichtet|anteilig/) const minus = renderMail('K13', values, { minus: true }) assert.match(minus.text, /ohne anteilige Erstattung/) assert.match(minus.text, /keine neuen E-Mails/) - assert.doesNotMatch(minus.text, /wird eingerichtet|14 Tagen widerrufen|im Anhang/) + assert.doesNotMatch(minus.text, /wird eingerichtet|14 Tagen widerrufen|im Anhang|schon bezahlt/) const t = loadTemplate('T13') const teilweise = renderMail('T13', { ...sampleValues(t), pakete: 5 }, { einrichten: true, teilweise: true })