Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)

Neue Team-Aktionen abnahme (Freischalten ohne Ticket-Schliessung, K16
einmal, unter feature-activate:<id> mit Haken freischaltHindernis) und
ticket (Ticket nachlegen: Zahlung bei Stripe pruefen und Webhook-Weg,
geloeschtes Ticket ersetzen, vorhandenes unberuehrt). Ticketanlage aus
markPaid in legeTicketAn herausgeloest.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 01:08:03 +00:00
1 parent 8fe14c2fe6
commit f830698dc3
3 files changed
+699 -12

No files matched your search

+188 -9
View File
@@ -358,20 +358,34 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
})
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
const customer = await customerOf(doc.customerId).catch(() => null)
const feature = featureConfig(doc.featureKey)
const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {}
const { ticketId } = await createFeatureTicket({
customerId: doc.customerId,
project,
featureKey: doc.featureKey,
siteKey: doc.siteKey,
...texte,
})
const ticketId = await legeTicketAn(setup, project, customer)
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
return { doc: updated, created: true, fall: null }
})
}
/**
* Das Einrichtungs-Ticket einer bezahlten Funktion anlegen (Betreff und Details aus dem Register, ticketTexte) und seine ID liefern.
* Die einzige Stelle, die das Ticket anlegt: markPaid (Zahlung) und ticketNachlegen (b, geloeschtes Ticket ersetzen). Schreibt nichts ins
* Funktionsdokument (die ticketId traegt der Aufrufer ein) und sendet keine Mail.
* @param {object} doc portalFeatures-Dokument (customerId, featureKey, siteKey)
* @param {object} project websiteProjects-Dokument
* @param {object|null} customer Kundendokument, best effort
* @returns {Promise<string>} ID des neuen Tickets
*/
async function legeTicketAn(doc, project, customer) {
const feature = featureConfig(doc.featureKey)
const texte = feature ? await ticketTexte({ feature, doc, project, customer }) : {}
const { ticketId } = await createFeatureTicket({
customerId: doc.customerId,
project,
featureKey: doc.featureKey,
siteKey: doc.siteKey,
...texte,
})
return ticketId
}
/**
* Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck.
* Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte
@@ -586,6 +600,171 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul
return activated
}
// ---------------------------------------------------------------------------
// Team-Aktionen "Freischalten (Abnahme)" und "Ticket nachlegen" (WOMS, Kunden-Reiter Funktionen)
// ---------------------------------------------------------------------------
const ABNAHME_NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
const TICKET_NUR_SETUP = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
/** Klartext und Status je Grund aus freischaltHindernis (termine/freischaltung.js); ein unbekannter Grund sperrt mit einem allgemeinen 409. */
const ABNAHME_HINDERNIS = {
kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'],
keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'],
abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'],
}
/** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */
async function dokumentFuerTeamAktion(customerId, projectId, key) {
assertKey(key)
const doc = await getFeatureDoc(customerId, projectId, key)
if (!doc) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
return doc
}
/** Das Dokument unter der Sperre frisch lesen; ist es inzwischen geloescht (Kunde geloescht), gilt dieselbe Meldung wie ohne Dokument. */
async function frischesDokument(vorab, key) {
try {
return await getDocument(config.collections.portalFeatures, vorab.$id)
} catch (err) {
if (err.status === 404) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
throw err
}
}
/**
* Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen (Terminplaner:
* laufendes Wartungs-Abo und gespeicherte Einstellung); fehlt der Haken (News), gibt es keine Pruefung und kein Abo wird gelesen.
* Die Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert).
*/
async function abnahmeHindernis(doc) {
if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null
const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js')
return pruefe(doc)
}
/**
* "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen
* wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben.
* Danach K16 (meldeFreigeschaltet, Fehler werden dort geloggt). Unter derselben Sperre feature-activate:<id> wie der Freischalt-Job
* (activateClosedTickets) nach frischem Lesen: wer zuerst kommt, aktiviert und sendet; der andere sieht 'active' und tut nichts.
* - Dokument fehlt: 409 "Für dieses Projekt gibt es noch {keine}".
* - schon 'active': Erfolg ohne Schreibzugriff und ohne Mail, activated false.
* - 'off' | 'ordered' | 'disabled': 409. Ein Hindernis (kein Wartungs-Abo, keine Einstellung): 409; Abo nicht lesbar: 503.
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string, now?: Date }} args
* @returns {Promise<{ doc: object, activated: boolean }>}
*/
export async function freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now = new Date() }) {
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
const ergebnis = await withLocks([`feature-activate:${vorab.$id}`], async () => {
const doc = await frischesDokument(vorab, key)
if (doc.state === 'active') return { doc, activated: false }
if (doc.state !== 'setup') throw httpError(409, ABNAHME_NUR_SETUP)
const grund = await abnahmeHindernis(doc)
if (grund) {
const [status, text] = ABNAHME_HINDERNIS[grund] ?? [409, 'Freischalten ist noch nicht möglich.']
throw httpError(status, text)
}
const note = adminNote ?? byNote(by, 'Freigeschaltet (Abnahme)')
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('active'),
activatedAt: now.toISOString(),
...(note ? { adminNote: note } : {}),
})
return { doc: updated, activated: true }
})
if (ergebnis.activated) {
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
await meldeFreigeschaltet(ergebnis.doc)
}
return ergebnis
}
/** Ob das Ticket noch existiert; nur 404 heisst "geloescht", jeder andere Fehler wird weitergereicht. */
async function ticketBesteht(ticketId) {
try {
await getDocument(config.collections.workorders, ticketId)
return true
} catch (err) {
if (err.status === 404) return false
throw err
}
}
/** Die bezahlte Stripe-Sitzung des Dokuments; nicht bezahlt: 409, Stripe nicht erreichbar oder nicht konfiguriert: 503 (Ursache im Log). */
async function bezahlteSitzung(doc) {
if (!doc.stripeSessionId) throw httpError(409, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
let session
try {
session = await retrieveCheckoutSession(doc.stripeSessionId)
} catch (err) {
console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message)
throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.')
}
if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.')
return session
}
/**
* Ticket nachlegen (a): 'setup' ohne ticketId, das Ticket entstand nie. Die Zahlung wird bei Stripe geprueft und dann derselbe Weg wie
* im Webhook gegangen (handleFeatureCheckoutEvent): Ticket, K15 und T16 entstehen genau einmal, ein spaeterer Stripe-Retry findet das
* Ticket und sendet nichts. Keine Sperre feature-paid:<session> hier, markPaid nimmt sie selbst (withLocks ist nicht wiederbetretbar).
*/
async function ticketNachZahlung(doc, note) {
const session = await bezahlteSitzung(doc)
await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })
const nachher = await getDocument(config.collections.portalFeatures, doc.$id)
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 Mails 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
* schreibt; das Dokument wird darunter erneut gelesen.
*/
async function ersetzeGeloeschtesTicket(doc, note) {
return withLocks([`feature-paid:${doc.stripeSessionId || doc.$id}`], async () => {
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
if (frisch.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
if (frisch.ticketId !== doc.ticketId) return { doc: frisch, created: false }
const project = await getDocument(config.collections.websiteProjects, frisch.projectId)
const customer = await customerOf(frisch.customerId).catch(() => null)
const ticketId = await legeTicketAn(frisch, project, customer)
const updated = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId, ...(note ? { adminNote: note } : {}) })
return { doc: updated, created: true }
})
}
/**
* "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);
* (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
* (ein Ticket, created nur einmal true).
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string }} args
* @returns {Promise<{ doc: object, created: boolean }>}
*/
export async function ticketNachlegen({ customerId, projectId, key, by, adminNote }) {
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
const note = adminNote ?? byNote(by, 'Ticket nachgelegt')
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 (await ticketBesteht(doc.ticketId)) return { doc, created: false }
return ersetzeGeloeschtesTicket(doc, note)
})
}
/** Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. */
export async function findActiveBySiteKey(siteKey, featureKey = 'news') {
if (!/^[a-z2-7]{16}$/.test(String(siteKey || '')) || !FEATURE_KEYS.includes(featureKey)) return null