Öffnungszeiten: Abo-Abgleich mit 30 Tagen Frist

gleicheZeitenAboAb(now) koppelt die im Wartungs-Abo enthaltenen Zeiten
(unlockedBy abo) an das Abo (Spec 5, Z11): Abo-Ende erkennen (aboEndeAt, K26),
nach 30 Berliner Kalendertagen abschalten (abo_ende, K27, Ersatz-Ticket),
bei Rückkehr des Abos wieder einschalten (Neubau nach dem Abschalten, K24
wieder), 30 Tage nach dem Abschalten Daten löschen und state off.
Lesefehler beim Abo: Kunde überspringen, nie abschalten oder löschen.

R25: activate() leert aboEndeAt und behaltenSessionId; starteBehalten lehnt
bei laufendem Wartungs-Abo mit 409 ab (Lesefehler 503).
R17: k24Mail mit einbau null und wieder schaltet beide Einbau-Blöcke ab,
K24 bekommt dafür den Block weiter um den Kasten "Wie geht es weiter?".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 03:16:56 +00:00
1 parent 53253f0451
commit efc6dcf332
8 files changed
+1285 -10

No files matched your search

+4
View File
@@ -462,6 +462,10 @@ export async function activate(docId, { by, adminNote } = {}) {
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
// Oeffnungszeiten live (Spec 5): das erkannte Abo-Ende und eine offene Behalten-Zahlung gehoeren zum alten Lauf und enden mit der
// Aktivierung (die Felder legt scripts/zeiten-setup.mjs an; News und Terminplaner teilen die Collection)
aboEndeAt: null,
behaltenSessionId: '',
...(note ? { adminNote: note } : {}),
})
}
+348
View File
@@ -0,0 +1,348 @@
import { config } from '../../config.js'
import { aboLage, aboMemo } from '../aboStatus.js'
import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { activate, customerOf, disable, notizVorn, stateFields, unlockedByVon } from '../siteFeatures.js'
import { LOESCHFRIST_TAGE } from '../termine/frist.js'
import { pruefeNow, zeitMs } from '../termine/util.js'
import { heute, plusTage, zuUtc } from '../termine/zeit.js'
import { nachAbschaltung } from './abschalten.js'
import { k24Mail, k26Mail, k27Mail, sendeZeitenMail } from './mails.js'
import { stosseNeubauAn } from './neubau.js'
import { ladeZeiten, loescheZeiten } from './speicher.js'
/**
* Abo-Abgleich von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 5 "Abo-Abgleich" und Z11):
* Die Funktion gehört im Wartungs-Abo ohne Aufpreis dazu und endet mit ihm, aber nicht sofort: 30 Tage Frist zum Behalten (30 €), danach
* ist sie abgeschaltet und die Eingaben bleiben noch 30 Tage. gleicheZeitenAboAb(now) ist der Baustein des Tagesjobs (angeschlossen in
* Task 22); er kennt weder Schalter noch Überlappungssperre: der Aufrufer lässt nie zwei Läufe gleichzeitig laufen.
*
* Nur Dokumente mit unlockedBy 'abo' (unlockedByVon) sind ans Abo gekoppelt; 'kauf' und 'team' fasst der Abgleich nie an, und das Abo
* wird dann nicht einmal gelesen. Je Dokument gilt (Abo gelesen mit aboLage, grosszügige Regel inklusive Wechsel-Regel):
*
* setup/active, Abo läuft nicht, aboEndeAt leer Schritt 1: aboEndeAt = now, K26 (Variante nach art; Frist = Berliner Tag von
* aboEndeAt + 30 Tage)
* setup/active, Abo läuft nicht, Frist erreicht Schritt 2: disabled/'abo_ende', K27 (Löschdatum = Abschalten + 30 Tage), danach
* das Ersatz-Ticket des Teams (nachAbschaltung)
* setup/active, Abo läuft, aboEndeAt gesetzt Schritt 3a: das Abo ist vor dem Abschalten zurück: aboEndeAt, Marker und offene
* Behalten-Sitzung leeren, Zustand bleibt, KEIN Neubau (die Leisten waren nie weg),
* K24 "wieder" ohne den Kasten "Wie geht es weiter?" (k24Mail einbau null, R17b)
* disabled/'abo_ende', Abo läuft Schritt 3b: zurück auf active (war die Funktion schon einmal freigeschaltet:
* activatedAt) sonst setup; Neubau nach der Sperre (stosseNeubauAn), K24 "wieder"
* mit dem echten einbau. Solange die Eingaben noch da sind: auch am Tag der Löschung
* geht die Rückkehr der Löschung vor (die Rückkehr wird je Lauf zuerst entschieden).
* disabled/'abo_ende', Abo läuft nicht, 30 Tage Schritt 4: loescheZeiten (daten, team, Merker) und state 'off'; ticketId, einbau,
* nach disabledAt k24At, aboEndeAt und behaltenSessionId leer (ersatzHash lebt im gelöschten
* Zeiten-Dokument). Grund 'team' (und jeder andere Grund) wird nie gelöscht.
*
* Tage zählen nach BERLINER KALENDERTAGEN (plusTage, nie Vielfache von 24 Stunden): "Tag 30" ist der 30. Berliner Tag nach dem Tag, an dem
* das Abo-Ende erkannt wurde, egal um welche Uhrzeit der Tagesjob an diesem Tag läuft (er läuft im 24-Stunden-Takt ab dem Start des Servers
* und kann je Tag ein paar Minuten früher oder später liegen; eine Zeitumstellung verschöbe die Frist um eine Stunde). Dieselben Tage stehen
* in K26 und K27.
*
* Regeln (wie termine/aboAbgleich.js):
* - Eine Listenabfrage für alle Kunden (Entwürfe 'off' bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird für die
* Vorauswahl nur einmal gelesen; Dokumente, bei denen nichts zu tun ist (Abo läuft, kein Abo-Ende), kosten weder Sperre noch Schreiben.
* - 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): der frische Stand entscheidet, nicht die Vorauswahl. Mails, Neubau und Ersatz-Ticket folgen NACH
* der Sperre (Netzwerk; Sperren sind nicht wiederbetretbar).
* - Fail-closed: ist das Abo nicht lesbar (Vorauswahl oder unter der Sperre), wird nichts abgeschaltet, nichts gelöscht und nichts
* eingeschaltet. Der Kunde wird übersprungen, `fehler` zählt, der nächste Lauf versucht es wieder. Ein Dokument mit unlesbarem Datum
* wird übersprungen (`fehler`), die übrigen laufen weiter. Das Log nennt weder Namen noch Adressen.
* - Mails: aboEndeMailAt ist der Marker "die Mail dieser Phase ging raus", gesetzt NACH dem Versand: in der Frist gilt er der K26, nach dem
* Abschalten der K27 (er wird beim Abschalten zurückgesetzt). Scheitert der Versand, bleibt der Marker leer, der nächste Lauf sendet nach
* (dieselben Daten; die Frist wächst nie, aboEndeAt und disabledAt ändert der Abgleich nicht mehr). Ein Kunde ohne Adresse bekommt den
* Marker (es gibt nichts zu senden). K24 "wieder" geht nur an Kunden, die K26 oder K27 bekommen haben (Marker, vor dem Leeren gelesen)
* und hat keinen Marker: höchstens einmal je Rückkehr (scheitert der Versand, steht es im Log und in `fehler`).
* - Ruling R25: die Rückkehr leert aboEndeAt und behaltenSessionId; eine noch offene Behalten-Zahlung bucht danach nicht mehr zum Kauf
* (bucheBehalten: Sitzung passt nicht, T19), und starteBehalten lehnt bei laufendem Abo ab.
*/
const KEY = 'zeiten'
const GRUND = 'abo_ende'
/** Frist zum Behalten nach dem erkannten Abo-Ende (Z11); die Datenfrist nach dem Abschalten ist LOESCHFRIST_TAGE (30). */
const FRIST_TAGE = 30
const NOTIZ_ABGESCHALTET = 'System: Abgeschaltet: Wartungs-Abo beendet, Frist abgelaufen'
const NOTIZ_WIEDER = 'System: Wieder eingeschaltet: Wartungs-Abo läuft wieder'
const NOTIZ_OHNE_NEUBAU = 'System: Wartungs-Abo läuft wieder, Abo-Ende zurückgenommen'
const NOTIZ_GELOESCHT = `System: Öffnungszeiten gelöscht, ${LOESCHFRIST_TAGE} Tage nach dem Abschalten`
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
/** Fehlertext fürs Log: Name und gekürzte 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 portalFeatures = () => config.collections.portalFeatures
const istLaufend = (doc) => doc.state === 'setup' || doc.state === 'active'
const istAbgeschaltet = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND
/** Ans Wartungs-Abo gekoppelt: von ihm freigeschaltet und im laufenden Betrieb oder wegen des Abo-Endes abgeschaltet. */
const istGekoppelt = (doc) => unlockedByVon(doc) === 'abo' && (istLaufend(doc) || istAbgeschaltet(doc))
/** Berliner Kalendertag eines Datumsfeldes; unlesbar ist ein Fehler (das Dokument wird übersprungen), nie "heute". */
function tagVon(wert, feld) {
const t = zeitMs(wert)
if (!Number.isFinite(t)) throw new Error(`${feld} nicht lesbar`)
return heute(new Date(t))
}
/** Tag, an dem abgeschaltet wird: Berliner Tag von aboEndeAt + 30 Tage. */
const fristTag = (doc) => plusTage(tagVon(doc.aboEndeAt, 'aboEndeAt'), FRIST_TAGE)
/** Tag, an dem die Eingaben gelöscht werden: Berliner Tag von disabledAt + 30 Tage. */
const loeschTag = (doc) => plusTage(tagVon(doc.disabledAt, 'disabledAt'), LOESCHFRIST_TAGE)
/** Ein Date mitten an einem Berliner Tag: die Mail-Builder zeigen den Berliner Tag dieses Zeitpunkts (kein Springen über Mitternacht). */
const mittags = (tag) => zuUtc(tag, '12:00')
/**
* Was mit dem Dokument bei dieser Abo-Lage und diesem Tag zu tun ist (reine Entscheidung, null = nichts):
* 'ende' | 'k26' | 'abschalten' | 'k27' | 'loeschen' | 'zurueck' | 'zurueck_ohne_neubau'. Wirft bei unlesbarem Datum.
*/
function entscheide(doc, lage, now) {
const tag = heute(now)
if (istAbgeschaltet(doc)) {
if (lage.laeuft) return 'zurueck'
if (tag >= loeschTag(doc)) return 'loeschen'
return doc.aboEndeMailAt ? null : 'k27'
}
if (lage.laeuft) return doc.aboEndeAt ? 'zurueck_ohne_neubau' : null
if (!doc.aboEndeAt) return 'ende'
if (tag >= fristTag(doc)) return 'abschalten'
return doc.aboEndeMailAt ? null : 'k26'
}
/** Sind Eingaben des Kunden gespeichert? Lesefehler werden weitergereicht (dann bleibt alles, wie es ist). */
async function datenDa(featureId) {
return (await ladeZeiten(featureId))?.daten != null
}
/** Daten löschen (unter der Sperre feature-admin:<projectId>), danach das Dokument auf 'off' und leer. */
async function loesche(frisch, ctx) {
await loescheZeiten(frisch.$id)
await updateDocument(portalFeatures(), frisch.$id, {
...stateFields('off'),
ticketId: '',
einbau: '',
k24At: null,
aboEndeAt: null,
behaltenSessionId: '',
adminNote: notizVorn(frisch.adminNote, `${ctx.jetzt} ${NOTIZ_GELOESCHT}`),
})
ctx.erg.geloescht += 1
}
/**
* Ein Dokument unter der Sperre feature-admin:<projectId>: frisch lesen, frisch das Abo lesen, den frischen Stand entscheiden lassen und
* schreiben. Liefert, was nach der Sperre zu tun ist ({ aktion, doc, lage, hatteMail }), oder null (nichts mehr zu tun, das Team war
* schneller). Wirft bei Lesefehlern (Dokument oder Abo): dann ist nichts geschrieben.
*/
async function unterSperre(doc, ctx) {
const { erg, jetzt, now } = ctx
const frisch = await getDocument(portalFeatures(), doc.$id)
if (!istGekoppelt(frisch)) return null // das Team war schneller (anderer Grund, anderer Zustand, nicht mehr im Abo)
const lage = await aboLage(frisch.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist
const aktion = entscheide(frisch, lage, now)
const hatteMail = Boolean(frisch.aboEndeMailAt) // vor dem Leeren gelesen: wer nie vom Ende erfuhr, hört nicht von der Rückkehr
switch (aktion) {
case 'ende': {
const neu = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: jetzt, aboEndeMailAt: null })
erg.aboEnde += 1
return { aktion: 'k26', doc: neu, lage }
}
case 'k26':
case 'k27':
return { aktion, doc: frisch, lage }
case 'abschalten': {
const aus = await disable(frisch.$id, { reason: GRUND, adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_ABGESCHALTET}`) })
if (aus.state !== 'disabled' || aus.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller
erg.abgeschaltet += 1
// ab jetzt gilt der Marker der K27, nicht mehr der der K26
return { aktion: 'k27', doc: await updateDocument(portalFeatures(), frisch.$id, { aboEndeMailAt: null }), lage }
}
case 'loeschen':
await loesche(frisch, ctx)
return null
case 'zurueck': {
// Löschtag erreicht und die Daten sind schon weg (ein früherer Lauf wurde nach dem Löschen unterbrochen): nichts einzuschalten
if (heute(now) >= loeschTag(frisch) && !(await datenDa(frisch.$id))) {
await loesche(frisch, ctx)
return null
}
const note = notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_WIEDER}`)
const zurueck = frisch.activatedAt
? await activate(frisch.$id, { adminNote: note })
: await updateDocument(portalFeatures(), frisch.$id, {
...stateFields('setup'),
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
aboEndeAt: null,
behaltenSessionId: '',
adminNote: note,
})
erg.wiederEin += 1
return { aktion, doc: zurueck, lage, hatteMail }
}
case 'zurueck_ohne_neubau': {
const zurueck = await updateDocument(portalFeatures(), frisch.$id, {
aboEndeAt: null,
aboEndeMailAt: null,
behaltenSessionId: '',
adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_OHNE_NEUBAU}`),
})
erg.wiederEin += 1
return { aktion, doc: zurueck, lage, hatteMail }
}
default:
return null
}
}
/** Das Projekt für Neubau und Mailtext; nicht lesbar oder gelöscht ergibt null (der Neubau meldet dann 'manuell', der Name fehlt). */
async function ladeProjekt(projectId) {
try {
return await getDocument(config.collections.websiteProjects, projectId)
} catch (err) {
if (err?.status !== 404) console.error(`[zeiten-abo] Projekt ${projectId} nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
return null
}
}
const projektName = (projekt) => projekt?.projectName || projekt?.subdomain || ''
const preis = () => config.features.zeiten.priceLabel
/** K26 oder K27 an die E-Mail des Kundenkontos; danach der Marker. Wirft nie bei Versandfehlern (fehler zählt, der nächste Lauf wiederholt). */
async function sendeMitMarker(doc, bauen, ctx) {
const customer = await customerOf(doc.customerId)
const projekt = projektName(await ladeProjekt(doc.projectId))
const { sent, reason } = await sendeZeitenMail(customer, bauen({ kunde: customer, projekt }))
if (sent) ctx.erg.mails += 1
else if (reason !== 'keine_adresse') {
ctx.erg.fehler += 1 // Marker bleibt leer: der nächste Lauf sendet nach
return
}
await updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: ctx.jetzt })
}
/** K24 "wieder" (ohne Marker, höchstens einmal je Rückkehr). einbau null = es gab keinen Neubau. */
async function sendeWiederMail(doc, einbau, ctx) {
const customer = await customerOf(doc.customerId)
const projekt = projektName(await ladeProjekt(doc.projectId))
const { sent, reason } = await sendeZeitenMail(customer, k24Mail({ kunde: customer, projekt, einbau, wieder: true }))
if (sent) ctx.erg.mails += 1
else if (reason !== 'keine_adresse') ctx.erg.fehler += 1
}
/**
* Was nach der Sperre folgt (Netzwerk: Ticket, Neubau, Mails): K26 mit Marker; nach dem Abschalten das Ersatz-Ticket (R23,
* nachAbschaltung ist idempotent über den Ersatz-Hash und wirft nie), dann K27 mit Marker; nach der Rückkehr aus 'disabled' der Neubau
* und K24 "wieder" mit dem echten einbau, nach der Rückkehr ohne Abschalten K24 "wieder" mit einbau null.
*/
async function danach({ aktion, doc, lage, hatteMail }, ctx) {
switch (aktion) {
case 'k26':
await sendeMitMarker(doc, ({ kunde, projekt }) => k26Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx)
return
case 'k27':
if ((await nachAbschaltung(doc, { now: ctx.now })) === 'fehler') ctx.erg.fehler += 1
await sendeMitMarker(doc, ({ kunde, projekt }) => k27Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx)
return
case 'zurueck': {
const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId))
try {
await updateDocument(portalFeatures(), doc.$id, { einbau })
} catch (err) {
console.error(`[zeiten-abo] einbau für Funktion ${doc.$id} nicht gespeichert: ${fehlerText(err)}`)
}
if (hatteMail) await sendeWiederMail(doc, einbau, ctx)
return
}
case 'zurueck_ohne_neubau':
if (hatteMail) await sendeWiederMail(doc, null, ctx)
return
default:
}
}
/** Ein Dokument: unter der Sperre schreiben, danach die Folgen. Wirft bei Fehlern (der Aufrufer zählt fehler). */
async function bearbeiteDokument(doc, ctx) {
const folge = await withLocks([`feature-admin:${doc.projectId}`], () => unterSperre(doc, ctx))
if (folge) await danach(folge, ctx)
}
async function bearbeiteKunde(customerId, dokumente, ctx) {
const { erg, now } = ctx
const gekoppelt = dokumente.filter(istGekoppelt)
if (gekoppelt.length === 0) return // nichts zu bewerten: das Abo wird nicht gelesen
let lage
try {
lage = await ctx.memo.lage(customerId)
} catch (err) {
erg.fehler += 1
console.error(`[zeiten-abo] Abo-Lage eines Kunden nicht lesbar, ${gekoppelt.length} Funktion(en) übersprungen: ${fehlerText(err)}`)
return
}
erg.geprueft += gekoppelt.length
for (const doc of gekoppelt) {
// je Dokument gefangen (auch Entscheidung, Builder und Versand): ein kaputtes Datum stoppt weder den Kunden noch den Lauf
try {
if (!entscheide(doc, lage, now)) continue // Vorauswahl mit dem Memo: nichts zu tun kostet weder Sperre noch Abo-Abfrage
await bearbeiteDokument(doc, ctx)
} catch (err) {
erg.fehler += 1
console.error(`[zeiten-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`)
}
}
}
/**
* Täglicher Abgleich von Öffnungszeiten live 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 Zeitpunkt des Laufs (die Tage zählen nach dem Berliner Kalendertag von now)
* @returns {Promise<{ geprueft: number, aboEnde: number, abgeschaltet: number, wiederEin: number, geloescht: number, mails: number, fehler: number }>}
* geprueft = ans Abo gekoppelte Dokumente (unlockedBy abo, setup/active oder abgeschaltet mit Grund abo_ende), deren Kunde lesbar war;
* aboEnde = in diesem Lauf als beendet erkannt (Schritt 1); abgeschaltet = in diesem Lauf abgeschaltet (2); wiederEin = in diesem Lauf
* zurückgekehrt (3, mit und ohne Abschalten); geloescht = in diesem Lauf gelöscht (4); mails = versendete Kundenmails K24 "wieder", K26 und
* K27; fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie gescheiterte Mails und Ersatz-Tickets
*/
export async function gleicheZeitenAboAb(now) {
pruefeNow(now, 'gleicheZeitenAboAb')
const erg = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 }
try {
let dokumente
try {
dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', KEY), Query.notEqual('state', 'off')])
} catch (err) {
erg.fehler += 1
console.error(`[zeiten-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 gälte "kein Abo": nie abschalten oder löschen, was sich nicht zuordnen lässt
if (unlockedByVon(doc) === 'abo') {
erg.fehler += 1
console.error(`[zeiten-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, jetzt: now.toISOString(), memo: aboMemo() }
for (const [customerId, liste] of nachKunde) {
try {
await bearbeiteKunde(customerId, liste, ctx)
} catch (err) {
erg.fehler += 1
console.error(`[zeiten-abo] Kunde nicht bearbeitet: ${fehlerText(err)}`)
}
}
} catch (err) {
erg.fehler += 1
console.error(`[zeiten-abo] Abgleich abgebrochen: ${fehlerText(err)}`)
}
return erg
}
+22 -1
View File
@@ -1,4 +1,5 @@
import { config } from '../../config.js'
import { aboLage } from '../aboStatus.js'
import { getDocument, updateDocument } from '../appwriteAdmin.js'
import { featureConfig } from '../featureRegistry.js'
import { withLocks } from '../keyedLock.js'
@@ -29,6 +30,8 @@ const KEY = 'zeiten'
const konflikt = (text) => Object.assign(new Error(text), { status: 409 })
const NICHT_MOEGLICH = 'Behalten ist gerade nicht möglich. Das geht nur nach dem Ende deines Wartungs-Abos, solange deine Öffnungszeiten noch nicht gelöscht sind.'
const ABO_LAEUFT = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
const ABO_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'
const ZAHLUNG_LAEUFT = 'Deine Zahlung wird gerade bestätigt. Bitte schau in einigen Minuten noch einmal vorbei.'
const STRIPE_NICHT_ERREICHBAR = 'Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.'
@@ -48,6 +51,22 @@ async function behaltenMoeglich(doc) {
return false
}
/**
* Läuft das Wartungs-Abo wieder, ist die Funktion darin enthalten und der Abo-Abgleich nimmt das Abo-Ende zurück (zeiten/aboAbgleich.js):
* in der Zeit dazwischen soll niemand 30 € für etwas bezahlen, das ohnehin enthalten ist (Ruling R25). Frisch gelesen (aboLage direkt, nie
* ein Zwischenspeicher); ein Lesefehler ist 503 (fail-closed: unklar, ob das Abo läuft), im Log stehen nur Art und Status.
*/
async function pruefeAboLaeuftNicht(customerId) {
let lage
try {
lage = await aboLage(customerId)
} catch (err) {
console.error(`[zeiten] Behalten: Wartungs-Abo nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
throw Object.assign(new Error(ABO_NICHT_PRUEFBAR), { status: 503 })
}
if (lage.laeuft === true) throw konflikt(ABO_LAEUFT)
}
/** Die gespeicherte Behalten-Sitzung bei Stripe: offen => { url }; abgelaufen oder unbekannt => null (neue Sitzung); sonst 409 bzw. 503. */
async function offeneSitzung(sessionId) {
let session
@@ -73,7 +92,7 @@ async function offeneSitzung(sessionId) {
* @param {{ customer: { $id: string }, projectId: string, nachweis: (sessionId: string) => Promise<unknown>, now?: Date }} p
* customer: Kundendokument; nachweis: speichert den Zustimmungsnachweis zur Stripe-Sitzung; now: Zeit des Klicks (Metadaten consentAt)
* @returns {Promise<{ url: string }>}
* @throws {Error} 409 (nicht moeglich, Zahlung laeuft), 503 (Stripe nicht erreichbar), sonst Fehler von Appwrite, Stripe und Nachweis
* @throws {Error} 409 (nicht moeglich, Wartungs-Abo laeuft wieder, Zahlung laeuft), 503 (Abo oder Stripe nicht erreichbar), sonst Fehler von Appwrite, Stripe und Nachweis
*/
export async function starteBehalten({ customer, projectId, nachweis, now = new Date() } = {}) {
if (typeof customer?.$id !== 'string' || customer.$id === '') throw new TypeError('starteBehalten: customer fehlt')
@@ -84,6 +103,8 @@ export async function starteBehalten({ customer, projectId, nachweis, now = new
return withLocks([`feature-order:${customerId}:${projectId}`], async () => {
const doc = await getFeatureDoc(customerId, projectId, KEY)
if (!(await behaltenMoeglich(doc))) throw konflikt(NICHT_MOEGLICH)
// läuft das Abo wieder (der Abgleich hat das Abo-Ende noch nicht zurückgenommen), gibt es weder eine neue noch die alte Zahlungsseite
await pruefeAboLaeuftNicht(customerId)
// eine schon begonnene Zahlung: dieselbe Seite nochmal, nie eine zweite Sitzung daneben
if (doc.behaltenSessionId) {
+5 -1
View File
@@ -39,12 +39,16 @@ function datum(wert, feld) {
* @param {{ kunde: string | { name?: string }, projekt: string, einbau?: string | null, wieder?: boolean, ticketNr?: string | number | null }} p
* einbau: 'angestossen' (Neubau läuft: Leisten erscheinen nach dem nächsten Veröffentlichen) oder 'manuell' (das Team baut ein);
* alles andere, auch ein fehlender Wert, sagt das Vorsichtige: das Team baut ein.
* Ausnahme: einbau null zusammen mit wieder = es gab keinen Neubau (das Abo kehrte vor dem Abschalten zurück, die Leisten waren nie weg):
* beide Einbau-Blöcke und der ganze Kasten „Wie geht es weiter?“ (Block weiter) entfallen.
* wieder: das Wartungs-Abo läuft wieder und die Funktion wurde automatisch wieder eingeschaltet.
* ticketNr: Nummer des Einbau-Tickets; ohne sie steht kein Ticket in der Mail.
*/
export function k24Mail({ kunde, projekt, einbau, wieder = false, ticketNr = null }) {
const nr = String(ticketNr ?? '').trim().replace(/^#/, '')
const keinNeubau = wieder === true && einbau === null
const angestossen = einbau === 'angestossen'
const manuell = !keinNeubau && !angestossen
return {
template: 'K24',
values: {
@@ -54,7 +58,7 @@ export function k24Mail({ kunde, projekt, einbau, wieder = false, ticketNr = nul
ticket: nr ? ` (Ticket #${nr})` : '',
portal_url: portalUrl(),
},
blocks: { angestossen, manuell: !angestossen, wieder: Boolean(wieder) },
blocks: { angestossen, manuell, wieder: Boolean(wieder), weiter: angestossen || manuell },
}
}