From 408f7d0cc2158ee284bffcccf6bec8be1130bc97 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:34:22 +0000 Subject: [PATCH] Kundenansicht P6: K38 an den Kunden und T24 ans Team beim Einschalten Co-Authored-By: Claude Sonnet 5.5 --- server/services/aboFunktionenMeldung.js | 108 ++++++++++++++++++++++ server/services/funktionEinschalten.js | 21 ++++- test/funktionEinschaltenMeldung.test.js | 116 ++++++++++++++++++++++++ test/funktionenEinschaltenRoute.test.js | 6 +- 4 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 server/services/aboFunktionenMeldung.js create mode 100644 test/funktionEinschaltenMeldung.test.js diff --git a/server/services/aboFunktionenMeldung.js b/server/services/aboFunktionenMeldung.js new file mode 100644 index 0000000..6ae0235 --- /dev/null +++ b/server/services/aboFunktionenMeldung.js @@ -0,0 +1,108 @@ +import { aboProdukt } from './aboFunktionen.js' +import { unterlagenFuer } from './aboUnterlagen.js' +import { k38Mail, kontostandText, t24Mail } from './aboFunktionenMails.js' +import { featureConfig } from './featureRegistry.js' +import { getLedger } from './hourLedger.js' +import { featureLegalAttachments } from './legalDocs.js' +import { sendAdminNotice, sendCustomerMail } from './mailer.js' +import { customerOf, projectName } from './siteFeatures.js' + +/** + * Mails zum Einschalten einer Funktion im Abo-Modell (Kundenansicht neu, P6; Builder in aboFunktionenMails.js): + * K38 an den Kunden (BCC Team wie sendCustomerMail): Die Funktion wird eingerichtet, fuer news, termine, shop und unterkunft; + * nicht fuer zeiten, dort sendet holeZeitenNach die K27. Mit den Vertragsunterlagen als PDF (ohne Widerrufsbelehrung), wenn + * die Funktion welche hat und sie lesbar sind. + * T24 ans Team, nur wenn der Kunde selbst eingeschaltet hat (by 'kunde'), fuer alle fuenf Funktionen. + * meldeEingeschaltet wirft nie: jeder Schritt steht in einem eigenen try, ein Fehler ist eine Logzeile mit Art und Status (weder + * Name noch Adresse des Kunden) und der Rest laeuft weiter. Aufrufer ist einschalten() (funktionEinschalten.js). + */ + +/** Austauschpunkte fuer Tests: die Anhaenge der Vertragsunterlagen (Standard: Leistungsbeschreibung und AVV, ohne Widerrufsbelehrung). */ +export const meldungDeps = { + anhaenge: (key) => featureLegalAttachments(key, { ohne: ['widerruf'] }), +} + +/** Fehlerart fuers Log: Name und Status, nie die Meldung (SMTP-Antworten nennen gern die Adresse). */ +const fehlerArt = (err) => `${err?.name ?? 'Fehler'} ${err?.status ?? err?.responseCode ?? ''}`.trim() + +/** + * Meldung nach dem Einschalten: K38 an den Kunden, danach (nur by 'kunde') T24 ans Team. + * @param {{ doc: object, ticketWoid?: string|null, by: 'kunde'|'team', abrechnung: 'abo'|'projekt' }} p + * doc: das eingeschaltete Funktionsdokument (setup, mit Ticket); ticketWoid: Nummer des Einrichtungs-Tickets. + * T24 traegt den Block vorschuss, wenn abrechnung 'abo' ist, das Abo ein Wartungs-Abo ist und das Stundenkonto balanceMinutes <= 0 + * zeigt oder schon einen Vorschuss hat; anhang_fehlt, wenn die Funktion Rechtsunterlagen hat und der Abruf der PDFs scheiterte. + * @returns {Promise<{ k38: boolean, t24: boolean }>} was tatsaechlich versandt wurde + */ +export async function meldeEingeschaltet({ doc, ticketWoid = null, by, abrechnung }) { + const ergebnis = { k38: false, t24: false } + try { + const key = doc?.featureKey + if (!featureConfig(key)) { + console.error(`[funktionen-mail] Meldung entfällt: unbekannte Funktion (${String(key)})`) + return ergebnis + } + const customerId = doc.customerId + + // Kunde und Projektname; fehlt etwas, bleibt es leer (Anrede ohne Namen), die Meldung geht trotzdem raus + let customer = null + let projekt = '' + try { + ;[customer, projekt] = await Promise.all([customerOf(customerId), projectName(doc.projectId)]) + } catch (err) { + console.error(`[funktionen-mail] Kunde oder Projekt nicht lesbar (${key}): ${fehlerArt(err)}`) + } + const hatUnterlagen = unterlagenFuer(key).avv !== null + let anhangFehler = false + + // K38 an den Kunden (nicht fuer zeiten: dort bleibt K27) + if (key !== 'zeiten') { + try { + const to = String(customer?.email ?? '').trim() + if (!to) { + console.warn(`[funktionen-mail] K38 (${key}) entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse`) + } else { + let anhaenge = [] + try { + const geholt = await meldungDeps.anhaenge(key) + anhaenge = Array.isArray(geholt) ? geholt : [] + } catch (err) { + anhangFehler = true + console.error(`[funktionen-mail] Vertragsunterlagen (${key}) nicht lesbar, K38 geht ohne Anhang raus: ${fehlerArt(err)}`) + } + const nachricht = k38Mail({ kunde: customer, projekt, key, abrechnung, ticketNr: ticketWoid, anhang: anhaenge.length > 0 }) + await sendCustomerMail(to, { ...nachricht, attachments: anhaenge }) + ergebnis.k38 = true + } + } catch (err) { + console.error(`[funktionen-mail] K38 (${key}) nicht gesendet: ${fehlerArt(err)}`) + } + } + + // T24 ans Team, nur wenn der Kunde selbst eingeschaltet hat + if (by === 'kunde') { + let kontostand = '' + if (abrechnung === 'abo') { + try { + if ((await aboProdukt(customerId)).produkt === 'wartung') { + const ledger = await getLedger(customerId) + if (ledger.balanceMinutes <= 0 || ledger.vorschussMinutes > 0) kontostand = kontostandText(ledger) + } + } catch (err) { + console.error(`[funktionen-mail] Stundenkonto für T24 (${key}) nicht lesbar: ${fehlerArt(err)}`) + } + } + try { + const nachricht = t24Mail({ + kunde: customer, email: customer?.email, projekt, key, customerId, projectId: doc.projectId, woid: ticketWoid, + kontostand, anhangFehlt: anhangFehler && hatUnterlagen, + }) + ergebnis.t24 = (await sendAdminNotice(nachricht))?.sent === true + } catch (err) { + console.error(`[funktionen-mail] T24 (${key}) nicht gesendet: ${fehlerArt(err)}`) + } + } + } catch (err) { + console.error(`[funktionen-mail] Meldung nach dem Einschalten fehlgeschlagen: ${fehlerArt(err)}`) + } + return ergebnis +} diff --git a/server/services/funktionEinschalten.js b/server/services/funktionEinschalten.js index 24f4533..a21f602 100644 --- a/server/services/funktionEinschalten.js +++ b/server/services/funktionEinschalten.js @@ -1,6 +1,7 @@ import { config } from '../config.js' import { updateDocument } from './appwriteAdmin.js' import { aboDeckt } from './aboFunktionen.js' +import { meldeEingeschaltet } from './aboFunktionenMeldung.js' import { berlinDay } from './deadlines.js' import { bestellFehler } from './featureOrder.js' import { featureConfig, ticketTexte } from './featureRegistry.js' @@ -11,8 +12,9 @@ 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 (Sperre feature-order, Nachweis vor dem Zustand, Ticket danach, Heilung statt Doppelticket). Am Ende meldet + * meldeEingeschaltet (aboFunktionenMeldung.js, P6) das Einschalten mit K38 an den Kunden und T24 ans Team; die Team-Aktivierung + * (adminActivate) steht in siteFeatures.js. 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. */ @@ -131,10 +133,15 @@ export async function einschalten({ customerId, projectId, key, by, abrechnung, await pruefeAboDeckung(customerId, key, now) pruefeEinschaltZustand(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, changed } = await einschaltenZeiten({ customerId, projectId, by, staff, nachweis, now, abrechnung, vorpruefung }) + const woid = await ticketWoid(doc.ticketId) + // nur wenn dieser Aufruf den Zustand gesetzt hat (K27 sendet holeZeitenNach); ein idempotenter Aufruf meldet nichts + if (changed === true) await meldeEingeschaltet({ doc, ticketWoid: woid, by, abrechnung }) + return { doc, ticketWoid: woid } } + // Abrechnung, mit der dieser Aufruf endet: bei der Heilung die am Dokument gespeicherte + let abgerechnet = abrechnung 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 pruefeAboDeckung(customerId, key, now) @@ -144,6 +151,7 @@ export async function einschalten({ customerId, projectId, key, by, abrechnung, pruefeEinschaltZustand(bisher) if (heilbar(bisher)) { const gespeichert = Object.hasOwn(ABRECHNUNGS_ZEILEN, bisher.einrichtungAbrechnung) ? bisher.einrichtungAbrechnung : abrechnung + abgerechnet = gespeichert return legeTicketAn(bisher, { key, feature, project, customerId, abrechnung: gespeichert }) } @@ -168,5 +176,8 @@ export async function einschalten({ customerId, projectId, key, by, abrechnung, // 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) } + // Der Aufruf hat den Zustand gesetzt oder das Ticket angelegt (Heilung zaehlt); die Meldung wirft nie + const woid = await ticketWoid(doc.ticketId) + await meldeEingeschaltet({ doc, ticketWoid: woid, by, abrechnung: abgerechnet }) + return { doc, ticketWoid: woid } } diff --git a/test/funktionEinschaltenMeldung.test.js b/test/funktionEinschaltenMeldung.test.js new file mode 100644 index 0000000..1bfe5ea --- /dev/null +++ b/test/funktionEinschaltenMeldung.test.js @@ -0,0 +1,116 @@ +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' + +// Meldung beim Einschalten im Abo-Modell (P6 Task 6): K38 an den Kunden, T24 ans Team (aboFunktionenMeldung.js, Aufruf am Ende +// von einschalten). Aufbau wie test/funktionEinschalten.test.js, dazu hourCredits und die Mail-Senke. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + featureConsents: schemaKeys('featureConsents'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + hourCredits: schemaKeys('hourCredits'), + }, + 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 { customers, hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = 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 { einschalten } = await import('../server/services/funktionEinschalten.js') +const { meldeEingeschaltet, meldungDeps } = await import('../server/services/aboFunktionenMeldung.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 +}) + +const E = (over = {}) => einschalten({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'kunde', abrechnung: 'abo', now: JETZT, ...over }) +const projekt = (id, customerId = 'c1') => aw.seed('websiteProjects', { $id: id, customerId, projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static' }) +const kunde = (over = {}) => ({ doc: aw.docs('portalFeatures').get('f_p1_news'), ticketWoid: '100150', by: 'kunde', abrechnung: 'abo', ...over }) + +let mails +const betreff = (start) => mails.sent.filter((m) => m.subject.startsWith(start)) +const originalRebuild = zeitenDeps.rebuild +beforeEach(async () => { + for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents', 'hourCredits']) { + 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']) projekt(id) + aw.seed('customers', { ...customers.c1 }) + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['*'] + mails = mailSink() + setMailTransportForTests(mails) + zeitenDeps.rebuild = async () => ({ queued: true }) + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + mock.method(console, 'log', () => {}) +}) +afterEach(() => { + mock.restoreAll() + zeitenDeps.rebuild = originalRebuild +}) + +test('Kunde, Wartung ohne Guthaben: K38 mit Konto-Satz an den Kunden, T24 mit Vorschuss ans Team', async () => { + await E() // einschalten wie P5 Task 9: ruft die Meldung selbst + const [k38] = betreff('Deine Funktion wird eingerichtet: News-Pinnwand'); assert.deepEqual(k38.to.map((a) => a.address), ['k@example.invalid']) + assert.match(k38.text, /geht von deinem Stundenkonto ab/); assert.match(k38.text, /\(Ticket #\w+\)/) + const [t24] = betreff('[Abo] Funktion eingeschaltet: News-Pinnwand'); assert.match(t24.text, /Guthaben 0 min, Vorschuss 0 min/) + assert.equal(betreff('[Abo] Funktion eingeschaltet').length, 1) +}) +test('Guthaben reicht (Gutschrift 300 min): T24 ohne Vorschuss-Satz; Hosting-Abo: auch keiner', async () => { + aw.seed('hourCredits', { $id: 'h1', customerId: 'c1', minutes: 300, grantedAt: '2026-10-01T00:00:00.000Z', expiresAt: '2099-01-01T00:00:00.000Z' }) + await E(); assert.doesNotMatch(betreff('[Abo] Funktion eingeschaltet')[0].text, /reicht voraussichtlich nicht/) + // ein Hosting-Abo hat kein Stundenkonto: auch ohne Guthaben kein Vorschuss-Satz + await deleteDocument('hourCredits', 'h1'); await deleteDocument('subscriptions', 'sub_h'); aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) + mails.sent.length = 0 + assert.deepEqual(await meldeEingeschaltet(kunde()), { k38: true, t24: true }) + assert.doesNotMatch(betreff('[Abo] Funktion eingeschaltet')[0].text, /reicht voraussichtlich nicht/) +}) +test('Terminplaner: K38 mit zwei PDF-Anhängen; PDFs nicht lesbar: K38 ohne Anhang, T24 mit Hinweis', async () => { + await E({ key: 'termine' }); const k = betreff('Deine Funktion wird eingerichtet')[0] + assert.deepEqual(k.attachments.map((a) => a.contentType), ['application/pdf', 'application/pdf']); assert.match(k.text, /hängen als PDF an/) + mails.sent.length = 0; const echt = meldungDeps.anhaenge; meldungDeps.anhaenge = async () => { throw new Error('weg') } + try { await E({ key: 'termine', projectId: 'p2' }) } finally { meldungDeps.anhaenge = echt } + assert.equal(betreff('Deine Funktion wird eingerichtet')[0].attachments.length, 0); assert.match(betreff('[Abo]')[0].text, /von Hand nachsenden/) +}) +test('Team im Projekt: K38 mit Projektkosten-Satz, kein Konto-Satz, kein T24; Zeiten: nur T24 (K27 bleibt)', async () => { + await E({ by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' }, nachweis: null }) + const k = betreff('Deine Funktion wird eingerichtet')[0]; assert.match(k.text, /Von deinem Stundenkonto geht nichts ab/); assert.match(k.text, /Die Funktion läuft, solange dein Wartungs-Abo läuft/); assert.doesNotMatch(k.text, /geht von deinem Stundenkonto ab/); assert.equal(betreff('[Abo]').length, 0) + mails.sent.length = 0; await E({ key: 'zeiten', projectId: 'p2' }) + assert.deepEqual([betreff('Deine Funktion wird eingerichtet').length, betreff('[Abo] Funktion eingeschaltet').length], [0, 1]) +}) +test('Wiederholung und Heilung: höchstens eine Meldung je Einschalten; Versandfehler und Kunde ohne Adresse brechen nichts ab', async () => { + await E(); await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet'); assert.equal(betreff('Deine Funktion').length, 1) + mails.sent.length = 0; aw.failNext({ collection: 'workorders', method: 'POST', status: 500 }) + await assert.rejects(E({ projectId: 'p2' }), (e) => e.status === 503); assert.equal(mails.sent.length, 0) // Ticket scheitert: noch keine Mail + await E({ projectId: 'p2' }); assert.equal(betreff('Deine Funktion').length, 1) // Heilung: genau eine + setMailTransportForTests({ sendMail: async () => { throw new Error('smtp') } }) + const r = await meldeEingeschaltet(kunde()); assert.deepEqual(r, { k38: false, t24: false }) + setMailTransportForTests(mails); aw.seed('customers', { ...customers.c1, email: '' }); assert.equal((await meldeEingeschaltet(kunde())).k38, false) +}) diff --git a/test/funktionenEinschaltenRoute.test.js b/test/funktionenEinschaltenRoute.test.js index 59332d7..5f91af4 100644 --- a/test/funktionenEinschaltenRoute.test.js +++ b/test/funktionenEinschaltenRoute.test.js @@ -104,13 +104,15 @@ afterEach(() => { einschaltenDeps.jetzt = () => new Date() }) -test('Erfolg (Betrieb, Wartung): Form, Zustand, Ticket, Nachweis mit Kundenart, keine Mail, kein Preis', async () => { +test('Erfolg (Betrieb, Wartung): Form, Zustand, Ticket, Nachweis mit Kundenart, K38 und T24 (P6) ohne Preis, kein Stripe', async () => { aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) const r = await go('news') assert.deepEqual([r.status, Object.keys(r.body).sort(), r.body.feature.state, r.body.feature.unlockedBy], [200, ['feature', 'ticketWoid'], 'setup', 'abo']) assert.equal(r.body.ticketWoid, r.body.feature.ticketWoid) assert.equal(aw.docs('workorders').size, 1) - assert.deepEqual([sink.sent.length, st.calls.length], [0, 0], 'keine Mail, kein Stripe') + // P6: das Einschalten meldet K38 an den Kunden und T24 ans Team (aboFunktionenMeldung.js); kein Preis, kein Stripe + assert.deepEqual([sink.sent.map((m) => m.subject.split(':')[0]), st.calls.length], [['Deine Funktion wird eingerichtet', '[Abo] Funktion eingeschaltet'], 0]) + assert.doesNotMatch(JSON.stringify(sink.sent), /€/) const n = [...aw.docs('featureConsents').values()][0] assert.deepEqual([JSON.parse(n.texte).map((t) => t.id), n.kundenart, n.stripeSessionId], [['einschalten-zeile', 'knopf'], 'unternehmen', '']) assert.doesNotMatch(JSON.stringify(r.body), /€/)