Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03bdeb4461
commit
2c059cb3d6
11 files changed
+574
-39
No files matched your search
@@ -7,12 +7,14 @@
|
||||
Betreff: Deine Bestellung: {{funktion}}
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}}, {{fassung}}, {{lb_url}}, {{avv_url}}, {{widerruf_url}}, {{zustimmungen_text}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung, anlagen, anhang, zustimmungen
|
||||
funktion = Name der Funktion; funktion_gen/_akk/_nom/_pron = ihre Formen im Satz (Genitiv, Akkusativ, Nominativ mit Artikel, Pronomen im Akkusativ), aus dem Register (mails.formen).
|
||||
preis = Preis der Einrichtung aus dem Katalog der Funktion; preis_hinweis = Zusatz in Klammern dahinter (mails.preisHinweis).
|
||||
danach = Satz, was der Kunde nach der Freischaltung tun kann (mails.danach). portal_url = Adresse der Funktion im Kundenportal.
|
||||
rechnung = Link zur Stripe-Rechnung (nur wenn Stripe die Rechnung schon geliefert hat).
|
||||
anlagen = Vertragsunterlagen der Funktion (nur Funktionen mit Rechtsdokumenten): fassung = Fassung der Texte, lb_url/avv_url/widerruf_url = versionierte Adressen von Leistungsbeschreibung, Auftragsverarbeitungsvertrag und Widerrufsbelehrung mit Muster-Widerrufsformular. anhang (innerhalb von anlagen) = die drei PDFs hängen an der Mail (dauerhafter Datenträger); fehlt der Anhang, entfällt der Satz.
|
||||
zustimmungen = Wortlaut und Zeitpunkt der erteilten Zustimmungen (zustimmungen_text, mehrzeilig) aus dem Nachweis zur bezahlten Bestellung.
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -41,6 +43,12 @@ a{color:#111111;}
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten {{funktion_akk}} jetzt für Dein Projekt ein.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{funktion}}</strong> – Einrichtung</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">({{preis_hinweis}})</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;">Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei und Du bekommst eine weitere E-Mail. {{danach}}</div></td></tr></table></td></tr>
|
||||
<!-- WENN anlagen -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Deine Vertragsunterlagen</div><div class="mut" style="color:#4a4843;">Fassung {{fassung}}: <a href="{{lb_url}}" style="color:#111111;">Leistungsbeschreibung</a>, <a href="{{avv_url}}" style="color:#111111;">Auftragsverarbeitungsvertrag</a> und <a href="{{widerruf_url}}" style="color:#111111;">Widerrufsbelehrung mit Muster-Widerrufsformular</a>.<!-- WENN anhang --> Alle drei hängen als PDF an dieser E-Mail. Bitte bewahre sie auf.<!-- ENDE anhang --></div></td></tr></table></td></tr>
|
||||
<!-- ENDE anlagen -->
|
||||
<!-- WENN zustimmungen -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Deine Zustimmungen</div><div class="mut" style="color:#4a4843;">{{zustimmungen_text}}</div></td></tr></table></td></tr>
|
||||
<!-- ENDE zustimmungen -->
|
||||
<!-- WENN rechnung -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{rechnung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Rechnung ansehen →</a></td></tr></table></td></tr>
|
||||
<!-- ENDE rechnung -->
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
|
||||
Empfänger: kenso@webklar.com, andrej@webklar.com
|
||||
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{funktion}}, {{woid}}, {{sitekey}}, {{sitzung}}, {{zustand}}, {{anleitung_url}}, {{gesundheit_text}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit, anhang_fehlt
|
||||
funktion = Name der Funktion aus dem Register (zum Beispiel News-Pinnwand, Terminplaner); ohne bekannte Funktion steht „Funktion“.
|
||||
ungeklaert = eine bezahlte Stripe-Sitzung passt zu keiner offenen Bestellung (Betreff und Inhalt: von Hand pruefen); sitzung nennt Sitzungs-ID, Kunde und Projekt.
|
||||
zahlung_fehlgeschlagen = die Zahlung (SEPA) ist nicht eingegangen; es wurde nichts bestellt, woid und sitekey sind dann leer.
|
||||
@@ -16,6 +16,7 @@
|
||||
bereits_aktiv = die Zahlung ist da, aber die Funktion war schon in dem Zustand {{zustand}}; es wurde kein Ticket angelegt und nichts eingerichtet (Einbau nachholen oder erstatten); sitzung nennt die Stripe-Sitzung.
|
||||
anleitung = Knopf „Im Ticketsystem öffnen“ zur Einbauanleitung des Projekts (anleitung_url); nur bei Funktionen mit Anleitung und angelegtem Ticket.
|
||||
gesundheit = Warnzeile „Gesundheitsbereich prüfen“ mit gesundheit_text (Hinweise zur Branche des Betriebs).
|
||||
anhang_fehlt = die Vertragsunterlagen (PDF) sind nicht beim Kunden angekommen (Anhang nicht lesbar, Mail nicht versandt oder Unterlagen einer anderen Fassung als bestellt); das Team sendet sie von Hand nach.
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -57,6 +58,9 @@ a{color:#111111;}
|
||||
<!-- WENN gesundheit -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:16px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;">Gesundheitsbereich prüfen: {{gesundheit_text}}</div></td></tr></table></td></tr>
|
||||
<!-- ENDE gesundheit -->
|
||||
<!-- WENN anhang_fehlt -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Unterlagen von Hand nachsenden</div><div class="mut" style="color:#4a4843;">Die Vertragsunterlagen (Leistungsbeschreibung, Auftragsverarbeitungsvertrag und Widerrufsbelehrung mit Muster-Widerrufsformular, jeweils als PDF) sind nicht mit der Bestätigung beim Kunden angekommen. Bitte dem Kunden die Unterlagen in der bestellten Fassung per E-Mail nachsenden. Der Grund steht im Log des Portals.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE anhang_fehlt -->
|
||||
<!-- WENN anleitung -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{anleitung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem öffnen →</a></td></tr></table></td></tr>
|
||||
<!-- ENDE anleitung -->
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
listCustomerFeatures,
|
||||
markOrdered,
|
||||
runActivation,
|
||||
unterlagenAdressen,
|
||||
} from '../services/siteFeatures.js'
|
||||
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
|
||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||
@@ -115,6 +116,31 @@ async function termineAngebot(email, customerId) {
|
||||
return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '', hinweis }
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertragsunterlagen je Funktion fuer die Liste (GET /api/funktionen, Feld legal): Funktionen mit Rechtsdokumenten (Terminplaner)
|
||||
* { version, links: { lb, avv, widerruf } (absolut, versioniert), belehrung } mit der Widerrufsbelehrung samt Muster-Formular aus
|
||||
* dem Haken belehrung (derselbe Text wie auf der Bestellseite und im Nachweis), alle anderen (News) null. Kann der Haken die
|
||||
* Belehrung nicht liefern, steht belehrung null: die Liste faellt dafuer nicht aus.
|
||||
*/
|
||||
async function rechtlichesJeFunktion() {
|
||||
const legal = {}
|
||||
for (const key of featureKeys()) {
|
||||
const unterlagen = unterlagenAdressen(featureConfig(key))
|
||||
if (!unterlagen) {
|
||||
legal[key] = null
|
||||
continue
|
||||
}
|
||||
let belehrung = null
|
||||
try {
|
||||
belehrung = (await featureHaken(key).belehrung?.()) ?? null
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Widerrufsbelehrung (${key}) nicht lesbar: ${err?.name ?? 'Fehler'}`)
|
||||
}
|
||||
legal[key] = { version: unterlagen.fassung, links: { lb: unterlagen.lb, avv: unterlagen.avv, widerruf: unterlagen.widerruf }, belehrung }
|
||||
}
|
||||
return legal
|
||||
}
|
||||
|
||||
// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
try {
|
||||
@@ -139,6 +165,8 @@ router.get('/', requireSession, async (req, res) => {
|
||||
features: Object.fromEntries(featureKeys().map((key) => [key, oeffentlicheAnsicht(key)])),
|
||||
// Preishinweis fuer alle Bestellseiten: die einzige Stelle, an der er in die Oberflaeche gelangt
|
||||
ustHinweis: config.ustHinweis,
|
||||
// Vertragsunterlagen und Widerrufsbelehrung je Funktion (Terminplaner: Fassung, versionierte Links, Belehrung; News: null)
|
||||
legal: await rechtlichesJeFunktion(),
|
||||
setupEstimate: config.funktionen.setupEstimate,
|
||||
consentText: config.funktionen.consentText,
|
||||
// Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch
|
||||
|
||||
@@ -38,6 +38,16 @@ async function termineFreischaltbar(doc) {
|
||||
return (await freischaltHindernis(doc)) === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertragsunterlagen des Terminplaners abrufbar? (Stufe 5b des Checkouts, vor jeder Stripe-Sitzung): HTML-Seiten und PDFs der
|
||||
* aktuellen Fassung sind da und lesbar (legalDocs.featureLegalReady, Task F8; fail-closed, wirft nie). Geladen wird legalDocs erst
|
||||
* beim Aufruf.
|
||||
*/
|
||||
async function termineUnterlagenBereit() {
|
||||
const { featureLegalReady } = await import('./legalDocs.js')
|
||||
return featureLegalReady('termine')
|
||||
}
|
||||
|
||||
/**
|
||||
* Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen (Gesetzesmuster, Sie-Form), die die Bestellseite vor dem Knopf
|
||||
* zeigt und der Zustimmungsnachweis speichert: dieselbe Zeichenkette an beiden Stellen (Task F7, Text aus Task F3). Geladen
|
||||
@@ -50,7 +60,7 @@ async function termineBelehrung() {
|
||||
|
||||
/**
|
||||
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
|
||||
* (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
|
||||
* (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
|
||||
* und im Nachweis gespeichert wird) und B11 freischaltbar. News hat keine Standard-Haken, also auch keinen belehrung-Eintrag
|
||||
* im Nachweis. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert.
|
||||
*/
|
||||
@@ -59,7 +69,7 @@ const STANDARD_HAKEN = {
|
||||
termine: {
|
||||
verfuegbar: (email) => termineFuerKunde(email),
|
||||
bestellpruefung: termineBestellpruefung,
|
||||
unterlagenBereit: async () => true,
|
||||
unterlagenBereit: termineUnterlagenBereit,
|
||||
belehrung: termineBelehrung,
|
||||
freischaltbar: termineFreischaltbar,
|
||||
},
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und
|
||||
// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum.
|
||||
// Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben.
|
||||
import { access, readFile } from 'node:fs/promises'
|
||||
import { access, open, readFile, stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
|
||||
const publicDir = fileURLToPath(new URL('../../public/', import.meta.url))
|
||||
/** Wurzel der ausgelieferten Dateien (Standard: public/); nur Tests tauschen sie gegen ein Verzeichnis mit Kopien aus. */
|
||||
let basisDir = publicDir
|
||||
/** Nur fuer Tests: Verzeichnis, in dem legalPdfFiles und featureLegalReady die Dateien suchen (null = public/). */
|
||||
export function setLegalDirForTests(dir) {
|
||||
basisDir = dir ?? publicDir
|
||||
}
|
||||
const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/**
|
||||
@@ -56,7 +62,7 @@ export function legalPdfFiles(satz, version) {
|
||||
const dokumente = satzDokumente(satz)
|
||||
const v = pruefeFassung(version)
|
||||
return Object.fromEntries(
|
||||
Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(publicDir, 'rechtliches', `${d.slug}-${v}.pdf`) }])
|
||||
Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(basisDir, 'rechtliches', `${d.slug}-${v}.pdf`) }])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,18 +96,36 @@ export async function featureLegalAttachments(featureKey, opts) {
|
||||
return satz ? legalPdfAttachments(satz, aktuelleFassung(satz), opts) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Datei ein lesbares PDF? Grösse > 0 (stat) und die ersten fünf Bytes "%PDF-"; nur diese fünf Bytes werden gelesen, nie die
|
||||
* ganze Datei (featureLegalReady läuft bei jeder Bestellung, vor jeder Stripe-Sitzung). Wirft bei jedem Lesefehler.
|
||||
*/
|
||||
async function istLesbaresPdf(pfad) {
|
||||
const info = await stat(pfad)
|
||||
if (!info.isFile() || info.size <= 0) return false
|
||||
const datei = await open(pfad, 'r')
|
||||
try {
|
||||
const kopf = Buffer.alloc(5)
|
||||
const { bytesRead } = await datei.read(kopf, 0, 5, 0)
|
||||
return bytesRead === 5 && kopf.toString('latin1') === '%PDF-'
|
||||
} finally {
|
||||
await datei.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* true, wenn alle Rechtsdokumente der Funktion in der aktuellen Fassung vorliegen: HTML-Seiten (die die Bestellseite
|
||||
* verlinkt) und PDFs (die Mail anhaengt). Unbekannte Funktion und jeder Lesefehler: false (fail-closed).
|
||||
* verlinkt) und PDFs (die Mail anhaengt; je PDF stat und die ersten fuenf Bytes, nicht der ganze Inhalt). Unbekannte
|
||||
* Funktion und jeder Lesefehler: false (fail-closed), nie ein Wurf.
|
||||
*/
|
||||
export async function featureLegalReady(featureKey) {
|
||||
try {
|
||||
const satz = funktionSatz(featureKey)
|
||||
if (!satz) return true
|
||||
const v = aktuelleFassung(satz)
|
||||
await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(publicDir, adresse))))
|
||||
await legalPdfAttachments(satz, v)
|
||||
return true
|
||||
await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(basisDir, adresse))))
|
||||
const lesbar = await Promise.all(Object.values(legalPdfFiles(satz, v)).map((f) => istLesbaresPdf(f.path)))
|
||||
return lesbar.every(Boolean)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
+125
-12
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user