import { config } from '../../config.js' 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 { activate, customerOf, disable, listCustomerFeatureDocs, meldeErstattung, projectName } from '../siteFeatures.js' import { loeschzeit } from './frist.js' import { ladeEinstellung } from './kontext.js' import { pruefeNow, zeitMs } from './util.js' import { datumKurz } from './zeit.js' /** * Abo-Abgleich des Terminplaners (Terminplaner TP2, Block E): der Terminplaner gehoert zum Wartungs-Abo und endet mit ihm. * gleicheAboAb(now) ist der Baustein fuer den Tagesjob (angeschlossen in E7); er kennt weder Schalter noch Ueberlappungssperre. * * 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 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 * 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). * K23 geht nur an Kunden, die K22 bekommen haben (Marker aboEndeMailAt, vor dem activate gelesen). Nach der Loeschung ist * das Dokument `off` und wird nie automatisch aktiv. Nicht automatisch: 'abo_ende_vor_einrichtung' (die Erstattung laeuft, * das Team entscheidet per WOMS), 'team', 'kunde_geloescht' und Altbestand ohne Grund. * * Sofortpfad (E9): endeTermineFuerKunde(customerId) beendet die Terminplaner eines Kunden sofort, wenn dessen Wechsel ins Wartungs- * Abo in der Frist widerrufen wird (routes/abo.js, withdrawOrder). Dieselben Aktionen wie Schritt 1 und 1b, aber OHNE Abo-Pruefung * (der Spiegel steht noch auf Wartung, das Team setzt ihn von Hand zurueck); die Meldung (meldeAboEnde) ist dieselbe wie im * Tagesabgleich. Danach laesst der Tagesabgleich diese Dokumente in Ruhe (aboLage kennt die Wechsel-Regel: kein Flattern). * * 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. 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: (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: 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:), 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 * 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. */ 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 NOTIZ_WIEDER = 'Wieder eingeschaltet: Wartungs-Abo läuft wieder' const NOTIZ_WECHSEL = 'Abgeschaltet: Wechsel widerrufen' // Lage fuer den Sofortpfad: das Abo bleibt (oder wird wieder) ein Hosting-Abo, K22 nimmt den Block hosting const LAGE_WECHSEL_WIDERRUFEN = { laeuft: false, art: 'wechsel_widerrufen', endedAt: null } const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g /** Fehlertext fuers Log: Name und gekuerzte Meldung, E-Mail-Adressen (SMTP-Antworten nennen sie) unkenntlich. */ const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(ADRESSE, '[Adresse]').slice(0, 200)}` const istAusgenommen = (doc) => doc.state === 'active' && doc.unlockedByPurchase !== true const istBewertet = (doc) => (doc.state === 'active' && doc.unlockedByPurchase === true) || doc.state === 'ordered' || 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. */ export 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 const portalFeatures = () => config.collections.portalFeatures /** * 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 des Abo-Endes (lage.endedAt), sonst von disabledAt; * loeschdatum = 30 Tage nach disabledAt. * - 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 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:). 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 } | null, now: Date, onGesendet?: () => void }} options onGesendet wird nach jedem versendeten K22 gerufen * @returns {Promise} 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 = null, now, onGesendet = null }) { const nowMs = pruefeNow(now, 'meldeAboEnde') const vorEinrichtung = doc.disabledReason === GRUND_VOR 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 { 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 (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() return true } 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 // enddatum ist der Tag des Abo-Endes (lage.endedAt: endedAt || canceledAt des Abos), wenn bekannt - das Abschalten kann Tage danach liegen; // sonst (Sofortpfad, Nachlauf ohne Abo-Lesen, kein lesbares Datum) der Tag des Abschaltens. loeschdatum bleibt 30 Tage nach dem Abschalten. // Nur bei art 'beendet' ist lage.endedAt das Ende des Wartungs-Abos; bei 'hosting' gehoert es zum Hosting-Abo (etwa eine vorgemerkte Kuendigung). const aboEnde = lage?.art === 'beendet' ? zeitMs(lage.endedAt) : NaN const ende = Number.isFinite(aboEnde) ? new Date(aboEnde) : beginn 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: { kunde_name: customer.name || '', projekt, enddatum: datumKurz(ende), loeschdatum: datumKurz(loeschzeit(beginn)), preis: config.features.termine.priceLabel, portal_url: `${config.publicBaseUrl}/dashboard.html#termine`, }, blocks: grundBlock, }) gesendet = true onGesendet?.() await setzeMarker() return true } catch (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 } } /** * 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: 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 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 (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 null // das Abo ist zurueck: nichts mehr abzuschalten oder nachzuholen (die Rueckkehr ist Schritt 3, schalteWiederEin) let zuMelden = frisch 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 } 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 } /** * 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: 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} 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 } /** * Sofortpfad, ein Dokument: unter der Sperre feature-admin: frisch lesen (das Team kann inzwischen aktiviert oder * abgeschaltet, der Webhook die Zahlung gebucht haben) und nach dem FRISCHEN Zustand abschalten: aktiv und vom Kauf freigeschaltet => * 'abo_ende', ordered/setup => 'abo_ende_vor_einrichtung' (aktionFuer, wie im Tagesabgleich). Das Abo wird nicht gelesen. * @returns {Promise} das abgeschaltete Dokument (mit Kauf- und Zahlungsfeldern fuer meldeAboEnde) oder null, wenn nichts zu tun war */ async function beendeSofort(doc) { return withLocks([`feature-admin:${doc.projectId}`], async () => { const frisch = await getDocument(portalFeatures(), doc.$id) const aktion = aktionFuer(frisch) const grund = aktion === 'abschalten' ? GRUND : aktion === 'einrichtung' ? GRUND_VOR : null if (!grund) return null const abgeschaltet = await disable(frisch.$id, { reason: grund, adminNote: NOTIZ_WECHSEL }) return abgeschaltet.state === 'disabled' && abgeschaltet.disabledReason === grund ? abgeschaltet : null // sonst war jemand schneller }) } /** * Sofortpfad beim Widerruf des Wechsels ins Wartungs-Abo (E9): alle Terminplaner des Kunden, die der Wechsel getragen hat, werden * sofort abgeschaltet, ohne Abo-Pruefung (der Spiegel ist veraltet). Aktiv und vom Kauf freigeschaltet => 'abo_ende'; ordered/setup => * 'abo_ende_vor_einrichtung'. Aktive Dokumente ohne Kauf (Pilot, Kulanz), News, 'off' und schon Abgeschaltete bleiben unberuehrt. * Danach je Dokument NACH der Sperre meldeAboEnde: K22 (Block hosting bzw. vor_einrichtung); eine bezahlte Einrichtung meldet zuerst * T19 (genau einmal), ein unbezahltes ordered bekommt weder T19 noch K22 noch Marker. Scheitert eine Meldung, holt der Tagesabgleich * sie nach. Ein Fehler bei einem Dokument (Lesen, Abschalten) stoppt die anderen nicht; Fehler stehen im Log (ohne Namen und Adressen). * Wirft nie, wie endAnalyseForCustomer. * @param {string} customerId * @param {{ now?: Date }} [options] Zeitpunkt des Widerrufs (Marker aboEndeMailAt) * @returns {Promise<{ beendet: string[] }>} die IDs der abgeschalteten Funktionsdokumente */ export async function endeTermineFuerKunde(customerId, { now = new Date() } = {}) { const beendet = [] if (!customerId) return { beendet } try { const kandidaten = (await listCustomerFeatureDocs(customerId)).filter( (doc) => doc.featureKey === 'termine' && doc.projectId && ['abschalten', 'einrichtung'].includes(aktionFuer(doc)) ) for (const doc of kandidaten) { try { const abgeschaltet = await beendeSofort(doc) if (!abgeschaltet) continue beendet.push(abgeschaltet.$id) await meldeAboEnde(abgeschaltet, { lage: LAGE_WECHSEL_WIDERRUFEN, now }) // nach der Sperre; bei Misserfolg wiederholt der Tagesabgleich } catch (err) { console.error(`[termine-abo] Sofortpfad: Funktion ${doc.$id} nicht vollständig beendet, der Tagesabgleich holt nach: ${fehlerText(err)}`) } } } catch (err) { console.error(`[termine-abo] Sofortpfad: Funktionen eines Kunden nicht lesbar, nichts beendet: ${fehlerText(err)}`) } return { beendet } } /** * 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 * Aktivieren geleert, es gibt also keine Wiederholung am naechsten Tag (K23 geht hoechstens einmal je Rueckkehr). Ohne Kunde oder * ohne E-Mail-Adresse entfaellt die Mail (Hinweis im Log, kein Fehler). Wirft nie. */ async function sendeWiederEin(doc, ctx) { const { erg } = ctx try { const customer = await customerOf(doc.customerId) if (!customer?.email) { console.warn('[termine-abo] K23 entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse') return } await sendCustomerMail(customer.email, { template: 'K23', values: { kunde_name: customer.name || '', projekt: (await projectName(doc.projectId)) || 'Dein Projekt', portal_url: `${config.publicBaseUrl}/dashboard.html#termine`, }, }) erg.mails += 1 } catch (err) { erg.fehler += 1 console.error(`[termine-abo] K23 für Funktion ${doc.$id} nicht gesendet, der Terminplaner bleibt eingeschaltet: ${fehlerText(err)}`) } } /** * Schritt 3, ein Dokument: in der Liste abgeschaltet mit Grund 'abo_ende' und das Abo laeuft. Unter der Sperre feature-admin: * frisch lesen (noch disabled/abo_ende: das Team kann inzwischen aktiviert oder mit anderem Grund abgeschaltet haben), frisch das * Abo lesen (laeuft es wieder nicht, bleibt alles), die Einstellung lesen (fehlt sie, ist sie geloescht oder nie gespeichert worden: * nie automatisch aktiv), dann activate. Ob K22 ging, zeigt der Marker aboEndeMailAt, der VOR dem activate gelesen wird (activate * leert ihn). K23 folgt nach der Sperre und nur, wenn K22 ging; wer nie von dem Ende erfuhr, hoert auch nicht von der Rueckkehr. * Lesefehler werfen (der Aufrufer zaehlt fehler): dann bleibt das Dokument abgeschaltet. */ async function schalteWiederEin(doc, ctx) { const { erg } = ctx const eingeschaltet = await withLocks([`feature-admin:${doc.projectId}`], async () => { const frisch = await getDocument(portalFeatures(), doc.$id) if (!istRueckkehr(frisch)) return null const lage = await aboLage(doc.customerId) if (!lage.laeuft) return null // das Abo endete zwischen Vorauswahl und Sperre wieder if ((await ladeEinstellung(frisch.$id)) === null) return null // Daten geloescht oder nie da: Rueckkehr nur, solange sie existieren const k22Ging = Boolean(frisch.aboEndeMailAt) await activate(frisch.$id, { adminNote: NOTIZ_WIEDER }) erg.wiederEin += 1 return { k22Ging } }) if (eingeschaltet?.k22Ging) await sendeWiederEin(doc, ctx) } async function bearbeiteKunde(customerId, dokumente, ctx) { const { erg } = ctx erg.ausgenommen += dokumente.filter(istAusgenommen).length 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, ${uebrige.length} Funktion(en) übersprungen: ${fehlerText(err)}`) return } erg.geprueft += uebrige.length 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 try { await bearbeite(doc, ctx) } catch (err) { erg.fehler += 1 console.error(`[termine-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`) } } } /** * Taeglicher Abgleich des Terminplaners mit dem Wartungs-Abo (siehe Kopf der Datei). Wirft nie (ausser bei fehlendem `now`, wie * alle Termine-Jobs); jedes Problem steht im Log und in `fehler`. * @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; 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) */ export async function gleicheAboAb(now) { pruefeNow(now, 'gleicheAboAb') const erg = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 } try { let dokumente try { dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', 'termine'), Query.notEqual('state', 'off')]) } catch (err) { erg.fehler += 1 console.error(`[termine-abo] Funktionen nicht lesbar, der Abgleich entfällt: ${fehlerText(err)}`) return erg } const nachKunde = new Map() for (const doc of dokumente) { if (!doc.customerId || !doc.projectId) { // ohne Kunde gaelte "kein Abo": nie abschalten, was sich nicht zuordnen laesst erg.fehler += 1 console.error(`[termine-abo] Funktion ${doc.$id} ohne Kunde oder Projekt übersprungen`) continue } if (!nachKunde.has(doc.customerId)) nachKunde.set(doc.customerId, []) nachKunde.get(doc.customerId).push(doc) } const ctx = { erg, now, memo: aboMemo() } for (const [customerId, liste] of nachKunde) { try { await bearbeiteKunde(customerId, liste, ctx) } catch (err) { erg.fehler += 1 console.error(`[termine-abo] Kunde nicht bearbeitet: ${fehlerText(err)}`) } } } catch (err) { erg.fehler += 1 console.error(`[termine-abo] Abgleich abgebrochen: ${fehlerText(err)}`) } return erg }