Terminplaner: Erstattungsmeldung unabhaengig vom Abo nachholen (R107)

Der taegliche Abo-Abgleich holt T19 fuer jedes bezahlte, abgeschaltete Dokument mit
Grund abo_ende_vor_einrichtung und zahlungMeldung ungleich gemeldet nach, auch wenn das
Wartungs-Abo wieder laeuft oder nicht lesbar ist (der Nachlauf liest das Abo nie).
Reihenfolge wie bisher: T19, dann K22 (nur wenn aboEndeMailAt leer), dann Marker.
meldeAboEnde sendet bei gesetztem K22-Marker und offener Erstattung nur T19.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 17:31:54 +00:00
1 parent 13523ca5e2
commit 1c74c89feb
2 files changed
+342 -11

No files matched your search

+57 -8
View File
@@ -21,6 +21,11 @@ import { datumKurz } from './zeit.js'
* Schritt 2 fuer jedes abgeschaltete Dokument mit Grund 'abo_ende' oder (bezahlt) 'abo_ende_vor_einrichtung' ohne aboEndeMailAt
* die Meldung nachholen (auch fuer die eben abgeschalteten); der Marker aboEndeMailAt wird erst nach erfolgreichem
* Versand gesetzt
* Schritt 2b Erstattung nachholen, unabhaengig vom Abo (R107, E5b): fuer jedes BEZAHLTE (unlockedByPurchase true), abgeschaltete
* Dokument mit Grund 'abo_ende_vor_einrichtung' und zahlungMeldung !== 'gemeldet' geht T19 an das Team, ob das Wartungs-
* Abo inzwischen wieder laeuft oder nicht lesbar ist (die Erstattung bleibt nach D3 voll geschuldet, die Rueckkehr nach
* Schritt 3 gilt dafuer nicht). Der Nachlauf liest das Abo nie. Danach, in dieser Reihenfolge und nur nach erfolgreichem
* T19: K22 (Block vor_einrichtung), falls aboEndeMailAt noch leer ist, und der Marker. Ein unbezahltes Dokument bekommt nichts.
* Schritt 3 Rueckkehr: ein abgeschaltetes Dokument mit Grund 'abo_ende', dessen Wartungs-Abo wieder laeuft, wird wieder eingeschaltet
* (activate: Daten und Einstellungen bleiben, Grund, Marker und disabledAt enden), solange die Einstellung noch da ist, auch
* nach Tag 30 (der Abgleich laeuft im Tagesjob vor der Loeschung; sonst blockierten sich Loeschung und Rueckkehr).
@@ -36,6 +41,8 @@ import { datumKurz } from './zeit.js'
* Abo (aboLage direkt, nie der Memo): zwischen Vorauswahl und Sperre kann das Abo zurueckkommen oder das Team abschalten.
* Die Mails (T19, K22) und die Marker folgen NACH der Sperre (meldeAboEnde), denn T19 laeuft ueber meldeErstattung mit eigener
* Sperre feature-meldung:<docId> und darf nie innerhalb einer feature-admin- oder feature-paid-Sperre gerufen werden.
* - Ausnahme von "nach frischem Lesen des Abos": der Nachlauf der Erstattung (Schritt 2b) liest nur das Dokument frisch (unter
* feature-admin:<projectId>), nie das Abo - er schaltet nichts ab und nichts ein, er meldet nur, was geschuldet ist.
* - Fail-closed: ist etwas nicht lesbar (Liste, Abo, Ereignisse, Dokument), wird nichts abgeschaltet. Kunde bzw. Dokument werden
* uebersprungen, `fehler` zaehlt, der naechste Lauf versucht es wieder. Das Log nennt weder Namen noch Adressen.
* - disabledAt aendert dieser Abgleich nie nach dem Abschalten: schlaegt eine Mail fehl, wiederholt der naechste Tag den Versand mit
@@ -62,6 +69,13 @@ const istBewertet = (doc) =>
doc.state === 'setup' ||
(doc.state === 'disabled' && typeof doc.disabledReason === 'string' && doc.disabledReason.startsWith('abo_ende'))
/**
* Schritt 2b: bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist.
* Das Abo spielt keine Rolle: die Erstattung bleibt geschuldet, auch wenn es zurueckkommt.
*/
const istErstattungOffen = (doc) =>
doc.state === 'disabled' && doc.disabledReason === GRUND_VOR && doc.unlockedByPurchase === true && doc.zahlungMeldung !== 'gemeldet'
/** Schritt 3: abgeschaltet wegen des Abo-Endes (nur 'abo_ende', nicht 'abo_ende_vor_einrichtung'). */
const istRueckkehr = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND
@@ -77,20 +91,24 @@ const portalFeatures = () => config.collections.portalFeatures
* siteFeatures das als T19 und der Abgleich sendet dann K22). Ein bezahltes Dokument meldet ZUERST die Erstattung an das Team
* (meldeErstattung, T19, genau einmal je Dokument, auch ohne erreichbaren Kunden), dann K22 mit Block `vor_einrichtung` (ohne
* `daten`: es gibt nichts zu loeschen). Scheitert T19 (weder gesendet noch schon gemeldet), folgen weder K22 noch Marker: der
* naechste Tag wiederholt beides in dieser Reihenfolge.
* naechste Tag wiederholt beides in dieser Reihenfolge. Der Marker aboEndeMailAt gilt nur fuer K22: steht er schon, aber T19 ist
* noch nicht gemeldet (R107), geht nur T19 und K22 nicht noch einmal. `lage` braucht dieser Grund nicht (nur 'abo_ende' fuer
* den Block hosting).
* Der Aufrufer liest `doc` frisch und haelt KEINE feature-admin- und keine feature-paid-Sperre (meldeErstattung sperrt selbst
* feature-meldung:<docId>). Ohne Kunde oder ohne E-Mail gibt es keine K22 zu senden: der Marker wird gesetzt, damit es kein
* Wiederholen gibt. Wirft nie.
* @param {object} doc portalFeatures-Dokument (disabled)
* @param {{ lage: { art: string }, now: Date, onGesendet?: () => void }} options onGesendet wird nach jedem versendeten K22 gerufen
* @param {{ lage?: { art: string } | null, now: Date, onGesendet?: () => void }} options onGesendet wird nach jedem versendeten K22 gerufen
* @returns {Promise<boolean>} true = nichts mehr offen (Marker gesetzt oder nichts zu melden), false = Versand oder Marker
* gescheitert (der naechste Tag wiederholt)
*/
export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) {
export async function meldeAboEnde(doc, { lage = null, now, onGesendet = null }) {
const nowMs = pruefeNow(now, 'meldeAboEnde')
const vorEinrichtung = doc.disabledReason === GRUND_VOR
if ((doc.disabledReason !== GRUND && !vorEinrichtung) || doc.aboEndeMailAt) return true // mit Marker ist alles gesagt
if (doc.disabledReason !== GRUND && !vorEinrichtung) return true
if (vorEinrichtung && doc.unlockedByPurchase !== true) return true // nichts zu erstatten, nichts zu sagen: die Zahlung kommt eventuell noch
const erstattungOffen = vorEinrichtung && doc.zahlungMeldung !== 'gemeldet'
if (doc.aboEndeMailAt && !erstattungOffen) return true // mit Marker ist alles gesagt
const setzeMarker = () => updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: new Date(nowMs).toISOString() })
let gesendet = false
try {
@@ -102,6 +120,7 @@ export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) {
return false
}
}
if (doc.aboEndeMailAt) return true // K22 ging schon, hier fehlte nur T19
if (!customer?.email) {
console.warn('[termine-abo] K22 entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, Marker wird gesetzt')
await setzeMarker()
@@ -186,6 +205,27 @@ async function bearbeiteDokument(doc, ctx) {
if (!fertig) erg.fehler += 1
}
/**
* Schritt 2b, ein Dokument (R107): in der Liste bezahlt, abgeschaltet mit Grund 'abo_ende_vor_einrichtung' und T19 nicht gemeldet.
* Unter der Sperre feature-admin:<projectId> frisch lesen (das Team kann inzwischen aktiviert haben, der Webhook T19 gemeldet), das
* Abo wird NICHT gelesen. Die Meldung (T19, danach K22 und Marker, meldeAboEnde) folgt nach der Sperre. Wirft nie.
* @returns {Promise<boolean>} true = erledigt oder gescheitert (fehler gezaehlt, der naechste Tag wiederholt): das Dokument ist fuer
* diesen Lauf versorgt; false = nichts zu tun (die frische Fassung ist nicht mehr offen): der Abo-Pfad darf es noch bewerten
*/
async function holeErstattungNach(doc, ctx) {
const { erg, now } = ctx
try {
const frisch = await withLocks([`feature-admin:${doc.projectId}`], () => getDocument(portalFeatures(), doc.$id))
if (!istErstattungOffen(frisch)) return false
const fertig = await meldeAboEnde(frisch, { now, onGesendet: () => (erg.mails += 1) })
if (!fertig) erg.fehler += 1
} catch (err) {
erg.fehler += 1
console.error(`[termine-abo] Erstattung für Funktion ${doc.$id} nicht gemeldet, der nächste Lauf wiederholt: ${fehlerText(err)}`)
}
return true
}
/**
* K23 an die E-Mail des Kundenkontos. Nur nach geschriebenem `active` gerufen, nie innerhalb einer Sperre. Scheitert der Versand,
* bleibt die Reaktivierung bestehen: das Log (ohne Adresse) und `fehler` halten es fest; der Marker aboEndeMailAt ist mit dem
@@ -245,17 +285,26 @@ async function bearbeiteKunde(customerId, dokumente, ctx) {
const bewertet = dokumente.filter(istBewertet)
if (bewertet.length === 0) return // nichts zu bewerten: das Abo wird nicht gelesen
// Schritt 2b (R107) VOR dem Abo: die Erstattung haengt nicht davon ab, ob das Abo lesbar ist oder wieder laeuft
const versorgt = new Set()
for (const doc of bewertet.filter(istErstattungOffen)) {
if (await holeErstattungNach(doc, ctx)) versorgt.add(doc)
}
erg.geprueft += versorgt.size
const uebrige = bewertet.filter((doc) => !versorgt.has(doc))
if (uebrige.length === 0) return // alles versorgt: das Abo wird nicht gelesen
let lage
try {
lage = await ctx.memo.lage(customerId)
} catch (err) {
erg.fehler += 1
console.error(`[termine-abo] Abo-Lage eines Kunden nicht lesbar, ${bewertet.length} Funktion(en) übersprungen: ${fehlerText(err)}`)
console.error(`[termine-abo] Abo-Lage eines Kunden nicht lesbar, ${uebrige.length} Funktion(en) übersprungen: ${fehlerText(err)}`)
return
}
erg.geprueft += bewertet.length
erg.geprueft += uebrige.length
for (const doc of bewertet) {
for (const doc of uebrige) {
// laeuft das Abo, gibt es nur die Rueckkehr (Schritt 3); sonst abschalten, einrichten, nachholen (Schritte 1, 1b, 2)
const bearbeite = lage.laeuft ? istRueckkehr(doc) && schalteWiederEin : aktionFuer(doc) && bearbeiteDokument
if (!bearbeite) continue
@@ -274,7 +323,7 @@ async function bearbeiteKunde(customerId, dokumente, ctx) {
* @param {Date} now
* @returns {Promise<{ geprueft: number, abgeschaltet: number, wiederEin: number, mails: number, ausgenommen: number, fehler: number }>}
* geprueft = bewertete Dokumente (aktiv mit unlockedByPurchase true, ordered/setup, disabled mit Grund abo_ende*, deren Kunde
* lesbar war), abgeschaltet = in diesem Lauf abgeschaltet, wiederEin = in diesem Lauf wieder eingeschaltet (Schritt 3), mails =
* lesbar war; der Nachlauf der Erstattung, Schritt 2b, braucht das Abo nicht und zaehlt immer), abgeschaltet = in diesem Lauf abgeschaltet, wiederEin = in diesem Lauf wieder eingeschaltet (Schritt 3), mails =
* versendete Kundenmails K22 und K23 (T19 geht ans Team und zaehlt nicht), ausgenommen = aktive Dokumente ohne Kauf (Pilot, Kulanz),
* fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie ein gescheiterter K23-Versand (das Dokument ist dann
* trotzdem wieder aktiv)