@@ -35,7 +40,7 @@ a{color:#111111;}
|
-| Neue Funktion bestellt: News-Pinnwand |
+| Neue Funktion bestellt: {{funktion}} |
| Kunde | {{kunde_name}} | | E-Mail | {{email}} | | Projekt | {{projekt}} | | Ticket | {{woid}} | | Site-Key | {{sitekey}} |
|
Zahlung fehlgeschlagen Die Zahlung ist nicht eingegangen (zum Beispiel SEPA-Lastschrift abgelehnt). Es wurde nichts eingerichtet und kein Ticket angelegt; die Bestellung steht im Portal wieder auf „nicht bestellt“. |
|
@@ -43,6 +48,18 @@ a{color:#111111;}
Bezahlt, aber nicht zuzuordnen Der Kunde hat bezahlt, die Zahlung passt aber zu keiner offenen Bestellung. Es wurde nichts eingerichtet und kein Ticket angelegt. Bitte von Hand prüfen: {{sitzung}} |
|
+
+Kein laufendes Wartungs-Abo Die Zahlung ist eingegangen und das Ticket {{woid}} ist angelegt, aber der Kunde hat kein laufendes Wartungs-Abo. Die Freischaltung bleibt gesperrt. Endet das Abo vor der Fertigstellung, wird die Einrichtung voll erstattet (Mail „Erstattung fällig“). |
|
+
+
+Bezahlt, aber schon vorhanden Zahlung eingegangen, aber die Funktion war schon {{zustand}}. Es wurde kein Ticket angelegt und nichts eingerichtet. Bitte prüfen: Einbau nachholen oder erstatten. Sitzung: {{sitzung}} |
|
+
+
+Gesundheitsbereich prüfen: {{gesundheit_text}} |
|
+
+
+ |
+
| |
|
diff --git a/server/mail-templates/T19 Erstattung faellig.html b/server/mail-templates/T19 Erstattung faellig.html
new file mode 100644
index 0000000..42723ec
--- /dev/null
+++ b/server/mail-templates/T19 Erstattung faellig.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+[Abo] Erstattung fällig: {{funktion}}-Einrichtung
+
+
+
+
+{{kunde_name}}: {{betrag}} für die {{funktion}}-Einrichtung erstatten. ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Intern · Kundenportal |
+
|
+
+
+ |
+| Erstattung fällig: {{funktion}}-Einrichtung |
+| Kunde | {{kunde_name}} | | E-Mail | {{email}} | | Projekt | {{projekt}} | | Betrag | {{betrag}} | | Sitzung | {{sitzung}} |
|
+Volle Erstattung von Hand Der Kunde hat die Einrichtung bezahlt, sein Wartungs-Abo lief aber nicht mehr bzw. endete vor der Freischaltung. Die Einrichtung wird deshalb voll erstattet: bitte von Hand in Stripe zurückzahlen. Eine automatische Rückzahlung gibt es nicht. |
|
+
+Zahlung nach dem Abo-Ende Die Zahlung ist erst nach dem Abo-Ende eingegangen, es wurde kein Ticket angelegt. |
|
+
+
+Ticket noch offen Das Ticket {{woid}} ist noch offen. Bitte das Ticket mit einem Vermerk schließen; die Freischaltung beim Schließen schaltet dann nicht frei, weil die Funktion abgeschaltet ist. |
|
+
+
+ |
+
+| |
+
+ |
+| Automatische Benachrichtigung aus dem WEBklar Kundenportal · Ticketsystem |
+
+ |
+
+
+
diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js
index 0b0a432..be2ab98 100644
--- a/server/services/siteFeatures.js
+++ b/server/services/siteFeatures.js
@@ -4,7 +4,7 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro
import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
-import { featureConfig, featureKeys, ticketTexte } from './featureRegistry.js'
+import { anleitungsLink, featureConfig, featureKeys, ticketTexte } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.js'
import { createFeatureTicket } from './ticketAdmin.js'
@@ -572,37 +572,63 @@ async function ticketWoidOf(doc) {
return doc.ticketId ? (await ticketWoid(doc.ticketId)) || '' : ''
}
-async function teamNotice(doc, customer, { failed = false } = {}) {
- await sendAdminNotice({
+/**
+ * T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus.
+ * Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". kein_abo, bereits_aktiv und gesundheit sind in der Vorlage
+ * deklariert und bleiben hier aus (B10a und D4 fuellen sie).
+ */
+function sendT16({ funktion, ...werte }, bloecke) {
+ return sendAdminNotice({
template: 'T16',
values: {
+ kunde_name: '',
+ email: '',
+ projekt: '',
+ funktion: funktion || 'Funktion',
+ woid: '',
+ sitekey: '',
+ sitzung: '',
+ zustand: '',
+ anleitung_url: '',
+ gesundheit_text: '',
+ ...werte,
+ },
+ blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, ...bloecke },
+ })
+}
+
+async function teamNotice(doc, customer, { failed = false } = {}) {
+ const mails = featureConfig(doc.featureKey)?.mails
+ // Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde
+ const anleitung = Boolean(mails?.anleitung) && !failed
+ await sendT16(
+ {
kunde_name: customer?.name || customer?.email || doc.customerId,
email: customer?.email || '',
projekt: await projectName(doc.projectId),
+ funktion: mails?.funktion,
woid: failed ? '' : await ticketWoidOf(doc),
sitekey: failed ? '' : doc.siteKey || '',
- sitzung: '',
+ anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '',
},
- blocks: { zahlung_fehlgeschlagen: failed, ungeklaert: false },
- })
+ { zahlung_fehlgeschlagen: failed, anleitung }
+ )
}
/** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */
async function unmatchedNotice(session) {
- const { customerId, projectId } = session.metadata || {}
+ const { customerId, projectId, featureKey } = session.metadata || {}
const customer = customerId ? await customerOf(customerId).catch(() => null) : null
- await sendAdminNotice({
- template: 'T16',
- values: {
+ await sendT16(
+ {
kunde_name: customer?.name || customer?.email || customerId || 'unbekannt',
email: customer?.email || '',
projekt: projectId ? (await projectName(projectId).catch(() => '')) || projectId : '',
- woid: '',
- sitekey: '',
+ funktion: featureConfig(featureKey)?.mails.funktion,
sitzung: `${session.id} (Kunde ${customerId || '-'}, Projekt ${projectId || '-'})`,
},
- blocks: { zahlung_fehlgeschlagen: false, ungeklaert: true },
- })
+ { ungeklaert: true }
+ )
}
/**
diff --git a/test/featureMails.test.js b/test/featureMails.test.js
index f8642f7..93e93e5 100644
--- a/test/featureMails.test.js
+++ b/test/featureMails.test.js
@@ -7,7 +7,7 @@ import { schemaKeys } from './support/schema.js'
// Mails K15 (Bestellung eingegangen) und K16 (Funktion bereit) je Funktion (Task B9a). Der News-Wortlaut ist als Literal
// eingefroren (vor dem Umbau der Vorlagen aus den damaligen Mails uebernommen): "News-Mails bleiben woertlich".
-// B9b ergaenzt T16 und T19 in dieser Datei. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema.
+// B9b ergaenzt T16 (Team-Hinweis je Funktion) und T19 (Erstattung faellig) in dieser Datei. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema.
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
@@ -80,6 +80,51 @@ async function bezahle(key, projectName, extra = {}) {
return { projectId, sessionId, mail: k15[0] }
}
+const failedEvent = (sessionId, projectId, key) => ({
+ id: `evt_f_${sessionId}`,
+ object: 'event',
+ type: 'checkout.session.async_payment_failed',
+ data: {
+ object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'unpaid', metadata: { customerId: 'c1', projectId, featureKey: key } },
+ },
+})
+
+/** Die T16-Mails (Team-Hinweis "Neue Funktion bestellt") ab einer Position von mails.sent. */
+const t16Seit = (vor) => mails.sent.slice(vor).filter((m) => m.subject.startsWith('Neue Funktion bestellt'))
+
+/** Bestellung bezahlen: die eine T16-Mail an das Team, dazu Projekt, Ticketnummer und Site-Key aus dem Dokument. */
+async function t16Bezahlt(key, projectName) {
+ const { projectId, sessionId } = await bestelle(key, projectName)
+ const vor = mails.sent.length
+ await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, key))
+ const t16 = t16Seit(vor)
+ assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
+ const doc = await sf.getFeatureDoc('c1', projectId, key)
+ const woid = aw.docs('workorders').get(doc.ticketId).woid
+ return { projectId, sessionId, doc, woid, mail: t16[0] }
+}
+
+/** Zahlung fehlgeschlagen (SEPA): bestellen, dann async_payment_failed. */
+async function t16Fehlgeschlagen(key, projectName) {
+ const { projectId, sessionId } = await bestelle(key, projectName)
+ const vor = mails.sent.length
+ await sf.handleFeatureCheckoutEvent(failedEvent(sessionId, projectId, key))
+ const t16 = t16Seit(vor)
+ assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
+ return { projectId, mail: t16[0] }
+}
+
+/** Bezahlte Sitzung ohne Bestellung zum Projekt (Schlüssel der Funktion wie in den Metadaten, auch fremd oder leer): T16 mit Block "ungeklaert". */
+async function t16Ungeklaert(sessionId, projectId, key) {
+ const vor = mails.sent.length
+ await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, key))
+ const t16 = t16Seit(vor)
+ assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
+ return t16[0]
+}
+
+const T16_FUSS = 'Automatische Benachrichtigung aus dem WEBklar Kundenportal · Ticketsystem (https://ticket.webklar.com/customers)'
+
// ------------------------------------------------------------------------- News bleibt woertlich ---
test('News-Mails bleiben wörtlich: K15 mit Rechnung', async () => {
const { mail } = await bezahle('news', 'Bäckerei', { invoice: 'in_1' })
@@ -185,6 +230,91 @@ test('News-Mails bleiben wörtlich: K16', async () => {
assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test'])
})
+test('News-Mails bleiben wörtlich: T16', async () => {
+ const { mail, woid, doc } = await t16Bezahlt('news', 'Bäckerei')
+ assert.equal(mail.subject, 'Neue Funktion bestellt: News-Pinnwand')
+ assert.equal(
+ mail.text,
+ [
+ 'WEBklar',
+ '',
+ 'Intern · Kundenportal',
+ '',
+ 'Funktionen',
+ '',
+ 'Neue Funktion bestellt: News-Pinnwand',
+ '',
+ 'Kunde: Kunde Eins',
+ 'E-Mail: kunde1@example.test',
+ 'Projekt: Bäckerei',
+ `Ticket: ${woid}`,
+ `Site-Key: ${doc.siteKey}`,
+ '',
+ T16_FUSS,
+ '',
+ ].join('\n')
+ )
+ assert.deepEqual(mail.to.map((t) => t.address), ['kenso@webklar.com', 'andrej@webklar.com'])
+})
+
+test('News-Mails bleiben wörtlich: T16 Zahlung fehlgeschlagen', async () => {
+ const { mail } = await t16Fehlgeschlagen('news', 'Bäckerei')
+ assert.equal(mail.subject, 'Neue Funktion bestellt: News-Pinnwand')
+ assert.equal(
+ mail.text,
+ [
+ 'WEBklar',
+ '',
+ 'Intern · Kundenportal',
+ '',
+ 'Funktionen',
+ '',
+ 'Neue Funktion bestellt: News-Pinnwand',
+ '',
+ 'Kunde: Kunde Eins',
+ 'E-Mail: kunde1@example.test',
+ 'Projekt: Bäckerei',
+ 'Ticket:',
+ 'Site-Key:',
+ '',
+ 'Zahlung fehlgeschlagen',
+ 'Die Zahlung ist nicht eingegangen (zum Beispiel SEPA-Lastschrift abgelehnt). Es wurde nichts eingerichtet und kein Ticket angelegt; die Bestellung steht im Portal wieder auf „nicht bestellt“.',
+ '',
+ T16_FUSS,
+ '',
+ ].join('\n')
+ )
+})
+
+test('News-Mails bleiben wörtlich: T16 bezahlt, aber nicht zuzuordnen', async () => {
+ const mail = await t16Ungeklaert('cs_unklar_news', 'pX_news', 'news')
+ assert.equal(mail.subject, 'Neue Funktion bestellt: News-Pinnwand')
+ assert.equal(
+ mail.text,
+ [
+ 'WEBklar',
+ '',
+ 'Intern · Kundenportal',
+ '',
+ 'Funktionen',
+ '',
+ 'Neue Funktion bestellt: News-Pinnwand',
+ '',
+ 'Kunde: Kunde Eins',
+ 'E-Mail: kunde1@example.test',
+ 'Projekt: pX_news',
+ 'Ticket:',
+ 'Site-Key:',
+ '',
+ 'Bezahlt, aber nicht zuzuordnen',
+ 'Der Kunde hat bezahlt, die Zahlung passt aber zu keiner offenen Bestellung. Es wurde nichts eingerichtet und kein Ticket angelegt. Bitte von Hand prüfen: cs_unklar_news (Kunde c1, Projekt pX_news)',
+ '',
+ T16_FUSS,
+ '',
+ ].join('\n')
+ )
+})
+
// ------------------------------------------------------------------------------------- Termine ---
test('Termine-Mails: K15 mit den Werten des Terminplaners', async () => {
const termine = config.features.termine.mails
@@ -221,6 +351,137 @@ test('Termine-Mails: K16 mit den Werten des Terminplaners', () => {
assert.ok(mail.text.includes(`Termine öffnen: ${BASE}/dashboard.html#termine`), mail.text)
})
+// -------------------------------------------------------------------- T16 je Funktion (Team-Hinweis) ---
+test('Termine-T16: Betreff, Text und Knopf nennen den Terminplaner', async () => {
+ const { mail, projectId, woid, doc } = await t16Bezahlt('termine', 'Salon Haarlinie')
+ assert.equal(mail.subject, 'Neue Funktion bestellt: Terminplaner')
+ for (const teil of [mail.subject, mail.text, mail.html]) assert.doesNotMatch(teil, /News|Beitr/)
+ assert.match(mail.text, /Funktionen\n\nNeue Funktion bestellt: Terminplaner\n\nKunde: Kunde Eins\nE-Mail: kunde1@example\.test\nProjekt: Salon Haarlinie\n/)
+ assert.ok(mail.text.includes(`Ticket: ${woid}\nSite-Key: ${doc.siteKey}\n`), mail.text)
+ // der Knopf führt in das WOMS-Kundenpanel "Funktionen" mit geöffneter Anleitung dieses Projekts (anleitungsLink aus B8)
+ const link = `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=${projectId}`
+ assert.equal(link, reg.anleitungsLink('c1', projectId))
+ assert.ok(mail.text.includes(`Im Ticketsystem öffnen: ${link}`), mail.text)
+ assert.ok(mail.html.includes(`href="${link.replaceAll('&', '&')}"`), mail.html)
+ // die Warnblöcke bleiben aus, solange B10a/D4 sie nicht füllen
+ for (const warnung of ['Zahlung fehlgeschlagen', 'Bezahlt, aber nicht zuzuordnen', 'Kein laufendes Wartungs-Abo', 'Gesundheitsbereich prüfen', 'Bezahlt, aber schon vorhanden']) {
+ assert.ok(!mail.text.includes(warnung), `kein Block "${warnung}"`)
+ }
+})
+
+test('Termine-T16: Zahlung fehlgeschlagen und nicht zuzuordnen nennen den Terminplaner, ohne Knopf zur Anleitung', async () => {
+ const { mail: fehl } = await t16Fehlgeschlagen('termine', 'Salon Haarlinie')
+ assert.equal(fehl.subject, 'Neue Funktion bestellt: Terminplaner')
+ assert.match(fehl.text, /Neue Funktion bestellt: Terminplaner\n\nKunde: Kunde Eins/)
+ assert.match(fehl.text, /Zahlung fehlgeschlagen\nDie Zahlung ist nicht eingegangen/)
+ assert.doesNotMatch(fehl.text, /News|Beitr|Im Ticketsystem öffnen/, 'es wurde nichts bestellt: keine Anleitung')
+
+ const unklar = await t16Ungeklaert('cs_unklar_termine', 'pX_termine', 'termine')
+ assert.equal(unklar.subject, 'Neue Funktion bestellt: Terminplaner')
+ assert.match(unklar.text, /Bezahlt, aber nicht zuzuordnen\nDer Kunde hat bezahlt.*cs_unklar_termine \(Kunde c1, Projekt pX_termine\)\n/)
+ assert.doesNotMatch(unklar.text, /News|Beitr|Im Ticketsystem öffnen/)
+})
+
+test('T16 ohne bekannten Funktionsschlüssel: "Funktion", sonst leer', async () => {
+ for (const [sessionId, projectId, key] of [
+ ['cs_ohne_schluessel', 'pY1', undefined],
+ ['cs_fremder_schluessel', 'pY2', 'gibtesnicht'],
+ ]) {
+ const mail = await t16Ungeklaert(sessionId, projectId, key)
+ assert.equal(mail.subject, 'Neue Funktion bestellt: Funktion', sessionId)
+ assert.match(mail.text, /Neue Funktion bestellt: Funktion\n\nKunde: Kunde Eins/)
+ assert.match(mail.text, /Ticket:\nSite-Key:\n/, 'Ticket und Site-Key leer')
+ assert.match(mail.text, new RegExp(`Bezahlt, aber nicht zuzuordnen\\nDer Kunde hat bezahlt.*${sessionId} \\(Kunde c1, Projekt ${projectId}\\)\\n`))
+ assert.doesNotMatch(mail.text, /News|Beitr|Im Ticketsystem öffnen/, sessionId)
+ }
+})
+
+test('T16 Blöcke kein_abo und bereits_aktiv: Wortlaut und Werte', () => {
+ const werte = { kunde_name: 'Kunde Eins', email: 'kunde1@example.test', projekt: 'Salon', funktion: 'Terminplaner', woid: '10042', sitekey: 'abc', sitzung: 'cs_test_1', zustand: 'aktiv', anleitung_url: '', gesundheit_text: '' }
+ const aus = { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false }
+ const keinAbo = renderMail('T16', werte, { ...aus, kein_abo: true })
+ assert.deepEqual(keinAbo.problems, [])
+ assert.match(keinAbo.text, /Kein laufendes Wartungs-Abo\nDie Zahlung ist eingegangen und das Ticket 10042 ist angelegt, aber der Kunde hat kein laufendes Wartungs-Abo\. Die Freischaltung bleibt gesperrt\. Endet das Abo vor der Fertigstellung, wird die Einrichtung voll erstattet \(Mail „Erstattung fällig“\)\.\n/)
+ assert.doesNotMatch(keinAbo.text, /schon aktiv|nicht zuzuordnen/)
+ const aktiv = renderMail('T16', werte, { ...aus, bereits_aktiv: true })
+ assert.deepEqual(aktiv.problems, [])
+ assert.match(
+ aktiv.text,
+ /Bezahlt, aber schon vorhanden\nZahlung eingegangen, aber die Funktion war schon aktiv\. Es wurde kein Ticket angelegt und nichts eingerichtet\. Bitte prüfen: Einbau nachholen oder erstatten\. Sitzung: cs_test_1\n/
+ )
+ assert.doesNotMatch(aktiv.text, /Kein laufendes Wartungs-Abo/)
+ // ohne Block steht nichts davon in der Mail
+ const keiner = renderMail('T16', werte, aus)
+ assert.doesNotMatch(keiner.text, /Wartungs-Abo|schon aktiv|Im Ticketsystem öffnen|Gesundheitsbereich/)
+})
+
+test('T16 gesundheit: Block aus ohne Warntext, Block an mit dem Text', () => {
+ const werte = { kunde_name: 'Kunde Eins', email: 'kunde1@example.test', projekt: 'Salon', funktion: 'Terminplaner', woid: '10042', sitekey: 'abc', sitzung: '', zustand: '', anleitung_url: '', gesundheit_text: 'Branche Physiotherapie: Gesundheitsdaten möglich' }
+ const aus = renderMail('T16', werte, { gesundheit: false })
+ assert.deepEqual(aus.problems, [])
+ assert.doesNotMatch(aus.text, /Gesundheitsbereich prüfen|Physiotherapie/)
+ assert.doesNotMatch(aus.html, /Physiotherapie/)
+ const an = renderMail('T16', werte, { gesundheit: true })
+ assert.deepEqual(an.problems, [])
+ assert.ok(an.text.includes('Gesundheitsbereich prüfen: Branche Physiotherapie: Gesundheitsdaten möglich'), an.text)
+})
+
+test('T16 anleitung: Knopf nur mit Block, Link trägt &anleitung=', () => {
+ const link = reg.anleitungsLink('c1', 'p42')
+ assert.ok(link.endsWith('&anleitung=p42'))
+ const werte = { kunde_name: 'Kunde Eins', email: 'kunde1@example.test', projekt: 'Salon', funktion: 'Terminplaner', woid: '10042', sitekey: 'abc', sitzung: '', zustand: '', anleitung_url: link, gesundheit_text: '' }
+ const an = renderMail('T16', werte, { anleitung: true })
+ assert.deepEqual(an.problems, [])
+ assert.ok(an.text.includes(`Im Ticketsystem öffnen: ${link}`), an.text)
+ assert.ok(an.html.includes('&anleitung=p42"'), an.html)
+ const aus = renderMail('T16', werte, { anleitung: false })
+ assert.doesNotMatch(aus.text, /Im Ticketsystem öffnen/)
+ assert.ok(!aus.text.includes('anleitung=p42'))
+})
+
+// ------------------------------------------------------------------------ T19 Erstattung fällig ---
+test('T19 rendert beide Fälle', () => {
+ const werte = {
+ kunde_name: 'Kunde Eins',
+ email: 'kunde1@example.test',
+ projekt: 'Salon Haarlinie',
+ funktion: 'Terminplaner',
+ betrag: '149,00 €',
+ sitzung: 'cs_test_erstattung',
+ woid: '10042',
+ rechnung_url: 'https://invoice.stripe.test/i/1',
+ }
+ const gemeinsam = (mail) => {
+ assert.deepEqual(mail.problems, [])
+ assert.equal(mail.subject, '[Abo] Erstattung fällig: Terminplaner-Einrichtung')
+ for (const teil of ['Kunde: Kunde Eins', 'E-Mail: kunde1@example.test', 'Projekt: Salon Haarlinie', 'Betrag: 149,00 €', 'Sitzung: cs_test_erstattung']) {
+ assert.ok(mail.text.includes(teil), `T19 enthält: ${teil}\n---\n${mail.text}`)
+ }
+ // volle Erstattung von Hand, keine automatische Rückzahlung
+ assert.match(mail.text, /von Hand in Stripe/)
+ assert.match(mail.text, /Eine automatische Rückzahlung gibt es nicht\./)
+ assert.match(mail.html, /Automatische Benachrichtigung aus dem WEBklar Kundenportal · ]*>Ticketsystem<\/a>/)
+ assert.doesNotMatch(mail.text, /News|Beitr/)
+ }
+
+ const nachEnde = renderMail('T19', werte, { zahlung_nach_ende: true, ticket_offen: false, rechnung: false })
+ gemeinsam(nachEnde)
+ assert.ok(nachEnde.text.includes('Die Zahlung ist erst nach dem Abo-Ende eingegangen, es wurde kein Ticket angelegt.'), nachEnde.text)
+ assert.doesNotMatch(nachEnde.text, /noch offen|10042|Rechnung ansehen/)
+
+ const ticketOffen = renderMail('T19', werte, { zahlung_nach_ende: false, ticket_offen: true, rechnung: false })
+ gemeinsam(ticketOffen)
+ assert.ok(ticketOffen.text.includes('Das Ticket 10042 ist noch offen.'), ticketOffen.text)
+ assert.match(ticketOffen.text, /Ticket mit einem Vermerk schließen/)
+ assert.match(ticketOffen.text, /schaltet dann nicht frei/)
+ assert.doesNotMatch(ticketOffen.text, /erst nach dem Abo-Ende|Rechnung ansehen/)
+
+ const mitRechnung = renderMail('T19', werte, { zahlung_nach_ende: true, ticket_offen: false, rechnung: true })
+ gemeinsam(mitRechnung)
+ assert.ok(mitRechnung.text.includes('Rechnung ansehen: https://invoice.stripe.test/i/1'), mitRechnung.text)
+ assert.match(mitRechnung.html, /]*>Rechnung ansehen/)
+})
+
// ----------------------------------------------------------------------------- Platzhalter, Blöcke ---
test('Platzhalter-/Blocklisten exakt: K15 und K16', () => {
const k15 = loadTemplate('K15')
@@ -247,6 +508,19 @@ test('Platzhalter-/Blocklisten exakt: K15 und K16', () => {
assert.equal(k16.subject, '{{funktion_dein}} ist bereit')
})
+test('Platzhalter-/Blocklisten exakt: T16 und T19', () => {
+ const t16 = loadTemplate('T16')
+ assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
+ assert.deepEqual(t16.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'sitekey', 'sitzung', 'zustand', 'anleitung_url', 'gesundheit_text'])
+ assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit'])
+ const t19 = loadTemplate('T19')
+ assert.equal(t19.file, 'T19 Erstattung faellig.html')
+ assert.equal(t19.subject, '[Abo] Erstattung fällig: {{funktion}}-Einrichtung')
+ assert.deepEqual(t19.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'betrag', 'sitzung', 'woid', 'rechnung_url'])
+ assert.deepEqual(t19.blocks, ['zahlung_nach_ende', 'ticket_offen', 'rechnung'])
+ for (const tpl of [t16, t19]) assert.doesNotMatch(tpl.body, /News|Beitr|Terminplaner/, `${tpl.id}: keine feste Funktion`)
+})
+
test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab', () => {
const ctx = { kundeName: 'Kunde Eins', projekt: 'Salon', invoiceUrl: 'https://invoice.stripe.test/i/1' }
for (const key of reg.featureKeys()) {
diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js
index b885138..a9ef4bd 100644
--- a/test/mailTemplates.test.js
+++ b/test/mailTemplates.test.js
@@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
-// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15 und K16 gelten je Funktion, Terminplaner TP2 B9a); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen); T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweis; T19 „Erstattung fällig“ folgt in Block B)
+// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
-const EXPECTED = [...ids('G', 8), ...ids('K', 21), ...ids('T', 18), 'T20']
+const EXPECTED = [...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20']
const GUEST = ids('G', 8)
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -97,6 +97,23 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text
}
})
+// Terminplaner TP2 (B9b): T16 gilt je Funktion (neue Platzhalter und Blöcke), T19 ist neu (Erstattung fällig, Team-Hinweis)
+test('T16, T19: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
+ const t16 = loadTemplate('T16')
+ assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
+ assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit'])
+ const t19 = loadTemplate('T19')
+ assert.equal(t19.file, 'T19 Erstattung faellig.html')
+ assert.match(t19.subject, /^\[Abo\] Erstattung fällig: \{\{funktion\}\}-Einrichtung$/)
+ assert.deepEqual(t19.blocks, ['zahlung_nach_ende', 'ticket_offen', 'rechnung'])
+ for (const tpl of [t16, t19]) {
+ for (const key of tpl.placeholders) assert.ok(tpl.body.includes(`{{${key}}}`) || tpl.subject.includes(`{{${key}}}`), `${tpl.id}: {{${key}}} steht in der Vorlage`)
+ for (const block of tpl.blocks) {
+ assert.equal((tpl.body.match(new RegExp(``, 'g')) || []).length, 1, `${tpl.id}: Block ${block} genau einmal`)
+ }
+ }
+})
+
test('Werte werden HTML-sicher eingesetzt, Zeilenumbrüche werden ', () => {
const mail = renderMail('T01', { kunde_name: 'Max &"\'', nachricht: 'Zeile 1\nZeile <2>' })
assert.match(mail.html, /Max <b>&"' hat im Kundenportal geschrieben/)
@@ -530,7 +547,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => {
})
// Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem
-const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T20']
+const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20']
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
for (const id of EXPECTED) {
|