Zusatzspeicher: Plan nach Stripe-Abgleich erneut prüfen, Aufgabenliste unter Sperre, K13 ohne Kosten

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-29 09:31:28 +00:00
1 parent 662f8d8144
commit f66a713531
4 files changed
+153 -25

No files matched your search

@@ -8,8 +8,8 @@
Absender: „WEBklar“ <Portal-Postfach> Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern · Anhang bei einer Bestellung (Plus): Vertragsunterlagen der zugestimmten Fassung (PDF) 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}} Platzhalter: {{name}}, {{pakete}}, {{gesamt_gb}}, {{monatspreis_zusatz}}, {{wirksam_ab}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): plus, minus Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): plus, plus_bezahlt, bestellung, 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. 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.
--> -->
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
@@ -35,9 +35,9 @@ a{color:#111111;}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; E-Mail-Zusatzspeicher</td></tr></table></td></tr> <tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; E-Mail-Zusatzspeicher</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihr E-Mail-Zusatzspeicher</td></tr> <tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihr E-Mail-Zusatzspeicher</td></tr>
<!-- WENN plus --> <!-- WENN bestellung -->
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>vielen Dank für Ihre Bestellung. Hiermit bestätigen wir Ihren E-Mail-Zusatzspeicher.</td></tr> <tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>vielen Dank für Ihre Bestellung. Hiermit bestätigen wir Ihren E-Mail-Zusatzspeicher.</td></tr>
<!-- ENDE plus --> <!-- ENDE bestellung -->
<!-- WENN minus --> <!-- WENN minus -->
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>hiermit bestätigen wir die Änderung Ihres E-Mail-Zusatzspeichers.</td></tr> <tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>hiermit bestätigen wir die Änderung Ihres E-Mail-Zusatzspeichers.</td></tr>
<!-- ENDE minus --> <!-- ENDE minus -->
@@ -46,14 +46,17 @@ a{color:#111111;}
<!-- WENN plus --> <!-- WENN plus -->
<tr><td class="px" style="padding:24px 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;">Bereitstellung und Abrechnung</div><div class="mut" style="color:#4a4843;">Wir stellen die Grenzen Ihrer Postfächer in der Regel innerhalb eines Werktags um; bis dahin zeigt das Kundenportal „wird eingerichtet“. Den Rest des laufenden Abrechnungsmonats berechnen wir anteilig mit der nächsten Abbuchung, danach {{monatspreis_zusatz}} im Monat. Pakete, die für den laufenden Monat schon bezahlt waren, berechnen wir nicht noch einmal.</div></td></tr></table></td></tr> <tr><td class="px" style="padding:24px 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;">Bereitstellung und Abrechnung</div><div class="mut" style="color:#4a4843;">Wir stellen die Grenzen Ihrer Postfächer in der Regel innerhalb eines Werktags um; bis dahin zeigt das Kundenportal „wird eingerichtet“. Den Rest des laufenden Abrechnungsmonats berechnen wir anteilig mit der nächsten Abbuchung, danach {{monatspreis_zusatz}} im Monat. Pakete, die für den laufenden Monat schon bezahlt waren, berechnen wir nicht noch einmal.</div></td></tr></table></td></tr>
<!-- ENDE plus --> <!-- ENDE plus -->
<!-- WENN plus_bezahlt -->
<tr><td class="px" style="padding:24px 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;">Keine zusätzlichen Kosten in diesem Monat</div><div class="mut" style="color:#4a4843;">Die Pakete sind für den laufenden Monat schon bezahlt; es entstehen keine zusätzlichen Kosten. Ab dem nächsten Abrechnungsmonat gelten {{pakete}} Pakete.</div></td></tr></table></td></tr>
<!-- ENDE plus_bezahlt -->
<!-- WENN minus --> <!-- WENN minus -->
<tr><td class="px" style="padding:24px 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;">Wirksam zum Ende des Abrechnungsmonats</div><div class="mut" style="color:#4a4843;">Ihre Änderung wirkt zum {{wirksam_ab}}, ohne anteilige Erstattung; bis dahin bleibt der bisherige Speicher bestehen. Passen die gespeicherten E-Mails dann nicht mehr hinein, nimmt das betroffene Postfach keine neuen E-Mails an, bis wieder Platz ist.</div></td></tr></table></td></tr> <tr><td class="px" style="padding:24px 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;">Wirksam zum Ende des Abrechnungsmonats</div><div class="mut" style="color:#4a4843;">Ihre Änderung wirkt zum {{wirksam_ab}}, ohne anteilige Erstattung; bis dahin bleibt der bisherige Speicher bestehen. Passen die gespeicherten E-Mails dann nicht mehr hinein, nimmt das betroffene Postfach keine neuen E-Mails an, bis wieder Platz ist.</div></td></tr></table></td></tr>
<!-- ENDE minus --> <!-- ENDE minus -->
<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: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>
<!-- WENN plus --> <!-- WENN bestellung -->
<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 den Regeln zum E-Mail-Zusatzspeicher<br>&#128206;&nbsp; Widerrufsbelehrung mit Muster-Widerrufsformular</div></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 den Regeln zum E-Mail-Zusatzspeicher<br>&#128206;&nbsp; Widerrufsbelehrung mit Muster-Widerrufsformular</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;">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.</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;">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.</td></tr>
<!-- ENDE plus --> <!-- ENDE bestellung -->
<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> <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> </table>
</td></tr> </td></tr>
+40 -10
View File
@@ -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 (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)) { 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 }) { async function recordStorageEvent({ doc, change, consent, viaCancellation }) {
try { try {
return await recordContractEvent({ const event = await recordContractEvent({
customerId: doc.customerId, customerId: doc.customerId,
subscriptionId: doc.$id, subscriptionId: doc.$id,
type: 'speicher', type: 'speicher',
@@ -270,9 +283,15 @@ async function recordStorageEvent({ doc, change, consent, viaCancellation }) {
consentUserAgent: consent.consentUserAgent || null, consentUserAgent: consent.consentUserAgent || null,
} }
: null, : null,
status: 'offen', status: change.task ? 'offen' : 'erledigt',
dueAt: change.dueAt, 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) { } catch (err) {
console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message) console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message)
return null return null
@@ -327,10 +346,14 @@ async function sendStorageMails({ doc, change, consent, viaCancellation, proofMi
monatspreis_zusatz: fmtEuro(change.monthlyCents), monatspreis_zusatz: fmtEuro(change.monthlyCents),
wirksam_ab: fmtDate(change.effectiveAt), 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, attachments,
}).catch((e) => console.error(`[speicher] K13 an ${email} fehlgeschlagen:`, e.message)) }).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({ await sendAdminNotice({
template: 'T13', template: 'T13',
values: { values: {
@@ -371,12 +394,13 @@ export async function changeStorage(customerId, newUnits, consent, options = {})
// Vorpruefung mit dem Spiegel: Grenzen, Richtung, Zustimmung, Status - ohne Stripe-Aufruf // 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 }) const plan = planStorageChange({ currentUnits: unitsOf(doc), paidUnits: paidUnitsOf(doc), newUnits, maxUnits: config.mailStorage.maxUnits })
assertPlanAllowed(plan, consent) assertPlanAllowed(plan, consent)
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id) // Sperre pruefen und setzen ohne await dazwischen; die offenen Ereignisse erst unter der Sperre lesen, damit
assertChangeAllowed(doc, events, plan.direction, viaCancellation) // das Ersetzen aelterer Aufgaben nie auf einer veralteten Liste arbeitet
// Sperre pruefen und setzen ohne await dazwischen
if (activeChanges.has(doc.$id)) throw httpError(409, 'Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.') 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) activeChanges.add(doc.$id)
try { 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 }) return await performChange({ doc, events, newUnits, consent, viaCancellation })
} finally { } finally {
activeChanges.delete(doc.$id) activeChanges.delete(doc.$id)
@@ -393,14 +417,20 @@ async function performChange({ doc, events, newUnits, consent, viaCancellation }
const currentUnits = unitsOf(fresh) const currentUnits = unitsOf(fresh)
const paidUnits = paidUnitsOf(fresh) const paidUnits = paidUnitsOf(fresh)
const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits }) 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 plus = plan.direction === 'plus'
const now = new Date().toISOString() const now = new Date().toISOString()
const kind = plus && newUnits > paidUnits ? 'einrichten' : 'periodenende' 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 reductionAt = reductionEffectiveAt(fresh)
const change = { const change = {
direction: plan.direction, direction: plan.direction,
kind, kind,
task,
fromUnits: currentUnits, fromUnits: currentUnits,
toUnits: newUnits, toUnits: newUnits,
// Fuer den Kunden: ein Plus gilt sofort, ein Minus zum Periodenende; fuers Team: die Einrichtung ist // 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, 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 }) const event = await recordStorageEvent({ doc: fresh, change, consent, viaCancellation })
// 2. Stripe, Schritt fuer Schritt (planStorageChange) // 2. Stripe, Schritt fuer Schritt (planStorageChange)
+93 -5
View File
@@ -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 () => { test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, Aufgabe zum Periodenende, kein Einrichten', async () => {
await resetSubH({ units: 1, paidUnits: 3 }) await resetSubH({ units: 1, paidUnits: 3 })
const before = st.calls.length const before = st.calls.length
const result = await changeStorage('c1', 3, consent) const result = await changeStorage('c1', 2, consent)
const upd = subscriptionUpdates(before) const upd = subscriptionUpdates(before)
assert.equal(upd.length, 1) 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(upd[0].body['proration_behavior'], 'none')
assert.equal(doc().storageUnits, 3) assert.equal(doc().storageUnits, 2)
assert.equal(doc().storagePaidUnits, 3) assert.equal(doc().storagePaidUnits, 3)
const [ev] = await listContractEvents('c1') const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'offen')
assert.equal(ev.details.kind, 'periodenende') 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.dueAt, hostingDoc.currentPeriodEnd)
assert.equal(ev.termsVersion, consent.termsVersion, 'Bestellung mit Zustimmung') assert.equal(ev.termsVersion, consent.termsVersion, 'Bestellung mit Zustimmung')
assert.equal(result.change.kind, 'periodenende') 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.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.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 () => { 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 () => { test('Ereignis nicht speicherbar: Stripe-Änderung läuft, T13 mit Block nachweis_fehlt', async () => {
await resetSubH({ units: 0 }) await resetSubH({ units: 0 })
const errors = [] const errors = []
+11 -4
View File
@@ -209,7 +209,9 @@ test('Wechsel-Mails nennen keine feste Frist von 23 Stunden', () => {
test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => { test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => {
assert.equal(loadTemplate('K13').subject, 'Ihr E-Mail-Zusatzspeicher – Bestätigung') 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').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}}') 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']) { 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.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']) assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise'])
const values = sampleValues(loadTemplate('K13')) 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.deepEqual(plus.problems, [])
assert.match(plus.text, /gemäß § 19 UStG ohne Umsatzsteuer/) assert.match(plus.text, /gemäß § 19 UStG ohne Umsatzsteuer/)
assert.match(plus.text, /wird eingerichtet/) assert.match(plus.text, /wird eingerichtet/)
assert.match(plus.text, /14 Tagen widerrufen/) assert.match(plus.text, /14 Tagen widerrufen/)
assert.match(plus.text, /Vertragsunterlagen im Anhang/) 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 }) const minus = renderMail('K13', values, { minus: true })
assert.match(minus.text, /ohne anteilige Erstattung/) assert.match(minus.text, /ohne anteilige Erstattung/)
assert.match(minus.text, /keine neuen E-Mails/) 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 t = loadTemplate('T13')
const teilweise = renderMail('T13', { ...sampleValues(t), pakete: 5 }, { einrichten: true, teilweise: true }) const teilweise = renderMail('T13', { ...sampleValues(t), pakete: 5 }, { einrichten: true, teilweise: true })