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>
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.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<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">
<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>
<!-- 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>
<!-- ENDE plus -->
<!-- ENDE bestellung -->
<!-- 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>
<!-- ENDE minus -->
@@ -46,14 +46,17 @@ a{color:#111111;}
<!-- 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>
<!-- 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 -->
<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 -->
<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 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>
</table>
</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 (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)