Öffnungszeiten: Fixrunde Server (Schema-Kopplung, K27, Wertersatz, Widerruf, Ticket nachlegen)

- activate() schreibt die Zeiten-Felder (aboEndeAt, behaltenSessionId) nur noch über extra
  (nur Abo-Rückkehr und Team-Aktivierung der Zeiten); News und Terminplaner laufen auch vor zeiten-setup.mjs
- Abo-Abgleich: K27 gilt als offen, solange der Marker vor disabledAt liegt (kein Zurücksetzen nach disable() nötig);
  K27 geht erst nach dem Ersatz-Ticket raus (scheitert es, wiederholt der nächste Lauf beides);
  Löschung setzt auch activatedAt zurück
- Zustimmung "beginn" (bezahlt) mit Wertersatz-Satz (§ 357a Abs. 2 BGB) wie bei News und Terminplaner, Fassung 2026-10-05
- Widerruf: Bestellung "zeiten" in ORDERS, FEATURE_OF_ORDER, orderLabel und widerrufen.html
- Team "Ticket nachlegen" der Zeiten ohne Kauf geht über holeZeitenNach statt über den Zahlungsweg
- Einschalten-Route nimmt nur { zustimmung }, nicht die Altform { consent: true }
- Team-Aktivierung behält kauf/unlockedByPurchase bei einem bisher gekauften Dokument

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:04:05 +00:00
1 parent c10e50d0b3
commit ecf67d6978
13 files changed
+337 -48

No files matched your search

+41 -8
View File
@@ -462,11 +462,26 @@ export async function markCheckoutFailed(sessionId) {
})
}
/**
* Oeffnungszeiten live (Spec 5): das erkannte Abo-Ende und eine offene Behalten-Zahlung gehoeren zum alten Lauf und enden mit der
* Aktivierung. Als `extra` an activate() (nur der Zeiten-Pfad; die Felder legt scripts/zeiten-setup.mjs an, News und Terminplaner
* teilen die Collection).
*/
export const ZEITEN_AKTIVIERUNG_FELDER = Object.freeze({ aboEndeAt: null, behaltenSessionId: '' })
function byNote(by, verb) {
return by ? `${verb} von ${by}`.slice(0, 300) : undefined
}
export async function activate(docId, { by, adminNote } = {}) {
/**
* Funktion aktivieren (jede Funktion gleich). `extra` sind zusaetzliche Felder, die der Aufrufer mitschreibt: Oeffnungszeiten live
* leert damit sein erkanntes Abo-Ende und eine offene Behalten-Zahlung (ZEITEN_AKTIVIERUNG_FELDER). Diese Felder legt erst
* scripts/zeiten-setup.mjs an; darum schreibt activate() sie nie von selbst, sonst scheiterte die Aktivierung von News und
* Terminplaner mit "Unknown attribute", solange der Code vor dem Skript laeuft.
* @param {string} docId
* @param {{ by?: string, adminNote?: string, extra?: Record<string, unknown> }} [options]
*/
export async function activate(docId, { by, adminNote, extra } = {}) {
const note = adminNote ?? byNote(by, 'Freigeschaltet')
return updateDocument(config.collections.portalFeatures, docId, {
...stateFields('active'),
@@ -477,10 +492,7 @@ 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: '',
...(extra ?? {}),
...(note ? { adminNote: note } : {}),
})
}
@@ -583,7 +595,7 @@ export async function adminActivate(customerId, projectId, key, { by, adminNote
await assertOwnProject(customerId, projectId)
const doc = await ensureFeatureDoc(customerId, projectId, key)
if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId)
return activate(doc.$id, { by, adminNote })
return activate(doc.$id, { by, adminNote, ...(key === 'zeiten' ? { extra: ZEITEN_AKTIVIERUNG_FELDER } : {}) })
}
/**
@@ -814,6 +826,26 @@ async function ticketNachZahlung(doc, note) {
}
}
/**
* Ticket nachlegen (a) fuer Oeffnungszeiten live ohne Kauf (Abo, Team): es gibt keine Zahlung zu pruefen; das Einrichten holt
* holeZeitenNach nach (Neubau, Einbau-Ticket, K24 nur bei Abo), derselbe Weg wie der 5-Minuten-Job und das Einschalten. Dienst
* erst beim Aufruf laden (zeiten/* nur per await import()). Sperren: feature-ticket (hier gehalten) -> zeiten-nachholen ->
* zeiten-neubau; nichts darin nimmt feature-admin. Wirft, wenn das Ticket nicht angelegt werden kann (der Aufrufer meldet es).
*/
async function ticketNachHolen(doc, note) {
const { holeZeitenNach } = await import('./zeiten/einschalten.js')
const nachher = await holeZeitenNach(doc)
if (!nachher.ticketId) return { doc: nachher, created: false }
if (!note) return { doc: nachher, created: true }
try {
return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true }
} catch (err) {
// Ticket und Mail sind raus; die Notiz ist nur ein Vermerk
console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message)
return { doc: nachher, created: true }
}
}
/**
* Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim
* ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:<session>), damit kein Stripe-Retry daneben
@@ -835,7 +867,8 @@ async function ersetzeGeloeschtesTicket(doc, note) {
/**
* "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409).
* (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen,
* Ticket + K15 + T16 genau einmal (ticketNachZahlung);
* Ticket + K15 + T16 genau einmal (ticketNachZahlung); Oeffnungszeiten live ohne Kauf (Abo, Team) haben keine Zahlung: dort holt
* holeZeitenNach Neubau, Ticket und K24 nach (ticketNachHolen);
* (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
* (c) Ticket vorhanden: nichts, created false.
* Eine Sperre feature-ticket:<id> je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten
@@ -849,7 +882,7 @@ export async function ticketNachlegen({ customerId, projectId, key, by, adminNot
return withLocks([`feature-ticket:${vorab.$id}`], async () => {
const doc = await frischesDokument(vorab, key)
if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
if (!doc.ticketId) return ticketNachZahlung(doc, note)
if (!doc.ticketId) return key === 'zeiten' && unlockedByVon(doc) !== 'kauf' ? ticketNachHolen(doc, note) : ticketNachZahlung(doc, note)
if (await ticketBesteht(doc.ticketId)) return { doc, created: false }
return ersetzeGeloeschtesTicket(doc, note)
})