Mails: Produktname statt fest Wartungs-Abo, neue Mails für Wechsel und Zusatzspeicher, UptimeRobot-Hinweis

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-29 10:38:43 +00:00
1 parent 0d2254ca20
commit 19f9c747a2
24 files changed
+407 -100

No files matched your search

+36 -13
View File
@@ -1,6 +1,4 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
@@ -20,7 +18,7 @@ import {
reconcilePendingSwitch,
switchToWartung,
} from '../services/planSwitch.js'
import { PRODUCT_KEYS, describeItems, productView, termsDocFor } from '../services/products.js'
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, productLabel, productView, termsDocFor } from '../services/products.js'
import {
ACTIVE_STATUSES,
cancelAtPeriodEnd,
@@ -38,7 +36,6 @@ import {
} from '../services/stripeBilling.js'
const router = Router()
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
const fmtDate = (iso) =>
iso
@@ -68,6 +65,12 @@ function minTermEndOf(sub) {
// Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6)
const productKeyOf = (sub) => sub?.productKey || 'wartung'
const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung'
// {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo"
const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo')
// {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne
// gefundenes Abo die aktuelle Fassung (F08)
const endParagraphOf = (sub) => termsDocFor(sub ? sub.termsVersion : config.abo.termsVersion).endParagraph
/**
* Abo fuers Portal. events: Vertragsereignisse des Kunden (listContractEvents); es zaehlen die
@@ -411,15 +414,26 @@ async function customerNameOf(sub, fallback = '') {
return customer?.name || fallback || ''
}
/**
* K01 an den Kunden: Vertragsbestaetigung je Produkt (Katalogname, Preis, Liste "Enthalten" ueber die Bloecke
* hosting/wartung) mit den Vertragsunterlagen der Fassung, der der Kunde zugestimmt hat (§ 312f BGB,
* contractAttachmentFor: v3 Archiv-PDF, v4 aktuelles PDF). Ohne lesbare Datei geht die Mail ohne Anhang raus.
*/
async function sendContractConfirmation(subDoc, fallbackName = '') {
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
const pdfPath = path.join(publicDir, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
const pdf = await readFile(pdfPath).catch(() => null)
const attachment = contractAttachmentFor(subDoc.termsVersion)
const pdf = await readFile(attachment.path).catch((err) => {
console.error(`[abo] Vertragsunterlagen ${attachment.filename} nicht lesbar:`, err.message)
return null
})
const lateStart = subDoc.startChoice === 'nach_frist'
const productKey = productKeyOf(subDoc)
await sendCustomerMail(subDoc.email, {
template: 'K01',
values: {
name: await customerNameOf(subDoc, fallbackName),
produkt: productLabel(productKey),
preis: `${fmtEuro(config.products[productKey].amountCents)} / Monat`,
angebot_text: subDoc.offerText || '',
vertragsschluss: fmtDate(subDoc.startedAt),
leistungsbeginn: fmtDate(lateStart ? subDoc.trialEnd : subDoc.startedAt),
@@ -430,12 +444,12 @@ async function sendContractConfirmation(subDoc, fallbackName = '') {
},
blocks: {
angebot: Boolean(subDoc.offerText),
hosting: productKey === 'hosting',
wartung: productKey === 'wartung',
sofortbeginn: !lateStart,
beginn_nach_widerrufsfrist: lateStart,
},
attachments: pdf
? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
: [],
attachments: pdf ? [{ filename: attachment.filename, content: pdf, contentType: 'application/pdf' }] : [],
})
await updateDocument(config.collections.subscriptions, subDoc.$id, {
confirmationSentAt: new Date().toISOString(),
@@ -450,16 +464,19 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) {
template: 'K02',
values: {
name: await customerNameOf(sub),
produkt: productLabelOf(sub),
eingang_zeitpunkt: fmtDateTime(receivedAt),
vertragsende: fmtDate(endsAt),
vertragsende_paragraph: endParagraphOf(sub),
},
blocks: { wartung: isWartung(sub) },
})
await updateDocument(config.collections.subscriptions, sub.$id, {
cancelRequestedAt: receivedAt.toISOString(),
}).catch(() => {})
await sendAdminNotice({
template: 'T05',
values: { email: sub.email, stripe_abo_id: sub.stripeSubscriptionId, vertragsende: fmtDate(endsAt) },
values: { email: sub.email, produkt: productLabelOf(sub), stripe_abo_id: sub.stripeSubscriptionId, vertragsende: fmtDate(endsAt), vertragsende_paragraph: endParagraphOf(sub) },
})
}
@@ -525,6 +542,7 @@ async function handleStripeEvent(event) {
await sendAdminNotice({
template: 'T02',
values: {
produkt: productLabel(sub ? productKeyOf(sub) : obj.metadata?.productKey),
email: sub?.email || obj.customer_details?.email || '',
kunde_name: obj.customer_details?.name || obj.customer_details?.email || '',
appwrite_id: sub?.customerId || obj.client_reference_id || '',
@@ -599,7 +617,7 @@ async function handleStripeEvent(event) {
)
}
if (reactivated && sub) {
await sendAdminNotice({ template: 'T06', values: { email: sub.email, stripe_abo_id: obj.id } })
await sendAdminNotice({ template: 'T06', values: { email: sub.email, produkt: productLabelOf(sub), stripe_abo_id: obj.id } })
}
return
}
@@ -607,7 +625,7 @@ async function handleStripeEvent(event) {
const sub = await syncSubscription(obj.id)
await sendAdminNotice({
template: 'T07',
values: { email: sub?.email || '', stripe_abo_id: obj.id, status: obj.status },
values: { email: sub?.email || '', produkt: productLabelOf(sub), stripe_abo_id: obj.id, status: obj.status, vertragsende_paragraph: endParagraphOf(sub) },
})
return
}
@@ -888,6 +906,7 @@ router.post('/public/cancel', async (req, res) => {
email: d.email,
kundennummer: d.customerNumber,
vertrag: contractLabel(d.contract, sub),
produkt: productLabelOf(sub),
// Eine Verringerung des Speichers wirkt immer zum Ende des Abrechnungsmonats
wunschtermin: storage ? 'Ende des Abrechnungsmonats' : d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
grund: d.reason,
@@ -903,11 +922,13 @@ router.post('/public/cancel', async (req, res) => {
await sendCustomerMail(d.email, {
template: 'K03',
values: { ...common, pakete_neu: reduced ? result.details.toUnits : '', kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) },
values: { ...common, pakete_neu: reduced ? result.details.toUnits : '', kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt), vertragsende_paragraph: endParagraphOf(sub) },
blocks: {
kundennummer: Boolean(d.customerNumber),
grund: Boolean(d.reason),
abo: !storage,
// Stundenguthaben nur im Wartungs-Abo (Block innerhalb von abo)
wartung: !storage && isWartung(sub),
automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich',
@@ -1070,6 +1091,7 @@ router.post('/public/withdraw', async (req, res) => {
values: {
name: d.name,
bestellung: orderLabel(d.order, sub, { customer: true }),
produkt: productLabelOf(sub),
eingang_zeitpunkt: fmtDateTime(receivedAt),
email: d.email,
kundennummer: d.customerNumber,
@@ -1091,6 +1113,7 @@ router.post('/public/withdraw', async (req, res) => {
name: d.name,
kundennummer: d.customerNumber,
bestellung: orderLabel(d.order, sub),
produkt: productLabelOf(sub),
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',