I-6: Neue Collection shopRechtstexteArchiv (ID r + 35 Hex aus sha256(shopId + hash)); das Bestaetigen legt die Fassung samt Versand-/Zahlungstext und Gewaehrleistungstext ab, G09 zeigt die Fassung zum rechtstexteHash der Bestellung (Rueckfall mit Warnung auf die aktuell bestaetigte), loescheShopDaten loescht das Archiv mit. I-7: Grenzen AGB 20000, Belehrung 12000, Formular 3000 plus Pruefung der maskierten Laenge beim Speichern; G09 ersetzt die AGB nie mehr durch einen Link, sondern scheitert (Marker bleibt offen, wird wiederholt). Test mit Texten in Hoechstlaenge gegen die Grenzen der Warteschlange. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
405 lines
20 KiB
JavaScript
405 lines
20 KiB
JavaScript
import { config } from '../../config.js'
|
||
import { renderMail, loadTemplate } from '../mailTemplates.js'
|
||
import { sendCustomerMail } from '../mailer.js'
|
||
import { customerOf } from '../siteFeatures.js'
|
||
import { einreihen } from '../termine/outbox.js'
|
||
import { pruefeNow, zeitMs } from '../termine/util.js'
|
||
import { datumKurz, uhrzeit } from '../termine/zeit.js'
|
||
import { euro } from './geld.js'
|
||
import { oeffentlicheRechtstexte } from './kontext.js'
|
||
import { holeArchivierteRechtstexte } from './archiv.js'
|
||
import { gewaehrleistungsHinweis } from './rechtstexte.js'
|
||
|
||
/**
|
||
* Mails des Webshops (Spezifikation 2026-10-05-webshop-design.md, Abschnitte 5.3, 5.4 und 8; Plan Task E4).
|
||
*
|
||
* Kaeufer (G09 Bestellbestaetigung, G10 versendet, G11 abholbereit, G12 Eingang Widerruf, G13 Erstattung, G14 Zahlung
|
||
* fehlgeschlagen, G15 Eingang der Bestellung) laufen durch die Mail-Warteschlange des Terminplaners (termine/outbox.js einreihen): Absendername = Shopname,
|
||
* Absenderadresse bleibt SMTP_FROM, Antworten an die E-Mail des Haendlers, nie BCC. featureId = ID des portalFeatures-Dokuments des
|
||
* Shops, bookingId = Bestell-ID. Der Betreff steht in der Vorlage (G-Vorlagen des Shops haben keinen {{betreff}}-Wert). Die
|
||
* Empfaenger-Kappe der Terminplaner-Mails (limits.darfEmpfaenger) gilt hier nicht: G09 ist Vertragspflicht.
|
||
* Haendler (K25 neue Bestellung, K26 Widerruf) gehen direkt per sendCustomerMail an die Kunden-E-Mail, ohne BCC ans Team
|
||
* (die Mails enthalten Kaeuferdaten).
|
||
*
|
||
* Beide Wege werfen nie wegen eines Mailfehlers: Rueckgabe { ok: true, ... } | { ok: false, fehler }; der Fehlertext steht im Log (ohne
|
||
* Adresse), die Bestellung bleibt unberuehrt.
|
||
*
|
||
* Erwartete Form der Bestellung (`order` = shopOrders-Dokument; der JSON-Schnappschuss steht in `bestellung`, ersatzweise `daten`,
|
||
* als Text oder Objekt; alle Felder sind optional, Fehlendes erscheint als "–"):
|
||
* order.bestellnummer, order.kaeuferEmail, order.bezahltAt | order.erstelltAt
|
||
* snap.positionen[] { name, varianteText, menge, summeCent, summeText?, widerruf ('gesetzlich'|'verderblich'|'individuell'|'hygiene') }
|
||
* snap.summen | snap { zwischensummeCent, versandCent, gesamtCent, texte?: { zwischensumme, versand, gesamt, steuerHinweis } }
|
||
* snap.lieferart ('versand'|'abholung'), snap.land ('DE'|'AT')
|
||
* snap.kaeufer { name, email, telefon } (oder snap.name)
|
||
* snap.lieferadresse { name?, line1|strasse, line2?, postal_code|plz, city|ort, country|land }
|
||
* snap.zahlungsart 'card' | 'klarna' | 'sepa_debit' | Text
|
||
* snap.einstellungenAuszug { anbieter, lieferzeit, abholung } (sonst die aktuellen Einstellungen des Shops)
|
||
*/
|
||
|
||
const LEER = '–'
|
||
const GAST_ARTEN = Object.freeze(['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15'])
|
||
/** Grenzen des Outbox-Schemas (html 100000, text 50000) mit etwas Luft. */
|
||
const MAX_HTML = 95000
|
||
const MAX_TEXT = 47000
|
||
const LANDNAMEN = { DE: 'Deutschland', AT: 'Österreich' }
|
||
const ZAHLUNGSARTEN = { card: 'Kreditkarte, Apple Pay, Google Pay', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' }
|
||
// Wie WIDERRUF_TEXTE in produkte.js (dort nicht exportiert)
|
||
const WIDERRUF_AUSSCHLUSS = {
|
||
verderblich: 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware',
|
||
individuell: 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt',
|
||
hygiene: 'Vom Widerruf ausgeschlossen: versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde',
|
||
}
|
||
const ABHOLUNG_HINWEIS = 'Bitte bringen Sie Ihre Bestellnummer mit.'
|
||
|
||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
|
||
const text = (wert) => (typeof wert === 'string' ? wert.trim() : '')
|
||
const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER)
|
||
const einzeilig = (wert) => text(wert).replace(/\s+/g, ' ')
|
||
|
||
// ---------------------------------------------------------------- Bausteine
|
||
|
||
/** Schnappschuss der Bestellung als Objekt ({} wenn es keinen oder keinen lesbaren gibt). */
|
||
function schnappschuss(order) {
|
||
for (const feld of ['bestellung', 'daten']) {
|
||
const roh = order?.[feld]
|
||
if (istObjekt(roh)) return roh
|
||
if (typeof roh === 'string' && roh !== '') {
|
||
try {
|
||
const geparst = JSON.parse(roh)
|
||
if (istObjekt(geparst)) return geparst
|
||
} catch {
|
||
// unlesbar: naechstes Feld
|
||
}
|
||
}
|
||
}
|
||
return {}
|
||
}
|
||
|
||
const einstellungenVon = (ctx) => ctx?.shop?.einstellungen ?? {}
|
||
const anbieterVon = (ctx, snap) => ({ ...(einstellungenVon(ctx).anbieter ?? {}), ...(istObjekt(snap?.einstellungenAuszug?.anbieter) ? snap.einstellungenAuszug.anbieter : {}) })
|
||
|
||
/** Name des Shops in Betreff, Kopf und Absender: Shopname, sonst Name des Anbieters. */
|
||
function shopName(ctx, snap = {}) {
|
||
return einzeilig(snap?.einstellungenAuszug?.shopname) || einzeilig(einstellungenVon(ctx).shopname) || einzeilig(anbieterVon(ctx, snap).name) || 'Webshop'
|
||
}
|
||
|
||
function anbieterZeile(a) {
|
||
const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ')
|
||
const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : ''
|
||
const anschrift = [text(a.strasse), ort, land].filter(Boolean).join(', ')
|
||
return [text(a.name), anschrift, text(a.email), text(a.telefon) ? `Tel. ${einzeilig(a.telefon)}` : ''].filter(Boolean).join(', ') || LEER
|
||
}
|
||
|
||
const siteKeyVon = (ctx) => ctx?.shop?.siteKey || ctx?.doc?.siteKey || ''
|
||
const shopSeite = (ctx) => `${config.publicBaseUrl}/shop/${siteKeyVon(ctx)}`
|
||
|
||
/** Akzentfarbe als #rrggbb (nur geprueft, damit nichts in das style-Attribut gelangt). */
|
||
const farbeVon = (e) => (/^#[0-9a-fA-F]{6}$/.test(e?.akzent ?? '') ? e.akzent : '#111111')
|
||
|
||
/** Menge × Name (Variante) – Summe, eine Zeile je Position. */
|
||
function positionenText(positionen) {
|
||
const zeilen = (Array.isArray(positionen) ? positionen : [])
|
||
.filter(istObjekt)
|
||
.map((p) => {
|
||
const variante = text(p.varianteText) ? ` (${einzeilig(p.varianteText)})` : ''
|
||
const summe = hatText(p.summeText) ? p.summeText.trim() : Number.isInteger(p.summeCent) ? euro(p.summeCent) : LEER
|
||
return `${Number.isInteger(p.menge) ? p.menge : LEER} × ${einzeilig(p.name) || LEER}${variante} – ${summe}`
|
||
})
|
||
return zeilen.length ? zeilen.join('\n') : LEER
|
||
}
|
||
|
||
function adresseText(a, fallbackName) {
|
||
if (!istObjekt(a)) return LEER
|
||
const feld = (...namen) => text(namen.map((n) => a[n]).find(hatText))
|
||
const land = feld('country', 'land')
|
||
const zeilen = [
|
||
feld('name') || text(fallbackName),
|
||
feld('line1', 'strasse'),
|
||
feld('line2'),
|
||
[feld('postal_code', 'plz'), feld('city', 'ort')].filter(Boolean).join(' '),
|
||
land && land !== 'DE' ? (LANDNAMEN[land] ?? land) : '',
|
||
].filter(Boolean)
|
||
return zeilen.length ? zeilen.join('\n') : LEER
|
||
}
|
||
|
||
function zahlungsartText(snap, e) {
|
||
const roh = text(snap.zahlungsart)
|
||
if (roh) return ZAHLUNGSARTEN[roh] ?? roh
|
||
return Array.isArray(e.zahlungsarten) && e.zahlungsarten.length === 1 ? (ZAHLUNGSARTEN[e.zahlungsarten[0]] ?? LEER) : 'Online-Zahlung'
|
||
}
|
||
|
||
function ausschlussText(positionen) {
|
||
return (Array.isArray(positionen) ? positionen : [])
|
||
.filter((p) => istObjekt(p) && Object.hasOwn(WIDERRUF_AUSSCHLUSS, p.widerruf))
|
||
.map((p) => `${einzeilig(p.name) || LEER}: ${WIDERRUF_AUSSCHLUSS[p.widerruf]}`)
|
||
.join('\n')
|
||
}
|
||
|
||
/** "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit); ungueltige Zeitpunkte werfen. */
|
||
function eingangText(eingang) {
|
||
const ms = zeitMs(eingang)
|
||
if (!Number.isFinite(ms)) throw new TypeError('Eingang ist kein Zeitpunkt')
|
||
const d = new Date(ms)
|
||
return `${datumKurz(d)}, ${uhrzeit(d)} Uhr`
|
||
}
|
||
|
||
/** Datum des ersten brauchbaren Zeitpunkts (Berliner Tag), sonst des Ersatzdatums. */
|
||
const datumVon = (now, ...zeitpunkte) => {
|
||
for (const wert of zeitpunkte) {
|
||
const ms = zeitMs(wert)
|
||
if (Number.isFinite(ms)) return datumKurz(new Date(ms))
|
||
}
|
||
return datumKurz(now)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Werte
|
||
|
||
/**
|
||
* Gemeinsame Werte der Kaeufermails aus dem Schnappschuss der Bestellung und dem Shop-Kontext: Kopf und Fuss (farbe, betrieb_name,
|
||
* anbieter_zeile, widerruf_url, impressum_url, datenschutz_url) und alle Werte von G09 (bestellnummer, datum, positionen, Summen,
|
||
* Lieferart, Adresse, Zahlungsart, Rechtstexte). `order` darf null sein (G12 ohne passende Bestellung): dann sind die
|
||
* Bestellwerte "–". Es sind mehr Werte als eine einzelne Vorlage braucht; anKaeufer reicht nur die deklarierten weiter.
|
||
* @param {{ doc?: object, shop: object }} ctx
|
||
* @param {object|null} order
|
||
* @param {Date} [now] Ersatzdatum, wenn die Bestellung keinen Zeitpunkt hat
|
||
* @param {{ archiv?: object|null }} [optionen] archiv: archivierte Rechtstexte der Bestellung (archiv.js); ohne sie die aktuell bestaetigten
|
||
* @returns {Record<string, string>}
|
||
*/
|
||
export function kaeuferWerte(ctx, order, now = new Date(), { archiv = null } = {}) {
|
||
if (!istObjekt(ctx?.shop)) throw new TypeError('kaeuferWerte: ctx.shop fehlt')
|
||
const snap = schnappschuss(order)
|
||
const e = einstellungenVon(ctx)
|
||
const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {}
|
||
const anbieter = anbieterVon(ctx, snap)
|
||
const abholung = { ...(e.abholung ?? {}), ...(istObjekt(auszug.abholung) ? auszug.abholung : {}) }
|
||
const rechtstexte = (istObjekt(archiv) ? archiv : oeffentlicheRechtstexte(ctx)) ?? {}
|
||
const summen = istObjekt(snap.summen) ? snap.summen : snap
|
||
const texte = istObjekt(summen.texte) ? summen.texte : {}
|
||
const cent = (wert) => (Number.isInteger(wert) ? euro(wert) : LEER)
|
||
const lieferart = snap.lieferart === 'abholung' ? 'abholung' : snap.lieferart === 'versand' ? 'versand' : ''
|
||
const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {}
|
||
const land = text(snap.land)
|
||
const shop = shopSeite(ctx)
|
||
|
||
return {
|
||
farbe: farbeVon(e),
|
||
betrieb_name: shopName(ctx, snap),
|
||
anbieter_zeile: anbieterZeile(anbieter),
|
||
widerruf_url: `${shop}/widerruf`,
|
||
impressum_url: text(rechtstexte.impressumUrl) || shop,
|
||
datenschutz_url: text(rechtstexte.datenschutzUrl) || shop,
|
||
bestellnummer: oderLeer(order?.bestellnummer),
|
||
datum: datumVon(now, order?.erstelltAt),
|
||
zahlungsdatum: datumVon(now, order?.bezahltAt, order?.erstelltAt),
|
||
positionen: positionenText(snap.positionen),
|
||
zwischensumme: text(texte.zwischensumme) || cent(summen.zwischensummeCent),
|
||
versand: text(texte.versand) || (lieferart === 'abholung' ? 'Abholung – kostenlos' : cent(summen.versandCent)),
|
||
gesamt: text(texte.gesamt) || cent(summen.gesamtCent ?? order?.summeCent),
|
||
steuer_hinweis: text(texte.steuerHinweis) || (e.kleinunternehmer === true ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : LEER),
|
||
lieferart_text: lieferart === 'abholung' ? 'Abholung' : lieferart === 'versand' ? `Versand${land ? ` nach ${LANDNAMEN[land] ?? land}` : ''}` : LEER,
|
||
lieferadresse: adresseText(snap.lieferadresse ?? kaeufer.lieferadresse ?? kaeufer.adresse, kaeufer.name ?? snap.name),
|
||
abholort: oderLeer(abholung.adresse),
|
||
lieferzeit: oderLeer(auszug.lieferzeit ?? e.lieferzeit),
|
||
zahlungsart: zahlungsartText(snap, e),
|
||
ausschluss_text: ausschlussText(snap.positionen),
|
||
agb: oderLeer(rechtstexte.agb),
|
||
widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung),
|
||
widerrufsformular: oderLeer(rechtstexte.widerrufsformular),
|
||
gewaehrleistung: hatText(rechtstexte.gewaehrleistung) ? rechtstexte.gewaehrleistung.trim() : gewaehrleistungsHinweis(),
|
||
// fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat)
|
||
hinweis: text(abholung.hinweis) || ABHOLUNG_HINWEIS,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Werte und Bloecke einer Kaeufermail (alle Werte von kaeuferWerte plus die Werte der Aktion aus `extra`, gefiltert auf die
|
||
* Platzhalter der Vorlage).
|
||
* extra je Art:
|
||
* G10 versanddienst, sendungsnummer (beide optional; Block sendung, wenn eines da ist)
|
||
* G11 hinweis (ersetzt den Hinweis des Haendlers)
|
||
* G12 name, bestellnummer (Eingaben des Kaeufers; ohne Angabe die der Bestellung), eingang (Date | ISO, Pflicht), nachricht
|
||
* G13 betragCent (Pflicht, ganze Cent), grund
|
||
*/
|
||
function kaeuferMail(art, ctx, order, extra, now, archiv = null) {
|
||
const alle = kaeuferWerte(ctx, order, now, { archiv })
|
||
const blocks = {}
|
||
switch (art) {
|
||
case 'G09':
|
||
blocks.versand = schnappschuss(order).lieferart === 'versand'
|
||
blocks.abholung = schnappschuss(order).lieferart === 'abholung'
|
||
blocks.ausschluss = alle.ausschluss_text !== ''
|
||
break
|
||
case 'G10':
|
||
alle.versanddienst = oderLeer(extra.versanddienst)
|
||
alle.sendungsnummer = oderLeer(extra.sendungsnummer)
|
||
blocks.sendung = hatText(extra.versanddienst) || hatText(extra.sendungsnummer)
|
||
break
|
||
case 'G11':
|
||
if (hatText(extra.hinweis)) alle.hinweis = extra.hinweis.trim()
|
||
break
|
||
case 'G12':
|
||
alle.name = text(extra.name)
|
||
if (hatText(extra.bestellnummer)) alle.bestellnummer = extra.bestellnummer.trim()
|
||
alle.eingang = eingangText(extra.eingang)
|
||
alle.nachricht = text(extra.nachricht)
|
||
blocks.nachricht = alle.nachricht !== ''
|
||
break
|
||
case 'G13':
|
||
if (!Number.isInteger(extra.betragCent) || extra.betragCent <= 0) throw new TypeError('extra.betragCent muss eine positive ganze Zahl sein')
|
||
alle.betrag = euro(extra.betragCent)
|
||
alle.grund = text(extra.grund)
|
||
blocks.grund = alle.grund !== ''
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
const vorlage = loadTemplate(art)
|
||
const values = Object.fromEntries(Object.entries(alle).filter(([schluessel]) => vorlage.placeholders.includes(schluessel)))
|
||
return { values, blocks: Object.fromEntries(Object.entries(blocks).filter(([name]) => vorlage.blocks.includes(name))) }
|
||
}
|
||
|
||
/** Archivierte Rechtstexte zum Hash der Bestellung (G09); null (mit Warnung) fuer Bestellungen ohne Archiveintrag. Datenbankfehler werfen. */
|
||
async function archivFuer(ctx, order) {
|
||
const hash = text(schnappschuss(order).rechtstexteHash)
|
||
if (hash === '') return null
|
||
const shopId = ctx?.shop?.id || ctx?.doc?.$id
|
||
const archiv = await holeArchivierteRechtstexte(shopId, hash)
|
||
if (!archiv) console.warn(`[shop-mails] G09: Rechtstexte der Bestellung ${String(order?.$id).slice(0, 36)} fehlen im Archiv, die aktuell bestätigte Fassung wird verwendet`)
|
||
return archiv
|
||
}
|
||
|
||
const zuLang = (mail) => mail.html.length > MAX_HTML || mail.text.length > MAX_TEXT
|
||
|
||
// ---------------------------------------------------------------- Einreihen
|
||
|
||
/**
|
||
* Reiht eine Kaeufermail (G09-G14) in die Warteschlange ein. Empfaenger ist extra.an, sonst order.kaeuferEmail. bookingId ist
|
||
* order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". G09 enthaelt die Rechtstexte in der Fassung der
|
||
* Bestellung (archiv.js, Hash bestellung.rechtstexteHash); fehlt sie im Archiv, gilt mit Warnung die aktuell bestaetigte. Ist die
|
||
* Mail zu lang fuer die Warteschlange, scheitert sie (ok: false), statt Texte durch einen Link zu ersetzen.
|
||
* @param {'G09'|'G10'|'G11'|'G12'|'G13'|'G14'} art
|
||
* @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js)
|
||
* @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null)
|
||
* @param {object} [extra] siehe kaeuferMail
|
||
* @param {Date} [now]
|
||
* @returns {Promise<{ ok: true, eintrag: object } | { ok: false, fehler: string }>}
|
||
*/
|
||
export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) {
|
||
try {
|
||
if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`)
|
||
pruefeNow(now, 'anKaeufer')
|
||
extra = extra ?? {}
|
||
const archiv = art === 'G09' ? await archivFuer(ctx, order) : null
|
||
const { values, blocks } = kaeuferMail(art, ctx, order, extra, now, archiv)
|
||
const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail)
|
||
if (an === '') throw new TypeError('Empfänger fehlt')
|
||
|
||
// Nie ein Link statt der Texte: die Bestaetigung muss den Vertragsinhalt selbst enthalten (§ 312f Abs. 2 BGB). Passt die Mail nicht
|
||
// in die Warteschlange, scheitert sie hier; der Aufrufer behaelt den Marker offen, zaehlt den Versuch und wiederholt.
|
||
const mail = renderMail(art, values, blocks)
|
||
if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange')
|
||
|
||
const e = einstellungenVon(ctx)
|
||
const eintrag = await einreihen(
|
||
{
|
||
featureId: ctx.doc.$id,
|
||
bookingId: order?.$id || (hatText(extra.bookingId) ? extra.bookingId : '-'),
|
||
art,
|
||
an,
|
||
fromName: shopName(ctx, schnappschuss(order)).slice(0, 120),
|
||
replyTo: text(anbieterVon(ctx, schnappschuss(order)).email) || text(e.anbieter?.email) || null,
|
||
template: art,
|
||
values,
|
||
blocks,
|
||
},
|
||
now,
|
||
)
|
||
return { ok: true, eintrag }
|
||
} catch (err) {
|
||
console.warn(`[shop-mails] ${String(art).slice(0, 20)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||
return { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' }
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Haendler
|
||
|
||
/** Portal-Adresse der Webshop-Seite des Projekts. */
|
||
const portalUrl = (ctx) => `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(ctx?.shop?.projectId || ctx?.doc?.projectId || '')}`
|
||
|
||
/**
|
||
* Werte und Bloecke von K25 "Neue Bestellung" (an den Haendler) aus der bezahlten Bestellung.
|
||
* @param {{ ueberverkauft?: boolean }} [optionen] ueberverkauft: der Bestand reichte nicht (Block ueberverkauft)
|
||
* @returns {{ values: Record<string, string>, blocks: Record<string, boolean> }}
|
||
*/
|
||
export function haendlerK25(ctx, order, { ueberverkauft = false } = {}) {
|
||
const snap = schnappschuss(order)
|
||
const alle = kaeuferWerte(ctx, order)
|
||
const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {}
|
||
return {
|
||
values: {
|
||
shopname: alle.betrieb_name,
|
||
bestellnummer: alle.bestellnummer,
|
||
gesamt: alle.gesamt,
|
||
positionen: alle.positionen,
|
||
lieferart_text: alle.lieferart_text,
|
||
kaeufer_name: oderLeer(kaeufer.name ?? snap.name),
|
||
portal_url: portalUrl(ctx),
|
||
},
|
||
blocks: { ueberverkauft: ueberverkauft === true, sepa: text(snap.zahlungsart) === 'sepa_debit' },
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Werte und Bloecke von K26 "Widerruf eingegangen" (an den Haendler) aus dem Widerruf (shopWiderrufe-Dokument oder
|
||
* { name, email, bestellnummer, nachricht, eingang }); eingang = Date | ISO.
|
||
*/
|
||
export function haendlerK26(ctx, widerruf, now = new Date()) {
|
||
const nachricht = text(widerruf?.nachricht)
|
||
return {
|
||
values: {
|
||
shopname: shopName(ctx),
|
||
bestellnummer: oderLeer(widerruf?.bestellnummer),
|
||
kaeufer_name: oderLeer(widerruf?.name),
|
||
kaeufer_email: oderLeer(widerruf?.email),
|
||
eingang: eingangText(widerruf?.eingang ?? widerruf?.eingangAt ?? now),
|
||
nachricht,
|
||
portal_url: portalUrl(ctx),
|
||
},
|
||
blocks: { nachricht: nachricht !== '' },
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Schickt eine Haendlermail (K25, K26) an die E-Mail des Kundenkontos, ohne BCC ans Team. kunde_name, shopname und portal_url
|
||
* ergaenzt die Funktion, wenn die Vorlage sie kennt und values sie nicht setzt.
|
||
* @param {string} template 'K25' | 'K26'
|
||
* @param {{ doc: object, shop: object }} ctx
|
||
* @param {Record<string, string>} values
|
||
* @param {Record<string, boolean>} [blocks]
|
||
* @returns {Promise<{ ok: true } | { ok: false, fehler: string }>}
|
||
*/
|
||
export async function anHaendler(template, ctx, values = {}, blocks = {}) {
|
||
try {
|
||
const kunde = await customerOf(ctx?.shop?.customerId)
|
||
if (!hatText(kunde?.email)) {
|
||
console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: Kunde ohne E-Mail-Adresse`)
|
||
return { ok: false, fehler: 'Der Kunde hat keine E-Mail-Adresse.' }
|
||
}
|
||
const erlaubt = loadTemplate(template).placeholders
|
||
const standard = { kunde_name: text(kunde.name) || 'Kunde', shopname: shopName(ctx), portal_url: portalUrl(ctx) }
|
||
const alle = { ...standard, ...values }
|
||
await sendCustomerMail(kunde.email, {
|
||
template,
|
||
values: Object.fromEntries(Object.entries(alle).filter(([schluessel]) => erlaubt.includes(schluessel))),
|
||
blocks,
|
||
bccTeam: false,
|
||
})
|
||
return { ok: true }
|
||
} catch (err) {
|
||
// Nur Art und Code des Fehlers: Meldungen des Mailservers koennen die Adresse enthalten
|
||
console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: ${err?.name} ${String(err?.code ?? err?.responseCode ?? err?.status ?? '').slice(0, 40)}`.trim())
|
||
return { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' }
|
||
}
|
||
}
|