Abo-Abgleich: Abo endet vor der Einrichtung ⇒ abgeschaltet, T19 und K22

Terminplaner TP2 E5. Schritt 1b in gleicheAboAb: Dokumente im Zustand ordered
oder setup werden bei fehlendem Wartungs-Abo unter feature-admin:<projectId> nach
frischem Lesen von Dokument und Abo mit Grund abo_ende_vor_einrichtung abgeschaltet.
Die Meldelogik liegt in meldeAboEnde (auch für den Sofortpfad E9): bei bezahlter
Einrichtung zuerst T19 über meldeErstattung (außerhalb jeder feature-admin-Sperre),
dann K22 mit Block vor_einrichtung, dann der Marker aboEndeMailAt; scheitert T19,
folgen weder K22 noch Marker und der nächste Tag wiederholt beides. Ein unbezahltes
ordered-Dokument wird nur abgeschaltet. Schritt 2 holt auch abo_ende_vor_einrichtung
nach. Die Stripe-Sitzung und das Ticket bleiben unangetastet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:32:14 +00:00
1 parent 17f933dbc7
commit addb88d5fd
2 files changed
+450 -43

No files matched your search

+82 -35
View File
@@ -3,7 +3,7 @@ import { aboLage, aboMemo } from '../aboStatus.js'
import { Query, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { sendCustomerMail } from '../mailer.js'
import { customerOf, disable, projectName } from '../siteFeatures.js'
import { customerOf, disable, meldeErstattung, projectName } from '../siteFeatures.js'
import { loeschzeit } from './frist.js'
import { pruefeNow, zeitMs } from './util.js'
import { datumKurz } from './zeit.js'
@@ -14,25 +14,33 @@ import { datumKurz } from './zeit.js'
*
* Schritt 1 aktive, bezahlte Terminplaner (state active, unlockedByPurchase true) ohne laufendes Wartungs-Abo => abschalten
* (disabledReason 'abo_ende'; ab disabledAt laeuft die 30-Tage-Frist bis zur Loeschung, jobs.js)
* Schritt 2 fuer jedes abgeschaltete Dokument mit Grund 'abo_ende' ohne aboEndeMailAt die Mail K22 nachholen (auch fuer die
* eben abgeschalteten); der Marker aboEndeMailAt wird erst nach erfolgreichem Versand gesetzt
* Schritt 1b Einrichtungen, die das Abo-Ende erreicht (state ordered oder setup) => abschalten (disabledReason
* 'abo_ende_vor_einrichtung'); eine bezahlte Einrichtung wird dem Team als Erstattung gemeldet (T19), der Kunde bekommt K22
* mit dem Block vor_einrichtung. Die offene Stripe-Sitzung wird nicht storniert, das Ticket nicht angefasst.
* 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
*
* Regeln (Block E):
* - Eine Listenabfrage fuer alle Kunden (Entwuerfe `off` bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird
* fuer die Vorauswahl nur einmal gelesen. Dokumente, die nicht vom Kauf freigeschaltet wurden (Pilot, Kulanz: unlockedByPurchase
* nicht true), fasst der Abgleich nie an; sie zaehlen in `ausgenommen` und kosten keine Abo-Abfrage.
* fuer die Vorauswahl nur einmal gelesen. Aktive Dokumente, die nicht vom Kauf freigeschaltet wurden (Pilot, Kulanz:
* unlockedByPurchase nicht true), fasst der Abgleich nie an; sie zaehlen in `ausgenommen` und kosten keine Abo-Abfrage.
* - Geschrieben wird nur unter der Sperre feature-admin:<projectId> (wie die Team-Route) und nach frischem Lesen von Dokument UND
* 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.
* - 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 K22 fehl, wiederholt der naechste Tag den Versand mit
* - disabledAt aendert dieser Abgleich nie nach dem Abschalten: schlaegt eine Mail fehl, wiederholt der naechste Tag den Versand mit
* denselben Daten (Enddatum, Loeschdatum), die Loeschfrist waechst nicht.
* - Nur die Gruende aus siteFeatures.DISABLED_REASONS gelten. 'team', 'kunde_geloescht' und Altbestand ohne Grund fasst der
* Abgleich nie an; 'abo_ende_vor_einrichtung' gehoert zu E5, ordered/setup ebenfalls (sie zaehlen hier nur als geprueft).
* Abgleich nie an.
*/
const GRUND = 'abo_ende'
const NOTIZ = 'Abgeschaltet: Wartungs-Abo beendet'
const GRUND_VOR = 'abo_ende_vor_einrichtung'
const NOTIZ_VOR = 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung'
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
@@ -49,13 +57,19 @@ const istBewertet = (doc) =>
const portalFeatures = () => config.collections.portalFeatures
/**
* Der gemeinsame Melder fuer das Ende des Terminplaners (Schritt 2 und der Sofortpfad E9 rufen nur ihn, nie K22 einzeln):
* K22 an die E-Mail des Kundenkontos, danach der Marker aboEndeMailAt = now.
* Grund 'abo_ende': Block `abo_ende` (Wartungs-Abo beendet) bzw. `hosting` (lage.art 'hosting' oder 'wechsel_widerrufen': das
* Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag von disabledAt, loeschdatum = 30 Tage danach.
* Der Aufrufer liest `doc` frisch und haelt die Sperre feature-admin:<projectId> (diese Funktion sperrt nicht, damit der Sofortpfad
* sie aus seiner eigenen Sperre rufen kann). Ohne Kunde oder ohne E-Mail gibt es nichts zu senden: der Marker wird gesetzt, damit
* es kein Wiederholen gibt. Wirft nie.
* Der gemeinsame Melder fuer das Ende des Terminplaners (Schritt 2, Schritt 1b und der Sofortpfad E9 rufen nur ihn, nie T19 oder
* K22 einzeln). Reihenfolge: (T19,) K22 an die E-Mail des Kundenkontos, danach der Marker aboEndeMailAt = now.
* - Grund 'abo_ende': K22 mit Block `abo_ende` (Wartungs-Abo beendet) bzw. `hosting` (lage.art 'hosting' oder 'wechsel_widerrufen':
* das Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag von disabledAt, loeschdatum = 30 Tage danach.
* - Grund 'abo_ende_vor_einrichtung': ein unbezahltes Dokument (unlockedByPurchase nicht true, die Bestellung wartet noch auf die
* Zahlung) hat nichts zu erstatten: weder T19 noch K22 noch Marker, kein Schreibzugriff (kommt die Zahlung spaeter, meldet
* 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.
* 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
* @returns {Promise<boolean>} true = nichts mehr offen (Marker gesetzt oder nichts zu melden), false = Versand oder Marker
@@ -63,11 +77,20 @@ const portalFeatures = () => config.collections.portalFeatures
*/
export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) {
const nowMs = pruefeNow(now, 'meldeAboEnde')
if (doc.disabledReason !== GRUND || doc.aboEndeMailAt) return true // E5 ergaenzt abo_ende_vor_einrichtung; mit Marker ist alles gesagt
const vorEinrichtung = doc.disabledReason === GRUND_VOR
if ((doc.disabledReason !== GRUND && !vorEinrichtung) || doc.aboEndeMailAt) return true // mit Marker ist alles gesagt
if (vorEinrichtung && doc.unlockedByPurchase !== true) return true // nichts zu erstatten, nichts zu sagen: die Zahlung kommt eventuell noch
const setzeMarker = () => updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: new Date(nowMs).toISOString() })
let gesendet = false
try {
const customer = await customerOf(doc.customerId)
if (vorEinrichtung) {
const erstattung = await meldeErstattung(doc, { customer, nachAboEnde: false })
if (!erstattung.sent && !erstattung.schonGemeldet) {
console.error(`[termine-abo] T19 für Funktion ${doc.$id} nicht gesendet, der nächste Lauf wiederholt (K22 folgt danach)`)
return false
}
}
if (!customer?.email) {
console.warn('[termine-abo] K22 entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, Marker wird gesetzt')
await setzeMarker()
@@ -75,6 +98,9 @@ export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) {
}
const projekt = (await projectName(doc.projectId)) || 'Dein Projekt'
const beginn = new Date(Number.isFinite(zeitMs(doc.disabledAt)) ? zeitMs(doc.disabledAt) : nowMs) // disabledAt fehlt nie, ausser bei Altdaten
const grundBlock = vorEinrichtung
? { vor_einrichtung: true }
: { [lage?.art === 'hosting' || lage?.art === 'wechsel_widerrufen' ? 'hosting' : 'abo_ende']: true, daten: true }
await sendCustomerMail(customer.email, {
template: 'K22',
values: {
@@ -85,45 +111,68 @@ export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) {
preis: config.features.termine.priceLabel,
portal_url: `${config.publicBaseUrl}/dashboard.html#termine`,
},
blocks: { [lage?.art === 'hosting' || lage?.art === 'wechsel_widerrufen' ? 'hosting' : 'abo_ende']: true, daten: true },
blocks: grundBlock,
})
gesendet = true
onGesendet?.()
await setzeMarker()
return true
} catch (err) {
console.error(`[termine-abo] K22 für Funktion ${doc.$id}: ${gesendet ? 'Marker nicht gesetzt' : 'nicht gesendet'}, der nächste Lauf wiederholt: ${fehlerText(err)}`)
console.error(`[termine-abo] Meldung für Funktion ${doc.$id}: ${gesendet ? 'Marker nicht gesetzt' : 'nicht gesendet'}, der nächste Lauf wiederholt: ${fehlerText(err)}`)
return false
}
}
/**
* Ein Dokument unter seiner Sperre: frisch lesen, frisch das Abo lesen, erst dann schreiben.
* Dokument war in der Liste aktiv => noch aktiv und vom Kauf freigeschaltet, Abo laeuft noch nicht wieder => abschalten, dann K22.
* Dokument war in der Liste abgeschaltet (abo_ende) => noch so und ohne Marker, Abo laeuft weiter nicht => K22 nachholen.
* Was der Abgleich mit einem Dokument tut, das er aus der Liste bekommt (null = nichts):
* 'abschalten' aktiv und vom Kauf freigeschaltet (Grund abo_ende), 'einrichtung' ordered/setup (Grund abo_ende_vor_einrichtung),
* 'nachholen' abgeschaltet mit dem Grund abo_ende oder (bezahlt) abo_ende_vor_einrichtung ohne Marker. Ein unbezahltes,
* abgeschaltetes abo_ende_vor_einrichtung-Dokument hat nichts nachzuholen.
*/
function aktionFuer(doc) {
if (doc.state === 'active') return doc.unlockedByPurchase === true ? 'abschalten' : null
if (doc.state === 'ordered' || doc.state === 'setup') return 'einrichtung'
if (doc.state !== 'disabled' || doc.aboEndeMailAt) return null
if (doc.disabledReason === GRUND) return 'nachholen'
return doc.disabledReason === GRUND_VOR && doc.unlockedByPurchase === true ? 'nachholen' : null
}
/**
* Ein Dokument: unter der Sperre feature-admin:<projectId> frisch lesen, frisch das Abo lesen, erst dann schreiben; die Meldung
* (meldeAboEnde) folgt nach der Sperre.
* 'abschalten': in der Liste aktiv => noch aktiv und vom Kauf freigeschaltet, Abo laeuft noch nicht wieder => abschalten.
* 'einrichtung': in der Liste ordered/setup => noch ordered/setup (das Team kann inzwischen aktiviert haben), Abo laeuft nicht
* => abschalten (abo_ende_vor_einrichtung). Eine bezahlte Bestellung, die zwischen Liste und Sperre vom Webhook auf setup
* gestellt wurde, liefert disable() bereits als bezahlt zurueck.
* 'nachholen': in der Liste abgeschaltet mit Grund und ohne Marker => noch so und ohne Marker, Abo laeuft weiter nicht.
*/
async function bearbeiteDokument(doc, ctx) {
const { erg, now } = ctx
const warAktiv = doc.state === 'active'
await withLocks([`feature-admin:${doc.projectId}`], async () => {
const aktion = aktionFuer(doc)
const grund = aktion === 'einrichtung' ? GRUND_VOR : GRUND
const gefunden = await withLocks([`feature-admin:${doc.projectId}`], async () => {
const frisch = await getDocument(portalFeatures(), doc.$id)
if (warAktiv) {
if (frisch.state !== 'active' || frisch.unlockedByPurchase !== true) return // das Team war schneller
} else if (frisch.state !== 'disabled' || frisch.disabledReason !== GRUND || frisch.aboEndeMailAt) {
return
if (aktion === 'abschalten') {
if (frisch.state !== 'active' || frisch.unlockedByPurchase !== true) return null // das Team war schneller
} else if (aktion === 'einrichtung') {
if (frisch.state !== 'ordered' && frisch.state !== 'setup') return null // aktiviert oder abgeschaltet
} else if (frisch.state !== 'disabled' || frisch.disabledReason !== doc.disabledReason || frisch.aboEndeMailAt) {
return null
}
const lage = await aboLage(doc.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist
if (lage.laeuft) return // das Abo ist zurueck (Schritt 2 und die Wiedereinschaltung gehoeren E6)
if (lage.laeuft) return null // das Abo ist zurueck (Schritt 2 und die Wiedereinschaltung gehoeren E6)
let zuMelden = frisch
if (warAktiv) {
zuMelden = await disable(frisch.$id, { reason: GRUND, adminNote: NOTIZ })
if (zuMelden.state !== 'disabled' || zuMelden.disabledReason !== GRUND) return // jemand war zwischen Lesen und Schreiben schneller
if (aktion !== 'nachholen') {
zuMelden = await disable(frisch.$id, { reason: grund, adminNote: grund === GRUND_VOR ? NOTIZ_VOR : NOTIZ })
if (zuMelden.state !== 'disabled' || zuMelden.disabledReason !== grund) return null // jemand war zwischen Lesen und Schreiben schneller
erg.abgeschaltet += 1
}
const fertig = await meldeAboEnde(zuMelden, { lage, now, onGesendet: () => (erg.mails += 1) })
if (!fertig) erg.fehler += 1
return { zuMelden, lage }
})
if (!gefunden) return
const fertig = await meldeAboEnde(gefunden.zuMelden, { lage: gefunden.lage, now, onGesendet: () => (erg.mails += 1) })
if (!fertig) erg.fehler += 1
}
async function bearbeiteKunde(customerId, dokumente, ctx) {
@@ -144,9 +193,7 @@ async function bearbeiteKunde(customerId, dokumente, ctx) {
if (lage.laeuft) return // laeuft weiter (E6 schaltet abgeschaltete wieder ein)
for (const doc of bewertet) {
const aktiv = doc.state === 'active'
const nachholen = doc.state === 'disabled' && doc.disabledReason === GRUND && !doc.aboEndeMailAt
if (!aktiv && !nachholen) continue
if (!aktionFuer(doc)) continue
try {
await bearbeiteDokument(doc, ctx)
} catch (err) {