From f830698dc3b67cbd19012072628e01fb87b77ce6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:08:03 +0000 Subject: [PATCH] Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team) Neue Team-Aktionen abnahme (Freischalten ohne Ticket-Schliessung, K16 einmal, unter feature-activate: 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 --- server/routes/admin/funktionenAdmin.js | 19 +- server/services/siteFeatures.js | 197 +++++++++- test/funktionenTermine.test.js | 495 ++++++++++++++++++++++++- 3 files changed, 699 insertions(+), 12 deletions(-) diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 065145e..1811c77 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -1,6 +1,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument } from '../../services/appwriteAdmin.js' +import { featureConfig } from '../../services/featureRegistry.js' import { withLocks } from '../../services/keyedLock.js' import { FEATURE_KEYS, @@ -8,9 +9,11 @@ import { adminActivate, assertOwnProject, disable, + freischaltenNachAbnahme, getFeatureDoc, listCustomerFeatureDocs, listCustomerFeatures, + ticketNachlegen, } from '../../services/siteFeatures.js' /** @@ -69,19 +72,27 @@ funktionenAdminRouter.get('/:customerId', async (req, res) => { } }) -// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404 +// Je Funktion (/news, /termine; jeder andere Schluessel -> 404): activate (ohne Zahlung), disable, abnahme (Freischalten nach Abnahme, in +// setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket nachlegen, in setup). abnahme und ticket gibt es nur fuer +// Funktionen mit ticketFreischaltung im Register. +const ACTIONS = ['activate', 'disable', 'abnahme', 'ticket'] +const TICKET_ACTIONS = ['abnahme', 'ticket'] + funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { try { const { customerId, key } = req.params if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' }) const { projectId, action } = req.body || {} const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : '' - if (action !== 'activate' && action !== 'disable') { + if (!ACTIONS.includes(action)) { return res.status(400).json({ error: 'Unbekannte Aktion' }) } if (typeof projectId !== 'string' || !projectId) { return res.status(400).json({ error: 'Projekt fehlt' }) } + if (TICKET_ACTIONS.includes(action) && featureConfig(key)?.ticketFreischaltung !== true) { + return res.status(409).json({ error: 'Diese Aktion gibt es für diese Funktion nicht.' }) + } await assertOwnProject(customerId, projectId) const by = adminName(req) await withLocks([`feature-admin:${projectId}`], async () => { @@ -89,6 +100,10 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { const adminNote = composeAdminNote(doc?.adminNote, { by, action, note }) if (action === 'activate') { await adminActivate(customerId, projectId, key, { by, adminNote }) + } else if (action === 'abnahme') { + await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote }) + } else if (action === 'ticket') { + await ticketNachlegen({ customerId, projectId, key, by, adminNote }) } else { if (!doc) { throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 }) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index af28dfb..aa7148b 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -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} 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: 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: 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:), 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: 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 diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index 0491cd8..ee7ef4a 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -21,9 +21,17 @@ const sf = await import('../server/services/siteFeatures.js') const { loeschTag } = await import('../server/services/termine/frist.js') const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') const { aboMeldung } = await import('../server/services/featureOrder.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes }) -after(() => Promise.all([app.close(), aw.close(), st.close()])) +// Mails (K16 der Abnahme, K15/T16 des nachgelegten Tickets) landen in einer Senke +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) seedFixtures(aw) aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' }) @@ -546,3 +554,488 @@ test('adminActivate (Dienst): bezahlter Terminplaner ohne Abo => Fehler 409, bei const pilot = legeKunde({ over: { unlockedByPurchase: false } }) assert.equal((await sf.adminActivate(pilot.customerId, pilot.projectId, 'termine', { by: 'test' })).state, 'active') }) + +// ------------------------------------------ Team-Aktionen Freischalten (Abnahme) und Ticket nachlegen (D7) --- +// abnahme: Terminplaner in setup freischalten, ohne dass das Einrichtungs-Ticket geschlossen wird (Ticket und Kauf bleiben, K16 einmal). +// ticket: ein fehlendes Einrichtungs-Ticket nachlegen - (a) Ticket nie angelegt: Zahlung bei Stripe pruefen und den Weg des Webhooks gehen +// (Ticket, K15, T16 genau einmal), (b) Ticket geloescht: neues Ticket, keine Mails, (c) Ticket da: nichts. +const K16_BETREFF = { termine: 'Dein Terminplaner ist bereit', news: 'Deine News-Pinnwand ist bereit' } +const K15_BETREFF = 'Deine Bestellung: Terminplaner' +const T16_BETREFF = 'Neue Funktion bestellt: Terminplaner' +const NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.' +const NUR_SETUP_TICKET = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.' +const OHNE_ABO = 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.' +const OHNE_EINSTELLUNG = 'Es sind noch keine Einstellungen gespeichert.' +const ABO_UNKLAR = 'Das Abo konnte gerade nicht geprüft werden.' +const NICHT_BEZAHLT = 'Die Zahlung ist noch nicht eingegangen.' +const KEINE_AKTION = 'Diese Aktion gibt es für diese Funktion nicht.' +const mailsAn = (email, subject) => mails.sent.filter((m) => m.subject === subject && m.to.some((t) => t.address === email)) +const seit = (n) => mails.sent.slice(n) + +let d7Nr = 0 +/** + * Kunde mit Projekt, Funktionsdokument (Zustand state), Wartungs-Abo und gespeicherter Einstellung. + * ticket: 'Open' | 'Closed' (Ticket da) | 'geloescht' (ticketId gesetzt, Ticket fehlt) | null (nie angelegt). + * session: null | 'paid' | 'unpaid' legt die Stripe-Sitzung samt Rechnung im Nachbau an und traegt sie am Dokument ein. + */ +function legeEinrichtung({ key = 'termine', abo = 'wartung', einstellung = true, ticket = 'Open', session = null, state = 'setup', over = {} } = {}) { + d7Nr += 1 + const n = d7Nr + const customerId = `cd${n}` + const projectId = `cdp${n}` + const docId = `f_${projectId}_${key}` + const ticketId = `t_cd${n}` + const sessionId = session ? `cs_cd${n}` : '' + const email = `cd${n}@example.invalid` + aw.seed('customers', { $id: customerId, name: `Salon ${n}`, email, status: 'active' }) + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${n}` }) + if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_cd${n}`, { ...(abo === 'wartung' ? wartungDoc : hostingDoc), customerId })) + if (ticket === 'Open' || ticket === 'Closed') { + aw.seed('workorders', { $id: ticketId, woid: 7000 + n, status: ticket, topic: `Terminplaner einbauen – Salon ${n}`, details: 'Original-Ticket' }) + } + if (session) { + st.invoices.set(`in_cd${n}`, { id: `in_cd${n}`, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/in_cd${n}` }) + st.checkoutSessions.set(sessionId, { + id: sessionId, object: 'checkout.session', mode: 'payment', status: 'complete', payment_status: session, invoice: `in_cd${n}`, + metadata: { customerId, projectId, featureKey: key }, + }) + } + aw.seed('portalFeatures', { + $id: docId, customerId, projectId, featureKey: key, siteKey: 'abcdefghijklmnop', state, enabled: state === 'active', + unlockedByPurchase: true, purchaseStatus: 'paid', orderedAt: '2026-10-01T10:00:00.000Z', stripeSessionId: sessionId, + ...(ticket ? { ticketId } : {}), adminNote: '', ...over, + }) + if (einstellung) { + aw.seed('termineConfig', { + $id: docId, customerId, projectId, einstellung: JSON.stringify({ betrieb: { name: `Salon ${n}` } }), version: 1, updatedAt: '2026-10-03T08:00:00.000Z', updatedBy: 'test', + }) + } + return { customerId, projectId, docId, ticketId, sessionId, email } +} +const abnahme = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'abnahme' }, opts) +const nachlegen = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'ticket' }, opts) +const ticketDoc = (id) => aw.docs('workorders').get(id) +const dienst = (k, fn, key = 'termine') => sf[fn]({ customerId: k.customerId, projectId: k.projectId, key, by: 'Andrej', adminNote: `x Andrej: ${fn}` }) + +// ----------------------------------------------------------------------------- abnahme --- +test('abnahme auf setup mit Einstellung und Abo: active, Ticket (Open) und Kauf bleiben, adminNote, K16 genau einmal', async () => { + const k = legeEinrichtung() + const res = await abnahme(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.projects.map((p) => p.projectId), [k.projectId]) + const doc = docById(k.docId) + assert.deepEqual([doc.state, doc.enabled], ['active', true]) + assert.ok(Math.abs(Date.parse(doc.activatedAt) - Date.now()) < 5000, 'activatedAt gesetzt') + assert.equal(featuresOf(res, k.projectId).termine.state, 'active') + assert.equal(featuresOf(res, k.projectId).termine.activatedAt, doc.activatedAt) + assert.equal(doc.ticketId, k.ticketId, 'ticketId bleibt') + assert.equal(ticketDoc(k.ticketId).status, 'Open', 'das Ticket wird nicht geschlossen') + assert.equal(doc.unlockedByPurchase, true) + assert.equal(doc.purchaseStatus, 'paid') + assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: abnahme$/) + const k16 = mailsAn(k.email, K16_BETREFF.termine) + assert.equal(k16.length, 1, 'K16 genau einmal') + assert.match(k16[0].text, /dashboard\.html#termine/) +}) + +test('abnahme: zweiter Klick => 200 ohne zweite Mail und ohne Schreibzugriff; Dienst meldet activated true, dann false', async () => { + const k = legeEinrichtung() + assert.equal((await abnahme(k)).status, 200) + const vorher = { ...docById(k.docId) } + const nochmal = await abnahme(k) + assert.equal(nochmal.status, 200) + assert.equal(featuresOf(nochmal, k.projectId).termine.state, 'active') + assert.deepEqual(docById(k.docId), vorher, 'nichts geschrieben: activatedAt und Notiz bleiben') + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1) + + const k2 = legeEinrichtung() + const erster = await dienst(k2, 'freischaltenNachAbnahme') + assert.equal(erster.activated, true) + assert.equal(erster.doc.state, 'active') + const zweiter = await dienst(k2, 'freischaltenNachAbnahme') + assert.equal(zweiter.activated, false) + assert.equal(zweiter.doc.state, 'active') + assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1) +}) + +test('abnahme: zwei parallele Klicks => eine Mail, ein Zustandswechsel', async () => { + const k = legeEinrichtung() + const [a, b] = await Promise.all([abnahme(k), abnahme(k)]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(docById(k.docId).state, 'active') + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1) + // auch ohne die Sperre der Route: zwei gleichzeitige Dienstaufrufe aktivieren nur einmal + const k2 = legeEinrichtung() + const [x, y] = await Promise.all([dienst(k2, 'freischaltenNachAbnahme'), dienst(k2, 'freischaltenNachAbnahme')]) + assert.deepEqual([x.activated, y.activated].sort(), [false, true]) + assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1) +}) + +test('abnahme und der Freischalt-Job (runActivation) bei geschlossenem Ticket gleichzeitig => eine Mail insgesamt, in beiden Reihenfolgen', async () => { + for (const abnahmeZuerst of [true, false]) { + const k = legeEinrichtung({ ticket: 'Closed' }) + const start = [() => abnahme(k), () => sf.runActivation({ customerId: k.customerId })] + if (!abnahmeZuerst) start.reverse() + await Promise.all(start.map((f) => f())) + assert.equal(docById(k.docId).state, 'active', `abnahmeZuerst ${abnahmeZuerst}`) + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1, `abnahmeZuerst ${abnahmeZuerst}`) + } +}) + +test('abnahme: bereits aktive Funktion (Pilot, Kulanz) => 200 ohne Mail und ohne Schreibzugriff', async () => { + const k = legeEinrichtung({ state: 'active', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '', activatedAt: '2026-10-02T10:00:00.000Z' } }) + const vorher = { ...docById(k.docId) } + const res = await abnahme(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(docById(k.docId), vorher) + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0) +}) + +test('abnahme: off, ordered und disabled => 409 mit Text, nichts geschrieben, keine Mail', async () => { + const faelle = [ + legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }), + legeEinrichtung({ state: 'ordered', ticket: null, over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }), + legeEinrichtung({ state: 'disabled', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }), + ] + for (const k of faelle) { + const vorher = { ...docById(k.docId) } + const res = await abnahme(k) + assert.equal(res.status, 409, vorher.state) + assert.equal(res.body.error, NUR_SETUP, vorher.state) + assert.deepEqual(docById(k.docId), vorher, vorher.state) + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0, vorher.state) + } +}) + +test('abnahme ohne laufendes Wartungs-Abo (kein Abo, nur Hosting, beendet) => 409, setup bleibt, keine Mail', async () => { + const faelle = [ + legeEinrichtung({ abo: null }), + legeEinrichtung({ abo: 'hosting' }), + legeEinrichtung({ abo: 'wartung' }), + ] + // beendetes Wartungs-Abo: das dritte Abo auf canceled stellen + aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: faelle[2].customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' })) + for (const k of faelle) { + const vorher = { ...docById(k.docId) } + const res = await abnahme(k) + assert.equal(res.status, 409, k.customerId) + assert.equal(res.body.error, OHNE_ABO) + assert.deepEqual(docById(k.docId), vorher, 'setup bleibt, nichts geschrieben') + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0) + } +}) + +test('abnahme: eine vorgemerkte Kuendigung und past_due laufen noch => 200', async () => { + for (const aboOver of [{ cancelAtPeriodEnd: true }, { status: 'past_due' }]) { + const k = legeEinrichtung() + aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: k.customerId, ...aboOver })) + assert.equal((await abnahme(k)).status, 200, JSON.stringify(aboOver)) + assert.equal(docById(k.docId).state, 'active') + } +}) + +test('abnahme ohne gespeicherte Einstellung => 409, setup bleibt', async () => { + const k = legeEinrichtung({ einstellung: false }) + const vorher = { ...docById(k.docId) } + const res = await abnahme(k) + assert.equal(res.status, 409) + assert.equal(res.body.error, OHNE_EINSTELLUNG) + assert.deepEqual(docById(k.docId), vorher) + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0) +}) + +test('abnahme: Abo nicht lesbar => 503 mit Text (geloggt), setup bleibt; danach geht es', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + const k = legeEinrichtung() + const vorher = { ...docById(k.docId) } + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' }) + const res = await abnahme(k) + assert.equal(res.status, 503) + assert.equal(res.body.error, ABO_UNKLAR) + assert.deepEqual(docById(k.docId), vorher, 'nie freischalten, wenn etwas nicht lesbar war') + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0) + assert.ok(log.mock.calls.some((c) => /nicht prüfbar/.test(c.arguments.join(' '))), 'der Fehler steht im Log') + assert.equal((await abnahme(k)).status, 200) + assert.equal(docById(k.docId).state, 'active') +}) + +test('abnahme: Projekt eines anderen Kunden => 403, Kundensitzung statt JWT => 401/403, ohne Dokument => 409 und nichts angelegt', async () => { + const k = legeEinrichtung() + const vorher = { ...docById(k.docId) } + assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403, 'pt3 gehoert c2') + assert.equal((await team(k.customerId, 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403) + assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'abnahme' })).status, 403) + const alsKunde = await abnahme(k, 'termine', { cookie: customerCookie(k.customerId) }) + assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`) + assert.deepEqual(docById(k.docId), vorher) + const docs = aw.docs('portalFeatures').size + aw.seed('websiteProjects', { $id: 'pt_d7_leer', customerId: 'c1', projectName: 'Ohne Dokument D7' }) + const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer', action: 'abnahme' }) + assert.equal(ohneDoc.status, 409) + assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner') + assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden') + assert.equal(mails.sent.filter((m) => m.subject === K16_BETREFF.termine && m.to.some((to) => to.address === 'k@example.invalid')).length, 0) +}) + +test('abnahme bei News funktioniert ohne Abo-Pruefung (es gibt keinen Haken freischaltbar): active, K16 News, Abo nicht gelesen', async () => { + const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false }) + const abfragen = aw.listCalls('subscriptions').length + const res = await abnahme(k, 'news') + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(featuresOf(res, k.projectId).news.state, 'active') + assert.equal(docById(k.docId).state, 'active') + assert.equal(docById(k.docId).ticketId, k.ticketId) + assert.equal(aw.listCalls('subscriptions').length, abfragen, 'keine Abo-Abfrage') + assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 1) +}) + +test('abnahme und ticket bei analyse und unbekannten Schluesseln => 404, nichts angelegt', async () => { + const k = legeEinrichtung() + const docs = aw.docs('portalFeatures').size + for (const action of ['abnahme', 'ticket']) { + for (const key of ['analyse', 'kalender']) { + const res = await team(k.customerId, key, { projectId: k.projectId, action }) + assert.equal(res.status, 404, `${key} ${action}`) + } + } + assert.equal(aw.docs('portalFeatures').size, docs) +}) + +test('abnahme und ticket brauchen ticketFreischaltung im Register, sonst 409 ohne Schreibzugriff', async () => { + const k = legeEinrichtung({ key: 'news' }) + const vorher = { ...docById(k.docId) } + const alt = config.features.news.ticketFreischaltung + config.features.news.ticketFreischaltung = false + try { + for (const action of ['abnahme', 'ticket']) { + const res = await team(k.customerId, 'news', { projectId: k.projectId, action }) + assert.equal(res.status, 409, action) + assert.equal(res.body.error, KEINE_AKTION, action) + } + } finally { + config.features.news.ticketFreischaltung = alt + } + assert.deepEqual(docById(k.docId), vorher) + assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 0) + // activate und disable gehoeren nicht dazu und gehen weiter + assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200) +}) + +test('abnahme: scheitert der Versand von K16, ist die Funktion trotzdem freigeschaltet (Fehler im Log)', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + const k = legeEinrichtung() + setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } }) + t.after(() => setMailTransportForTests(mails)) + const res = await abnahme(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(docById(k.docId).state, 'active') + assert.ok(log.mock.calls.some((c) => /K16 an Kunde .* fehlgeschlagen/.test(c.arguments.join(' ')))) +}) + +// ----------------------------------------------------------------------------- ticket --- +test('ticket (a): setup ohne ticketId, Sitzung bezahlt => Ticket mit Kurzfassung, K15 und T16 je einmal; zweiter Klick und Webhook-Wiederholung aendern nichts', async () => { + const k = legeEinrichtung({ ticket: null, session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const res = await nachlegen(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const doc = docById(k.docId) + assert.ok(doc.ticketId, 'ticketId gesetzt') + assert.equal(workorders(), tickets + 1) + const ticket = ticketDoc(doc.ticketId) + assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/) + assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'), ticket.details) + assert.equal(ticket.status, 'Open') + assert.deepEqual([doc.state, doc.unlockedByPurchase], ['setup', true]) + assert.match(doc.adminNote, /Andrej: ticket/) + assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false) + // K15 an den Kunden, T16 ans Team, je einmal + assert.equal(mailsAn(k.email, K15_BETREFF).length, 1) + const neu = seit(mailsVorher) + assert.equal(neu.filter((m) => m.subject === T16_BETREFF).length, 1) + assert.match(neu.find((m) => m.subject === T16_BETREFF).text, new RegExp(String(ticket.woid))) + assert.equal(neu.length, 2, 'sonst nichts') + + // zweiter Klick: Ticket vorhanden => nichts Neues + const nochmal = await nachlegen(k) + assert.equal(nochmal.status, 200) + assert.equal(workorders(), tickets + 1) + assert.equal(docById(k.docId).ticketId, doc.ticketId) + assert.equal(seit(mailsVorher).length, 2, 'keine weiteren Mails') + + // ein folgender Webhook mit derselben Sitzung (Stripe-Retry) sendet und legt nichts doppelt an + await sf.handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: st.checkoutSessions.get(k.sessionId) } }) + assert.equal(workorders(), tickets + 1) + assert.equal(seit(mailsVorher).length, 2, 'Webhook-Wiederholung: keine weiteren Mails') + assert.equal(docById(k.docId).ticketId, doc.ticketId) +}) + +test('ticket (a), Dienst: created true beim Anlegen, false danach; zwei gleichzeitige Aufrufe legen ein Ticket und senden K15/T16 einmal', async () => { + const k = legeEinrichtung({ ticket: null, session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const [a, b] = await Promise.all([dienst(k, 'ticketNachlegen'), dienst(k, 'ticketNachlegen')]) + assert.deepEqual([a.created, b.created].sort(), [false, true]) + assert.equal(a.doc.ticketId, b.doc.ticketId) + assert.equal(workorders(), tickets + 1) + assert.equal(seit(mailsVorher).length, 2, 'K15 und T16 je einmal') + const spaeter = await dienst(k, 'ticketNachlegen') + assert.equal(spaeter.created, false) + assert.equal(workorders(), tickets + 1) +}) + +test('ticket (a): zwei parallele Klicks => ein Ticket, K15 und T16 je einmal', async () => { + const k = legeEinrichtung({ ticket: null, session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(workorders(), tickets + 1) + assert.equal(seit(mailsVorher).length, 2) + assert.equal(mailsAn(k.email, K15_BETREFF).length, 1) +}) + +test('ticket (a): Sitzung nicht bezahlt => 409, kein Ticket, keine Mail; Stripe-Ausfall => 503, danach geht es', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + const unbezahlt = legeEinrichtung({ ticket: null, session: 'unpaid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const vorher = { ...docById(unbezahlt.docId) } + const res = await nachlegen(unbezahlt) + assert.equal(res.status, 409) + assert.equal(res.body.error, NICHT_BEZAHLT) + assert.deepEqual(docById(unbezahlt.docId), vorher) + assert.equal(workorders(), tickets) + + const k = legeEinrichtung({ ticket: null, session: 'paid' }) + const vorherK = { ...docById(k.docId) } + st.failNext({ method: 'GET', path: /^\/v1\/checkout\/sessions\//, status: 400, message: 'Nachbau: Stripe kaputt' }) + const aus = await nachlegen(k) + assert.equal(aus.status, 503) + assert.equal(typeof aus.body.error, 'string') + assert.ok(!JSON.stringify(aus.body).includes('Nachbau'), 'nichts Technisches nach aussen') + assert.deepEqual(docById(k.docId), vorherK) + assert.equal(workorders(), tickets) + assert.equal(seit(mailsVorher).length, 0) + assert.ok(log.mock.calls.some((c) => /Nachbau: Stripe kaputt/.test(c.arguments.join(' '))), 'Ursache im Log') + + assert.equal((await nachlegen(k)).status, 200, 'Stripe wieder da') + assert.equal(workorders(), tickets + 1) + assert.equal(seit(mailsVorher).length, 2) +}) + +test('ticket (a): ohne vermerkte Sitzung => 409, Stripe wird nicht gefragt', async () => { + const k = legeEinrichtung({ ticket: null }) + const aufrufe = st.calls.length + const res = await nachlegen(k) + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Zu dieser Funktion ist keine Zahlung vermerkt.') + assert.equal(st.calls.length, aufrufe) +}) + +test('ticket (b): Ticket geloescht => neues Ticket mit neuer ticketId, keine Mail, Stripe nicht gefragt', async () => { + const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const aufrufe = st.calls.length + assert.equal(ticketDoc(k.ticketId), undefined, 'Ausgangslage: Ticket fehlt') + assert.equal(featuresOf({ body: await get(`/api/team/funktionen/${k.customerId}`, STAFF) }, k.projectId).termine.ticketMissing, true) + const res = await nachlegen(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const doc = docById(k.docId) + assert.ok(doc.ticketId && doc.ticketId !== k.ticketId, 'neue ticketId') + assert.equal(workorders(), tickets + 1) + const ticket = ticketDoc(doc.ticketId) + assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/) + assert.ok(ticket.details.includes('Konfigurationsstand: Version 1')) + assert.equal(ticket.status, 'Open') + assert.equal(doc.state, 'setup') + assert.match(doc.adminNote, /Andrej: ticket/) + assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false) + assert.equal(seit(mailsVorher).length, 0, 'keine Mails: K15/T16 gingen beim ersten Mal raus') + assert.equal(st.calls.length, aufrufe, 'Stripe nicht gefragt') +}) + +test('ticket (b): zwei parallele Klicks und zwei gleichzeitige Dienstaufrufe => genau ein neues Ticket', async () => { + const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' }) + const tickets = workorders() + const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(workorders(), tickets + 1) + + const k2 = legeEinrichtung({ ticket: 'geloescht', session: 'paid' }) + const tickets2 = workorders() + const [x, y] = await Promise.all([dienst(k2, 'ticketNachlegen'), dienst(k2, 'ticketNachlegen')]) + assert.deepEqual([x.created, y.created].sort(), [false, true]) + assert.equal(workorders(), tickets2 + 1) + assert.equal(x.doc.ticketId, y.doc.ticketId) +}) + +test('ticket (c): Ticket vorhanden (offen oder geschlossen) => no-op, created false, nichts geschrieben, Stripe nicht gefragt', async () => { + for (const ticket of ['Open', 'Closed']) { + const k = legeEinrichtung({ ticket, session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const aufrufe = st.calls.length + const vorher = { ...docById(k.docId) } + const res = await nachlegen(k) + assert.equal(res.status, 200, ticket) + assert.deepEqual(docById(k.docId), vorher, ticket) + assert.equal(workorders(), tickets, ticket) + assert.equal(seit(mailsVorher).length, 0, ticket) + assert.equal(st.calls.length, aufrufe, ticket) + const dienstErgebnis = await dienst(k, 'ticketNachlegen') + assert.equal(dienstErgebnis.created, false, ticket) + assert.equal(dienstErgebnis.doc.ticketId, k.ticketId, ticket) + } +}) + +test('ticket: nur in setup - off, ordered, active und disabled => 409; ohne Dokument => 409', async () => { + const faelle = [ + legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }), + legeEinrichtung({ state: 'ordered', ticket: null, session: 'paid', over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }), + legeEinrichtung({ state: 'active', ticket: 'geloescht', session: 'paid' }), + legeEinrichtung({ state: 'disabled', ticket: null, session: 'paid', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }), + ] + const tickets = workorders() + const mailsVorher = mails.sent.length + for (const k of faelle) { + const vorher = { ...docById(k.docId) } + const res = await nachlegen(k) + assert.equal(res.status, 409, vorher.state) + assert.equal(res.body.error, NUR_SETUP_TICKET, vorher.state) + assert.deepEqual(docById(k.docId), vorher, vorher.state) + } + assert.equal(workorders(), tickets) + assert.equal(seit(mailsVorher).length, 0) + aw.seed('websiteProjects', { $id: 'pt_d7_leer2', customerId: 'c1', projectName: 'Ohne Dokument D7b' }) + const docs = aw.docs('portalFeatures').size + const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer2', action: 'ticket' }) + assert.equal(ohneDoc.status, 409) + assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner') + assert.equal(aw.docs('portalFeatures').size, docs) +}) + +test('ticket: fremdes Projekt => 403, Kundensitzung statt JWT => 401/403, nichts geschrieben', async () => { + const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' }) + const vorher = { ...docById(k.docId) } + const tickets = workorders() + assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'ticket' })).status, 403) + const alsKunde = await nachlegen(k, 'termine', { cookie: customerCookie(k.customerId) }) + assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`) + assert.deepEqual(docById(k.docId), vorher) + assert.equal(workorders(), tickets) +}) + +test('ticket bei News (a): derselbe Weg des Webhooks, K15 und T16 der News-Pinnwand', async () => { + const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false, ticket: null, session: 'paid' }) + const mailsVorher = mails.sent.length + const res = await nachlegen(k, 'news') + assert.equal(res.status, 200, JSON.stringify(res.body)) + const doc = docById(k.docId) + assert.ok(doc.ticketId) + assert.equal(doc.state, 'setup') + const neu = seit(mailsVorher) + assert.equal(neu.filter((m) => m.subject === 'Neue Funktion bestellt: News-Pinnwand').length, 1) + assert.equal(neu.filter((m) => m.to.some((to) => to.address === k.email)).length, 1, 'K15 an den Kunden') +})