Kundenansicht P6: K38 an den Kunden und T24 ans Team beim Einschalten

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:34:22 +00:00
1 parent cdc8f5c424
commit 408f7d0cc2
4 files changed
+244 -7

No files matched your search

+108
View File
@@ -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
}
+16 -5
View File
@@ -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 }
}
+116
View File
@@ -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)
})
+4 -2
View File
@@ -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), /€/)