Öffnungszeiten: Bezahlen gesperrt, wenn im Abo enthalten

Checkout: neue Stufe 7b pruefeInklusive (Funktionen mit aboInklusive):
laeuft das Wartungs-Abo (auch nicht aktiv), 409 inklusive mit Hinweis aufs
Einschalten; kein Abo, Hosting und gekuendigt zahlen weiter 30 Euro.

Zahlung (markPaid): setup mit unlockedBy kauf, dann Neubau (stosseNeubauAn,
einbau am Dokument), erst danach das Einbau-Ticket mit dem Ergebnis; die
Webhook-Wiederholung baut nicht noch einmal. Spaete Zahlungen aendern ein
vorhandenes unlockedBy abo/team nicht. News und Terminplaner unveraendert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 02:18:30 +00:00
1 parent 66793d06d5
commit 47ae572fff
4 files changed
+518 -4

No files matched your search

+4 -2
View File
@@ -22,7 +22,7 @@ import { zeitenFuerKunde } from '../services/zeiten/schalter.js'
import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js'
import { aboMemo } from '../services/aboStatus.js'
import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js'
import { bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
import { bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
import {
ELIGIBILITY_MESSAGES,
@@ -238,7 +238,7 @@ async function pruefeUnterlagen(haken) {
// Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news.
// Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung,
// kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter,
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo.
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo, 7b inklusive (Abo laeuft: einschalten statt bezahlen).
router.post('/:key/checkout', requireSession, async (req, res) => {
// Zeit des Klicks: Eingang der Anfrage, vor jeder Sperre und jedem Stripe-Aufruf. Sie ist der eine Zeitpunkt von Nachweis,
// Stripe-Metadaten (consentAt) und Dokument und damit der Anker der Widerrufsfrist (UTC mit Z)
@@ -282,6 +282,8 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
}
// 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv
await pruefeAbo(feature, customerId)
// 7b Funktionen mit aboInklusive (Oeffnungszeiten live): im laufenden Wartungs-Abo gibt es nichts zu bezahlen (409 inklusive)
await pruefeInklusive(feature, customerId)
// Zustand, Sitzung, Nachweis und Eintrag als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine zwei Sitzungen
// erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
+25
View File
@@ -144,6 +144,31 @@ export async function pruefeAbo(feature, customerId) {
throw bestellFehler(409, aboMeldung(feature, grund), grund, { grund })
}
const INKLUSIVE_TEXT = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.'
/**
* Pruefstufe 7b: Funktionen mit aboInklusive (Oeffnungszeiten live) kosten im laufenden Wartungs-Abo nichts. Laeuft das Abo und ist
* es nicht gekuendigt (bestellbar ok) oder nur gerade nicht aktiv (nicht_aktiv: Probezeit, offene Zahlung), gibt es keinen Grund zu
* bezahlen: 409 inklusive mit dem Hinweis aufs Einschalten; body.grund ist null (Abo laeuft) oder 'nicht_aktiv' (wie zeiten.grund in
* GET /api/funktionen). kein_abo und gekuendigt lassen den 30-Euro-Weg offen. Wie pruefeAbo: gleicher Memo je Anfrage, ein Lesefehler
* wirft (500, die Route zeigt nur ihren allgemeinen Text), im Zweifel nicht bestellbar; ein Ergebnis ohne gueltigen Grund ist ein Fehler.
* Funktionen ohne aboInklusive (News, Terminplaner) lassen es unberuehrt und lesen das Abo hier nicht.
*/
export async function pruefeInklusive(feature, customerId) {
if (feature.aboInklusive !== true) return
let ergebnis
try {
ergebnis = await orderDeps.aboMemo().bestellbar(customerId)
} catch (err) {
throw Object.assign(new Error(`Abo-Prüfung fehlgeschlagen: ${err?.message}`), { status: 500, cause: err })
}
if (ergebnis?.ok === true) throw bestellFehler(409, INKLUSIVE_TEXT, 'inklusive', { grund: null })
const grund = ergebnis?.grund
if (grund === 'nicht_aktiv') throw bestellFehler(409, INKLUSIVE_TEXT, 'inklusive', { grund })
if (grund === 'kein_abo' || grund === 'gekuendigt') return
throw new Error('Abo-Prüfung ohne gültiges Ergebnis')
}
const GRATIS_ABO_TEXT = {
kein_abo_gekuendigt: (name, preis) => `Ohne laufendes Wartungs-Abo kannst Du ${name} für einmalig ${preis} bestellen.`,
nicht_aktiv: () => 'Einschaltbar, sobald Dein Abo aktiv ist.',
+35 -2
View File
@@ -330,6 +330,26 @@ function notizVorn(previous, entry) {
return aelter ? `${neu} | ${aelter}`.slice(0, NOTE_MAX) : neu
}
/**
* Funktionen mit aboInklusive (Oeffnungszeiten live) halten am Dokument fest, wodurch sie freigeschaltet wurden (unlockedBy) und was der
* Neubau fuer den Einbau ergab (einbau). Fuer die uebrigen Funktionen (News, Terminplaner) schreibt der Kaufweg beides nicht: das Schema
* dieser Felder gibt es nur, wo zeiten-setup.mjs gelaufen ist.
*/
const mitAbo = (key) => featureConfig(key)?.aboInklusive === true
/**
* Neubau fuer den Einbau des Zeiten-Skripts (Spec 4.6) beim Kauf, VOR dem Einbau-Ticket: das Ticket nennt das Ergebnis (einbau).
* Steht einbau schon am Dokument (Wiederholung des Webhooks nach einem gescheiterten Ticket), wird nicht noch einmal gebaut.
* stosseNeubauAn nimmt die eigene Sperre 'zeiten-neubau' (andere Schluessel als feature-paid, also kein Wiederbetreten) und wirft nie;
* zeiten/* nur per dynamischem Import (siehe Kopf). Liefert das Dokument mit dem gespeicherten einbau.
*/
async function baueZeitenEin(doc, project) {
if (doc.einbau) return doc
const { stosseNeubauAn } = await import('./zeiten/neubau.js')
const einbau = await stosseNeubauAn(project)
return updateDocument(config.collections.portalFeatures, doc.$id, { einbau })
}
/**
* Eine Zahlung trifft ein Dokument, das nicht (mehr) auf die Bezahlung wartet: active, disabled oder setup mit Ticket. Sie wird
* einmalig verbucht (purchaseStatus 'paid', Rechnung, Notiz) und der Fall im eigenen Feld zahlungMeldung vermerkt - in einem einzigen
@@ -343,6 +363,9 @@ async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) {
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
purchaseStatus: 'paid',
unlockedByPurchase: true,
// Eine Zahlung, die nichts einrichtet, aendert nicht, wodurch das Dokument freigeschaltet wurde: ein abo oder team bleibt (der
// Abo-Abgleich und die Loeschfrist haengen an unlockedBy abo); nur ohne Angabe (Altbestand) wird es zu kauf, passend zu unlockedByPurchase
...(mitAbo(doc.featureKey) && !doc.unlockedBy ? { unlockedBy: 'kauf' } : {}),
stripeInvoiceId: invoiceId || '',
invoiceUrl: invoiceUrl || '',
adminNote: notizVorn(doc.adminNote, `${new Date().toISOString()} System: Zahlung eingegangen (Sitzung ${sessionId}), nichts eingerichtet`),
@@ -356,6 +379,9 @@ async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) {
* wird auf 'setup' gestellt, dann das Ticket erstellt; scheitert das, bleibt 'setup' ohne ticketId
* (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. Stripe wiederholt den Webhook:
* 'setup' ohne ticketId gilt dann als fortsetzbar (Ticket anlegen, created:true -> K15/T16 gehen raus).
* Funktionen mit aboInklusive (Oeffnungszeiten live): 'setup' traegt unlockedBy 'kauf' (und einbau leer), danach stoesst baueZeitenEin den
* Neubau an und speichert einbau, erst dann entsteht das Ticket mit dem Ergebnis. Fortsetzbar ist dort nur setup mit unlockedBy kauf; ein
* per Abo oder Team eingeschaltetes Dokument ohne Ticket heilt holeZeitenNach, die Zahlung ist fuer es ein Fall (bereits_aktiv).
*
* Liefert { doc, created, fall }. fall ist null, ausser die Zahlung traf ein Dokument, das nicht mehr bestellt ist ('active',
* 'disabled', 'setup' mit Ticket): dann wird sie einmalig verbucht und 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' geliefert,
@@ -368,20 +394,27 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
const doc = await findBySession(sessionId)
if (!doc) return { doc: null, created: false, fall: null }
if (doc.state === 'off') return { doc, created: false, fall: null }
const resumable = doc.state === 'setup' && !doc.ticketId
const mitAboKauf = mitAbo(doc.featureKey)
// Funktionen mit aboInklusive: nur ein Dokument, das der Kauf auf setup gestellt hat (unlockedBy kauf), ist fortsetzbar. Eines, das per Abo
// oder vom Team auf setup steht, bekommt sein Ticket vom Nachholen (holeZeitenNach); die Zahlung ist dort ein Fall fuers Team
const resumable = doc.state === 'setup' && !doc.ticketId && (!mitAboKauf || unlockedByVon(doc) === 'kauf')
if (doc.state !== 'ordered' && !resumable) return bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl })
const setup = resumable
let setup = resumable
? doc
: await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('setup'),
purchaseStatus: 'paid',
unlockedByPurchase: true,
// einbau beginnt leer: Reste eines frueheren Laufs gelten nicht, der Neubau (baueZeitenEin) setzt es neu
...(mitAboKauf ? { unlockedBy: 'kauf', einbau: '' } : {}),
stripeInvoiceId: invoiceId || '',
invoiceUrl: invoiceUrl || '',
zahlungMeldung: '',
})
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
// Oeffnungszeiten live: erst der Neubau (Zustand ist schon gespeichert), dann das Ticket mit dem frischen einbau (Spec 4.6)
if (mitAboKauf) setup = await baueZeitenEin(setup, project)
const customer = await customerOf(doc.customerId).catch(() => null)
const feature = featureConfig(doc.featureKey)
const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {}