Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 03:20:56 +00:00
1 parent 03bdeb4461
commit 2c059cb3d6
11 files changed
+574 -39

No files matched your search

+125 -12
View File
@@ -7,6 +7,7 @@ import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.js'
import { aboLage, laeuft } from './aboStatus.js'
import { berlinDateTime } from './berlinTime.js'
import { loeschTag } from './termine/frist.js'
import { createFeatureTicket } from './ticketAdmin.js'
@@ -791,12 +792,27 @@ function einrichtungsPreis(feature) {
return eur(amountCents)
}
/**
* Absolute, versionierte Adressen der Vertragsunterlagen einer Funktion aus dem Register (Fassung der Leistungsbeschreibung und
* die Adressen von Leistungsbeschreibung, AVV und Widerrufsbelehrung); null bei Funktionen ohne Rechtsdokumente (News).
* @returns {{ fassung: string, lb: string, avv: string, widerruf: string } | null}
*/
export function unterlagenAdressen(feature) {
const { leistungsbeschreibung: lb, avv, widerruf } = feature ?? {}
if (!lb?.url || !avv?.url || !widerruf?.url) return null
const absolut = (pfad) => `${config.publicBaseUrl}${pfad}`
return { fassung: lb.version, lb: absolut(lb.url), avv: absolut(avv.url), widerruf: absolut(widerruf.url) }
}
/**
* K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag
* (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab.
* Ohne Anhaenge: die Rechtsunterlagen der Termine-Bestellung haengt F8 direkt in onFeaturePaid an.
* Funktionen mit Rechtsdokumenten (Terminplaner): `unterlagen` (unterlagenAdressen) schaltet den Block anlagen ein und fuellt
* fassung und die drei Adressen; `anhang` sagt, dass die PDFs an der Mail haengen (Block anhang in anlagen); `zustimmungenText`
* (zustimmungenText) schaltet den Block zustimmungen ein. Die PDFs selbst gibt onFeaturePaid als `attachments` mit; es gibt keinen
* Registerhaken dafuer.
*/
export function bestellMail(feature, { kundeName, projekt, invoiceUrl }) {
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '' }) {
const { funktion, formen, preisHinweis, danach } = feature.mails
return {
template: 'K15',
@@ -814,11 +830,40 @@ export function bestellMail(feature, { kundeName, projekt, invoiceUrl }) {
danach,
portal_url: portalUrl(feature),
rechnung_url: invoiceUrl || '',
fassung: unterlagen?.fassung ?? '',
lb_url: unterlagen?.lb ?? '',
avv_url: unterlagen?.avv ?? '',
widerruf_url: unterlagen?.widerruf ?? '',
zustimmungen_text: zustimmungenText,
},
blocks: { rechnung: Boolean(invoiceUrl) },
blocks: { rechnung: Boolean(invoiceUrl), anlagen: unterlagen !== null, anhang: unterlagen !== null && anhang === true, zustimmungen: zustimmungenText !== '' },
}
}
/**
* Wortlaut und Zeitpunkt der erteilten Zustimmungen fuer K15 (Du-Form): die Haken-Texte aus dem Nachweis zur bezahlten Sitzung, der
* Zeitpunkt des Klicks (Europe/Berlin) und der Knopf, mit dem bestellt wurde. '' ohne verwertbaren Nachweis.
* @param {{ texte?: string, acceptedAt?: string } | null} nachweis featureConsents-Dokument (JSON-Text im Feld texte)
*/
export function zustimmungenText(nachweis) {
let texte
try {
texte = JSON.parse(nachweis?.texte ?? '')
} catch {
return ''
}
if (!Array.isArray(texte)) return ''
const haken = texte.filter((t) => t?.haken === true && typeof t.text === 'string' && t.text !== '')
if (haken.length === 0) return ''
const am = new Date(nachweis.acceptedAt)
const zeit = Number.isNaN(am.getTime()) ? '' : (({ date, time }) => ` am ${date} um ${time.slice(0, 2)}:${time.slice(2)} Uhr`)(berlinDateTime(am))
const knopf = texte.find((t) => t?.id === 'knopf' && typeof t.text === 'string' && t.text !== '')
return [
`Du hast${zeit} im Kundenportal zugestimmt${knopf ? ` und mit „${knopf.text}“ bestellt` : ''}:`,
...haken.map((t) => `– ${t.text}`),
].join('\n')
}
/** K16 (Funktion bereit) einer Funktion, Werte wie bei bestellMail aus dem Register-Eintrag. */
export function bereitMail(feature, { kundeName, projekt }) {
const { funktion, formen, bereit, knopf } = feature.mails
@@ -908,7 +953,16 @@ function sendT16({ funktion, ...werte }, bloecke) {
gesundheit_text: '',
...werte,
},
blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, ...bloecke },
blocks: {
zahlung_fehlgeschlagen: false,
ungeklaert: false,
kein_abo: false,
bereits_aktiv: false,
anleitung: false,
gesundheit: false,
anhang_fehlt: false,
...bloecke,
},
})
}
@@ -948,8 +1002,11 @@ async function gesundheitHinweis(doc) {
}
}
/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */
async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) {
/**
* keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo).
* anhangFehlt: die Vertragsunterlagen sind nicht beim Kunden angekommen, das Team sendet sie von Hand nach (Block anhang_fehlt).
*/
async function teamNotice(doc, customer, { failed = false, keinAbo = false, anhangFehlt = 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
@@ -965,7 +1022,7 @@ async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {
anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '',
gesundheit_text: gesundheit,
},
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '' }
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '', anhang_fehlt: anhangFehlt }
)
}
@@ -1120,6 +1177,45 @@ async function meldeFallZurZahlung(doc, fall, session) {
if (!ergebnis.sent && !ergebnis.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (${fall}) nicht gesendet`)
}
/** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */
const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), anlagen: false, anhang: false, zustimmungen: '', fehlt: false })
/**
* Rechtsunterlagen der K15 einer Funktion mit Rechtsdokumenten: die PDFs der aktuellen Fassung (legalDocs.featureLegalAttachments,
* erst beim Aufruf geladen; kein Registerhaken) und der Wortlaut der Zustimmungen aus dem Nachweis zur bezahlten Sitzung. Gehoert der
* Nachweis zu einer anderen Fassung als der aktuellen (die Fassung wurde zwischen Klick und Zahlung angehoben), gehen weder PDFs noch
* Links raus: es waeren nicht die Unterlagen, denen der Kunde zugestimmt hat; das Team sendet sie von Hand (fehlt). Nicht lesbare PDFs:
* K15 ohne Anhang, fehlt true, Fehler im Log. Wirft nie.
* @returns {Promise<{ attachments: object[], anlagen: boolean, anhang: boolean, zustimmungen: string, fehlt: boolean }>}
*/
async function k15Rechtsunterlagen(doc, feature, session) {
let nachweis = null
try {
nachweis = await zustimmungFuerSitzung(session.id)
} catch (err) {
console.error(`[funktionen] Zustimmungsnachweis zu Sitzung ${session.id} fuer K15 nicht lesbar, der Block zustimmungen entfaellt:`, err.message)
}
const zustimmungen = zustimmungenText(nachweis)
let fassungNachweis = ''
try {
fassungNachweis = nachweis ? JSON.parse(nachweis.fassungen)?.zustimmung ?? '' : ''
} catch {
fassungNachweis = ''
}
if (fassungNachweis && fassungNachweis !== feature.consent.version) {
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Nachweis gehoert zur Fassung ${fassungNachweis}, aktuell ist ${feature.consent.version} - ohne Unterlagen, das Team sendet sie von Hand`)
return { attachments: [], anlagen: false, anhang: false, zustimmungen, fehlt: true }
}
try {
const { featureLegalAttachments } = await import('./legalDocs.js')
const attachments = await featureLegalAttachments(doc.featureKey)
return { attachments, anlagen: true, anhang: attachments.length > 0, zustimmungen, fehlt: attachments.length === 0 }
} catch (err) {
console.error(`[funktionen] K15-Anhaenge fuer Funktion ${doc.$id} nicht lesbar, K15 geht ohne Anhang raus:`, err.message)
return { attachments: [], anlagen: true, anhang: false, zustimmungen, fehlt: true }
}
}
async function onFeaturePaid(session) {
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
const paid = { sessionId: session.id, invoiceId, invoiceUrl }
@@ -1138,16 +1234,33 @@ async function onFeaturePaid(session) {
} catch (err) {
console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message)
}
// K15 mit den Vertragsunterlagen (Terminplaner: drei PDFs als dauerhafter Datentraeger, § 312f BGB). Die Bestaetigung geht vor dem
// Anhang: sind die PDFs nicht lesbar oder kommt die Mail nicht an, bleibt die Zahlung nicht ohne Spur, T16 sagt dem Team
// "Unterlagen von Hand nachsenden" (Block anhang_fehlt).
const unterlagen = feature ? unterlagenAdressen(feature) : null
let anhangFehlt = false
if (customer?.email) {
try {
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
await sendCustomerMail(
customer.email,
bestellMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl })
)
const rechtlich = unterlagen ? await k15Rechtsunterlagen(doc, feature, session) : OHNE_RECHTSUNTERLAGEN
anhangFehlt = rechtlich.fehlt
await sendCustomerMail(customer.email, {
...bestellMail(feature, {
kundeName: customer.name || '',
projekt: await projectName(doc.projectId),
invoiceUrl,
unterlagen: rechtlich.anlagen ? unterlagen : null,
anhang: rechtlich.anhang,
zustimmungenText: rechtlich.zustimmungen,
}),
attachments: rechtlich.attachments,
})
} catch (err) {
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
if (unterlagen) anhangFehlt = true
}
} else if (unterlagen) {
anhangFehlt = true
}
// Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo).
// Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500.
@@ -1160,7 +1273,7 @@ async function onFeaturePaid(session) {
}
}
try {
await teamNotice(doc, customer, { keinAbo })
await teamNotice(doc, customer, { keinAbo, anhangFehlt })
} catch (err) {
console.error('[funktionen] T16 fehlgeschlagen:', err.message)
}