diff --git a/server/services/funktionEinschalten.js b/server/services/funktionEinschalten.js new file mode 100644 index 0000000..647af40 --- /dev/null +++ b/server/services/funktionEinschalten.js @@ -0,0 +1,162 @@ +import { config } from '../config.js' +import { updateDocument } from './appwriteAdmin.js' +import { aboDeckt } from './aboFunktionen.js' +import { berlinDay } from './deadlines.js' +import { bestellFehler } from './featureOrder.js' +import { featureConfig, ticketTexte } from './featureRegistry.js' +import { withLocks } from './keyedLock.js' +import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc, notizVorn, stateFields, ticketWoid, unlockedByVon } from './siteFeatures.js' +import { ABRECHNUNGS_ZEILEN, createFeatureTicket } from './ticketAdmin.js' +import { einschaltenZeiten } from './zeiten/einschalten.js' + +/** + * Generisches Einschalten einer Funktion im Abo-Modell (V7, Spec 5.4): ohne Zahlung, mit Einrichtungs-Ticket. Gleiches Muster wie + * einschaltenZeiten (Sperre feature-order, Nachweis vor dem Zustand, Ticket danach, Heilung statt Doppelticket). Keine Mails, kein + * Stundenkonto, keine Team-Aktivierung (P6). Fuer news, termine, shop und unterkunft laeuft alles hier; zeiten geht ueber + * einschaltenZeiten (Neubau, Ticket und K27 stehen dort) mit einer Vorpruefung, die dieselben Codes liefert. + */ + +/** Wie die Einrichtung abgerechnet wird: im Abo-Stundenkonto (abo) oder im Projekt enthalten (projekt). */ +export const ABRECHNUNGEN = Object.freeze(['abo', 'projekt']) + +export const EINSCHALTEN_TEXTE = Object.freeze({ + bereits: 'Diese Funktion ist für dieses Projekt schon eingeschaltet.', + laeuft: 'Für diese Funktion läuft schon eine Bestellung.', + abgeschaltet: 'Diese Funktion wurde für dieses Projekt abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.', + ticket: 'Die Einrichtung konnte gerade nicht angelegt werden. Bitte versuch es gleich noch einmal.', + abo: Object.freeze({ + kein_abo: 'Für diese Funktion brauchst du ein laufendes Abo.', + falsches_abo: 'Diese Funktion ist im Wartungs-Abo enthalten.', + gekuendigt: 'Dein Abo ist gekündigt. Zum Einschalten brauchst du ein laufendes Abo.', + nicht_aktiv: 'Dein Abo ist gerade nicht aktiv, zum Beispiel wegen einer offenen Zahlung. Sobald es wieder aktiv ist, kannst du die Funktion einschalten.', + beginnt_spaeter: 'Dein Abo beginnt am {datum}. Ab dann kannst du einschalten.', + }), + nichtPruefbar: 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.', +}) + +/** + * Kundentext zu einem Abo-Grund. Bei beginnt_spaeter steht der Berliner Tag von `ab` (ISO) als TT.MM.JJJJ im Text, + * z. B. "Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten." + */ +export function aboFehlerText(grund, ab = null) { + if (!Object.hasOwn(EINSCHALTEN_TEXTE.abo, grund)) throw new TypeError(`aboFehlerText: unbekannter Grund ${String(grund)}`) + const text = EINSCHALTEN_TEXTE.abo[grund] + if (grund !== 'beginnt_spaeter') return text + const ms = Date.parse(ab) + if (Number.isNaN(ms)) throw new TypeError('aboFehlerText: beginnt_spaeter braucht ab als Zeitpunkt') + const [jahr, monat, tag] = berlinDay(new Date(ms)).split('-') + return text.replace('{datum}', `${tag}.${monat}.${jahr}`) +} + +/** 409 abo_fehlt: body.grund, body.wechsel (Hosting-Kunde: Wechsel ins Wartungs-Abo anbieten) und bei beginnt_spaeter body.ab. */ +const aboFehlt = (grund, ab) => + bestellFehler(409, aboFehlerText(grund, ab), 'abo_fehlt', { grund, wechsel: grund === 'falsches_abo', ...(grund === 'beginnt_spaeter' ? { ab } : {}) }) + +const konflikt = (code, text) => bestellFehler(409, text, code) + +/** Abo frisch lesen und gegen die Funktion pruefen (immer ohne Zwischenspeicher); Lesefehler sind 503, nie stillschweigend erlaubt. */ +async function pruefeAbo(customerId, key, now) { + let ergebnis + try { + ergebnis = await aboDeckt(customerId, key, { now }) + } catch (err) { + console.error(`[funktionen] Abo für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + throw bestellFehler(503, EINSCHALTEN_TEXTE.nichtPruefbar) + } + if (ergebnis.ok !== true) throw aboFehlt(ergebnis.grund, ergebnis.ab) +} + +/** setup ohne Ticket und durch Abo oder Projekt eingeschaltet: der Dienst heilt (nur das Ticket), es entsteht nichts Neues. */ +const heilbar = (doc) => doc?.state === 'setup' && !doc.ticketId && ['abo', 'projekt'].includes(unlockedByVon(doc)) + +/** Zustand des Dokuments pruefen: wirft den Konflikt mit Code; Heilung und ein frisches oder ausgeschaltetes Dokument sind frei. */ +function pruefeZustand(doc) { + if (!doc || doc.state === 'off') return + if (doc.state === 'ordered') throw konflikt('bestellung_laeuft', EINSCHALTEN_TEXTE.laeuft) + if (doc.state === 'disabled') throw konflikt('abgeschaltet', EINSCHALTEN_TEXTE.abgeschaltet) + if (!heilbar(doc)) throw konflikt('bereits_eingeschaltet', EINSCHALTEN_TEXTE.bereits) +} + +/** Notiz des Teams: staff.adminNote oder " : Eingeschaltet ()" vor der bisherigen Notiz; ohne beides keine. */ +function teamNotiz(staff, bisherige, abrechnung, now) { + if (typeof staff?.adminNote === 'string' && staff.adminNote) return staff.adminNote.slice(0, 300) + if (!staff?.name) return null + return notizVorn(bisherige, `${now.toISOString()} ${staff.name}: Eingeschaltet (${abrechnung})`) +} + +/** Einrichtungs-Ticket anlegen und an das Dokument haengen; jeder Fehler hier ist 503 (der Zustand bleibt setup, der naechste Aufruf heilt). */ +async function legeTicketAn(doc, { key, feature, project, customerId, abrechnung }) { + try { + const customer = await customerOf(customerId).catch(() => null) + const texte = await ticketTexte({ feature, doc, project, customer }) + const { ticketId } = await createFeatureTicket({ customerId, project, featureKey: key, siteKey: doc.siteKey, ...texte, abrechnung }) + return await updateDocument(config.collections.portalFeatures, doc.$id, { ticketId }) + } catch (err) { + console.error(`[funktionen] Einrichtung ${key} (${doc.$id}) nicht angelegt: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.message ?? ''}`.trim()) + throw Object.assign(new Error(EINSCHALTEN_TEXTE.ticket), { status: 503, cause: err }) + } +} + +/** + * Funktion fuer ein Projekt einschalten (Zustand setup, Einrichtungs-Ticket mit Abrechnung). + * @param {{ customerId: string, projectId: string, key: string, by: 'kunde'|'team', abrechnung: 'abo'|'projekt', + * nachweis?: (() => Promise)|null, staff?: { name?: string, adminNote?: string }|null, now?: Date }} p + * by 'kunde': das Abo wird unter der Sperre frisch gegen die Funktion geprueft (aboDeckt); by 'team': keine Abo-Pruefung. + * nachweis: speichert den Zustimmungsnachweis, bevor der Zustand geaendert wird; wirft er, bleibt alles wie es war. + * @returns {Promise<{ doc: object, ticketWoid: string|null }>} + * @throws {TypeError} key ausserhalb des Registers (auch analyse), by oder abrechnung ungueltig + * @throws {Error} 403/409 (Projekt), 409 bereits_eingeschaltet | bestellung_laeuft | abgeschaltet | abo_fehlt, 503 (Abo nicht lesbar, Ticket gescheitert) + */ +export async function einschalten({ customerId, projectId, key, by, abrechnung, nachweis = null, staff = null, now = new Date() } = {}) { + const feature = featureConfig(key) + if (!feature) throw new TypeError(`einschalten: unbekannte Funktion ${String(key)}`) + if (by !== 'kunde' && by !== 'team') throw new TypeError(`einschalten: by muss kunde oder team sein, nicht ${String(by)}`) + if (!ABRECHNUNGEN.includes(abrechnung)) throw new TypeError(`einschalten: abrechnung muss abo oder projekt sein, nicht ${String(abrechnung)}`) + const project = await assertOwnProject(customerId, projectId, { forNewOrder: true }) + + // Oeffnungszeiten: Sperre, Nachweis, Zustand, Neubau, Ticket und K27 stehen in einschaltenZeiten; die Vorpruefung unter der Sperre + // liefert dieselben Codes wie unten (nur fuer den Kunden, das Team schaltet wie bisher) + if (key === 'zeiten') { + const vorpruefung = async () => { + await pruefeAbo(customerId, key, now) + pruefeZustand(await getFeatureDoc(customerId, projectId, key)) + } + const { doc } = await einschaltenZeiten({ customerId, projectId, by, staff, nachweis, now, abrechnung, vorpruefung }) + return { doc, ticketWoid: await ticketWoid(doc.ticketId) } + } + + const doc = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { + // 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es + if (by === 'kunde') await pruefeAbo(customerId, key, now) + + // 2 Zustand: Heilung (setup ohne Ticket, durch Abo oder Projekt) legt nur das Ticket an + const bisher = await getFeatureDoc(customerId, projectId, key) + pruefeZustand(bisher) + if (heilbar(bisher)) { + const gespeichert = Object.hasOwn(ABRECHNUNGS_ZEILEN, bisher.einrichtungAbrechnung) ? bisher.einrichtungAbrechnung : abrechnung + return legeTicketAn(bisher, { key, feature, project, customerId, abrechnung: gespeichert }) + } + + // 3 Zustimmung speichern, vor dem Zustand: scheitert sie, bleibt alles wie es war + if (nachweis) await nachweis() + + // 4 Dokument auf setup. Reste einer frueheren Abschaltung gehoeren zum alten Lauf und enden hier. + const vorhanden = bisher ?? (await ensureFeatureDoc(customerId, projectId, key)) + const notiz = by === 'team' ? teamNotiz(staff, vorhanden.adminNote, abrechnung, now) : null + const gespeichert = await updateDocument(config.collections.portalFeatures, vorhanden.$id, { + ...stateFields('setup'), + featureKey: key, + unlockedBy: abrechnung, + unlockedByPurchase: false, + einrichtungAbrechnung: abrechnung, + disabledAt: null, + disabledReason: null, + aboEndeMailAt: null, + ...(notiz ? { adminNote: notiz } : {}), + }) + + // 5 Ticket; scheitert es, bleibt setup ohne Ticket und der naechste Aufruf heilt + return legeTicketAn(gespeichert, { key, feature, project, customerId, abrechnung }) + }) + return { doc, ticketWoid: await ticketWoid(doc.ticketId) } +} diff --git a/server/services/zeiten/einschalten.js b/server/services/zeiten/einschalten.js index 1d0f8fa..549b67e 100644 --- a/server/services/zeiten/einschalten.js +++ b/server/services/zeiten/einschalten.js @@ -4,7 +4,7 @@ import { pruefeAboFuerEinschalten } from '../featureOrder.js' import { featureConfig, ticketTexte } from '../featureRegistry.js' import { withLocks } from '../keyedLock.js' import { customerOf, ensureFeatureDoc, getFeatureDoc, stateFields, ticketWoid, unlockedByVon } from '../siteFeatures.js' -import { createFeatureTicket } from '../ticketAdmin.js' +import { ABRECHNUNGS_ZEILEN, createFeatureTicket } from '../ticketAdmin.js' import { k27Mail, sendeZeitenMail } from './mails.js' import { stosseNeubauAn } from './neubau.js' import { ladeZeiten } from './speicher.js' @@ -50,16 +50,24 @@ function notizDesTeams(staff, bisherige, now) { * by 'kunde': Abo frisch pruefen (kein_abo/gekuendigt/nicht_aktiv 409, Lesefehler 503), unlockedBy 'abo', K27 geht an den Kunden. * by 'team': keine Abo-Pruefung, unlockedBy 'team' (ein bisher gekauftes Dokument bleibt kauf), keine K27; staff ergibt die Notiz am Dokument. * nachweis: speichert den Zustimmungsnachweis (Schritt 3, nur wenn wirklich eingeschaltet wird); wirft er, bleibt der Zustand unveraendert. + * abrechnung ('' | 'abo' | 'projekt', Abo-Modell der Kundenansicht neu, V7): ueberschreibt unlockedBy ('abo' | 'projekt'; ein gekauftes + * Dokument bleibt kauf) und setzt einrichtungAbrechnung am Dokument (das Einrichtungs-Ticket traegt es in holeZeitenNach weiter). + * Ohne abrechnung bleibt alles wie bisher. Ein anderer Wert ist ein TypeError. + * vorpruefung: Funktion, die unter der Sperre statt pruefeAboFuerEinschalten laeuft (nur by 'kunde'); sie wirft, was der Aufrufer + * als Fehler will (das Einschalten im Abo-Modell prueft damit Abo und Zustand mit eigenen Codes). * @returns {Promise<{ doc: object, changed: boolean }>} doc: das Dokument nach dem Nachholen; changed: false bei idempotentem Aufruf * @throws {Error} 409 (Abo, anderer Zustand), 503 (Abo nicht lesbar; oder Zustand gespeichert, aber Neubau/Ticket nicht fertig: erneut aufrufen) */ -export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', staff = null, nachweis = null, now = new Date() } = {}) { +export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', staff = null, nachweis = null, now = new Date(), abrechnung = '', vorpruefung = null } = {}) { if (by !== 'kunde' && by !== 'team') throw new TypeError(`einschaltenZeiten: by muss kunde oder team sein, nicht ${String(by)}`) + if (abrechnung !== '' && abrechnung !== 'abo' && abrechnung !== 'projekt') { + throw new TypeError(`einschaltenZeiten: abrechnung muss '', 'abo' oder 'projekt' sein, nicht ${String(abrechnung)}`) + } const feature = featureConfig(KEY) const { doc, changed } = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { // 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es - if (by === 'kunde') await pruefeAboFuerEinschalten(customerId) + if (by === 'kunde') await (vorpruefung ? vorpruefung() : pruefeAboFuerEinschalten(customerId)) // 2 Zustand: schon eingeschaltet (setup/active) ist idempotent; sonst nur aus off (der Kunde) bzw. off/ordered/disabled (das Team) const bisher = await getFeatureDoc(customerId, projectId, KEY) @@ -84,8 +92,9 @@ export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', s const gespeichert = await updateDocument(config.collections.portalFeatures, vorhanden.$id, { ...stateFields('setup'), featureKey: KEY, - unlockedBy: gekauft ? 'kauf' : by === 'kunde' ? 'abo' : 'team', + unlockedBy: gekauft ? 'kauf' : abrechnung || (by === 'kunde' ? 'abo' : 'team'), unlockedByPurchase: gekauft, + ...(abrechnung ? { einrichtungAbrechnung: abrechnung } : {}), ...(gekauft ? {} : { einbau: '' }), aboEndeAt: null, behaltenSessionId: '', @@ -141,7 +150,9 @@ export async function holeZeitenNach(doc, { now = new Date() } = {}) { const customer = await customerOf(frisch.customerId) if (!frisch.ticketId) { const texte = await ticketTexte({ feature, doc: frisch, project, customer }) - const { ticketId } = await createFeatureTicket({ customerId: frisch.customerId, project, featureKey: KEY, siteKey: frisch.siteKey, ...texte }) + // Abo-Modell: die Abrechnung am Dokument (abo | projekt) geht ans Ticket; Dokumente ohne Marker (alte Ansicht) bleiben wie sie waren + const abrechnung = Object.hasOwn(ABRECHNUNGS_ZEILEN, frisch.einrichtungAbrechnung) ? { abrechnung: frisch.einrichtungAbrechnung } : {} + const { ticketId } = await createFeatureTicket({ customerId: frisch.customerId, project, featureKey: KEY, siteKey: frisch.siteKey, ...texte, ...abrechnung }) frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId }) } diff --git a/test/funktionEinschalten.test.js b/test/funktionEinschalten.test.js new file mode 100644 index 0000000..1cf7a62 --- /dev/null +++ b/test/funktionEinschalten.test.js @@ -0,0 +1,218 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Generisches Einschalten im Abo (V7): einschalten() fuer news, termine, shop, unterkunft und, ueber einschaltenZeiten, zeiten. +// Sperre feature-order, Nachweis vor Zustand, Ticket danach, Heilung statt Doppelticket. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + featureConsents: schemaKeys('featureConsents'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + datetimes: { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'], + featureConsents: ['acceptedAt'], + }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { ABRECHNUNGS_ZEILEN } = await import('../server/services/ticketAdmin.js') +const { ABRECHNUNGEN, EINSCHALTEN_TEXTE, aboFehlerText, einschalten } = await import('../server/services/funktionEinschalten.js') +after(() => Promise.all([aw.close(), st.close()])) +after(() => setMailTransportForTests(null)) + +const JETZT = new Date('2026-10-17T08:00:00.000Z') // ein Samstag +const modusZeiten = { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] } +after(() => { + config.zeiten.mode = modusZeiten.mode + config.zeiten.pilotEmails = modusZeiten.pilotEmails +}) + +let aufrufe +const nachweis = async () => { + aufrufe.push(aw.docs('portalFeatures').get('f_p1_news')?.state ?? 'kein Dokument') +} +const E = (over = {}) => einschalten({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'kunde', abrechnung: 'abo', nachweis, now: JETZT, ...over }) +const spuren = () => ({ docs: aw.docs('portalFeatures').size, tickets: aw.docs('workorders').size }) +const projekt = (id, customerId = 'c1') => aw.seed('websiteProjects', { $id: id, customerId, projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static' }) +// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geaendert und geloescht +const loescheAbos = async () => { + for (const id of aw.docs('subscriptions').keys()) await deleteDocument('subscriptions', id) +} + +let sink +let rebuilds +const originalRebuild = zeitenDeps.rebuild +beforeEach(async () => { + aufrufe = [] + for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents']) { + for (const id of aw.docs(col).keys()) await deleteDocument(col, id) + } + resetZeitenCacheForTests() + seedFixtures(aw) + aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)) + for (const id of ['p1', 'p2', 'p3']) projekt(id) + projekt('p_fremd', 'c2') + aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['*'] + sink = mailSink() + setMailTransportForTests(sink) + rebuilds = [] + zeitenDeps.rebuild = async (p) => { + rebuilds.push(p.$id) + return { queued: true } + } + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + mock.method(console, 'log', () => {}) +}) +afterEach(() => { + mock.restoreAll() + zeitenDeps.rebuild = originalRebuild +}) + +test('Erfolg im Abo: setup, unlockedBy abo, Marker, genau ein Ticket, Nachweis vor dem Zustand', async () => { + const { doc, ticketWoid } = await E() + assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase, doc.einrichtungAbrechnung], ['setup', false, 'abo', false, 'abo']) + assert.equal(aufrufe.length, 1) + assert.ok(['kein Dokument', 'off'].includes(aufrufe[0]), 'Nachweis lief vor dem Zustand') + const [t] = [...aw.docs('workorders').values()] + assert.deepEqual([aw.docs('workorders').size, t.topic, t.einrichtungFuer, t.einrichtungAbrechnung, t.woid], [1, 'News-Pinnwand einbauen – Projekt p1', 'news', 'abo', ticketWoid]) + assert.ok(t.details.endsWith(ABRECHNUNGS_ZEILEN.abo)) + assert.equal(doc.ticketId, t.$id) + assert.deepEqual(ABRECHNUNGEN, ['abo', 'projekt']) +}) + +test('Team im Projekt: ohne Abo-Prüfung, unlockedBy projekt, Notiz und Ticketzeile', async () => { + await loescheAbos() + const { doc } = await E({ by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' }, nachweis: null }) + assert.deepEqual([doc.unlockedBy, doc.einrichtungAbrechnung], ['projekt', 'projekt']) + assert.match(doc.adminNote, /Andrej: Eingeschaltet \(projekt\)$/) + assert.ok([...aw.docs('workorders').values()][0].details.endsWith(ABRECHNUNGS_ZEILEN.projekt)) + assert.equal(aufrufe.length, 0, 'ohne nachweis wird keiner geschrieben') +}) + +test('Team: staff.adminNote ersetzt die Standardnotiz', async () => { + const { doc } = await E({ by: 'team', abrechnung: 'abo', staff: { name: 'Andrej', adminNote: 'Per Telefon vereinbart' }, nachweis: null }) + assert.equal(doc.adminNote, 'Per Telefon vereinbart') +}) + +test('Konflikte mit klaren Codes, ohne zweites Ticket oder zweiten Nachweis', async () => { + await E() + const s = spuren() + const n = aufrufe.length + await assert.rejects(E(), (e) => e.status === 409 && e.code === 'bereits_eingeschaltet' && e.body.code === 'bereits_eingeschaltet') + await updateDocument('portalFeatures', 'f_p1_news', { state: 'ordered' }) + await assert.rejects(E(), (e) => e.status === 409 && e.code === 'bestellung_laeuft') + await updateDocument('portalFeatures', 'f_p1_news', { state: 'disabled' }) + await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abgeschaltet') + await updateDocument('portalFeatures', 'f_p1_news', { state: 'active' }) + await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet') + assert.deepEqual([spuren(), aufrufe.length], [s, n]) + assert.equal(EINSCHALTEN_TEXTE.bereits, 'Diese Funktion ist für dieses Projekt schon eingeschaltet.') +}) + +test('Heilung: setup ohne Ticket legt nur das Ticket an; gekauft (kauf) wird nicht geheilt', async () => { + aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo' }) + const { doc } = await E() + assert.deepEqual([aufrufe.length, aw.docs('workorders').size, doc.state, Boolean(doc.ticketId)], [0, 1, 'setup', true]) + await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet') + aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true }) + await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet') +}) + +test('Heilung nimmt die gespeicherte Abrechnung des Dokuments', async () => { + aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedBy: 'projekt', unlockedByPurchase: false, einrichtungAbrechnung: 'projekt' }) + const { doc } = await E({ abrechnung: 'abo' }) + assert.equal(aw.docs('workorders').get(doc.ticketId).einrichtungAbrechnung, 'projekt') +}) + +test('Ticket scheitert: 503, setup bleibt, der nächste Aufruf heilt ohne zweites Ticket', async () => { + aw.failNext({ collection: 'workorders', method: 'POST', status: 500 }) + await assert.rejects(E(), (e) => e.status === 503 && e.message === EINSCHALTEN_TEXTE.ticket) + assert.deepEqual([aw.docs('portalFeatures').get('f_p1_news').state, aw.docs('workorders').size], ['setup', 0]) + await E() + assert.deepEqual([aw.docs('workorders').size, aufrufe.length], [1, 1]) +}) + +test('gleichzeitig: ein Erfolg, ein 409, ein Ticket, ein Nachweis', async () => { + const r = await Promise.allSettled([E(), E()]) + assert.deepEqual(r.map((x) => x.status).sort(), ['fulfilled', 'rejected']) + assert.equal(r.find((x) => x.status === 'rejected').reason.code, 'bereits_eingeschaltet') + assert.deepEqual([aw.docs('workorders').size, aufrufe.length], [1, 1]) +}) + +test('Nachweis scheitert: kein Zustand, kein Ticket', async () => { + await assert.rejects(E({ nachweis: async () => { throw Object.assign(new Error('Nachweis weg'), { status: 500 }) } }), /Nachweis weg/) + assert.deepEqual(spuren(), { docs: 0, tickets: 0 }) +}) + +test('Abo fehlt unter der Sperre: abo_fehlt mit Grund und wechsel, nichts geschrieben', async () => { + aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) // Hosting läuft, news ist Wartung + await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abo_fehlt' && e.body.grund === 'falsches_abo' && e.body.wechsel === true && e.message === EINSCHALTEN_TEXTE.abo.falsches_abo) + await loescheAbos() + await assert.rejects(E(), (e) => e.body.grund === 'kein_abo' && e.body.wechsel === false) + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' })) // Probezeit: Abo beginnt später + await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abo_fehlt' && e.body.grund === 'beginnt_spaeter' && e.body.wechsel === false && e.body.ab === '2026-11-03T05:00:00.000Z' && e.message === 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.') + assert.equal(aboFehlerText('beginnt_spaeter', '2026-11-03T05:00:00.000Z'), 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.') + assert.equal(aboFehlerText('kein_abo'), EINSCHALTEN_TEXTE.abo.kein_abo) + assert.throws(() => aboFehlerText('gibtEsNicht'), TypeError) + assert.deepEqual([spuren(), aufrufe.length], [{ docs: 0, tickets: 0 }, 0]) + for (const text of Object.values(EINSCHALTEN_TEXTE.abo)) assert.ok(!/€/.test(text)) +}) + +test('Abo nicht lesbar: 503, nichts geschrieben (fail-closed)', async () => { + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) + await assert.rejects(E(), (e) => e.status === 503 && e.message === EINSCHALTEN_TEXTE.nichtPruefbar) + assert.deepEqual([spuren(), aufrufe.length], [{ docs: 0, tickets: 0 }, 0]) +}) + +test('Öffnungszeiten über den bestehenden Weg: Marker, unlockedBy je Abrechnung, kein Preistext im Abo-Fehler', async () => { + const { doc } = await E({ key: 'zeiten', nachweis: async () => {} }) + assert.deepEqual([doc.unlockedBy, doc.einrichtungAbrechnung, doc.state], ['abo', 'abo', 'setup']) + assert.equal(aw.docs('workorders').get(doc.ticketId).einrichtungAbrechnung, 'abo') + await assert.rejects(E({ key: 'zeiten' }), (e) => e.status === 409 && e.code === 'bereits_eingeschaltet') + await loescheAbos() + await assert.rejects(E({ key: 'zeiten', projectId: 'p2' }), (e) => e.code === 'abo_fehlt' && !/€/.test(e.message)) + const t = await E({ key: 'zeiten', projectId: 'p3', by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' } }) + assert.equal(t.doc.unlockedBy, 'projekt') + assert.equal(aw.docs('workorders').get(t.doc.ticketId).einrichtungAbrechnung, 'projekt') +}) + +test('Öffnungszeiten: Heilung und Zustände mit Codes', async () => { + aw.seed('portalFeatures', { $id: 'f_p1_zeiten', customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', siteKey: 'abcdefghijklmnop', state: 'ordered', enabled: false }) + await assert.rejects(E({ key: 'zeiten' }), (e) => e.code === 'bestellung_laeuft') + await updateDocument('portalFeatures', 'f_p1_zeiten', { state: 'disabled' }) + await assert.rejects(E({ key: 'zeiten' }), (e) => e.code === 'abgeschaltet') + await updateDocument('portalFeatures', 'f_p1_zeiten', { state: 'setup', unlockedBy: 'abo', unlockedByPurchase: false, einbau: '', einrichtungAbrechnung: 'abo' }) + const heil = await E({ key: 'zeiten' }) + assert.deepEqual([heil.doc.state, Boolean(heil.doc.ticketId), aufrufe.length], ['setup', true, 0]) +}) + +test('Parameter und Projekt', async () => { + for (const bad of [{ key: 'analyse' }, { key: 'foo' }, { key: '__proto__' }, { by: 'x' }, { abrechnung: 'frei' }, { abrechnung: '' }, { abrechnung: undefined }]) { + await assert.rejects(E(bad), TypeError, JSON.stringify(bad)) + } + await assert.rejects(E({ projectId: 'p_fremd' }), (e) => e.status === 403) + aw.seed('websiteProjects', { $id: 'p_archiv', customerId: 'c1', projectName: 'Alt', archived: true }) + await assert.rejects(E({ projectId: 'p_archiv' }), (e) => e.status === 409) + assert.deepEqual(spuren(), { docs: 0, tickets: 0 }) +})